Recovery from the 99937c9 audio-device-change commit broadened the
route-change recovery set to "everything except categoryChange /
routeConfigurationChange", which added .override. But .override is
fired by our own applyA2dpSpeakerFallback() -> overrideOutputAudioPort,
which recoverAudio() calls on every recovery. On an A2DP preset
(Stereo Mic / Mono Mic), disconnecting AirPods ping-ponged:
oldDeviceUnavailable -> recoverAudio -> applyA2dpSpeakerFallback
-> overrideOutputAudioPort(.speaker) -> .override routeChange
-> recoverAudio -> applyConfiguration (setCategory resets override)
-> applyA2dpSpeakerFallback -> overrideOutputAudioPort -> .override -> ...
Each iteration also rebuilt the AVAudioEngine via reconfigure() ->
rebuild() -- the audible reinitialize loop + CPU spin. Voice Chat and
Built-in Mic + Speaker were unaffected (applyA2dpSpeakerFallback
early-returns for non-A2DP modes, so no overrideOutputAudioPort call).
Two-part fix (pure Swift iOS-app target; no C ABI / proto / docs changes):
1. AudioSessionManager.handleRouteChange: added .override to the skip
list alongside .categoryChange / .routeConfigurationChange. .override
is only ever fired by our own overrideOutputAudioPort call, so
treating it as a recovery reason is the loop by definition. The
AVAudioEngineConfigurationChange observer in IOSVoiceProcessingEngine
remains as the backstop if an override ever actually stops the engine.
2. IOSAudioRouter.applyA2dpSpeakerFallback: made idempotent via a
lastAppliedOutputOverride tracker that skips the redundant
overrideOutputAudioPort call when the desired state (.none for
external output present, .speaker otherwise) already matches. Reset
to nil at the top of applyConfiguration() (setCategory can reset the
override) and on a failed call. Defense-in-depth on top of fix 1.
Build: xcodebuild -project clients/apple/iOS/VoiceCatiOS.xcodeproj
-scheme VoiceCatiOS -destination 'generic/platform=iOS' build green
(Xcode 26.5 / iOS 18.0).
739 lines
37 KiB
Swift
739 lines
37 KiB
Swift
import AVFoundation
|
|
import os
|
|
import VoiceCatCore
|
|
|
|
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
|
|
|
|
/// iOS audio routing layer — the sole owner of `AVAudioSession` on iOS. On iOS the core never
|
|
/// opens a hardware (miniaudio) device: a single `AVAudioEngine` (`IOSAudioEngine`) drives both
|
|
/// capture and playback and the core runs fully external (see docs/voice.md §8). This class just
|
|
/// configures the *route* — category / mode / options, preferred input, data source, polar
|
|
/// pattern, stereo capsule — and `IOSAudioEngine` binds to whatever route is established. After
|
|
/// any change here the engine is rebuilt via `IOSAudioEngine.reconfigure()` (a deterministic
|
|
/// Swift-only stop → reconfigure → start); there is no second (miniaudio) audio path to hand off
|
|
/// to, so a change cannot leave one direction dropped.
|
|
///
|
|
/// (The core's iOS `ma_context` is still configured with `sessionCategory = none` +
|
|
/// `noAudioSessionActivate/Deactivate` in `AudioEngine::make_context_config` so that, should the
|
|
/// core ever open a device, miniaudio would not reset the category — but on iOS it does not.)
|
|
///
|
|
/// 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 HFP→A2DP and mono→stereo 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?
|
|
/// Master voice-processing switch (Apple VPIO: AEC + noise suppression bundled together).
|
|
/// iOS exposes no per-stage toggle, so this is the finest "echo cancellation / noise
|
|
/// reduction" control available. Only takes effect on a VPIO-capable config (mono + standard
|
|
/// + not A2DP); stereo / A2DP configs can't use VPIO regardless. Default on.
|
|
@Published var voiceProcessingEnabled: Bool = true
|
|
/// VPIO automatic gain control — the one VPIO sub-stage iOS lets us toggle independently.
|
|
/// Only meaningful when voice processing is active. Default on.
|
|
@Published var agcEnabled: Bool = true
|
|
@Published var showsRawModeSpeakerWarning: Bool = false
|
|
@Published var showsA2dpNoAecWarning: Bool = false
|
|
@Published var hasBluetoothDevice: Bool = false
|
|
@Published var hasWiredHeadset: Bool = false
|
|
|
|
/// Audio presets — the four scenarios from the product spec. Pick a preset for a quick start,
|
|
/// then fine-tune individual settings under "Advanced". HFP / wired headsets are not separate
|
|
/// presets: Voice Chat lets the system route to them, and Advanced exposes manual selection.
|
|
enum AudioPreset: String, CaseIterable, Identifiable {
|
|
/// Voice chat: Apple VPIO does real AEC + noise suppression + AGC. Mono. The system picks
|
|
/// the best route (Bluetooth HFP / wired / speaker / earpiece). Always available.
|
|
case voiceChat = "Voice Chat"
|
|
/// Internal **stereo** built-in mic regardless of the output route. A2DP output when a
|
|
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO (stereo can't
|
|
/// use it). Always available.
|
|
case stereoMic = "Stereo Mic"
|
|
/// Internal **mono** built-in mic regardless of the output route. A2DP output when a
|
|
/// Bluetooth headset is connected, else built-in speaker / wired. No VPIO. Always available.
|
|
case monoMic = "Mono Mic"
|
|
/// Everything manual — input port, mic orientation / polar pattern, mono/stereo, Bluetooth
|
|
/// mode, raw vs standard, and the VPIO / AGC toggles. Also the display state when the
|
|
/// individual settings don't match a named preset.
|
|
case advanced = "Advanced"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var bluetoothMode: BluetoothMode {
|
|
switch self {
|
|
case .voiceChat: return .btHfpVoice
|
|
// Internal-mic presets: A2DP output when BT is connected; speaker/wired when not.
|
|
case .stereoMic, .monoMic: return .builtInMicBtA2dp
|
|
case .advanced: return .builtInMicSpeaker // placeholder; Advanced sets it manually
|
|
}
|
|
}
|
|
|
|
var captureChannels: CaptureChannels {
|
|
self == .stereoMic ? .stereo : .mono
|
|
}
|
|
|
|
var micMode: MicMode { .standard }
|
|
|
|
/// Whether this preset explicitly pins the built-in mic port (the internal-mic presets).
|
|
var usesBuiltInMic: Bool {
|
|
switch self {
|
|
case .stereoMic, .monoMic: 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"
|
|
private let kVoiceProcessing = "cat.voice.audio.voiceProcessing"
|
|
private let kAgc = "cat.voice.audio.agc"
|
|
|
|
/// 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
|
|
|
|
/// Last `overrideOutputAudioPort` value we successfully applied (`.none` or `.speaker`),
|
|
/// so `applyA2dpSpeakerFallback` can skip a redundant `overrideOutputAudioPort` call.
|
|
/// That call fires a `.override` route-change notification on every invocation, and on
|
|
/// an AirPods disconnect the fallback is invoked once per `recoverAudio()` — which
|
|
/// itself fires on every non-skipped route change — so without this guard the override
|
|
/// call and the route-change handler ping-pong: the AirPods-disconnect reinitialize
|
|
/// loop (each iteration also rebuilds the AVAudioEngine via reconfigure()).
|
|
/// `nil` = "unknown / assume not applied" — reset at the top of `applyConfiguration()`
|
|
/// because `setCategory` can reset the override out from under us, and on first run.
|
|
private var lastAppliedOutputOverride: AVAudioSession.PortOverride?
|
|
|
|
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 the user can pick. All four are always available — the named presets simply
|
|
/// describe what to do "regardless of the output route", and Advanced is always offered.
|
|
var availablePresets: [AudioPreset] { AudioPreset.allCases }
|
|
|
|
/// Which named preset matches the current settings, or `.advanced` if nothing matches.
|
|
var activePreset: AudioPreset {
|
|
for preset in [AudioPreset.voiceChat, .stereoMic, .monoMic] {
|
|
if bluetoothMode == preset.bluetoothMode
|
|
&& captureChannels == preset.captureChannels
|
|
&& micMode == preset.micMode {
|
|
return preset
|
|
}
|
|
}
|
|
return .advanced
|
|
}
|
|
|
|
/// Whether the current configuration should engage Apple's Voice-Processing I/O unit (VPIO:
|
|
/// real AEC + noise suppression + AGC, driven by `IOSAudioEngine`). VPIO forces mono and
|
|
/// can't run on an A2DP route, so it is available only for a mono + standard + non-A2DP
|
|
/// config, and then only when the user hasn't disabled it via the Advanced master toggle.
|
|
var currentConfigUsesVoiceProcessing: Bool {
|
|
voiceProcessingEnabled && voiceProcessingAvailable
|
|
}
|
|
|
|
/// Whether the current config *could* use VPIO (mono + standard + non-A2DP), independent of
|
|
/// the user's master toggle. Drives whether the Advanced "Voice Processing" switch is shown.
|
|
var voiceProcessingAvailable: Bool {
|
|
captureChannels == .mono && micMode == .standard && bluetoothMode != .builtInMicBtA2dp
|
|
}
|
|
|
|
// MARK: - Apply configuration
|
|
|
|
/// Apply the full audio configuration to AVAudioSession. Call this before (re)building the
|
|
/// `IOSAudioEngine` graph so the engine binds to the intended route (`applyAndReconfigure`
|
|
/// does both). 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
|
|
// setCategory below can reset the override out from under us, so drop our cached
|
|
// value — applyA2dpSpeakerFallback will re-derive and re-apply it from scratch.
|
|
lastAppliedOutputOverride = nil
|
|
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 carried by the
|
|
// engine's mic tap + 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 carried by the engine's mic tap (which captures 2 channels)
|
|
/// plus `vc_set_capture_channels(2)` so the core encodes stereo. We must NOT call
|
|
/// `setPreferredInputNumberOfChannels(2)` — that session-level call collapses the A2DP 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)
|
|
// VPIO toggles default ON when never set (object(forKey:) is nil → use true).
|
|
voiceProcessingEnabled = (UserDefaults.standard.object(forKey: kVoiceProcessing) as? Bool) ?? true
|
|
agcEnabled = (UserDefaults.standard.object(forKey: kAgc) as? Bool) ?? true
|
|
}
|
|
/// 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)
|
|
UserDefaults.standard.set(voiceProcessingEnabled, forKey: kVoiceProcessing)
|
|
UserDefaults.standard.set(agcEnabled, forKey: kAgc)
|
|
}
|
|
|
|
// MARK: - Selection setters (called from SettingsView pickers)
|
|
|
|
/// Shared tail for every setting change: persist, re-apply the AVAudioSession config, refresh
|
|
/// the route lists, re-evaluate the A2DP speaker fallback, and rebind the live engine to the
|
|
/// new route. `IOSAudioEngine.reconfigure()` is a no-op when not connected, so this is safe to
|
|
/// call from Settings whether or not a session is in progress. There is no longer a second
|
|
/// (miniaudio) audio path to hand off to, so one engine rebuild is the whole story.
|
|
private func applyAndReconfigure() {
|
|
savePreferences()
|
|
applyConfiguration()
|
|
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
|
refreshRoutes()
|
|
IOSAudioEngine.shared.reconfigure()
|
|
}
|
|
|
|
func selectInputPort(_ portId: String) {
|
|
selectedInputPortId = portId
|
|
selectedDataSourceId = nil
|
|
selectedPolarPattern = nil
|
|
applyAndReconfigure()
|
|
}
|
|
|
|
func selectDataSource(_ dataSourceId: String) {
|
|
selectedDataSourceId = dataSourceId
|
|
selectedPolarPattern = nil
|
|
applyAndReconfigure()
|
|
}
|
|
|
|
func selectPolarPattern(_ pattern: String) {
|
|
selectedPolarPattern = pattern
|
|
applyAndReconfigure()
|
|
}
|
|
|
|
func selectBluetoothMode(_ mode: BluetoothMode) {
|
|
bluetoothMode = mode
|
|
applyAndReconfigure()
|
|
}
|
|
|
|
func setForceSpeaker(_ on: Bool) {
|
|
forceSpeaker = on
|
|
applyAndReconfigure()
|
|
}
|
|
|
|
func selectMicMode(_ mode: MicMode) {
|
|
micMode = mode
|
|
applyAndReconfigure()
|
|
}
|
|
|
|
func setVoiceProcessingEnabled(_ on: Bool) {
|
|
voiceProcessingEnabled = on
|
|
applyAndReconfigure()
|
|
}
|
|
|
|
func setAgcEnabled(_ on: Bool) {
|
|
agcEnabled = on
|
|
// No session reconfigure needed — just rebuild the engine so VPIO picks up the AGC flag.
|
|
savePreferences()
|
|
IOSAudioEngine.shared.reconfigure()
|
|
}
|
|
|
|
func selectCaptureChannels(_ channels: CaptureChannels) {
|
|
captureChannels = channels
|
|
savePreferences()
|
|
applyConfiguration()
|
|
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
|
refreshRoutes()
|
|
// Push the channel count into the core's MIC stream, then rebuild the engine graph so the
|
|
// mic tap captures the right number of channels. The engine owns the route now, so there's
|
|
// no stereo-vs-A2DP race to sequence around.
|
|
IOSAudioEngine.shared.setCaptureChannels(channels.channelCount)
|
|
}
|
|
|
|
// MARK: - Presets
|
|
|
|
/// Apply a named preset — set all individual settings to the preset's values, then re-apply
|
|
/// the configuration and rebind the engine. The internal-mic presets pin the built-in mic.
|
|
func applyPreset(_ preset: AudioPreset) {
|
|
guard preset != .advanced else { return } // Advanced is a display state, not "applied"
|
|
|
|
bluetoothMode = preset.bluetoothMode
|
|
micMode = preset.micMode
|
|
captureChannels = preset.captureChannels
|
|
|
|
// Voice Chat is a phone-call experience — default to the loud speaker so output doesn't
|
|
// land on the quiet earpiece (receiver). Still yields to connected BT/wired output.
|
|
if preset == .voiceChat { forceSpeaker = true }
|
|
|
|
if preset.usesBuiltInMic {
|
|
// Pin the built-in mic. In stereo, iOS uses multiple capsules automatically; in mono
|
|
// the default orientation is fine — so don't force a specific data source / pattern.
|
|
if let builtInMic = (AVAudioSession.sharedInstance().availableInputs ?? []).first(where: {
|
|
$0.portType == .builtInMic
|
|
}) {
|
|
selectedInputPortId = builtInMic.uid
|
|
}
|
|
selectedDataSourceId = nil
|
|
selectedPolarPattern = nil
|
|
} else {
|
|
// Voice Chat: let the system pick the input (Bluetooth HFP / wired / built-in).
|
|
selectedInputPortId = nil
|
|
selectedDataSourceId = nil
|
|
selectedPolarPattern = nil
|
|
}
|
|
|
|
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
|
|
savePreferences()
|
|
applyConfiguration()
|
|
if AudioSessionManager.shared.isActive { applyA2dpSpeakerFallback() }
|
|
refreshRoutes()
|
|
// Push the channel count to the core, then rebuild the engine graph (VPIO on/off + tap).
|
|
IOSAudioEngine.shared.setCaptureChannels(preset.captureChannels.channelCount)
|
|
IOSAudioEngine.shared.reconfigure()
|
|
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)
|
|
}
|
|
|
|
/// Route fallback for the A2DP-output presets (Stereo Mic / Studio / BT Headphones + Mono
|
|
/// Mic, all `.builtInMicBtA2dp`). These presets deliberately omit `.defaultToSpeaker` (it
|
|
/// breaks A2DP routing) and skip the `forceSpeaker` override, so when NO external output
|
|
/// (Bluetooth A2DP / wired / AirPlay) is connected `.playAndRecord` pins output to the quiet
|
|
/// built-in receiver (earpiece). This routes to the loud built-in speaker instead via a
|
|
/// post-activation `overrideOutputAudioPort(.speaker)` — the documented "A2DP if connected,
|
|
/// else speaker" behavior. When an external output IS present we clear the override so A2DP /
|
|
/// headphones / AirPlay are honored. No-op outside `.builtInMicBtA2dp` mode (other modes pick
|
|
/// their route via category options). Must be called AFTER the session is active.
|
|
///
|
|
/// Idempotent: skips the `overrideOutputAudioPort` call when the desired override already
|
|
/// matches the last one we successfully applied. Each call fires a `.override` route-change
|
|
/// notification, and `AudioSessionManager.recoverAudio()` invokes this on every recovery —
|
|
/// so on an AirPods disconnect, without this guard, override + recoverAudio ping-pong and
|
|
/// each iteration also rebuilds the AVAudioEngine (the audible reinitialize loop). The cache
|
|
/// is reset to `nil` at the top of `applyConfiguration()` (setCategory can reset the
|
|
/// override) and on a failed call (so the next attempt re-derives from the live session).
|
|
func applyA2dpSpeakerFallback() {
|
|
guard bluetoothMode == .builtInMicBtA2dp else { return }
|
|
let session = AVAudioSession.sharedInstance()
|
|
// Treat the built-in receiver and speaker as "internal"; anything else (A2DP, headphones,
|
|
// USB, AirPlay) is an external output we should defer to.
|
|
let hasExternalOutput = session.currentRoute.outputs.contains {
|
|
$0.portType != .builtInReceiver && $0.portType != .builtInSpeaker
|
|
}
|
|
let desired: AVAudioSession.PortOverride = hasExternalOutput ? .none : .speaker
|
|
if desired == lastAppliedOutputOverride {
|
|
// Already in the desired state — calling overrideOutputAudioPort again would just
|
|
// fire a redundant `.override` route-change notification (the loop driver).
|
|
logger.debug("A2DP fallback — desired=\(self.overrideLabel(desired)) already applied, skipping")
|
|
return
|
|
}
|
|
do {
|
|
try session.overrideOutputAudioPort(desired)
|
|
lastAppliedOutputOverride = desired
|
|
logger.info("A2DP mode — override applied: \(self.overrideLabel(desired))")
|
|
} catch {
|
|
// Drop the cache so the next call re-derives from the live session state.
|
|
lastAppliedOutputOverride = nil
|
|
logger.error("A2DP speaker fallback failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func overrideLabel(_ o: AVAudioSession.PortOverride) -> String {
|
|
switch o {
|
|
case .none: return "none"
|
|
case .speaker: return "speaker"
|
|
@unknown default: return "unknown"
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|