fix(ios): fix stereo mic + A2DP output silence

Three coordinated fixes for the bug where enabling stereo mic capture
caused all audio output (A2DP, speaker, wired) to go silent:

1. audio_engine.cpp — open playback before capture
   On iOS, starting the stereo capture AudioUnit can trigger an audio
   route reconfiguration that drops A2DP before the playback device has
   a chance to claim the route. Opening and starting the playback device
   first commits the output route (A2DP), so iOS is less likely to drop
   it when stereo capture activates afterward.

2. client.cpp — decouple set_capture_channels from engine restart
   Previously vc_set_capture_channels() stopped and restarted the engine
   immediately, which opened capture first (old ordering) and raced
   against the settling AVAudioSession route. Now it only stores the
   channel count; the caller (Swift via vc_audio_restart) controls when
   the engine restarts, after the route has settled.

3. IOSAudioRouter.swift — call audioRestart() after channel config
   selectCaptureChannels() and applyPreset() now call audioRestart()
   after applyConfiguration() + setCaptureChannels(). This is the
   vc_audio_restart() path that was added to the ABI in fdcc84f but
   never wired up in the Swift layer. The restart sees the stored
   channel count and reopens devices in the correct order (playback
   first, capture second).

The doStartMicStream path is unaffected: setCaptureChannels is called
before the server acknowledges the stream (engine not yet running), so
ensure_audio_running() picks up capture_channels=2 directly when the
stream is confirmed and opens with the right count from the start.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 17:39:23 +02:00
parent fdcc84fb42
commit dcb7e6eeca
5 changed files with 62 additions and 60 deletions

View File

@@ -163,32 +163,10 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
#endif
#ifdef VOICECAT_HAS_AUDIO
// ── Capture device ──────────────────────────────────────────────────────
ma_device_id cap_id{};
bool have_cap_id = !p.capture_device_id.empty() &&
hex_decode_device_id(p.capture_device_id, &cap_id);
ma_device_config cap_cfg = ma_device_config_init(ma_device_type_capture);
cap_cfg.capture.format = ma_format_s16;
cap_cfg.capture.channels = p.capture_channels;
cap_cfg.sampleRate = p.sample_rate;
cap_cfg.dataCallback = capture_data_cb;
cap_cfg.pUserData = this;
cap_cfg.capture.pDeviceID = have_cap_id ? &cap_id : nullptr; // null = default device
// Hint: request the encoder's frame size as the callback period. WASAPI shared mode may
// not honor this (the hardware period is fixed), but when it is honored the accumulator
// 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_start(&capture_device_) == MA_SUCCESS) {
capture_started_ = true;
} else {
ma_device_uninit(&capture_device_);
}
}
// ── Playback device ─────────────────────────────────────────────────────
// ── 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
// and is less likely to be dropped when the capture AudioUnit activates.) ──────────
ma_device_id pb_id{};
bool have_pb_id = !p.playback_device_id.empty() &&
hex_decode_device_id(p.playback_device_id, &pb_id);
@@ -221,6 +199,31 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
}
}
}
// ── Capture device (opened after playback so the output route is already committed) ──
ma_device_id cap_id{};
bool have_cap_id = !p.capture_device_id.empty() &&
hex_decode_device_id(p.capture_device_id, &cap_id);
ma_device_config cap_cfg = ma_device_config_init(ma_device_type_capture);
cap_cfg.capture.format = ma_format_s16;
cap_cfg.capture.channels = p.capture_channels;
cap_cfg.sampleRate = p.sample_rate;
cap_cfg.dataCallback = capture_data_cb;
cap_cfg.pUserData = this;
cap_cfg.capture.pDeviceID = have_cap_id ? &cap_id : nullptr; // null = default device
// Hint: request the encoder's frame size as the callback period. WASAPI shared mode may
// not honor this (the hardware period is fixed), but when it is honored the accumulator
// 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_start(&capture_device_) == MA_SUCCESS) {
capture_started_ = true;
} else {
ma_device_uninit(&capture_device_);
}
}
#endif // VOICECAT_HAS_AUDIO
return true;