From f3c1172a3e9e6f8144fa04b82283126843682a39 Mon Sep 17 00:00:00 2001 From: Talon Date: Sat, 20 Jun 2026 03:03:34 +0200 Subject: [PATCH] docs(ios): drop stale debug comments + TeamTalk refs from audio router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Sources/VoiceCatCore/VoiceCatClient.swift | 5 +- .../iOS/VoiceCatiOS/AudioSessionManager.swift | 20 ++--- .../iOS/VoiceCatiOS/IOSAudioRouter.swift | 88 ++++++++----------- .../apple/iOS/VoiceCatiOS/SessionState.swift | 5 +- core/include/voicecat.h | 3 +- core/src/audio/audio_engine.h | 3 +- core/src/core/client.cpp | 3 +- 7 files changed, 51 insertions(+), 76 deletions(-) diff --git a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift index 32d8c24..59ce3ae 100644 --- a/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift +++ b/clients/apple/Sources/VoiceCatCore/VoiceCatClient.swift @@ -349,9 +349,8 @@ public final class VoiceCatClient { /// AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) so the /// 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 - /// active when they were opened), this fully re-initializes them. Mirrors TeamTalk5's - /// closeSoundDevices()/initSoundInputDevice()/initSoundOutputDevice() pattern. Safe to - /// call when the engine is not running (it will just start it). + /// active when they were opened), this fully re-initializes them. Safe to call when the + /// engine is not running (it will just start it). @discardableResult public func audioRestart() -> VoiceCatResult { VoiceCatResult(vc_audio_restart(handle)) diff --git a/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift index 7fe3b31..342a653 100644 --- a/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift +++ b/clients/apple/iOS/VoiceCatiOS/AudioSessionManager.swift @@ -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] = [] diff --git a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift index bff4a7e..4d50871 100644 --- a/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift +++ b/clients/apple/iOS/VoiceCatiOS/IOSAudioRouter.swift @@ -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 HFP→A2DP and mono→stereo 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 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. @@ -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") diff --git a/clients/apple/iOS/VoiceCatiOS/SessionState.swift b/clients/apple/iOS/VoiceCatiOS/SessionState.swift index c1ca79d..2c48b31 100644 --- a/clients/apple/iOS/VoiceCatiOS/SessionState.swift +++ b/clients/apple/iOS/VoiceCatiOS/SessionState.swift @@ -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: diff --git a/core/include/voicecat.h b/core/include/voicecat.h index dda05a4..ba70491 100644 --- a/core/include/voicecat.h +++ b/core/include/voicecat.h @@ -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 * devices so they pick up a new AVAudioSession route. Call this from the Swift layer AFTER * reconfiguring AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.) - * so the core's devices reopen against the new route. Mirrors TeamTalk5's - * closeSoundDevices()/initSoundInputDevice()/initSoundOutputDevice() reconfiguration pattern. + * so the core's devices reopen against the new route. * Safe to call when the engine is not running (it will just start it). */ VC_API vc_result vc_audio_restart(vc_client* c); diff --git a/core/src/audio/audio_engine.h b/core/src/audio/audio_engine.h index df9d41f..9bb6d3c 100644 --- a/core/src/audio/audio_engine.h +++ b/core/src/audio/audio_engine.h @@ -301,8 +301,7 @@ class AudioEngine { // 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 // 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" - // investigation. + // are no-ops on non-Apple backends. static ma_context_config make_context_config(); // Owned context, shared by the playback, capture, and loopback devices so they all honor diff --git a/core/src/core/client.cpp b/core/src/core/client.cpp index 707ac51..b2b3bbf 100644 --- a/core/src/core/client.cpp +++ b/core/src/core/client.cpp @@ -1426,8 +1426,7 @@ vc_result vc_client::audio_resume() { vc_result vc_client::audio_restart() { // Full uninit + re-init (not just stop/start like suspend/resume) so the miniaudio - // devices reopen against the current AVAudioSession route. Mirrors TeamTalk5's - // closeSoundDevices()/initSoundInputDevice()/initSoundOutputDevice() pattern. + // devices reopen against the current AVAudioSession route. // IMPORTANT: only restart if the engine was already running — calling // 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