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:
@@ -138,7 +138,28 @@ void JitterBuffer::reset() {
|
||||
|
||||
AudioEngine::AudioEngine() = default;
|
||||
|
||||
AudioEngine::~AudioEngine() { stop(); }
|
||||
AudioEngine::~AudioEngine() {
|
||||
stop();
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
stop_loopback_capture(); // loopback has an independent lifecycle — close it before the context
|
||||
if (context_inited_) {
|
||||
ma_context_uninit(&context_);
|
||||
context_inited_ = false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
ma_context_config AudioEngine::make_context_config() {
|
||||
ma_context_config cfg = ma_context_config_init();
|
||||
// iOS: leave AVAudioSession entirely to the Swift layer (IOSAudioRouter). See the
|
||||
// make_context_config() declaration in audio_engine.h for the full rationale.
|
||||
cfg.coreaudio.sessionCategory = ma_ios_session_category_none; // don't call setCategory
|
||||
cfg.coreaudio.noAudioSessionActivate = MA_TRUE; // don't setActive(true) on device init
|
||||
cfg.coreaudio.noAudioSessionDeactivate = MA_TRUE; // don't setActive(false) on device uninit
|
||||
return cfg;
|
||||
}
|
||||
#endif
|
||||
|
||||
bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
|
||||
if (running_.load()) return false;
|
||||
@@ -163,6 +184,18 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
|
||||
#endif
|
||||
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
// Own the ma_context (lazily, reused across restarts) so miniaudio does not touch
|
||||
// AVAudioSession on iOS — the Swift IOSAudioRouter is the sole session owner. Without
|
||||
// this, ma_device_init(nullptr, ...) below would reset the session category to Record/
|
||||
// Playback with no options on every open, killing headphone/A2DP output. See
|
||||
// make_context_config() in audio_engine.h. If context init fails we fall back to a NULL
|
||||
// context (degraded: miniaudio manages the session) rather than leaving audio dead.
|
||||
if (!context_inited_) {
|
||||
ma_context_config ctx_cfg = make_context_config();
|
||||
context_inited_ = (ma_context_init(nullptr, 0, &ctx_cfg, &context_) == MA_SUCCESS);
|
||||
}
|
||||
ma_context* ctx = context_inited_ ? &context_ : nullptr;
|
||||
|
||||
// ── Playback device (opened first on iOS: commits the output route — e.g. A2DP —
|
||||
// before the capture device starts. Starting stereo capture can trigger an iOS audio
|
||||
// route reconfiguration; opening playback first ensures A2DP is already committed
|
||||
@@ -179,7 +212,7 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
|
||||
pb_cfg.pUserData = this;
|
||||
pb_cfg.playback.pDeviceID = have_pb_id ? &pb_id : nullptr;
|
||||
|
||||
if (ma_device_init(nullptr, &pb_cfg, &playback_device_) == MA_SUCCESS) {
|
||||
if (ma_device_init(ctx, &pb_cfg, &playback_device_) == MA_SUCCESS) {
|
||||
if (ma_device_start(&playback_device_) == MA_SUCCESS) {
|
||||
playback_started_ = true;
|
||||
} else {
|
||||
@@ -190,7 +223,7 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
|
||||
// though WASAPI shared mode normally remixes transparently. Retry once at mono rather
|
||||
// than leaving playback dead.
|
||||
pb_cfg.playback.channels = 1;
|
||||
if (ma_device_init(nullptr, &pb_cfg, &playback_device_) == MA_SUCCESS) {
|
||||
if (ma_device_init(ctx, &pb_cfg, &playback_device_) == MA_SUCCESS) {
|
||||
if (ma_device_start(&playback_device_) == MA_SUCCESS) {
|
||||
playback_started_ = true;
|
||||
params_.playback_channels = 1;
|
||||
@@ -217,7 +250,7 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
|
||||
// below becomes a zero-copy passthrough rather than a copy every two callbacks.
|
||||
cap_cfg.periodSizeInFrames = static_cast<ma_uint32>(frame_samples_);
|
||||
|
||||
if (ma_device_init(nullptr, &cap_cfg, &capture_device_) == MA_SUCCESS) {
|
||||
if (ma_device_init(ctx, &cap_cfg, &capture_device_) == MA_SUCCESS) {
|
||||
if (ma_device_start(&capture_device_) == MA_SUCCESS) {
|
||||
capture_started_ = true;
|
||||
} else {
|
||||
@@ -232,8 +265,12 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
|
||||
std::vector<DeviceInfo> AudioEngine::enumerate_devices(bool capture) {
|
||||
std::vector<DeviceInfo> result;
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
// Use the no-AVAudioSession-management config here too: device enumeration runs at
|
||||
// SessionState init (refreshDevices) and on settings views, and a default-config context
|
||||
// would call setCategory()/setActive() on iOS, disrupting the session the Swift layer owns.
|
||||
ma_context ctx;
|
||||
if (ma_context_init(nullptr, 0, nullptr, &ctx) != MA_SUCCESS) return result;
|
||||
ma_context_config ctx_cfg = make_context_config();
|
||||
if (ma_context_init(nullptr, 0, &ctx_cfg, &ctx) != MA_SUCCESS) return result;
|
||||
|
||||
ma_device_info* playback_infos = nullptr;
|
||||
ma_uint32 playback_count = 0;
|
||||
@@ -619,14 +656,19 @@ bool AudioEngine::start_loopback_capture(int kind, int channels) {
|
||||
// along with everything else playing — an accepted self-echo-loop characteristic of
|
||||
// desktop-audio capture, not a bug.
|
||||
|
||||
if (ma_device_init(nullptr, &cfg, &loopback_device_) != MA_SUCCESS) {
|
||||
// Use the engine's owned context (shared with playback/capture) so loopback honors the
|
||||
// same no-AVAudioSession-management config. Loopback is Windows/WASAPI-only today, where
|
||||
// the coreaudio fields are irrelevant, but keep it consistent. start() inits the context
|
||||
// before any loopback can be requested; fall back to NULL if it somehow isn't.
|
||||
ma_context* ctx = context_inited_ ? &context_ : nullptr;
|
||||
if (ma_device_init(ctx, &cfg, &loopback_device_) != MA_SUCCESS) {
|
||||
// Fallback: some unusual render endpoints may reject channels=2 even though WASAPI
|
||||
// shared mode normally remixes transparently. Retry once at mono (mirror the playback
|
||||
// device's fallback in start()) rather than leaving screen-audio capture dead.
|
||||
if (ch != 1) {
|
||||
ch = 1;
|
||||
cfg.capture.channels = 1;
|
||||
if (ma_device_init(nullptr, &cfg, &loopback_device_) != MA_SUCCESS) return false;
|
||||
if (ma_device_init(ctx, &cfg, &loopback_device_) != MA_SUCCESS) return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user