Replaces the flat list of audio settings with a preset picker that shows context-appropriate options based on whether a Bluetooth device is connected. Presets: - Default (Phone Speaker): built-in mic + speaker, standard, mono - Bluetooth Headset (HFP): BT mic + BT output, standard, mono — only shown when a BT device is connected - BT Headphones + Phone Mic: A2DP stereo output + built-in mic, standard, mono — only shown when BT connected - BT Headphones + Stereo Mic: A2DP stereo output + built-in mic stereo (front+back capsules), standard, stereo — only shown when BT connected - Custom: shown when advanced settings don't match any preset When no Bluetooth device is connected, only 'Default' and 'Custom' appear, with a hint to connect Bluetooth headphones for more options. All granular controls (input port, orientation, polar pattern, mic mode, channels, bluetooth mode, output route, AirPlay) are now under an 'Advanced Audio' disclosure group, collapsed by default. IOSAudioRouter gains: - AudioPreset enum with bluetoothMode/captureChannels/micMode/usesBuiltInMic - hasBluetoothDevice detection (checks currentRoute + availableInputs for bluetoothA2DP/bluetoothHFP port types) - availablePresets (filtered by BT connection state) - activePreset (computed from current settings) - applyPreset() (sets all individual settings + finds built-in mic port UID)
493 lines
21 KiB
Swift
493 lines
21 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
|
|
@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
|
|
}
|
|
}
|