From 1a1c8a1dfed55ce627c2954d67d2c30b26c9fd4a Mon Sep 17 00:00:00 2001 From: Talon Date: Fri, 19 Jun 2026 14:09:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(ios):=20rework=20audio=20presets=20?= =?UTF-8?q?=E2=80=94=20always-available=20+=20device-specific?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presets reorganized to give users choice at every level: Always available (work with any output route): - Voice Chat: AEC/AGC/HPF on, mono, system picks best route (BT HFP, wired, or speaker). The standard iOS VoIP experience. - Stereo Mic: Stereo built-in mic (front+back capsules), A2DP output if BT connected else speaker/wired. Standard processing. - Studio (No Processing): Stereo mic, no AEC/AGC/HPF (raw mode). Maximum fidelity. Echo risk on speaker. When Bluetooth connected: - Bluetooth Headset (HFP): BT mic + BT output, AEC on, mono. - BT Headphones + Mono Mic: A2DP output + built-in mic, mono, no AEC. - BT Headphones + Stereo Mic: A2DP output + stereo built-in mic. When wired headset/earpods connected: - Wired Headset: Wired output + wired/built-in mic, AEC on, mono. Always: - Custom: shown when advanced settings don't match any preset. Key changes from previous version: - Stereo Mic is no longer gated behind Bluetooth — it's always available and uses A2DP output if BT is connected, else speaker/wired. - Wired headset detection (headphones/headsetMic/usbAudio port types) with a dedicated preset. - Voice Chat preset always available with AEC — the safe default. - activePreset checks device-specific presets first so e.g. when BT is connected and settings match 'Bluetooth Headset', it returns that instead of the equivalent 'Voice Chat'. - detectAudioDevices() replaces detectBluetooth(), detects both BT and wired devices from currentRoute + availableInputs. --- .../iOS/VoiceCatiOS/IOSAudioRouter.swift | 115 ++++++++++++++---- .../iOS/VoiceCatiOS/Views/SettingsView.swift | 6 +- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift index ba1501a..8278efa 100644 --- a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift +++ b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift @@ -50,16 +50,32 @@ final class IOSAudioRouter: ObservableObject { @Published var showsRawModeSpeakerWarning: Bool = false @Published var showsA2dpNoAecWarning: Bool = false @Published var hasBluetoothDevice: Bool = false + @Published var hasWiredHeadset: Bool = false + /// Audio presets — sensible combinations of settings for common scenarios. + /// The app is about choice: users can pick a preset for a quick start, then + /// fine-tune individual settings under "Advanced Audio". 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. + /// Standard iOS VoIP experience: AEC/AGC/HPF on, mono, system picks best route + /// (BT HFP if connected, wired if connected, speaker if nothing). Always available. + case voiceChat = "Voice Chat" + /// Stereo built-in mic capture (front+back capsules). A2DP output if BT is + /// connected, else speaker/wired. Standard processing. Always available. + case stereoMic = "Stereo Mic" + /// Maximum fidelity: stereo mic, no AEC/AGC/HPF (raw mode). A2DP output if BT + /// connected, else speaker/wired. Always available. Echo risk on speaker. + case studio = "Studio (No Processing)" + /// Bluetooth HFP: BT mic + BT output, AEC on, mono. Only when BT is connected. 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. + /// A2DP stereo output + built-in mono mic. No hardware AEC (A2DP incompatible). + /// Only when BT is connected. + case btHeadphonesMonoMic = "BT Headphones + Mono Mic" + /// A2DP stereo output + stereo built-in mic (front+back). No hardware AEC. + /// Only when BT is connected. case btHeadphonesStereoMic = "BT Headphones + Stereo Mic" + /// Wired headset/earpods: wired output + wired mic (or built-in), AEC on, mono. + /// Only when a wired audio device is connected. + case wiredHeadset = "Wired Headset" /// Settings don't match any preset — user has tweaked advanced controls. case custom = "Custom" @@ -67,35 +83,42 @@ final class IOSAudioRouter: ObservableObject { var requiresBluetooth: Bool { switch self { - case .default, .custom: return false - default: return true + case .bluetoothHeadset, .btHeadphonesMonoMic, .btHeadphonesStereoMic: return true + default: return false } } + var requiresWired: Bool { + self == .wiredHeadset + } + var bluetoothMode: BluetoothMode { switch self { - case .default: return .builtInMicSpeaker - case .bluetoothHeadset: return .btHfpVoice - case .btHeadphonesMic, .btHeadphonesStereoMic: return .builtInMicBtA2dp + case .voiceChat, .bluetoothHeadset: return .btHfpVoice + case .stereoMic, .studio, .btHeadphonesMonoMic, .btHeadphonesStereoMic: return .builtInMicBtA2dp + case .wiredHeadset: return .builtInMicSpeaker case .custom: return .builtInMicSpeaker // placeholder } } var captureChannels: CaptureChannels { switch self { - case .btHeadphonesStereoMic: return .stereo + case .stereoMic, .studio, .btHeadphonesStereoMic: return .stereo default: return .mono } } var micMode: MicMode { - .standard // all presets use standard processing + switch self { + case .studio: return .raw + default: return .standard + } } - /// Whether this preset selects the built-in mic explicitly (vs. system default). + /// Whether this preset explicitly selects the built-in mic port. var usesBuiltInMic: Bool { switch self { - case .btHeadphonesMic, .btHeadphonesStereoMic: return true + case .stereoMic, .studio, .btHeadphonesMonoMic, .btHeadphonesStereoMic: return true default: return false } } @@ -188,41 +211,79 @@ final class IOSAudioRouter: ObservableObject { } updateWarnings() - detectBluetooth() + detectAudioDevices() } - /// 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() { + /// Detect connected audio devices — Bluetooth (A2DP/HFP) and wired (headphones, + /// headset mic, USB audio). Drives which presets are shown: BT presets only appear + /// when a BT device is connected, wired presets only when a wired device is connected. + /// This avoids confusing users with irrelevant options. + private func detectAudioDevices() { let session = AVAudioSession.sharedInstance() let route = session.currentRoute + let inputs = session.availableInputs ?? [] + + // Bluetooth: check current route + available inputs let hasBTOutput = route.outputs.contains { $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP } let hasBTInput = route.inputs.contains { $0.portType == .bluetoothHFP } - let hasBTAvailable = (session.availableInputs ?? []).contains { + let hasBTAvailable = inputs.contains { $0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP } - let wasConnected = hasBluetoothDevice + let wasBT = hasBluetoothDevice hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable - if hasBluetoothDevice != wasConnected { + if hasBluetoothDevice != wasBT { logger.info("bluetooth device \(self.hasBluetoothDevice ? "connected" : "disconnected")") } + + // Wired: headphones, headset mic, USB audio (earpods, Lightning/USB-C headsets) + let hasWiredOutput = route.outputs.contains { + $0.portType == .headphones || $0.portType == .usbAudio + } + let hasWiredInput = route.inputs.contains { + $0.portType == .headsetMic || $0.portType == .usbAudio + } + let hasWiredAvailable = inputs.contains { + $0.portType == .headphones || $0.portType == .headsetMic || $0.portType == .usbAudio + } + let wasWired = hasWiredHeadset + hasWiredHeadset = hasWiredOutput || hasWiredInput || hasWiredAvailable + if hasWiredHeadset != wasWired { + logger.info("wired headset \(self.hasWiredHeadset ? "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. + /// The presets available given the current device connection state. + /// Always includes Voice Chat, Stereo Mic, Studio, and Custom. BT presets only when + /// a Bluetooth device is connected. Wired preset only when a wired device is connected. var availablePresets: [AudioPreset] { - AudioPreset.allCases.filter { !$0.requiresBluetooth || hasBluetoothDevice } + AudioPreset.allCases.filter { preset in + if preset == .custom { return true } + if preset.requiresBluetooth && !hasBluetoothDevice { return false } + if preset.requiresWired && !hasWiredHeadset { return false } + return true + } } /// Which preset matches the current settings, or .custom if nothing matches. + /// Checks device-specific presets first (BT, wired) so that e.g. when BT is connected + /// and settings match "Bluetooth Headset", it returns that instead of the equivalent + /// "Voice Chat" (which has the same bluetoothMode/micMode/channels but is more general). var activePreset: AudioPreset { - for preset in AudioPreset.allCases where preset != .custom { + // Check device-specific presets first (most specific → least specific) + let order: [AudioPreset] = [ + .bluetoothHeadset, .btHeadphonesMonoMic, .btHeadphonesStereoMic, + .wiredHeadset, + .voiceChat, .stereoMic, .studio, + ] + for preset in order { if bluetoothMode == preset.bluetoothMode && captureChannels == preset.captureChannels && micMode == preset.micMode { + // Don't match a BT preset if no BT is connected — fall through to Voice Chat + if preset.requiresBluetooth && !hasBluetoothDevice { continue } + if preset.requiresWired && !hasWiredHeadset { continue } return preset } } diff --git a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift index 29942d6..85ea919 100644 --- a/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift +++ b/clients/apple/iOS/VoiceCatiOS/Views/SettingsView.swift @@ -23,11 +23,11 @@ struct SettingsView: View { } .accessibilityLabel("Audio preset") - if !router.hasBluetoothDevice { - Text("Connect Bluetooth headphones to see Bluetooth presets.") + if !router.hasBluetoothDevice && !router.hasWiredHeadset { + Text("Connect Bluetooth headphones or a wired headset for more presets.") .font(.caption) .foregroundStyle(.secondary) - .accessibilityLabel("No Bluetooth device connected") + .accessibilityLabel("No external audio device connected") } }