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 @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 { 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" /// 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() 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 /// 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 AND bluetooth mode. // .voiceChat mode uses hardware AEC/AGC/HPF, but requires HFP-compatible routes. // A2DP output is NOT HFP — using .voiceChat with A2DP causes iOS to mute the output // because it can't set up the voice processing pipeline on an A2DP route. So: // - Standard + HFP or Speaker: .voiceChat (hardware AEC works) // - Standard + A2DP: .default (no hardware AEC, but audio routes correctly — A2DP // headphones are in-ear/over-ear so echo from built-in mic is minimal) // - Raw + any: .measurement (all processing off, regardless of bluetooth mode) let mode: AVAudioSession.Mode switch (micMode, bluetoothMode) { case (.standard, .builtInMicBtA2dp): mode = .default // A2DP + hardware AEC = incompatible case (.standard, _): mode = .voiceChat // HFP or speaker: hardware AEC works 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), options=\(self.optionsLabel(options))") } catch { logger.error("setCategory failed: \(error.localizedDescription)") } // 3. Set preferred input port (skip if "Default" — empty/nil ID means use system default). if let portId = selectedInputPortId, !portId.isEmpty, 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, !dataSourceId.isEmpty, 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, !polarPattern.isEmpty { 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 — ONLY for stereo (non-default). // Calling setPreferredInputNumberOfChannels(1) for mono is unnecessary (1 is the // default) and may put the session in a bad state on some devices. if captureChannels == .stereo { do { try session.setPreferredInputNumberOfChannels(2) logger.info("setPreferredInputNumberOfChannels ok — 2 (stereo)") } catch { logger.error("setPreferredInputNumberOfChannels failed: \(error.localizedDescription)") } } updateWarnings() } private func modeLabel(_ mode: AVAudioSession.Mode) -> String { switch mode { case .voiceChat: return "voiceChat" case .measurement: return "measurement" 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(.allowBluetooth) { parts.append("allowBluetooth") } 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) } /// 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() updateWarnings() } func selectCaptureChannels(_ channels: CaptureChannels) { captureChannels = channels savePreferences() 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 /// 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) // Standard mode + A2DP = no hardware AEC (A2DP incompatible with .voiceChat mode) showsA2dpNoAecWarning = (micMode == .standard && 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 } }