Three coordinated fixes for the bug where enabling stereo mic capture
caused all audio output (A2DP, speaker, wired) to go silent:
1. audio_engine.cpp — open playback before capture
On iOS, starting the stereo capture AudioUnit can trigger an audio
route reconfiguration that drops A2DP before the playback device has
a chance to claim the route. Opening and starting the playback device
first commits the output route (A2DP), so iOS is less likely to drop
it when stereo capture activates afterward.
2. client.cpp — decouple set_capture_channels from engine restart
Previously vc_set_capture_channels() stopped and restarted the engine
immediately, which opened capture first (old ordering) and raced
against the settling AVAudioSession route. Now it only stores the
channel count; the caller (Swift via vc_audio_restart) controls when
the engine restarts, after the route has settled.
3. IOSAudioRouter.swift — call audioRestart() after channel config
selectCaptureChannels() and applyPreset() now call audioRestart()
after applyConfiguration() + setCaptureChannels(). This is the
vc_audio_restart() path that was added to the ABI in fdcc84f but
never wired up in the Swift layer. The restart sees the stored
channel count and reopens devices in the correct order (playback
first, capture second).
The doStartMicStream path is unaffected: setCaptureChannels is called
before the server acknowledges the stream (engine not yet running), so
ensure_audio_running() picks up capture_channels=2 directly when the
stream is confirmed and opens with the right count from the start.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
672 lines
32 KiB
Swift
672 lines
32 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" (`.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
|
|
}
|
|
}
|