Files
voice-cat/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Talon 5be6d8430d feat(ios): user-toggleable speakerphone output
Add a "speaker output" override so users can route audio to the built-in
speaker instead of the earpiece when no headphones/Bluetooth are connected.
Previously the receiver was the only fallback on the default Voice Chat preset.

The toggle inserts .defaultToSpeaker into the AVAudioSession category options
(skipped for the A2DP mode, where it would break Bluetooth routing). It yields
to connected BT/wired output and is orthogonal to preset matching. Exposed both
as a call-bar button in VoiceControlsView and a persisted Settings toggle.
2026-06-21 15:47:20 +02:00

688 lines
33 KiB
Swift

import AVFoundation
import os
import VoiceCatCore
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
/// iOS audio routing layer drives all iOS audio route selection via `AVAudioSession`
/// *before* the core (miniaudio) opens its device. This class is the sole owner of the
/// session: miniaudio does NOT touch `AVAudioSession` on iOS, because the core opens its
/// devices through a `ma_context` configured with `sessionCategory = none` +
/// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in
/// `core/src/audio/audio_engine.cpp`). Without that, miniaudio's default path resets the
/// category to `Record`/`Playback` with no options on every device open, wiping
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output so that
/// config must stay in place. All iOS audio routing (input port selection, mic
/// orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture) must be
/// driven from here.
///
/// The three user-facing choices:
/// 1. **Input port** which physical input (built-in mic, Bluetooth HFP, headset,
/// USB, AirPlay). For the built-in mic, a sub-selection of **data source**
/// (orientation: front/back/top/bottom) and **polar pattern**
/// (omni/cardioid/subcardioid/bidirectional).
/// 2. **Bluetooth mode** how Bluetooth headsets are handled:
/// - "BT HFP voice" (`.allowBluetoothHFP` + `.allowBluetoothA2DP`): both profiles
/// allowed, iOS picks HFP for two-way mic or A2DP for output-only. Mono, AEC on.
/// - "Built-in Mic + BT A2DP stereo" (`.allowBluetoothA2DP` only): stereo output,
/// built-in mic, no HFP processing.
/// - "Built-in Mic + Speaker" (neither): no Bluetooth at all.
/// 3. **Mic processing mode** Standard (`.voiceChat`: AEC/AGC/HPF on) or
/// Raw/Studio (`.measurement`: all processing off). Raw mode is allowed always
/// but shows a warning when the output route is the speaker (echo risk, no AEC).
///
/// Additionally, **stereo capture** (2-channel built-in mic) is enabled by switching the
/// built-in mic's data source to the `.stereo` polar pattern. The recipe is:
/// `setPreferredDataSource(.stereo source)` + `setPreferredPolarPattern(.stereo)` +
/// `setPreferredInput(built-in mic)` + `setInputDataSource(stereo source)`. The channel
/// count itself must NOT be requested via `setPreferredInputNumberOfChannels(2)` that
/// session-level call collapses the A2DP output route. Instead the core is told to open the
/// device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the AVAudioSession
/// input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route stable during
/// the HFPA2DP and monostereo reconfigurations.
///
/// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center
/// for `.voiceChat` apps surfaced as a hint, not a programmatic toggle.
///
/// All choices are persisted in `UserDefaults` and re-applied on route changes.
@MainActor
final class IOSAudioRouter: ObservableObject {
static let shared = IOSAudioRouter()
// MARK: - Published state (drives SettingsView)
@Published var inputPorts: [IOSAudioInputPort] = []
@Published var outputRoutes: [IOSAudioOutputRoute] = []
@Published var bluetoothMode: BluetoothMode = .btHfpVoice
/// User-requested speaker fallback: when on, route to the built-in speaker instead of the
/// earpiece (receiver) when no headphones/Bluetooth are connected. Orthogonal to the
/// bluetooth mode and presets. Default off current behavior is unchanged for existing users.
@Published var forceSpeaker: Bool = false
@Published var micMode: MicMode = .standard
@Published var captureChannels: CaptureChannels = .mono
@Published var selectedInputPortId: String?
@Published var selectedDataSourceId: String?
@Published var selectedPolarPattern: String?
@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 {
/// 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 built-in speaker / wired. Standard processing (no AEC stereo needs a non-VPIO
/// mode). 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 mono mic, AEC off. Only when BT is connected. (For
/// A2DP output + stereo mic, use the Stereo Mic preset while BT is connected.)
case btHeadphonesMonoMic = "BT Headphones + Mono 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"
var id: String { rawValue }
var requiresBluetooth: Bool {
switch self {
case .bluetoothHeadset, .btHeadphonesMonoMic: return true
default: return false
}
}
var requiresWired: Bool {
self == .wiredHeadset
}
var bluetoothMode: BluetoothMode {
switch self {
case .voiceChat, .bluetoothHeadset: return .btHfpVoice
// A2DP output when BT is connected; falls back to speaker/wired when it isn't.
case .stereoMic, .studio, .btHeadphonesMonoMic: return .builtInMicBtA2dp
case .wiredHeadset: return .builtInMicSpeaker
case .custom: return .builtInMicSpeaker // placeholder
}
}
var captureChannels: CaptureChannels {
switch self {
case .stereoMic, .studio: return .stereo
default: return .mono
}
}
var micMode: MicMode {
switch self {
case .studio: return .raw
default: return .standard
}
}
/// Whether this preset explicitly selects the built-in mic port.
var usesBuiltInMic: Bool {
switch self {
case .stereoMic, .studio, .btHeadphonesMonoMic: return true
default: return false
}
}
}
enum BluetoothMode: String, CaseIterable, Identifiable {
case btHfpVoice = "BT HFP Voice"
case builtInMicBtA2dp = "Built-in Mic + BT A2DP"
case builtInMicSpeaker = "Built-in Mic + Speaker"
var id: String { rawValue }
}
enum MicMode: String, CaseIterable, Identifiable {
case standard = "Standard"
case raw = "Raw / Studio"
var id: String { rawValue }
}
enum CaptureChannels: String, CaseIterable, Identifiable {
case mono = "Mono"
case stereo = "Stereo"
var id: String { rawValue }
var channelCount: UInt32 { self == .stereo ? 2 : 1 }
}
// MARK: - UserDefaults keys
private let kBluetoothMode = "cat.voice.audio.bluetoothMode"
private let kMicMode = "cat.voice.audio.micMode"
private let kCaptureChannels = "cat.voice.audio.captureChannels"
private let kInputPortId = "cat.voice.audio.inputPortId"
private let kDataSourceId = "cat.voice.audio.dataSourceId"
private let kPolarPattern = "cat.voice.audio.polarPattern"
private let kPreset = "cat.voice.audio.preset"
private let kForceSpeaker = "cat.voice.audio.forceSpeaker"
/// Re-entrancy guard: setCategory/setPreferredInput/etc. trigger route-change
/// notifications synchronously on the same thread. Without this guard,
/// handleRouteChange applyConfiguration setCategory route-change notification
/// handleRouteChange applyConfiguration ... creates an infinite loop that
/// burns CPU and cycles the audio session on/off (the "glitching" bug).
private var isApplyingConfiguration = false
private init() {}
// MARK: - Load / refresh from AVAudioSession
/// Refresh the published input port list and output route list from the current
/// AVAudioSession state. Call after any route change or when the settings view appears.
func refreshRoutes() {
let session = AVAudioSession.sharedInstance()
let currentInput = session.preferredInput
let currentDataSource = currentInput?.preferredDataSource?.dataSourceID ?? nil
let currentPolarPattern = currentInput?.preferredDataSource?.preferredPolarPattern?.rawValue
inputPorts = (session.availableInputs ?? []).map { port in
let dataSources = port.dataSources?.map { ds in
IOSAudioDataSource(
id: String(describing: ds.dataSourceID),
name: ds.dataSourceName,
polarPatterns: ds.supportedPolarPatterns?.map { $0.rawValue },
isSelected: currentDataSource == ds.dataSourceID,
selectedPolarPattern: currentPolarPattern
)
}
return IOSAudioInputPort(
id: port.uid,
name: port.portName,
portType: port.portType.rawValue,
dataSources: dataSources,
isSelected: currentInput?.uid == port.uid
)
}
outputRoutes = session.currentRoute.outputs.map { port in
IOSAudioOutputRoute(
id: port.uid,
name: port.portName,
portType: port.portType.rawValue
)
}
if selectedInputPortId == nil {
selectedInputPortId = currentInput?.uid ?? inputPorts.first?.id
}
if selectedDataSourceId == nil {
selectedDataSourceId = currentDataSource.map { String(describing: $0) }
}
if selectedPolarPattern == nil {
selectedPolarPattern = currentPolarPattern
}
updateWarnings()
detectAudioDevices()
}
/// 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 = inputs.contains {
$0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP
}
let wasBT = hasBluetoothDevice
hasBluetoothDevice = hasBTOutput || hasBTInput || hasBTAvailable
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 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 { 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 {
// Check device-specific presets first (most specific least specific)
let order: [AudioPreset] = [
.bluetoothHeadset, .btHeadphonesMonoMic,
.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
}
}
return .custom
}
// MARK: - Apply configuration
/// Apply the full audio configuration to AVAudioSession. Call this before the core
/// opens its capture device (i.e. before `startMicStream` `activateForStreaming`).
/// Re-entrant-safe: if a route-change notification fires synchronously during a
/// `setCategory`/`setPreferredInput` call, the guard prevents re-entry.
func applyConfiguration() {
guard !isApplyingConfiguration else {
logger.debug("applyConfiguration skipped — already applying (re-entrancy guard)")
return
}
isApplyingConfiguration = true
defer { isApplyingConfiguration = false }
let session = AVAudioSession.sharedInstance()
// 1. Build category options from bluetooth mode.
// .mixWithOthers is ALWAYS set it keeps other audio (notably VoiceOver, which a
// blind user needs to operate the phone) audible while our session is active. Never
// drop it.
// .defaultToSpeaker is set for the speaker preset and, when the user enables the
// `forceSpeaker` toggle, for the HFP preset too it forces output to the built-in
// speaker instead of the receiver while still yielding to connected BT/wired output.
// It also actively breaks A2DP routing in .playAndRecord, so it must NEVER be set for
// the A2DP preset (forceSpeaker is intentionally ignored there).
// .allowAirPlay is added to the Bluetooth presets so AirPlay output also works.
var options: AVAudioSession.CategoryOptions = [.mixWithOthers]
switch bluetoothMode {
case .btHfpVoice:
// Voice Chat: allow BOTH HFP and A2DP, let iOS pick the right profile for the
// connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
// that blocks A2DP headphones from receiving audio. HFP is preferred (the system
// uses it when a two-way mic path is needed); A2DP stays available for output-only.
options.insert(.allowBluetoothHFP)
options.insert(.allowBluetoothA2DP)
options.insert(.allowAirPlay)
case .builtInMicBtA2dp:
// A2DP output only (no HFP). With HFP disabled the Bluetooth device can only be
// an OUTPUT (A2DP), so the system routes the mic to the built-in mic exactly
// what we want for "built-in mic + A2DP output", in either mono OR stereo.
options.insert(.allowBluetoothA2DP)
options.insert(.allowAirPlay)
case .builtInMicSpeaker:
// Built-in mic + speaker/wired output only. Prefer speaker over the receiver.
options.insert(.defaultToSpeaker)
}
// User-requested speaker fallback: route to the built-in speaker instead of the
// receiver when no headphones/BT are connected. Skipped for the A2DP mode because
// .defaultToSpeaker breaks A2DP routing (see note above). Redundant for
// builtInMicSpeaker, which already sets it.
if forceSpeaker && bluetoothMode != .builtInMicBtA2dp {
options.insert(.defaultToSpeaker)
}
// 2. Set category + mode, chosen per scenario:
// - Stereo capture: .default .voiceChat (the AEC/VPIO path) forces MONO, so stereo
// is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND
// keeps the A2DP output route alive.
// - Mono raw/studio: .measurement all system processing off.
// - Mono + A2DP output: .videoRecording keeps A2DP output without VPIO (no AEC).
// - Mono standard (HFP or speaker): .voiceChat hardware AEC/AGC/HPF.
let mode: AVAudioSession.Mode
if captureChannels == .stereo {
mode = .default
} else if micMode == .raw {
mode = .measurement
} else if bluetoothMode == .builtInMicBtA2dp {
mode = .videoRecording
} else {
mode = .voiceChat
}
do {
try session.setCategory(.playAndRecord, mode: mode, options: options)
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue), ch=\(self.captureChannels.rawValue), options=\(self.optionsLabel(options))")
} catch {
logger.error("setCategory failed: \(error.localizedDescription)")
}
// 3. Input & mic-capsule configuration.
if captureChannels == .stereo {
// Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input
// route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
// the system routes input to the built-in mic, but without the explicit
// preferred-input anchor the route can collapse during the mode switch
// (.voiceChat .default) and the output dies. The channel count is requested by
// miniaudio at the audio-unit level (vc_set_capture_channels), NOT via
// setPreferredInputNumberOfChannels(2) that call collapses the A2DP output route.
configureStereoCapture(session: session)
} else if let portId = selectedInputPortId, !portId.isEmpty,
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
// Mono with an explicit input-port selection (advanced settings).
do {
try session.setPreferredInput(port)
logger.info("setPreferredInput ok — \(port.portName)")
} catch {
logger.error("setPreferredInput failed: \(error.localizedDescription)")
}
configureMonoCapture(session: session, port: port)
} else {
// Mono, system-default input. Still clear any leftover .stereo capsule from a
// prior stereo session so we actually return to mono.
clearStereoPolarPattern(session: session)
}
updateWarnings()
}
/// Enable 2-channel capture on the built-in mic. The recipe that achieves stereo mic +
/// A2DP Bluetooth output simultaneously:
/// 1. `setPreferredDataSource(stereoSource)` on the built-in mic port
/// 2. `setPreferredPolarPattern(.stereo)` on that data source
/// 3. `setPreferredInput(builtIn)` anchor the input route explicitly. Without this
/// anchor the route can collapse during the mode switch (.voiceChat .default).
/// 4. `setInputDataSource(stereoSource)` commit the data source at the session level
/// The channel count itself is requested by miniaudio at the audio-unit level via
/// `vc_set_capture_channels(2)`. We must NOT call `setPreferredInputNumberOfChannels(2)`
/// that session-level call collapses the A2DP output route.
private func configureStereoCapture(session: AVAudioSession) {
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
else {
logger.warning("stereo requested but no built-in mic available — staying mono")
return
}
guard let stereoSource = builtIn.dataSources?.first(where: {
$0.supportedPolarPatterns?.contains(.stereo) == true
}) else {
logger.warning("stereo requested but built-in mic has no .stereo data source — staying mono")
return
}
do {
try builtIn.setPreferredDataSource(stereoSource)
try stereoSource.setPreferredPolarPattern(.stereo)
// Anchor the input route explicitly; without it the route can collapse during
// the mode switch (.voiceChat .default) and the A2DP output dies.
try session.setPreferredInput(builtIn)
// Commit the data source at the session level. setPreferredDataSource alone only
// sets the port-level preference; setInputDataSource makes it the active source.
try session.setInputDataSource(stereoSource)
logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored")
} catch {
logger.error("stereo capsule setup failed: \(error.localizedDescription)")
}
}
/// Configure mono capture on an explicitly selected port: apply the user's chosen data source
/// (orientation) and polar pattern, resetting any prior `.stereo` pattern back to default.
private func configureMonoCapture(session: AVAudioSession, port: AVAudioSessionPortDescription) {
guard let dataSourceId = selectedDataSourceId, !dataSourceId.isEmpty,
let dataSource = port.dataSources?.first(where: {
String(describing: $0.dataSourceID) == dataSourceId
}) else {
// No explicit capsule choice make sure we're not stuck on a prior .stereo pattern.
clearStereoPolarPattern(session: session)
return
}
do {
try port.setPreferredDataSource(dataSource)
logger.info("setPreferredDataSource ok — \(dataSource.dataSourceName)")
} catch {
logger.error("setPreferredDataSource failed: \(error.localizedDescription)")
}
if let polarPattern = selectedPolarPattern, !polarPattern.isEmpty {
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
try? dataSource.setPreferredPolarPattern(pattern)
logger.info("setPreferredPolarPattern ok — \(polarPattern)")
} else {
// Clear any prior .stereo selection so mono capture returns to a mono capsule.
try? dataSource.setPreferredPolarPattern(nil)
}
}
/// Reset any built-in-mic data source that's currently on the `.stereo` polar pattern back to
/// the default (mono) pattern. Used when switching from a stereo session back to mono with no
/// explicit capsule selection, so the prior stereo capsule doesn't linger.
private func clearStereoPolarPattern(session: AVAudioSession) {
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
else { return }
for ds in builtIn.dataSources ?? [] where ds.selectedPolarPattern == .stereo {
try? ds.setPreferredPolarPattern(nil)
}
}
private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
switch mode {
case .voiceChat: return "voiceChat"
case .measurement: return "measurement"
case .videoRecording: return "videoRecording"
case .default: return "default"
default: return "other"
}
}
private func optionsLabel(_ opts: AVAudioSession.CategoryOptions) -> String {
var parts: [String] = []
if opts.contains(.defaultToSpeaker) { parts.append("defaultToSpeaker") }
if opts.contains(.mixWithOthers) { parts.append("mixWithOthers") }
if opts.contains(.allowBluetoothHFP) { parts.append("allowBluetoothHFP") }
if opts.contains(.allowBluetoothA2DP) { parts.append("allowBluetoothA2DP") }
return parts.joined(separator: ",")
}
/// Apply stored preferences from UserDefaults. Called at app launch (before any
/// audio session activation).
func loadStoredPreferences() {
if let raw = UserDefaults.standard.string(forKey: kBluetoothMode),
let mode = BluetoothMode(rawValue: raw) {
bluetoothMode = mode
}
if let raw = UserDefaults.standard.string(forKey: kMicMode),
let mode = MicMode(rawValue: raw) {
micMode = mode
}
if let raw = UserDefaults.standard.string(forKey: kCaptureChannels),
let ch = CaptureChannels(rawValue: raw) {
captureChannels = ch
}
selectedInputPortId = UserDefaults.standard.string(forKey: kInputPortId)
selectedDataSourceId = UserDefaults.standard.string(forKey: kDataSourceId)
selectedPolarPattern = UserDefaults.standard.string(forKey: kPolarPattern)
forceSpeaker = UserDefaults.standard.bool(forKey: kForceSpeaker)
}
/// Persist current selections to UserDefaults.
func savePreferences() {
UserDefaults.standard.set(bluetoothMode.rawValue, forKey: kBluetoothMode)
UserDefaults.standard.set(micMode.rawValue, forKey: kMicMode)
UserDefaults.standard.set(captureChannels.rawValue, forKey: kCaptureChannels)
UserDefaults.standard.set(selectedInputPortId, forKey: kInputPortId)
UserDefaults.standard.set(selectedDataSourceId, forKey: kDataSourceId)
UserDefaults.standard.set(selectedPolarPattern, forKey: kPolarPattern)
UserDefaults.standard.set(forceSpeaker, forKey: kForceSpeaker)
}
// MARK: - Selection setters (called from SettingsView pickers)
func selectInputPort(_ portId: String) {
selectedInputPortId = portId
selectedDataSourceId = nil
selectedPolarPattern = nil
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectDataSource(_ dataSourceId: String) {
selectedDataSourceId = dataSourceId
selectedPolarPattern = nil
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectPolarPattern(_ pattern: String) {
selectedPolarPattern = pattern
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectBluetoothMode(_ mode: BluetoothMode) {
bluetoothMode = mode
savePreferences()
applyConfiguration()
refreshRoutes()
}
func setForceSpeaker(_ on: Bool) {
forceSpeaker = on
savePreferences()
applyConfiguration()
refreshRoutes()
}
func selectMicMode(_ mode: MicMode) {
micMode = mode
savePreferences()
applyConfiguration()
updateWarnings()
}
func selectCaptureChannels(_ channels: CaptureChannels) {
captureChannels = channels
savePreferences()
applyConfiguration()
// Update the core's stored capture channel count (does not restart the engine).
if let streamId = AudioSessionManager.shared.activeMicStreamId {
_ = AudioSessionManager.shared.client?.setCaptureChannels(
streamId: streamId, channels: channels.channelCount)
}
// Restart the engine AFTER AVAudioSession routing has settled and the channel
// count is stored. The engine reopens playback first (committing the A2DP/output
// route), then capture avoiding the race where stereo capture activation drops
// A2DP before the playback device has a chance to claim the route.
_ = AudioSessionManager.shared.client?.audioRestart()
}
// 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()
// Update the core's stored capture channel count (does not restart the engine).
if let streamId = AudioSessionManager.shared.activeMicStreamId {
_ = AudioSessionManager.shared.client?.setCaptureChannels(
streamId: streamId, channels: preset.captureChannels.channelCount)
}
// Restart the engine AFTER AVAudioSession routing has settled and the channel
// count is stored. Playback opens first (commits A2DP route), then capture.
_ = AudioSessionManager.shared.client?.audioRestart()
refreshRoutes()
logger.info("applyPreset — \(preset.rawValue)")
}
// MARK: - Helpers
/// Update warning indicators for the Settings UI.
private func updateWarnings() {
let session = AVAudioSession.sharedInstance()
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
// Raw/Studio mode + speaker = echo risk (no AEC in .measurement mode)
showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
// A2DP output runs without hardware AEC (the .voiceChat AEC path isn't available on an
// A2DP route). Applies to both mono and stereo A2DP. Stereo also has no AEC (it can't
// use .voiceChat at all), but the message is the same and the warning already shows when
// the bluetooth mode is A2DP.
showsA2dpNoAecWarning = (bluetoothMode == .builtInMicBtA2dp)
}
/// Whether the current configuration wants Bluetooth A2DP output. Used after session
/// activation to clear any lingering speaker override that would pin output to the speaker.
var wantsA2dpOutput: Bool { bluetoothMode == .builtInMicBtA2dp }
/// The selected input port object, if any.
var selectedPort: IOSAudioInputPort? {
inputPorts.first(where: { $0.id == selectedInputPortId })
}
/// The data sources of the selected input port, if it's the built-in mic.
var selectedPortDataSources: [IOSAudioDataSource]? {
selectedPort?.dataSources
}
/// Whether the selected input port is the built-in mic (has data sources / orientation).
var selectedPortIsBuiltInMic: Bool {
selectedPort?.portType == AVAudioSession.Port.builtInMic.rawValue
}
}