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 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
}
}

View File

@@ -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")
}
}