Files
voice-cat/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift
Talon ab973940df fix(ios): break audio session route-change feedback loop
handleRouteChange called applyConfiguration() unconditionally, which called
setCategory/setPreferredInput/etc., which triggered another route-change
notification, which called applyConfiguration() again — an infinite loop that
burned CPU (phone slowdown) and repeatedly tore down/rebuilt the audio session
(audio cycling on/off, VoiceOver glitching).

Two fixes:
1. handleRouteChange now only re-applies config on external device changes
   (.oldDeviceUnavailable / .newDeviceAvailable), not on .categoryChange /
   .routeConfigurationChange which are triggered by our own setCategory calls.
2. IOSAudioRouter.applyConfiguration() gained a re-entrancy guard
   (isApplyingConfiguration) for synchronous route-change notifications.

Also added os.Logger logging to both files (subsystem cat.voice.VoiceCatiOS)
so future issues can be debugged from Console.app on the Mac.
2026-06-19 13:26:23 +02:00

339 lines
14 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. miniaudio does NOT touch
/// `AVAudioSession` on iOS; it opens the current default route via CoreAudio and that's
/// it. 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" (`.allowBluetooth`): mono 8/16 kHz + heavy processing, BT mic.
/// - "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) can be enabled via
/// `setPreferredInputNumberOfChannels(2)` the core is then told via
/// `vc_set_capture_channels(streamId, 2)`.
///
/// 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
@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
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"
/// 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
}
updateRawModeWarning()
}
// 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.
var options: AVAudioSession.CategoryOptions = [.defaultToSpeaker, .mixWithOthers]
switch bluetoothMode {
case .btHfpVoice:
options.insert(.allowBluetooth)
// Note: .allowBluetoothA2DP is NOT inserted forces HFP for the mic path.
case .builtInMicBtA2dp:
options.insert(.allowBluetoothA2DP)
// Note: .allowBluetooth is NOT inserted no HFP, stereo A2DP output only.
case .builtInMicSpeaker:
// Neither Bluetooth option built-in mic + speaker/wired output only.
break
}
// 2. Set category + mode based on mic processing mode.
let mode: AVAudioSession.Mode
switch micMode {
case .standard:
mode = .voiceChat // AEC/AGC/HPF on
case .raw:
mode = .measurement // all processing off
}
do {
try session.setCategory(.playAndRecord, mode: mode, options: options)
logger.info("setCategory ok — mode=\(self.modeLabel(mode)), bt=\(self.bluetoothMode.rawValue)")
} catch {
logger.error("setCategory failed: \(error.localizedDescription)")
}
// 3. Set preferred input port.
if let portId = selectedInputPortId,
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
do {
try session.setPreferredInput(port)
logger.info("setPreferredInput ok — \(port.portName)")
} catch {
logger.error("setPreferredInput failed: \(error.localizedDescription)")
}
// 4. Set preferred data source (orientation) on the selected input port.
if let dataSourceId = selectedDataSourceId,
let dataSource = port.dataSources?.first(where: { String(describing: $0.dataSourceID) == dataSourceId }) {
do {
try port.setPreferredDataSource(dataSource)
logger.info("setPreferredDataSource ok — \(dataSource.dataSourceName)")
} catch {
logger.error("setPreferredDataSource failed: \(error.localizedDescription)")
}
// 5. Set preferred polar pattern on the data source.
if let polarPattern = selectedPolarPattern {
let pattern = AVAudioSession.PolarPattern(rawValue: polarPattern)
do {
try dataSource.setPreferredPolarPattern(pattern)
logger.info("setPreferredPolarPattern ok — \(polarPattern)")
} catch {
logger.error("setPreferredPolarPattern failed: \(error.localizedDescription)")
}
}
}
}
// 6. Set preferred input number of channels (stereo capture).
do {
try session.setPreferredInputNumberOfChannels(Int(captureChannels.channelCount))
logger.info("setPreferredInputNumberOfChannels ok — \(self.captureChannels.rawValue)")
} catch {
logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)")
}
updateRawModeWarning()
}
private func modeLabel(_ mode: AVAudioSession.Mode) -> String {
switch mode {
case .voiceChat: return "voiceChat"
case .measurement: return "measurement"
default: return "other"
}
}
/// 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)
}
/// 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)
}
// 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 selectMicMode(_ mode: MicMode) {
micMode = mode
savePreferences()
applyConfiguration()
updateRawModeWarning()
}
func selectCaptureChannels(_ channels: CaptureChannels) {
captureChannels = channels
savePreferences()
applyConfiguration()
}
// MARK: - Helpers
/// Show a warning when Raw/Studio mode is active and the output route is the speaker
/// (echo risk since AEC is off in .measurement mode).
private func updateRawModeWarning() {
let session = AVAudioSession.sharedInstance()
let outputIsSpeaker = session.currentRoute.outputs.contains { $0.portType == .builtInSpeaker }
showsRawModeSpeakerWarning = (micMode == .raw && outputIsSpeaker)
}
/// 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
}
}