fix(ios): stop core opening a second mic device — dual capture/crackle
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

On iOS the remote end heard the mic twice and crackly (BT headset + internal
mic in Voice Chat; mono + stereo copies of the internal mic in Stereo Mic).

Root cause is an io-thread ordering race. `ensure_audio_running()` only set
`external_capture` when a MIC stream already existed, but it also runs from
`sync_remote_streams` on the post-auth `ServerStateSnapshot` — before the user
joins voice. With `external_playback_` still false and no MIC stream,
`AudioEngine::start()` opened a real miniaudio capture device that stayed open
all session (later calls early-return on running()), racing the AVAudioEngine
input tap fed via `vc_stream_feed_pcm`. `on_capture_frame` then encoded+sent
both paths — the mic transmitted twice, the two unsynchronized capture clocks
producing the crackle.

- core (ensure_audio_running): force `external_capture = true` whenever
  `external_playback_` is set, so iOS unified mode never opens a hardware
  capture device. No-op on desktop.
- ios (AppState): move `setExternalPlayback(true)` before `connect()`, so the
  flag is set before the io thread processes any message — closing the race.

Verified on device: remote end hears the iOS mic once and clean in both Voice
Chat (+ BT) and Stereo Mic.
This commit is contained in:
2026-06-23 17:47:49 +02:00
parent 19c2fb6ec9
commit b14cf2a4e8
3 changed files with 61 additions and 6 deletions

View File

@@ -1184,6 +1184,16 @@ void vc_client::ensure_audio_running() {
p.external_capture = it->second.external_feed;
}
}
// iOS unified mode (external_playback): capture is ALWAYS external — the Swift AVAudioEngine
// owns the only mic path and feeds via vc_stream_feed_pcm. Force external_capture so start()
// never opens a hardware mic device, even when ensure_audio_running runs before a MIC stream
// exists (a remote stream arriving first — e.g. the post-auth ServerStateSnapshot — starts
// the engine for playback). Without this the core opens a miniaudio capture device that races
// the AVAudioEngine tap → dual mic capture → duplicated, crackly audio on the remote end.
// No-op on desktop, where external_playback_ is never set.
if (external_playback_.load(std::memory_order_acquire)) {
p.external_capture = true;
}
// iOS VPIO: skip the hardware playback device and drive the mixer on a timer, delivering the
// final mix to the mixed-output sink for the Swift VPIO renderer (vc_set_external_playback).
audio_engine_.set_external_playback(external_playback_.load(std::memory_order_acquire));