docs(ios): drop stale debug comments + TeamTalk refs from audio router

After the stereo-mic/A2DP debugging settled, the iOS audio code carried
leftover TeamTalk5 comparison notes, source-line citations, TEMP DIAGNOSTIC
markers, and "this was the bug" narratives that no longer help. Reworded
those to state the current rules; kept the comments that document real
constraints (the setPreferredInputNumberOfChannels(2) trap, the re-entrancy
guard, the ma_context no-session-management config).

Comment-only — no behavior change. core builds, ctest --preset dev 21/21.
This commit is contained in:
2026-06-20 03:03:34 +02:00
parent f1e1ef59ed
commit f3c1172a3e
7 changed files with 51 additions and 76 deletions

View File

@@ -349,9 +349,8 @@ public final class VoiceCatClient {
/// AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) so the /// AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) so the
/// core's devices reopen against the new route. Unlike `audioSuspend()`/`audioResume()` /// core's devices reopen against the new route. Unlike `audioSuspend()`/`audioResume()`
/// (which only stop/start the existing devices, leaving them bound to the route that was /// (which only stop/start the existing devices, leaving them bound to the route that was
/// active when they were opened), this fully re-initializes them. Mirrors TeamTalk5's /// active when they were opened), this fully re-initializes them. Safe to call when the
/// closeSoundDevices()/initSoundInputDevice()/initSoundOutputDevice() pattern. Safe to /// engine is not running (it will just start it).
/// call when the engine is not running (it will just start it).
@discardableResult @discardableResult
public func audioRestart() -> VoiceCatResult { public func audioRestart() -> VoiceCatResult {
VoiceCatResult(vc_audio_restart(handle)) VoiceCatResult(vc_audio_restart(handle))

View File

@@ -16,12 +16,10 @@ final class AudioSessionManager {
var activeMicStreamId: UInt32? var activeMicStreamId: UInt32?
/// Tracks whether WE activated the session. The session must be active whenever the /// Tracks whether WE activated the session. The session must be active whenever the
/// AudioEngine is running (for capture OR playback). Previously, the session was only /// AudioEngine is running (for capture OR playback), so it is activated when any audio
/// activated when the user joined voice (startMicStream), which meant: /// needs to play (a remote stream started OR the user joins voice) and only deactivated
/// - Remote audio was silent if the user hadn't joined voice yet. /// when disconnecting from the server not when leaving voice, since the user may still
/// - Leaving voice (stopMicStream) deactivated the session, killing remote audio. /// want to hear remote audio.
/// Now the session is activated when any audio needs to play (remote stream started OR
/// user joins voice) and only deactivated when disconnecting from the server.
private var isSessionActive = false private var isSessionActive = false
func configure() { func configure() {
@@ -78,12 +76,10 @@ final class AudioSessionManager {
logger.info("session deactivated") logger.info("session deactivated")
} }
/// TEMP DIAGNOSTIC (stereo-A2DP fix verification): log the full AVAudioSession state /// Log the full AVAudioSession state category, mode, options, and active route.
/// category, mode, options, and active route. The bug was miniaudio resetting the category /// Useful for diagnosing routing issues, e.g. confirming the session stays
/// to `Record` (no output) on device open; with the core's ma_context now configured to /// `PlayAndRecord` with `allowBluetoothA2DP` and keeps the A2DP output route even
/// leave the session alone, this should report `AVAudioSessionCategoryPlayAndRecord` with /// after the mic engine starts.
/// `allowBluetoothA2DP` set, and the output route should be the headphones/A2DP device
/// even after the mic engine starts. Remove once verified on-device.
func logSessionState(_ when: String) { func logSessionState(_ when: String) {
let s = AVAudioSession.sharedInstance() let s = AVAudioSession.sharedInstance()
var opts: [String] = [] var opts: [String] = []

View File

@@ -5,16 +5,16 @@ import VoiceCatCore
private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter") private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAudioRouter")
/// iOS audio routing layer drives all iOS audio route selection via `AVAudioSession` /// iOS audio routing layer drives all iOS audio route selection via `AVAudioSession`
/// *before* the core (miniaudio) opens its device. miniaudio does NOT touch /// *before* the core (miniaudio) opens its device. This class is the sole owner of the
/// `AVAudioSession` on iOS but ONLY because the core deliberately opens its devices /// session: miniaudio does NOT touch `AVAudioSession` on iOS, because the core opens its
/// through a `ma_context` configured with `sessionCategory = none` + /// devices through a `ma_context` configured with `sessionCategory = none` +
/// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in /// `noAudioSessionActivate/Deactivate` (see `AudioEngine::make_context_config` in
/// `core/src/audio/audio_engine.cpp`). With miniaudio's default NULL-context path it WOULD /// `core/src/audio/audio_engine.cpp`). Without that, miniaudio's default path resets the
/// reset the category to `Record`/`Playback` with no options on every device open, wiping /// category to `Record`/`Playback` with no options on every device open, wiping
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output (the long-standing /// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output so that
/// "stereo mic kills output" bug). With that disabled, this class is the sole owner of the /// config must stay in place. All iOS audio routing (input port selection, mic
/// session. All iOS audio routing (input port selection, mic orientation/polar patterns, /// orientation/polar patterns, HFP vs A2DP, measurement/raw mode, stereo capture) must be
/// HFP vs A2DP, measurement/raw mode, stereo capture) must be driven from here. /// driven from here.
/// ///
/// The three user-facing choices: /// The three user-facing choices:
/// 1. **Input port** which physical input (built-in mic, Bluetooth HFP, headset, /// 1. **Input port** which physical input (built-in mic, Bluetooth HFP, headset,
@@ -32,16 +32,14 @@ private let logger = Logger(subsystem: "cat.voice.VoiceCatiOS", category: "IOSAu
/// but shows a warning when the output route is the speaker (echo risk, no AEC). /// 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 /// 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 /// built-in mic's data source to the `.stereo` polar pattern. The recipe is:
/// TeamTalk5's `SoundDevicesModel.selectDataSource` + `UtilSound.setupSoundDevices`, which /// `setPreferredDataSource(.stereo source)` + `setPreferredPolarPattern(.stereo)` +
/// achieves stereo mic + A2DP output simultaneously) is: `setPreferredDataSource(.stereo /// `setPreferredInput(built-in mic)` + `setInputDataSource(stereo source)`. The channel
/// source)` + `setPreferredPolarPattern(.stereo)` + `setPreferredInput(built-in mic)` + /// count itself must NOT be requested via `setPreferredInputNumberOfChannels(2)` that
/// `setInputDataSource(stereo source)`. The channel count itself is NOT requested via /// session-level call collapses the A2DP output route. Instead the core is told to open the
/// `setPreferredInputNumberOfChannels(2)` that session-level call is what collapses the /// device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the AVAudioSession
/// A2DP output route (the original "stereo kills output" bug). Instead the core is told to /// input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route stable during
/// open the device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the /// the HFPA2DP and monostereo reconfigurations.
/// AVAudioSession input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route
/// stable during the HFPA2DP and monostereo reconfigurations.
/// ///
/// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center /// Voice Isolation / Wide Spectrum (iOS 17+/18+) are user-toggleable in Control Center
/// for `.voiceChat` apps surfaced as a hint, not a programmatic toggle. /// for `.voiceChat` apps surfaced as a hint, not a programmatic toggle.
@@ -332,12 +330,9 @@ final class IOSAudioRouter: ObservableObject {
switch bluetoothMode { switch bluetoothMode {
case .btHfpVoice: case .btHfpVoice:
// Voice Chat: allow BOTH HFP and A2DP, let iOS pick the right profile for the // 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): // connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
// [.allowBluetoothHFP, .allowAirPlay, .allowBluetoothA2DP] // that blocks A2DP headphones from receiving audio. HFP is preferred (the system
// Making HFP and A2DP mutually exclusive (HFP-only here) blocks A2DP headphones // uses it when a two-way mic path is needed); A2DP stays available for output-only.
// 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(.allowBluetoothHFP)
options.insert(.allowBluetoothA2DP) options.insert(.allowBluetoothA2DP)
options.insert(.allowAirPlay) options.insert(.allowAirPlay)
@@ -345,8 +340,6 @@ final class IOSAudioRouter: ObservableObject {
// A2DP output only (no HFP). With HFP disabled the Bluetooth device can only be // 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 // 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. // 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(.allowBluetoothA2DP)
options.insert(.allowAirPlay) options.insert(.allowAirPlay)
case .builtInMicSpeaker: case .builtInMicSpeaker:
@@ -354,12 +347,10 @@ final class IOSAudioRouter: ObservableObject {
options.insert(.defaultToSpeaker) options.insert(.defaultToSpeaker)
} }
// 2. Set category + mode. Recipe validated against TeamTalk5 / Ferrite, which both do // 2. Set category + mode, chosen per scenario:
// built-in stereo mic + A2DP Bluetooth output simultaneously:
// - Stereo capture: .default .voiceChat (the AEC/VPIO path) forces MONO, so stereo // - 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 // 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 // keeps the A2DP output route alive.
// channel-count request collapsed A2DP output the "stereo kills output" bug.)
// - Mono raw/studio: .measurement all system processing off. // - Mono raw/studio: .measurement all system processing off.
// - Mono + A2DP output: .videoRecording keeps A2DP output without VPIO (no AEC). // - Mono + A2DP output: .videoRecording keeps A2DP output without VPIO (no AEC).
// - Mono standard (HFP or speaker): .voiceChat hardware AEC/AGC/HPF. // - Mono standard (HFP or speaker): .voiceChat hardware AEC/AGC/HPF.
@@ -384,13 +375,12 @@ final class IOSAudioRouter: ObservableObject {
// 3. Input & mic-capsule configuration. // 3. Input & mic-capsule configuration.
if captureChannels == .stereo { if captureChannels == .stereo {
// Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input // Stereo: enable the built-in mic's .stereo polar pattern AND anchor the input
// route explicitly via setPreferredInput + setInputDataSource. The session-level // route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
// channel-count call (setPreferredInputNumberOfChannels(2)) is what collapses the // the system routes input to the built-in mic, but without the explicit
// A2DP output route NOT setPreferredInput (TeamTalk5 uses setPreferredInput and // preferred-input anchor the route can collapse during the mode switch
// gets stereo + A2DP). With HFP disabled the system routes input to the built-in // (.voiceChat .default) and the output dies. The channel count is requested by
// mic, but without the explicit preferred-input anchor the route can collapse // miniaudio at the audio-unit level (vc_set_capture_channels), NOT via
// during the mode switch (.voiceChat .default) and the output dies. The channel // setPreferredInputNumberOfChannels(2) that call collapses the A2DP output route.
// count is requested by miniaudio at the audio-unit level (vc_set_capture_channels).
configureStereoCapture(session: session) configureStereoCapture(session: session)
} else if let portId = selectedInputPortId, !portId.isEmpty, } else if let portId = selectedInputPortId, !portId.isEmpty,
let port = session.availableInputs?.first(where: { $0.uid == portId }) { let port = session.availableInputs?.first(where: { $0.uid == portId }) {
@@ -411,18 +401,16 @@ final class IOSAudioRouter: ObservableObject {
updateWarnings() updateWarnings()
} }
/// Enable 2-channel capture on the built-in mic. Mirrors TeamTalk5's recipe /// Enable 2-channel capture on the built-in mic. The recipe that achieves stereo mic +
/// (`SoundDevicesModel.selectDataSource` + `UtilSound.setupSoundDevices`), which /// A2DP Bluetooth output simultaneously:
/// achieves stereo mic + A2DP Bluetooth output simultaneously:
/// 1. `setPreferredDataSource(stereoSource)` on the built-in mic port /// 1. `setPreferredDataSource(stereoSource)` on the built-in mic port
/// 2. `setPreferredPolarPattern(.stereo)` on that data source /// 2. `setPreferredPolarPattern(.stereo)` on that data source
/// 3. `setPreferredInput(builtIn)` anchor the input route explicitly (this is NOT /// 3. `setPreferredInput(builtIn)` anchor the input route explicitly. Without this
/// what collapses A2DP the session-level `setPreferredInputNumberOfChannels(2)` /// anchor the route can collapse during the mode switch (.voiceChat .default).
/// is. Without this anchor the route can collapse during the mode switch.)
/// 4. `setInputDataSource(stereoSource)` commit the data source at the session level /// 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 /// 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)` /// `vc_set_capture_channels(2)`. We must NOT call `setPreferredInputNumberOfChannels(2)`
/// that session-level call is the one that collapses the A2DP output route. /// that session-level call collapses the A2DP output route.
private func configureStereoCapture(session: AVAudioSession) { private func configureStereoCapture(session: AVAudioSession) {
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic }) guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
else { else {
@@ -438,12 +426,10 @@ final class IOSAudioRouter: ObservableObject {
do { do {
try builtIn.setPreferredDataSource(stereoSource) try builtIn.setPreferredDataSource(stereoSource)
try stereoSource.setPreferredPolarPattern(.stereo) try stereoSource.setPreferredPolarPattern(.stereo)
// Anchor the input route explicitly. TeamTalk5 does this (SoundDevicesModel // Anchor the input route explicitly; without it the route can collapse during
// .selectDataSource:147); without it the route can collapse during the mode // the mode switch (.voiceChat .default) and the A2DP output dies.
// switch (.voiceChat .default) and the A2DP output dies.
try session.setPreferredInput(builtIn) try session.setPreferredInput(builtIn)
// Commit the data source at the session level (TeamTalk does this at // Commit the data source at the session level. setPreferredDataSource alone only
// SoundDevicesModel.selectDataSource:148). setPreferredDataSource alone only
// sets the port-level preference; setInputDataSource makes it the active source. // sets the port-level preference; setInputDataSource makes it the active source.
try session.setInputDataSource(stereoSource) try session.setInputDataSource(stereoSource)
logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored") logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored")

View File

@@ -101,8 +101,7 @@ final class SessionState {
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking") addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
case .streamStarted: case .streamStarted:
// A remote user started a stream ensure the audio session is active so we can // A remote user started a stream ensure the audio session is active so we can
// hear them even if we haven't joined voice ourselves. Previously the session was // hear them even if we haven't joined voice ourselves.
// only activated when the user pressed Join Voice, so remote audio was silent.
if ev.userId != selfUserId { if ev.userId != selfUserId {
do { do {
try AudioSessionManager.shared.ensureSessionActive() try AudioSessionManager.shared.ensureSessionActive()
@@ -110,8 +109,6 @@ final class SessionState {
addActivity("Audio session activate failed: \(error)") addActivity("Audio session activate failed: \(error)")
} }
} }
// TEMP DIAGNOSTIC: the core opens its miniaudio devices around now log the
// session state to confirm miniaudio is no longer resetting the category to Record.
AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))") AudioSessionManager.shared.logSessionState("stream started (user \(ev.userId))")
addActivity("Stream started (user \(ev.userId))") addActivity("Stream started (user \(ev.userId))")
case .streamStopped: case .streamStopped:

View File

@@ -491,8 +491,7 @@ VC_API vc_result vc_audio_resume(vc_client* c);
* were opened), vc_audio_restart() uninitializes and re-initializes the capture and playback * were opened), vc_audio_restart() uninitializes and re-initializes the capture and playback
* devices so they pick up a new AVAudioSession route. Call this from the Swift layer AFTER * devices so they pick up a new AVAudioSession route. Call this from the Swift layer AFTER
* reconfiguring AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) * reconfiguring AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.)
* so the core's devices reopen against the new route. Mirrors TeamTalk5's * so the core's devices reopen against the new route.
* closeSoundDevices()/initSoundInputDevice()/initSoundOutputDevice() reconfiguration pattern.
* Safe to call when the engine is not running (it will just start it). */ * Safe to call when the engine is not running (it will just start it). */
VC_API vc_result vc_audio_restart(vc_client* c); VC_API vc_result vc_audio_restart(vc_client* c);

View File

@@ -301,8 +301,7 @@ class AudioEngine {
// selected. The iOS Swift layer is the SOLE owner of the audio session (activated in // selected. The iOS Swift layer is the SOLE owner of the audio session (activated in
// AppState on connect, configured by IOSAudioRouter); miniaudio must only open the // AppState on connect, configured by IOSAudioRouter); miniaudio must only open the
// AudioUnit against the already-configured, already-active route. These coreaudio fields // AudioUnit against the already-configured, already-active route. These coreaudio fields
// are no-ops on non-Apple backends. See PROGRESS.md / the "stereo mic kills output" // are no-ops on non-Apple backends.
// investigation.
static ma_context_config make_context_config(); static ma_context_config make_context_config();
// Owned context, shared by the playback, capture, and loopback devices so they all honor // Owned context, shared by the playback, capture, and loopback devices so they all honor

View File

@@ -1426,8 +1426,7 @@ vc_result vc_client::audio_resume() {
vc_result vc_client::audio_restart() { vc_result vc_client::audio_restart() {
// Full uninit + re-init (not just stop/start like suspend/resume) so the miniaudio // Full uninit + re-init (not just stop/start like suspend/resume) so the miniaudio
// devices reopen against the current AVAudioSession route. Mirrors TeamTalk5's // devices reopen against the current AVAudioSession route.
// closeSoundDevices()/initSoundInputDevice()/initSoundOutputDevice() pattern.
// IMPORTANT: only restart if the engine was already running — calling // IMPORTANT: only restart if the engine was already running — calling
// ensure_audio_running() when the engine isn't running would start it prematurely // ensure_audio_running() when the engine isn't running would start it prematurely
// (opening the capture device / mic without an active mic stream), which on iOS // (opening the capture device / mic without an active mic stream), which on iOS