feat(ios): rework audio presets — always-available + device-specific

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.
This commit is contained in:
2026-06-19 14:09:08 +02:00
parent d6352627e9
commit 1a1c8a1dfe
2 changed files with 91 additions and 30 deletions

View File

@@ -50,16 +50,32 @@ final class IOSAudioRouter: ObservableObject {
@Published var showsRawModeSpeakerWarning: Bool = false @Published var showsRawModeSpeakerWarning: Bool = false
@Published var showsA2dpNoAecWarning: Bool = false @Published var showsA2dpNoAecWarning: Bool = false
@Published var hasBluetoothDevice: 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 { enum AudioPreset: String, CaseIterable, Identifiable {
/// Built-in mic + phone speaker. No Bluetooth. Standard processing, mono. /// Standard iOS VoIP experience: AEC/AGC/HPF on, mono, system picks best route
case `default` = "Default (Phone Speaker)" /// (BT HFP if connected, wired if connected, speaker if nothing). Always available.
/// Bluetooth HFP: BT mic + BT output. Standard processing, mono. Voice-quality. 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)" case bluetoothHeadset = "Bluetooth Headset (HFP)"
/// A2DP stereo output + built-in mic. Standard processing, mono. /// A2DP stereo output + built-in mono mic. No hardware AEC (A2DP incompatible).
case btHeadphonesMic = "BT Headphones + Phone Mic" /// Only when BT is connected.
/// A2DP stereo output + built-in mic stereo (front+back capsules). Standard, stereo. 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" 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. /// Settings don't match any preset user has tweaked advanced controls.
case custom = "Custom" case custom = "Custom"
@@ -67,35 +83,42 @@ final class IOSAudioRouter: ObservableObject {
var requiresBluetooth: Bool { var requiresBluetooth: Bool {
switch self { switch self {
case .default, .custom: return false case .bluetoothHeadset, .btHeadphonesMonoMic, .btHeadphonesStereoMic: return true
default: return true default: return false
} }
} }
var requiresWired: Bool {
self == .wiredHeadset
}
var bluetoothMode: BluetoothMode { var bluetoothMode: BluetoothMode {
switch self { switch self {
case .default: return .builtInMicSpeaker case .voiceChat, .bluetoothHeadset: return .btHfpVoice
case .bluetoothHeadset: return .btHfpVoice case .stereoMic, .studio, .btHeadphonesMonoMic, .btHeadphonesStereoMic: return .builtInMicBtA2dp
case .btHeadphonesMic, .btHeadphonesStereoMic: return .builtInMicBtA2dp case .wiredHeadset: return .builtInMicSpeaker
case .custom: return .builtInMicSpeaker // placeholder case .custom: return .builtInMicSpeaker // placeholder
} }
} }
var captureChannels: CaptureChannels { var captureChannels: CaptureChannels {
switch self { switch self {
case .btHeadphonesStereoMic: return .stereo case .stereoMic, .studio, .btHeadphonesStereoMic: return .stereo
default: return .mono default: return .mono
} }
} }
var micMode: MicMode { 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 { var usesBuiltInMic: Bool {
switch self { switch self {
case .btHeadphonesMic, .btHeadphonesStereoMic: return true case .stereoMic, .studio, .btHeadphonesMonoMic, .btHeadphonesStereoMic: return true
default: return false default: return false
} }
} }
@@ -188,41 +211,79 @@ final class IOSAudioRouter: ObservableObject {
} }
updateWarnings() updateWarnings()
detectBluetooth() detectAudioDevices()
} }
/// Detect whether a Bluetooth audio device is currently connected (A2DP or HFP). /// Detect connected audio devices Bluetooth (A2DP/HFP) and wired (headphones,
/// Drives which presets are shown BT presets are hidden when no BT device is /// headset mic, USB audio). Drives which presets are shown: BT presets only appear
/// connected to avoid confusing the user with irrelevant options. /// when a BT device is connected, wired presets only when a wired device is connected.
private func detectBluetooth() { /// This avoids confusing users with irrelevant options.
private func detectAudioDevices() {
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
let route = session.currentRoute let route = session.currentRoute
let inputs = session.availableInputs ?? []
// Bluetooth: check current route + available inputs
let hasBTOutput = route.outputs.contains { let hasBTOutput = route.outputs.contains {
$0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP
} }
let hasBTInput = route.inputs.contains { $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 $0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP
} }
let wasConnected = hasBluetoothDevice let wasBT = hasBluetoothDevice
hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable
if hasBluetoothDevice != wasConnected { if hasBluetoothDevice != wasBT {
logger.info("bluetooth device \(self.hasBluetoothDevice ? "connected" : "disconnected")") 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. /// The presets available given the current device connection state.
/// Always includes .default and .custom; BT presets only when a BT device is connected. /// 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] { 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. /// 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 { 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 if bluetoothMode == preset.bluetoothMode
&& captureChannels == preset.captureChannels && captureChannels == preset.captureChannels
&& micMode == preset.micMode { && 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 return preset
} }
} }

View File

@@ -23,11 +23,11 @@ struct SettingsView: View {
} }
.accessibilityLabel("Audio preset") .accessibilityLabel("Audio preset")
if !router.hasBluetoothDevice { if !router.hasBluetoothDevice && !router.hasWiredHeadset {
Text("Connect Bluetooth headphones to see Bluetooth presets.") Text("Connect Bluetooth headphones or a wired headset for more presets.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.accessibilityLabel("No Bluetooth device connected") .accessibilityLabel("No external audio device connected")
} }
} }