feat(ios): audio presets + advanced settings disclosure

Replaces the flat list of audio settings with a preset picker that shows
context-appropriate options based on whether a Bluetooth device is connected.

Presets:
- Default (Phone Speaker): built-in mic + speaker, standard, mono
- Bluetooth Headset (HFP): BT mic + BT output, standard, mono — only shown
  when a BT device is connected
- BT Headphones + Phone Mic: A2DP stereo output + built-in mic, standard,
  mono — only shown when BT connected
- BT Headphones + Stereo Mic: A2DP stereo output + built-in mic stereo
  (front+back capsules), standard, stereo — only shown when BT connected
- Custom: shown when advanced settings don't match any preset

When no Bluetooth device is connected, only 'Default' and 'Custom' appear,
with a hint to connect Bluetooth headphones for more options.

All granular controls (input port, orientation, polar pattern, mic mode,
channels, bluetooth mode, output route, AirPlay) are now under an
'Advanced Audio' disclosure group, collapsed by default.

IOSAudioRouter gains:
- AudioPreset enum with bluetoothMode/captureChannels/micMode/usesBuiltInMic
- hasBluetoothDevice detection (checks currentRoute + availableInputs for
  bluetoothA2DP/bluetoothHFP port types)
- availablePresets (filtered by BT connection state)
- activePreset (computed from current settings)
- applyPreset() (sets all individual settings + finds built-in mic port UID)
This commit is contained in:
2026-06-19 14:05:19 +02:00
parent 3e80af2f3f
commit d6352627e9
2 changed files with 260 additions and 112 deletions

View File

@@ -49,6 +49,57 @@ final class IOSAudioRouter: ObservableObject {
@Published var selectedPolarPattern: String? @Published var selectedPolarPattern: String?
@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
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.
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.
case btHeadphonesStereoMic = "BT Headphones + Stereo Mic"
/// Settings don't match any preset user has tweaked advanced controls.
case custom = "Custom"
var id: String { rawValue }
var requiresBluetooth: Bool {
switch self {
case .default, .custom: return false
default: return true
}
}
var bluetoothMode: BluetoothMode {
switch self {
case .default: return .builtInMicSpeaker
case .bluetoothHeadset: return .btHfpVoice
case .btHeadphonesMic, .btHeadphonesStereoMic: return .builtInMicBtA2dp
case .custom: return .builtInMicSpeaker // placeholder
}
}
var captureChannels: CaptureChannels {
switch self {
case .btHeadphonesStereoMic: return .stereo
default: return .mono
}
}
var micMode: MicMode {
.standard // all presets use standard processing
}
/// Whether this preset selects the built-in mic explicitly (vs. system default).
var usesBuiltInMic: Bool {
switch self {
case .btHeadphonesMic, .btHeadphonesStereoMic: return true
default: return false
}
}
}
enum BluetoothMode: String, CaseIterable, Identifiable { enum BluetoothMode: String, CaseIterable, Identifiable {
case btHfpVoice = "BT HFP Voice" case btHfpVoice = "BT HFP Voice"
@@ -78,6 +129,7 @@ final class IOSAudioRouter: ObservableObject {
private let kInputPortId = "cat.voice.audio.inputPortId" private let kInputPortId = "cat.voice.audio.inputPortId"
private let kDataSourceId = "cat.voice.audio.dataSourceId" private let kDataSourceId = "cat.voice.audio.dataSourceId"
private let kPolarPattern = "cat.voice.audio.polarPattern" private let kPolarPattern = "cat.voice.audio.polarPattern"
private let kPreset = "cat.voice.audio.preset"
/// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change /// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change
/// notifications synchronously on the same thread. Without this guard, /// notifications synchronously on the same thread. Without this guard,
@@ -136,6 +188,45 @@ final class IOSAudioRouter: ObservableObject {
} }
updateWarnings() updateWarnings()
detectBluetooth()
}
/// 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() {
let session = AVAudioSession.sharedInstance()
let route = session.currentRoute
let hasBTOutput = route.outputs.contains {
$0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP
}
let hasBTInput = route.inputs.contains { $0.portType == .bluetoothHFP }
let hasBTAvailable = (session.availableInputs ?? []).contains {
$0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP
}
let wasConnected = hasBluetoothDevice
hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable
if hasBluetoothDevice != wasConnected {
logger.info("bluetooth device \(self.hasBluetoothDevice ? "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.
var availablePresets: [AudioPreset] {
AudioPreset.allCases.filter { !$0.requiresBluetooth || hasBluetoothDevice }
}
/// Which preset matches the current settings, or .custom if nothing matches.
var activePreset: AudioPreset {
for preset in AudioPreset.allCases where preset != .custom {
if bluetoothMode == preset.bluetoothMode
&& captureChannels == preset.captureChannels
&& micMode == preset.micMode {
return preset
}
}
return .custom
} }
// MARK: - Apply configuration // MARK: - Apply configuration
@@ -278,7 +369,6 @@ final class IOSAudioRouter: ObservableObject {
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId) selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern) selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
} }
/// Persist current selections to UserDefaults. /// Persist current selections to UserDefaults.
func savePreferences() { func savePreferences() {
UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode) UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode)
@@ -335,6 +425,44 @@ final class IOSAudioRouter: ObservableObject {
applyConfiguration() applyConfiguration()
} }
// MARK: - Presets
/// Apply a preset sets all individual audio settings to the preset's values, then
/// applies the configuration. For presets that use the built-in mic (A2DP presets),
/// finds the built-in mic port UID from availableInputs.
func applyPreset(_ preset: AudioPreset) {
guard preset != .custom else { return } // can't "apply" custom it's a display state
bluetoothMode = preset.bluetoothMode
micMode = preset.micMode
captureChannels = preset.captureChannels
if preset.usesBuiltInMic {
// Find the built-in mic port from available inputs and select it.
let session = AVAudioSession.sharedInstance()
if let builtInMic = (session.availableInputs ?? []).first(where: {
$0.portType == .builtInMic
}) {
selectedInputPortId = builtInMic.uid
}
// Don't set a specific data source in stereo mode, iOS uses multiple mic
// capsules automatically. In mono, the default orientation is fine.
selectedDataSourceId = nil
selectedPolarPattern = nil
} else {
// For Default and Bluetooth Headset presets, let the system pick the input.
selectedInputPortId = nil
selectedDataSourceId = nil
selectedPolarPattern = nil
}
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
savePreferences()
applyConfiguration()
refreshRoutes()
logger.info("applyPreset — \(preset.rawValue)")
}
// MARK: - Helpers // MARK: - Helpers
/// Update warning indicators for the Settings UI. /// Update warning indicators for the Settings UI.

View File

@@ -6,142 +6,162 @@ struct SettingsView: View {
@Environment(AppState.self) private var appState @Environment(AppState.self) private var appState
@Bindable var session: SessionState @Bindable var session: SessionState
@StateObject private var router = IOSAudioRouter.shared @StateObject private var router = IOSAudioRouter.shared
@State private var showAdvanced = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { Form {
// MARK: - Audio Input // MARK: - Audio Preset
Section("Audio Input") { Section("Audio") {
// Input port picker (AVAudioSession.availableInputs) Picker("Preset", selection: Binding(
Picker("Input Port", selection: Binding( get: { router.activePreset },
get: { router.selectedInputPortId ?? "" }, set: { preset in router.applyPreset(preset) }
set: { id in
if !id.isEmpty { router.selectInputPort(id) }
}
)) { )) {
Text("Default").tag("") ForEach(router.availablePresets) { preset in
ForEach(router.inputPorts) { port in Text(preset.rawValue).tag(preset)
Text(port.name).tag(port.id)
} }
} }
.accessibilityLabel("Audio input port selection") .accessibilityLabel("Audio preset")
// Built-in mic sub-options: orientation (data source) + polar pattern if !router.hasBluetoothDevice {
if router.selectedPortIsBuiltInMic, Text("Connect Bluetooth headphones to see Bluetooth presets.")
let dataSources = router.selectedPortDataSources, .font(.caption)
!dataSources.isEmpty { .foregroundStyle(.secondary)
Picker("Mic Orientation", selection: Binding( .accessibilityLabel("No Bluetooth device connected")
get: { router.selectedDataSourceId ?? "" }, }
}
// MARK: - Advanced Audio
Section {
DisclosureGroup("Advanced Audio", isExpanded: $showAdvanced) {
// Input port picker (AVAudioSession.availableInputs)
Picker("Input Port", selection: Binding(
get: { router.selectedInputPortId ?? "" },
set: { id in set: { id in
if !id.isEmpty { router.selectDataSource(id) } if !id.isEmpty { router.selectInputPort(id) }
} }
)) { )) {
Text("Default").tag("") Text("Default").tag("")
ForEach(dataSources) { ds in ForEach(router.inputPorts) { port in
Text(ds.name).tag(ds.id) Text(port.name).tag(port.id)
} }
} }
.accessibilityLabel("Microphone orientation") .accessibilityLabel("Audio input port selection")
// Polar pattern sub-picker (only if the data source supports patterns) // Built-in mic sub-options: orientation (data source) + polar pattern
if let selectedDs = dataSources.first(where: { $0.id == router.selectedDataSourceId }), if router.selectedPortIsBuiltInMic,
let patterns = selectedDs.polarPatterns, let dataSources = router.selectedPortDataSources,
!patterns.isEmpty { !dataSources.isEmpty {
Picker("Polar Pattern", selection: Binding( Picker("Mic Orientation", selection: Binding(
get: { router.selectedPolarPattern ?? "" }, get: { router.selectedDataSourceId ?? "" },
set: { pattern in set: { id in
if !pattern.isEmpty { router.selectPolarPattern(pattern) } if !id.isEmpty { router.selectDataSource(id) }
} }
)) { )) {
Text("Default").tag("") Text("Default").tag("")
ForEach(patterns, id: \.self) { pattern in ForEach(dataSources) { ds in
Text(polarPatternLabel(pattern)).tag(pattern) Text(ds.name).tag(ds.id)
} }
} }
.accessibilityLabel("Microphone polar pattern") .accessibilityLabel("Microphone orientation")
}
}
// Mic processing mode: Standard vs Raw/Studio // Polar pattern sub-picker
Picker("Mic Mode", selection: Binding( if let selectedDs = dataSources.first(where: { $0.id == router.selectedDataSourceId }),
get: { router.micMode }, let patterns = selectedDs.polarPatterns,
set: { router.selectMicMode($0) } !patterns.isEmpty {
)) { Picker("Polar Pattern", selection: Binding(
ForEach(IOSAudioRouter.MicMode.allCases) { mode in get: { router.selectedPolarPattern ?? "" },
Text(mode.rawValue).tag(mode) set: { pattern in
} if !pattern.isEmpty { router.selectPolarPattern(pattern) }
} }
.accessibilityLabel("Microphone processing mode") )) {
Text("Default").tag("")
if router.showsRawModeSpeakerWarning { ForEach(patterns, id: \.self) { pattern in
Label( Text(polarPatternLabel(pattern)).tag(pattern)
"Raw mode on speaker — echo risk (no AEC)", }
systemImage: "exclamationmark.triangle.fill" }
) .accessibilityLabel("Microphone polar pattern")
.foregroundStyle(.orange)
.font(.caption)
.accessibilityLabel("Warning: Raw mode with speaker output may cause echo")
}
if router.showsA2dpNoAecWarning {
Label(
"A2DP mode — no echo cancellation (hardware AEC unavailable)",
systemImage: "info.circle.fill"
)
.foregroundStyle(.blue)
.font(.caption)
.accessibilityLabel("Info: A2DP output mode does not support hardware echo cancellation")
}
// Capture channels: Mono vs Stereo
Picker("Channels", selection: Binding(
get: { router.captureChannels },
set: { router.selectCaptureChannels($0) }
)) {
ForEach(IOSAudioRouter.CaptureChannels.allCases) { ch in
Text(ch.rawValue).tag(ch)
}
}
.accessibilityLabel("Capture channel count")
}
// MARK: - Audio Output
Section("Audio Output") {
// Bluetooth mode
Picker("Bluetooth Mode", selection: Binding(
get: { router.bluetoothMode },
set: { router.selectBluetoothMode($0) }
)) {
ForEach(IOSAudioRouter.BluetoothMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.accessibilityLabel("Bluetooth audio mode")
// Current output route (read-only)
if !router.outputRoutes.isEmpty {
ForEach(router.outputRoutes) { route in
HStack {
Text(route.name)
Spacer()
Text(route.portType)
.foregroundStyle(.secondary)
.font(.caption)
} }
.accessibilityLabel("Current output: \(route.name)")
} }
} else {
Text("No output route")
.foregroundStyle(.secondary)
}
// AirPlay button // Mic processing mode: Standard vs Raw/Studio
HStack { Picker("Mic Mode", selection: Binding(
Text("AirPlay") get: { router.micMode },
Spacer() set: { router.selectMicMode($0) }
RoutePickerButton() )) {
ForEach(IOSAudioRouter.MicMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.accessibilityLabel("Microphone processing mode")
if router.showsRawModeSpeakerWarning {
Label(
"Raw mode on speaker — echo risk (no AEC)",
systemImage: "exclamationmark.triangle.fill"
)
.foregroundStyle(.orange)
.font(.caption)
.accessibilityLabel("Warning: Raw mode with speaker output may cause echo")
}
if router.showsA2dpNoAecWarning {
Label(
"A2DP mode — no echo cancellation (hardware AEC unavailable)",
systemImage: "info.circle.fill"
)
.foregroundStyle(.blue)
.font(.caption)
.accessibilityLabel("Info: A2DP output mode does not support hardware echo cancellation")
}
// Capture channels: Mono vs Stereo
Picker("Channels", selection: Binding(
get: { router.captureChannels },
set: { router.selectCaptureChannels($0) }
)) {
ForEach(IOSAudioRouter.CaptureChannels.allCases) { ch in
Text(ch.rawValue).tag(ch)
}
}
.accessibilityLabel("Capture channel count")
// Bluetooth mode
Picker("Bluetooth Mode", selection: Binding(
get: { router.bluetoothMode },
set: { router.selectBluetoothMode($0) }
)) {
ForEach(IOSAudioRouter.BluetoothMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.accessibilityLabel("Bluetooth audio mode")
// Current output route (read-only)
if !router.outputRoutes.isEmpty {
ForEach(router.outputRoutes) { route in
HStack {
Text(route.name)
Spacer()
Text(route.portType)
.foregroundStyle(.secondary)
.font(.caption)
}
.accessibilityLabel("Current output: \(route.name)")
}
} else {
Text("No output route")
.foregroundStyle(.secondary)
}
// AirPlay button
HStack {
Text("AirPlay")
Spacer()
RoutePickerButton()
}
.accessibilityLabel("AirPlay output selector")
} }
.accessibilityLabel("AirPlay output selector")
} }
// MARK: - Voice // MARK: - Voice