The real root cause of "selecting Stereo Mic kills headphone/A2DP output on Join Voice." Every prior fix worked on the Swift IOSAudioRouter under the false premise that "miniaudio does NOT touch AVAudioSession on iOS." It does: the core opened devices via ma_device_init(nullptr, ...), and with a NULL context miniaudio 0.11.25 runs an iOS "hack" that sets the session category by device type, then ma_context_init__coreaudio calls setCategory()+setActive() on every device open -- capture -> AVAudioSessionCategoryRecord with zero options. That wipes the .playAndRecord category, the mode, and .allowBluetoothA2DP / .mixWithOthers / .allowAirPlay that IOSAudioRouter had just configured, killing headphone/A2DP (and even wired) output. Stereo presets break worst because they rely on the A2DP output route the wipe removes. TeamTalk avoids this by opening RemoteIO/VPIO AudioUnits directly and leaving the session entirely to the app. Fix (core, cross-platform safe): AudioEngine now owns a ma_context built by make_context_config() with coreaudio.sessionCategory = ma_ios_session_category_none and noAudioSessionActivate/Deactivate = MA_TRUE, and routes all ma_device_init calls (playback, capture, loopback) plus enumerate_devices through it. miniaudio no longer touches AVAudioSession; IOSAudioRouter is the sole owner (the session is already activated on connect in AppState before any device opens). Context is lazily inited in start(), reused across restarts, uninited in ~AudioEngine. Adds TEMP AudioSessionManager.logSessionState() diagnostics (after activate, on route change, on .streamStarted) to verify on-device that the category stays PlayAndRecord+allowBluetoothA2DP instead of flipping to Record. Remove once confirmed. Windows: cmake --build --preset dev clean; ctest --preset dev 21/21. iOS build + on-device verification pending on Mac. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
678 lines
33 KiB
Swift
678 lines
33 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 — but ONLY because the core deliberately opens its devices
|
|
/// through a `ma_context` configured with `sessionCategory = none` +
|
|
/// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in
|
|
/// `core/src/audio/audio_engine.cpp`). With miniaudio's default NULL-context path it WOULD
|
|
/// reset the category to `Record`/`Playback` with no options on every device open, wiping
|
|
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output (the long-standing
|
|
/// "stereo mic kills output" bug). With that disabled, this class is the sole owner of the
|
|
/// session. 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" (`.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 (mirroring
|
|
/// TeamTalk5's `SoundDevicesModel.selectDataSource` + `UtilSound.setupSoundDevices`, which
|
|
/// achieves stereo mic + A2DP output simultaneously) is: `setPreferredDataSource(.stereo
|
|
/// source)` + `setPreferredPolarPattern(.stereo)` + `setPreferredInput(built-in mic)` +
|
|
/// `setInputDataSource(stereo source)`. The channel count itself is NOT requested via
|
|
/// `setPreferredInputNumberOfChannels(2)` — that session-level call is what collapses the
|
|
/// A2DP output route (the original "stereo kills output" bug). 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
|
|
@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
|
|
@Published var hasWiredHeadset: Bool = false
|
|
|
|
/// Audio presets — sensible combinations of settings for common scenarios.
|
|
/// The app is about choice: users can pick a preset for a quick start, then
|
|
/// fine-tune individual settings under "Advanced Audio".
|
|
enum AudioPreset: String, CaseIterable, Identifiable {
|
|
/// Standard iOS VoIP experience: AEC/AGC/HPF on, mono, system picks best route
|
|
/// (BT HFP if connected, wired if connected, speaker if nothing). Always available.
|
|
case voiceChat = "Voice Chat"
|
|
/// Stereo built-in mic capture (front+back capsules). A2DP output if BT is connected,
|
|
/// else built-in speaker / wired. Standard processing (no AEC — stereo needs a non-VPIO
|
|
/// mode). Always available.
|
|
case stereoMic = "Stereo Mic"
|
|
/// Maximum fidelity: stereo mic, no AEC/AGC/HPF (raw mode). A2DP output if BT connected,
|
|
/// else speaker/wired. Always available. Echo risk on speaker.
|
|
case studio = "Studio (No Processing)"
|
|
/// Bluetooth HFP: BT mic + BT output, AEC on, mono. Only when BT is connected.
|
|
case bluetoothHeadset = "Bluetooth Headset (HFP)"
|
|
/// A2DP stereo output + built-in mono mic, AEC off. Only when BT is connected. (For
|
|
/// A2DP output + stereo mic, use the Stereo Mic preset while BT is connected.)
|
|
case btHeadphonesMonoMic = "BT Headphones + Mono Mic"
|
|
/// Wired headset/earpods: wired output + wired mic (or built-in), AEC on, mono.
|
|
/// Only when a wired audio device is connected.
|
|
case wiredHeadset = "Wired Headset"
|
|
/// Settings don't match any preset — user has tweaked advanced controls.
|
|
case custom = "Custom"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var requiresBluetooth: Bool {
|
|
switch self {
|
|
case .bluetoothHeadset, .btHeadphonesMonoMic: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var requiresWired: Bool {
|
|
self == .wiredHeadset
|
|
}
|
|
|
|
var bluetoothMode: BluetoothMode {
|
|
switch self {
|
|
case .voiceChat, .bluetoothHeadset: return .btHfpVoice
|
|
// A2DP output when BT is connected; falls back to speaker/wired when it isn't.
|
|
case .stereoMic, .studio, .btHeadphonesMonoMic: return .builtInMicBtA2dp
|
|
case .wiredHeadset: return .builtInMicSpeaker
|
|
case .custom: return .builtInMicSpeaker // placeholder
|
|
}
|
|
}
|
|
|
|
var captureChannels: CaptureChannels {
|
|
switch self {
|
|
case .stereoMic, .studio: return .stereo
|
|
default: return .mono
|
|
}
|
|
}
|
|
|
|
var micMode: MicMode {
|
|
switch self {
|
|
case .studio: return .raw
|
|
default: return .standard
|
|
}
|
|
}
|
|
|
|
/// Whether this preset explicitly selects the built-in mic port.
|
|
var usesBuiltInMic: Bool {
|
|
switch self {
|
|
case .stereoMic, .studio, .btHeadphonesMonoMic: 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()
|
|
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 available given the current device connection state.
|
|
/// Always includes Voice Chat, Stereo Mic, Studio, and Custom. BT presets only when
|
|
/// a Bluetooth device is connected. Wired preset only when a wired device is connected.
|
|
var availablePresets: [AudioPreset] {
|
|
AudioPreset.allCases.filter { preset in
|
|
if preset == .custom { return true }
|
|
if preset.requiresBluetooth && !hasBluetoothDevice { return false }
|
|
if preset.requiresWired && !hasWiredHeadset { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Which preset matches the current settings, or .custom if nothing matches.
|
|
/// Checks device-specific presets first (BT, wired) so that e.g. when BT is connected
|
|
/// and settings match "Bluetooth Headset", it returns that instead of the equivalent
|
|
/// "Voice Chat" (which has the same bluetoothMode/micMode/channels but is more general).
|
|
var activePreset: AudioPreset {
|
|
// Check device-specific presets first (most specific → least specific)
|
|
let order: [AudioPreset] = [
|
|
.bluetoothHeadset, .btHeadphonesMonoMic,
|
|
.wiredHeadset,
|
|
.voiceChat, .stereoMic, .studio,
|
|
]
|
|
for preset in order {
|
|
if bluetoothMode == preset.bluetoothMode
|
|
&& captureChannels == preset.captureChannels
|
|
&& micMode == preset.micMode {
|
|
// Don't match a BT preset if no BT is connected — fall through to Voice Chat
|
|
if preset.requiresBluetooth && !hasBluetoothDevice { continue }
|
|
if preset.requiresWired && !hasWiredHeadset { continue }
|
|
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.
|
|
// .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 ONLY for the speaker preset. It forces output to the
|
|
// built-in speaker instead of the receiver — but it also actively breaks A2DP
|
|
// routing in .playAndRecord, so it must NOT be set for the A2DP or HFP presets.
|
|
// .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. This matches TeamTalk5's default (UtilSound.swift:228):
|
|
// [.allowBluetoothHFP, .allowAirPlay, .allowBluetoothA2DP]
|
|
// Making HFP and A2DP mutually exclusive (HFP-only here) blocks A2DP headphones
|
|
// from receiving audio — the "Voice Chat kills Bluetooth output" regression.
|
|
// HFP is *preferred* (the system uses HFP when a two-way mic path is needed),
|
|
// but A2DP is still available for output-only scenarios.
|
|
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.
|
|
// This matches TeamTalk5's A2DP mode (UtilSound.swift:232-233): remove HFP from
|
|
// the default set, leaving only A2DP.
|
|
options.insert(.allowBluetoothA2DP)
|
|
options.insert(.allowAirPlay)
|
|
case .builtInMicSpeaker:
|
|
// Built-in mic + speaker/wired output only. Prefer speaker over the receiver.
|
|
options.insert(.defaultToSpeaker)
|
|
}
|
|
|
|
// 2. Set category + mode. Recipe validated against TeamTalk5 / Ferrite, which both do
|
|
// built-in stereo mic + A2DP Bluetooth output simultaneously:
|
|
// - 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. (Earlier .videoRecording + a session-level
|
|
// channel-count request collapsed A2DP output — the "stereo kills output" bug.)
|
|
// - 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. The session-level
|
|
// channel-count call (setPreferredInputNumberOfChannels(2)) is what collapses the
|
|
// A2DP output route — NOT setPreferredInput (TeamTalk5 uses setPreferredInput and
|
|
// gets stereo + A2DP). 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 requested by miniaudio at the audio-unit level (vc_set_capture_channels).
|
|
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. Mirrors TeamTalk5's recipe
|
|
/// (`SoundDevicesModel.selectDataSource` + `UtilSound.setupSoundDevices`), which
|
|
/// 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 (this is NOT
|
|
/// what collapses A2DP — the session-level `setPreferredInputNumberOfChannels(2)`
|
|
/// is. Without this anchor the route can collapse during the mode switch.)
|
|
/// 4. `setInputDataSource(stereoSource)` — commit the data source at the session level
|
|
/// The channel count itself is requested by miniaudio at the audio-unit level via
|
|
/// `vc_set_capture_channels(2)`. We do NOT call `setPreferredInputNumberOfChannels(2)`
|
|
/// — that session-level call is the one that collapses the A2DP output 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. TeamTalk5 does this (SoundDevicesModel
|
|
// .selectDataSource:147); 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 (TeamTalk does this at
|
|
// SoundDevicesModel.selectDataSource:148). 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)
|
|
}
|
|
/// 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()
|
|
// Update the core's stored capture channel count (does not restart the engine).
|
|
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
|
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
|
streamId: streamId, channels: channels.channelCount)
|
|
}
|
|
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
|
// count is stored. The engine reopens playback first (committing the A2DP/output
|
|
// route), then capture — avoiding the race where stereo capture activation drops
|
|
// A2DP before the playback device has a chance to claim the route.
|
|
_ = AudioSessionManager.shared.client?.audioRestart()
|
|
}
|
|
|
|
// 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()
|
|
// Update the core's stored capture channel count (does not restart the engine).
|
|
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
|
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
|
streamId: streamId, channels: preset.captureChannels.channelCount)
|
|
}
|
|
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
|
// count is stored. Playback opens first (commits A2DP route), then capture.
|
|
_ = AudioSessionManager.shared.client?.audioRestart()
|
|
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)
|
|
// 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)
|
|
}
|
|
|
|
/// Whether the current configuration wants Bluetooth A2DP output. Used after session
|
|
/// activation to clear any lingering speaker override that would pin output to the speaker.
|
|
var wantsA2dpOutput: Bool { 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
|
|
}
|
|
}
|