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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user