diff --git a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift index 6a3e2a5..ba1501a 100644 --- a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift +++ b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift @@ -49,6 +49,57 @@ final class IOSAudioRouter: ObservableObject { @Published var selectedPolarPattern: String? @Published var showsRawModeSpeakerWarning: Bool = false @Published var showsA2dpNoAecWarning: Bool = false + @Published var hasBluetoothDevice: Bool = false + + enum AudioPreset: String, CaseIterable, Identifiable { + /// Built-in mic + phone speaker. No Bluetooth. Standard processing, mono. + case `default` = "Default (Phone Speaker)" + /// Bluetooth HFP: BT mic + BT output. Standard processing, mono. Voice-quality. + case bluetoothHeadset = "Bluetooth Headset (HFP)" + /// A2DP stereo output + built-in mic. Standard processing, mono. + case btHeadphonesMic = "BT Headphones + Phone Mic" + /// A2DP stereo output + built-in mic stereo (front+back capsules). Standard, stereo. + case btHeadphonesStereoMic = "BT Headphones + Stereo Mic" + /// Settings don't match any preset — user has tweaked advanced controls. + case custom = "Custom" + + var id: String { rawValue } + + var requiresBluetooth: Bool { + switch self { + case .default, .custom: return false + default: return true + } + } + + var bluetoothMode: BluetoothMode { + switch self { + case .default: return .builtInMicSpeaker + case .bluetoothHeadset: return .btHfpVoice + case .btHeadphonesMic, .btHeadphonesStereoMic: return .builtInMicBtA2dp + case .custom: return .builtInMicSpeaker // placeholder + } + } + + var captureChannels: CaptureChannels { + switch self { + case .btHeadphonesStereoMic: return .stereo + default: return .mono + } + } + + var micMode: MicMode { + .standard // all presets use standard processing + } + + /// Whether this preset selects the built-in mic explicitly (vs. system default). + var usesBuiltInMic: Bool { + switch self { + case .btHeadphonesMic, .btHeadphonesStereoMic: return true + default: return false + } + } + } enum BluetoothMode: String, CaseIterable, Identifiable { case btHfpVoice = "BT HFP Voice" @@ -78,6 +129,7 @@ final class IOSAudioRouter: ObservableObject { private let kInputPortId = "cat.voice.audio.inputPortId" private let kDataSourceId = "cat.voice.audio.dataSourceId" private let kPolarPattern = "cat.voice.audio.polarPattern" + private let kPreset = "cat.voice.audio.preset" /// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change /// notifications synchronously on the same thread. Without this guard, @@ -136,6 +188,45 @@ final class IOSAudioRouter: ObservableObject { } updateWarnings() + detectBluetooth() + } + + /// Detect whether a Bluetooth audio device is currently connected (A2DP or HFP). + /// Drives which presets are shown — BT presets are hidden when no BT device is + /// connected to avoid confusing the user with irrelevant options. + private func detectBluetooth() { + let session = AVAudioSession.sharedInstance() + let route = session.currentRoute + let hasBTOutput = route.outputs.contains { + $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP + } + let hasBTInput = route.inputs.contains { $0.portType == .bluetoothHFP } + let hasBTAvailable = (session.availableInputs ?? []).contains { + $0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP + } + let wasConnected = hasBluetoothDevice + hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable + if hasBluetoothDevice != wasConnected { + logger.info("bluetooth device \(self.hasBluetoothDevice ? "connected" : "disconnected")") + } + } + + /// The presets available given the current Bluetooth connection state. + /// Always includes .default and .custom; BT presets only when a BT device is connected. + var availablePresets: [AudioPreset] { + AudioPreset.allCases.filter { !$0.requiresBluetooth || hasBluetoothDevice } + } + + /// Which preset matches the current settings, or .custom if nothing matches. + var activePreset: AudioPreset { + for preset in AudioPreset.allCases where preset != .custom { + if bluetoothMode == preset.bluetoothMode + && captureChannels == preset.captureChannels + && micMode == preset.micMode { + return preset + } + } + return .custom } // MARK: - Apply configuration @@ -278,7 +369,6 @@ final class IOSAudioRouter: ObservableObject { selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId) selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern) } - /// Persist current selections to UserDefaults. func savePreferences() { UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode) @@ -335,6 +425,44 @@ final class IOSAudioRouter: ObservableObject { applyConfiguration() } + // MARK: - Presets + + /// Apply a preset — sets all individual audio settings to the preset's values, then + /// applies the configuration. For presets that use the built-in mic (A2DP presets), + /// finds the built-in mic port UID from availableInputs. + func applyPreset(_ preset: AudioPreset) { + guard preset != .custom else { return } // can't "apply" custom — it's a display state + + bluetoothMode = preset.bluetoothMode + micMode = preset.micMode + captureChannels = preset.captureChannels + + if preset.usesBuiltInMic { + // Find the built-in mic port from available inputs and select it. + let session = AVAudioSession.sharedInstance() + if let builtInMic = (session.availableInputs ?? []).first(where: { + $0.portType == .builtInMic + }) { + selectedInputPortId = builtInMic.uid + } + // Don't set a specific data source — in stereo mode, iOS uses multiple mic + // capsules automatically. In mono, the default orientation is fine. + selectedDataSourceId = nil + selectedPolarPattern = nil + } else { + // For Default and Bluetooth Headset presets, let the system pick the input. + selectedInputPortId = nil + selectedDataSourceId = nil + selectedPolarPattern = nil + } + + UserDefaults.standard.set(preset.rawValue, forKey: kPreset) + savePreferences() + applyConfiguration() + refreshRoutes() + logger.info("applyPreset — \(preset.rawValue)") + } + // MARK: - Helpers /// Update warning indicators for the Settings UI. diff --git a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift index 23a4777..29942d6 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift @@ -6,142 +6,162 @@ struct SettingsView: View { @Environment(AppState.self) private var appState @Bindable var session: SessionState @StateObject private var router = IOSAudioRouter.shared + @State private var showAdvanced = false var body: some View { NavigationStack { Form { - // MARK: - Audio Input - Section("Audio Input") { - // Input port picker (AVAudioSession.availableInputs) - Picker("Input Port", selection: Binding( - get: { router.selectedInputPortId ?? "" }, - set: { id in - if !id.isEmpty { router.selectInputPort(id) } - } + // MARK: - Audio Preset + Section("Audio") { + Picker("Preset", selection: Binding( + get: { router.activePreset }, + set: { preset in router.applyPreset(preset) } )) { - Text("Default").tag("") - ForEach(router.inputPorts) { port in - Text(port.name).tag(port.id) + ForEach(router.availablePresets) { preset in + Text(preset.rawValue).tag(preset) } } - .accessibilityLabel("Audio input port selection") + .accessibilityLabel("Audio preset") - // 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 ?? "" }, + if !router.hasBluetoothDevice { + Text("Connect Bluetooth headphones to see Bluetooth presets.") + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityLabel("No Bluetooth device connected") + } + } + + // 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.selectDataSource(id) } + if !id.isEmpty { router.selectInputPort(id) } } )) { Text("Default").tag("") - ForEach(dataSources) { ds in - Text(ds.name).tag(ds.id) + ForEach(router.inputPorts) { port in + Text(port.name).tag(port.id) } } - .accessibilityLabel("Microphone orientation") + .accessibilityLabel("Audio input port selection") - // Polar pattern sub-picker (only if the data source supports patterns) - 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) } + // 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(patterns, id: \.self) { pattern in - Text(polarPatternLabel(pattern)).tag(pattern) + ForEach(dataSources) { ds in + Text(ds.name).tag(ds.id) } } - .accessibilityLabel("Microphone polar pattern") - } - } + .accessibilityLabel("Microphone orientation") - // 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") - - 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") - } - - // MARK: - Audio Output - Section("Audio Output") { - // 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) + // 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") } - .accessibilityLabel("Current output: \(route.name)") } - } else { - Text("No output route") - .foregroundStyle(.secondary) - } - // AirPlay button - HStack { - Text("AirPlay") - Spacer() - RoutePickerButton() + // 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") + + 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") } - .accessibilityLabel("AirPlay output selector") } // MARK: - Voice