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

@@ -71,6 +71,15 @@ final class AppState {
client.onEvent = { [weak self] ev in
Task { @MainActor [weak self] in self?.handleConnectEvent(ev, server: server) }
}
// Put the core into external-playback mode BEFORE connect, so the flag is set on the
// io thread before any message is processed. The server sends AuthResult immediately
// followed by ServerStateSnapshot; handle_server_state runs ensure_audio_running() on
// the io thread, and if external_playback_ were still false at that point the core
// would open a hardware miniaudio playback+capture device (see the matching fix in
// vc_client::ensure_audio_running). Setting it here before connect guarantees the
// unified external path is in effect from the first frame. setExternalPlayback only
// flips an atomic + forwards to the engine's setter; both are safe pre-connect.
client.setExternalPlayback(true)
client.connect(host: server.host, port: server.port)
// Auth is queued immediately the core serialises it behind TLS + TOFU.
@@ -161,12 +170,10 @@ final class AppState {
self.session = newSession
EventFeedback.shared.play(.login)
EventFeedback.shared.speak("Connected")
// Put the core into external-playback mode ONCE, now, before the session is
// activated or any remote stream can arrive so the core never opens a miniaudio
// device on iOS (the single ordering rule of the unified audio path). Then activate
// the session and start the engine in listening mode so remote audio plays the
// moment someone talks, even before we join voice (no "can't hear anyone").
client.setExternalPlayback(true)
// External-playback mode was enabled before connect() so the core never opens a
// miniaudio device on iOS (the single ordering rule of the unified audio path).
// Now activate the session and start the engine in listening mode so remote audio
// plays the moment someone talks, even before we join voice (no "can't hear anyone").
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {