fix(ios): stop miniaudio from clobbering AVAudioSession (stereo->A2DP output death)

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>
This commit is contained in:
2026-06-20 02:34:07 +02:00
parent dcb7e6eeca
commit f1e1ef59ed
6 changed files with 142 additions and 9 deletions

View File

@@ -292,6 +292,26 @@ class AudioEngine {
void on_capture(const int16_t* pcm, ma_uint32 frames);
void on_playback(int16_t* out, ma_uint32 frames);
// Build the ma_context config that keeps miniaudio from managing AVAudioSession on iOS.
// With a NULL context, ma_device_init runs miniaudio's iOS "hack" (miniaudio.h ~44057)
// that calls setCategory()/setActive() on EVERY device open — capture →
// AVAudioSessionCategoryRecord with zero options. That obliterates the category/mode/
// options the Swift IOSAudioRouter configured (notably .playAndRecord and
// .allowBluetoothA2DP), which is what killed headphone/A2DP output when stereo was
// 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.
static ma_context_config make_context_config();
// Owned context, shared by the playback, capture, and loopback devices so they all honor
// the no-session-management config above. Lives for the engine's lifetime (init lazily in
// start(), reused across stop()/start() restarts, uninit in the destructor) — loopback has
// an independent start/stop lifecycle, so the context must outlive a single stop().
ma_context context_{};
bool context_inited_ = false;
ma_device capture_device_{};
ma_device playback_device_{};
bool capture_started_ = false;