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 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 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. 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 } do { if hasExternalOutput { try session.overrideOutputAudioPort(.none) logger.info("A2DP mode — external output present, clearing speaker override") } else { try session.overrideOutputAudioPort(.speaker) logger.info("A2DP mode — no external output, routing to built-in speaker") } } catch { logger.error("A2DP speaker fallback failed: \(error.localizedDescription)") } } /// 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 } }