Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS): 1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane. Previously the button only toggled the local mic — receiving was always on (gated by channel membership alone). Added a protocol-level voice subscription concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked by the SFU relay recipient filter, and core-client gating of remote-stream decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe on Leave. Text chat works regardless of voice subscription. 2. Channel edit dialog now shows the channel's actual current settings. The read struct vc_channel was missing sort_order and audio fields — only the write struct vc_channel_info had them. Extended vc_channel with both (additive, no ABI break), updated the session model and list_channels marshaling to populate them, and updated all three clients' edit callers to use actual channel info instead of hardcoded defaults. 3. Channel parameter updates now automatically restart everyone's streams. Previously editing a channel's audio config persisted and broadcast a ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are frozen at announce time. handle_channel_event now detects audio-config changes on the user's current channel and stop->starts each active local stream. The server reads the updated config on re-announce; peers wire up fresh decoders at the new ssrc. All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not yet compile-verified (Windows environment).
342 lines
16 KiB
Swift
342 lines
16 KiB
Swift
import SwiftUI
|
|
import AVKit
|
|
import VoiceCatCore
|
|
|
|
struct SettingsView: View {
|
|
@Environment(AppState.self) private var appState
|
|
@Bindable var session: SessionState
|
|
@StateObject private var router = IOSAudioRouter.shared
|
|
@State private var showAdvanced = false
|
|
|
|
// Notification feedback prefs — keys shared with VoiceCatCore's FeedbackSettings, so the
|
|
// EventFeedback player reads the same values these toggles write.
|
|
@AppStorage("feedback.sounds") private var soundsEnabled = true
|
|
@AppStorage("feedback.speech") private var speechEnabled = false
|
|
@AppStorage("feedback.volume") private var soundsVolume = 1.0
|
|
@AppStorage("feedback.selfTalk") private var selfTalkEnabled = false
|
|
@AppStorage("feedback.ptt") private var pttSoundEnabled = false
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Form {
|
|
// MARK: - Audio Preset
|
|
Section("Audio") {
|
|
Picker("Preset", selection: Binding(
|
|
get: { router.activePreset },
|
|
set: { preset in router.applyPreset(preset) }
|
|
)) {
|
|
ForEach(router.availablePresets) { preset in
|
|
Text(preset.rawValue).tag(preset)
|
|
}
|
|
}
|
|
.accessibilityLabel("Audio preset")
|
|
|
|
Toggle("Speaker output", isOn: Binding(
|
|
get: { router.forceSpeaker },
|
|
set: { router.setForceSpeaker($0) }
|
|
))
|
|
.accessibilityLabel("Speaker output")
|
|
.accessibilityHint("Routes audio to the speaker instead of the earpiece when no headphones are connected.")
|
|
|
|
// Surface the voice-processing state. On Voice Chat the native iOS
|
|
// Voice-Processing unit (VPIO) does echo cancellation, noise suppression and
|
|
// automatic gain control; the stereo / mono-mic / A2DP configs can't use it.
|
|
if router.currentConfigUsesVoiceProcessing {
|
|
Label("Echo cancellation & noise suppression on (iOS voice processing)",
|
|
systemImage: "waveform.badge.mic")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel("Echo cancellation and noise suppression are on")
|
|
} else {
|
|
Label("No echo cancellation in this configuration (stereo / A2DP / off)",
|
|
systemImage: "waveform.slash")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel("Echo cancellation is off in this configuration")
|
|
}
|
|
}
|
|
|
|
// MARK: - Advanced Audio
|
|
Section {
|
|
DisclosureGroup("Advanced Audio", isExpanded: $showAdvanced) {
|
|
// Input port picker (AVAudioSession.availableInputs)
|
|
Picker("Input Port", selection: Binding(
|
|
get: { router.selectedInputPortId ?? "" },
|
|
set: { id in
|
|
if !id.isEmpty { router.selectInputPort(id) }
|
|
}
|
|
)) {
|
|
Text("Default").tag("")
|
|
ForEach(router.inputPorts) { port in
|
|
Text(port.name).tag(port.id)
|
|
}
|
|
}
|
|
.accessibilityLabel("Audio input port selection")
|
|
|
|
// Built-in mic sub-options: orientation (data source) + polar pattern
|
|
if router.selectedPortIsBuiltInMic,
|
|
let dataSources = router.selectedPortDataSources,
|
|
!dataSources.isEmpty {
|
|
Picker("Mic Orientation", selection: Binding(
|
|
get: { router.selectedDataSourceId ?? "" },
|
|
set: { id in
|
|
if !id.isEmpty { router.selectDataSource(id) }
|
|
}
|
|
)) {
|
|
Text("Default").tag("")
|
|
ForEach(dataSources) { ds in
|
|
Text(ds.name).tag(ds.id)
|
|
}
|
|
}
|
|
.accessibilityLabel("Microphone orientation")
|
|
|
|
// Polar pattern sub-picker
|
|
if let selectedDs = dataSources.first(where: { $0.id == router.selectedDataSourceId }),
|
|
let patterns = selectedDs.polarPatterns,
|
|
!patterns.isEmpty {
|
|
Picker("Polar Pattern", selection: Binding(
|
|
get: { router.selectedPolarPattern ?? "" },
|
|
set: { pattern in
|
|
if !pattern.isEmpty { router.selectPolarPattern(pattern) }
|
|
}
|
|
)) {
|
|
Text("Default").tag("")
|
|
ForEach(patterns, id: \.self) { pattern in
|
|
Text(polarPatternLabel(pattern)).tag(pattern)
|
|
}
|
|
}
|
|
.accessibilityLabel("Microphone polar pattern")
|
|
}
|
|
}
|
|
|
|
// Mic processing mode: Standard vs Raw/Studio
|
|
Picker("Mic Mode", selection: Binding(
|
|
get: { router.micMode },
|
|
set: { router.selectMicMode($0) }
|
|
)) {
|
|
ForEach(IOSAudioRouter.MicMode.allCases) { mode in
|
|
Text(mode.rawValue).tag(mode)
|
|
}
|
|
}
|
|
.accessibilityLabel("Microphone processing mode")
|
|
|
|
// Voice-processing (VPIO) controls — only meaningful on a VPIO-capable
|
|
// config (mono + standard + non-A2DP). iOS bundles echo cancellation and
|
|
// noise suppression into one master switch (no per-stage toggle); AGC is
|
|
// the one sub-stage it lets us control independently.
|
|
if router.voiceProcessingAvailable {
|
|
Toggle("Voice Processing (AEC + noise suppression)", isOn: Binding(
|
|
get: { router.voiceProcessingEnabled },
|
|
set: { router.setVoiceProcessingEnabled($0) }
|
|
))
|
|
.accessibilityLabel("Voice processing")
|
|
.accessibilityHint("Echo cancellation and noise suppression, bundled together by iOS.")
|
|
|
|
if router.voiceProcessingEnabled {
|
|
Toggle("Automatic Gain Control", isOn: Binding(
|
|
get: { router.agcEnabled },
|
|
set: { router.setAgcEnabled($0) }
|
|
))
|
|
.accessibilityLabel("Automatic gain control")
|
|
}
|
|
}
|
|
|
|
if router.showsRawModeSpeakerWarning {
|
|
Label(
|
|
"Raw mode on speaker — echo risk (no AEC)",
|
|
systemImage: "exclamationmark.triangle.fill"
|
|
)
|
|
.foregroundStyle(.orange)
|
|
.font(.caption)
|
|
.accessibilityLabel("Warning: Raw mode with speaker output may cause echo")
|
|
}
|
|
|
|
if router.showsA2dpNoAecWarning {
|
|
Label(
|
|
"A2DP mode — no echo cancellation (hardware AEC unavailable)",
|
|
systemImage: "info.circle.fill"
|
|
)
|
|
.foregroundStyle(.blue)
|
|
.font(.caption)
|
|
.accessibilityLabel("Info: A2DP output mode does not support hardware echo cancellation")
|
|
}
|
|
|
|
|
|
// Capture channels: Mono vs Stereo
|
|
Picker("Channels", selection: Binding(
|
|
get: { router.captureChannels },
|
|
set: { router.selectCaptureChannels($0) }
|
|
)) {
|
|
ForEach(IOSAudioRouter.CaptureChannels.allCases) { ch in
|
|
Text(ch.rawValue).tag(ch)
|
|
}
|
|
}
|
|
.accessibilityLabel("Capture channel count")
|
|
|
|
// Bluetooth mode
|
|
Picker("Bluetooth Mode", selection: Binding(
|
|
get: { router.bluetoothMode },
|
|
set: { router.selectBluetoothMode($0) }
|
|
)) {
|
|
ForEach(IOSAudioRouter.BluetoothMode.allCases) { mode in
|
|
Text(mode.rawValue).tag(mode)
|
|
}
|
|
}
|
|
.accessibilityLabel("Bluetooth audio mode")
|
|
|
|
// Current output route (read-only)
|
|
if !router.outputRoutes.isEmpty {
|
|
ForEach(router.outputRoutes) { route in
|
|
HStack {
|
|
Text(route.name)
|
|
Spacer()
|
|
Text(route.portType)
|
|
.foregroundStyle(.secondary)
|
|
.font(.caption)
|
|
}
|
|
.accessibilityLabel("Current output: \(route.name)")
|
|
}
|
|
} else {
|
|
Text("No output route")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
// AirPlay button
|
|
HStack {
|
|
Text("AirPlay")
|
|
Spacer()
|
|
RoutePickerButton()
|
|
}
|
|
.accessibilityLabel("AirPlay output selector")
|
|
}
|
|
}
|
|
|
|
// MARK: - Voice
|
|
Section("Voice") {
|
|
Picker("Input Mode", selection: Binding(
|
|
get: { session.voiceState.inputMode },
|
|
set: { session.setInputMode($0) }
|
|
)) {
|
|
Text("Voice Activation").tag(VoiceCatInputMode.voiceActivation)
|
|
Text("Push to Talk").tag(VoiceCatInputMode.pushToTalk)
|
|
Text("Always On").tag(VoiceCatInputMode.alwaysOn)
|
|
}
|
|
.accessibilityLabel("Voice input mode")
|
|
|
|
if session.voiceState.inputMode == .voiceActivation {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("VAD Threshold: \(String(format: "%.3f", session.voiceState.vadThreshold))")
|
|
.font(.caption)
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(session.voiceState.vadThreshold) },
|
|
set: { session.setVadThreshold(Float($0)) }
|
|
),
|
|
in: 0.001...0.1, step: 0.001
|
|
)
|
|
.accessibilityLabel("Voice activation threshold")
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Mic Volume: \(Int((session.voiceState.inputGain * 100).rounded()))%")
|
|
.font(.caption)
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(session.voiceState.inputGain) },
|
|
set: { session.setInputGain(Float($0)) }
|
|
),
|
|
in: 0...4, step: 0.05
|
|
)
|
|
.accessibilityLabel("Microphone volume")
|
|
.accessibilityValue("\(Int((session.voiceState.inputGain * 100).rounded())) percent")
|
|
}
|
|
|
|
Toggle("Noise Reduction (RNNoise)", isOn: Binding(
|
|
get: { session.voiceState.inputNoiseReduction },
|
|
set: { session.setInputNoiseReduction($0) }
|
|
))
|
|
.accessibilityLabel("Microphone noise reduction")
|
|
.accessibilityHint("Denoises your microphone signal for everyone listening.")
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
Section("Notifications") {
|
|
Toggle("Event sounds", isOn: $soundsEnabled)
|
|
.accessibilityLabel("Play event sounds")
|
|
if soundsEnabled {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Sound volume")
|
|
.font(.caption)
|
|
Slider(value: $soundsVolume, in: 0...1)
|
|
.accessibilityLabel("Sound volume")
|
|
}
|
|
}
|
|
Toggle("Speak events (text-to-speech)", isOn: $speechEnabled)
|
|
.accessibilityLabel("Speak events")
|
|
.accessibilityHint("Announces joins and leaves and reads message text aloud.")
|
|
Toggle("Your own voice-activity sounds", isOn: $selfTalkEnabled)
|
|
.accessibilityLabel("Voice activity sounds")
|
|
Toggle("Push-to-talk cue", isOn: $pttSoundEnabled)
|
|
.accessibilityLabel("Push to talk cue")
|
|
}
|
|
|
|
// MARK: - Admin
|
|
if session.permissions.canAdminAccounts || session.permissions.isAdmin {
|
|
Section("Administration") {
|
|
NavigationLink("Manage Accounts") {
|
|
AccountsView(session: session)
|
|
}
|
|
.accessibilityLabel("Manage server accounts")
|
|
}
|
|
}
|
|
|
|
// MARK: - Server
|
|
Section("Server") {
|
|
Button(role: .destructive) {
|
|
session.leaveVoice()
|
|
appState.disconnect()
|
|
} label: {
|
|
Label("Disconnect", systemImage: "phone.down")
|
|
.foregroundStyle(.red)
|
|
}
|
|
.accessibilityLabel("Disconnect from server")
|
|
}
|
|
|
|
// MARK: - About
|
|
Section("About") {
|
|
Text(VoiceCatClient.versionString)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.accessibilityLabel("Version: \(VoiceCatClient.versionString)")
|
|
}
|
|
}
|
|
.navigationTitle("Settings")
|
|
.onAppear {
|
|
router.refreshRoutes()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Human-readable label for AVAudioSession.PolarPattern raw values.
|
|
private func polarPatternLabel(_ rawValue: String) -> String {
|
|
switch rawValue {
|
|
case AVAudioSession.PolarPattern.omnidirectional.rawValue: return "Omnidirectional"
|
|
case AVAudioSession.PolarPattern.cardioid.rawValue: return "Cardioid"
|
|
case AVAudioSession.PolarPattern.subcardioid.rawValue: return "Subcardioid"
|
|
default: return rawValue
|
|
}
|
|
}
|
|
}
|
|
|
|
/// SwiftUI wrapper for AVRoutePickerView (AVKit's UIView for AirPlay route selection).
|
|
private struct RoutePickerButton: UIViewRepresentable {
|
|
func makeUIView(context: Context) -> AVRoutePickerView {
|
|
let view = AVRoutePickerView()
|
|
view.tintColor = .systemBlue
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {}
|
|
}
|