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