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

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

View File

@@ -5,16 +5,16 @@ 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` +
/// *before* the core (miniaudio) opens its device. This class is the sole owner of the
/// session: miniaudio does NOT touch `AVAudioSession` on iOS, because the core 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.
/// `core/src/audio/audio_engine.cpp`). Without that, miniaudio's default path resets the
/// category to `Record`/`Playback` with no options on every device open, wiping
/// `.allowBluetoothA2DP`/`.playAndRecord` and killing headphone/A2DP output so that
/// config must stay in place. 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,
@@ -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).
///
/// 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 HFPA2DP and monostereo reconfigurations.
/// built-in mic's data source to the `.stereo` polar pattern. The recipe is:
/// `setPreferredDataSource(.stereo source)` + `setPreferredPolarPattern(.stereo)` +
/// `setPreferredInput(built-in mic)` + `setInputDataSource(stereo source)`. The channel
/// count itself must NOT be requested via `setPreferredInputNumberOfChannels(2)` that
/// session-level call collapses the A2DP output route. Instead the core is told to open the
/// device with 2 channels via `vc_set_capture_channels(streamId, 2)`, and the AVAudioSession
/// input anchor (`setPreferredInput` + `setInputDataSource`) keeps the route stable during
/// the HFPA2DP and monostereo 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.
@@ -332,12 +330,9 @@ final class IOSAudioRouter: ObservableObject {
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.
// connected device. HFP and A2DP must NOT be made mutually exclusive (HFP-only)
// that blocks A2DP headphones from receiving audio. HFP is preferred (the system
// uses it when a two-way mic path is needed); A2DP stays available for output-only.
options.insert(.allowBluetoothHFP)
options.insert(.allowBluetoothA2DP)
options.insert(.allowAirPlay)
@@ -345,8 +340,6 @@ final class IOSAudioRouter: ObservableObject {
// 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:
@@ -354,12 +347,10 @@ final class IOSAudioRouter: ObservableObject {
options.insert(.defaultToSpeaker)
}
// 2. Set category + mode. Recipe validated against TeamTalk5 / Ferrite, which both do
// built-in stereo mic + A2DP Bluetooth output simultaneously:
// 2. Set category + mode, chosen per scenario:
// - Stereo capture: .default .voiceChat (the AEC/VPIO path) forces MONO, so stereo
// is only possible in a non-VPIO mode. .default supports multi-capsule stereo AND
// keeps the A2DP output route alive. (Earlier .videoRecording + a session-level
// channel-count request collapsed A2DP output the "stereo kills output" bug.)
// keeps the A2DP output route alive.
// - Mono raw/studio: .measurement all system processing off.
// - Mono + A2DP output: .videoRecording keeps A2DP output without VPIO (no AEC).
// - Mono standard (HFP or speaker): .voiceChat hardware AEC/AGC/HPF.
@@ -384,13 +375,12 @@ final class IOSAudioRouter: ObservableObject {
// 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).
// route explicitly via setPreferredInput + setInputDataSource. With HFP disabled
// the system routes input to the built-in mic, but without the explicit
// preferred-input anchor the route can collapse during the mode switch
// (.voiceChat .default) and the output dies. The channel count is requested by
// miniaudio at the audio-unit level (vc_set_capture_channels), NOT via
// setPreferredInputNumberOfChannels(2) that call collapses the A2DP output route.
configureStereoCapture(session: session)
} else if let portId = selectedInputPortId, !portId.isEmpty,
let port = session.availableInputs?.first(where: { $0.uid == portId }) {
@@ -411,18 +401,16 @@ final class IOSAudioRouter: ObservableObject {
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:
/// Enable 2-channel capture on the built-in mic. The recipe that achieves stereo mic +
/// A2DP Bluetooth output simultaneously:
/// 1. `setPreferredDataSource(stereoSource)` on the built-in mic port
/// 2. `setPreferredPolarPattern(.stereo)` on that data source
/// 3. `setPreferredInput(builtIn)` anchor the input route explicitly (this is NOT
/// what collapses A2DP the session-level `setPreferredInputNumberOfChannels(2)`
/// is. Without this anchor the route can collapse during the mode switch.)
/// 3. `setPreferredInput(builtIn)` anchor the input route explicitly. Without this
/// anchor the route can collapse during the mode switch (.voiceChat .default).
/// 4. `setInputDataSource(stereoSource)` commit the data source at the session level
/// The channel count itself is 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.
/// `vc_set_capture_channels(2)`. We must NOT call `setPreferredInputNumberOfChannels(2)`
/// that session-level call collapses the A2DP output route.
private func configureStereoCapture(session: AVAudioSession) {
guard let builtIn = session.availableInputs?.first(where: { $0.portType == .builtInMic })
else {
@@ -438,12 +426,10 @@ final class IOSAudioRouter: ObservableObject {
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.
// Anchor the input route explicitly; without it the route can collapse during
// the mode switch (.voiceChat .default) and the A2DP output dies.
try session.setPreferredInput(builtIn)
// Commit the data source at the session level (TeamTalk does this at
// SoundDevicesModel.selectDataSource:148). setPreferredDataSource alone only
// Commit the data source at the session level. setPreferredDataSource alone only
// sets the port-level preference; setInputDataSource makes it the active source.
try session.setInputDataSource(stereoSource)
logger.info("stereo capsule enabled — source=\(stereoSource.dataSourceName), pattern=.stereo, input anchored")

View File

@@ -101,8 +101,7 @@ final class SessionState {
addActivity(talking ? "\(who) started talking" : "\(who) stopped talking")
case .streamStarted:
// 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
// only activated when the user pressed Join Voice, so remote audio was silent.
// hear them even if we haven't joined voice ourselves.
if ev.userId != selfUserId {
do {
try AudioSessionManager.shared.ensureSessionActive()
@@ -110,8 +109,6 @@ final class SessionState {
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))")
addActivity("Stream started (user \(ev.userId))")
case .streamStopped: