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:
@@ -577,13 +577,16 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
captureChannels = channels
|
captureChannels = channels
|
||||||
savePreferences()
|
savePreferences()
|
||||||
applyConfiguration()
|
applyConfiguration()
|
||||||
// Sync the core's capture channel count. The core's set_capture_channels handles
|
// Update the core's stored capture channel count (does not restart the engine).
|
||||||
// the engine restart internally (stop + ensure_audio_running) — no need for the
|
|
||||||
// Swift layer to suspend/restart separately.
|
|
||||||
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
||||||
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
||||||
streamId: streamId, channels: channels.channelCount)
|
streamId: streamId, channels: channels.channelCount)
|
||||||
}
|
}
|
||||||
|
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
||||||
|
// count is stored. The engine reopens playback first (committing the A2DP/output
|
||||||
|
// route), then capture — avoiding the race where stereo capture activation drops
|
||||||
|
// A2DP before the playback device has a chance to claim the route.
|
||||||
|
_ = AudioSessionManager.shared.client?.audioRestart()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Presets
|
// MARK: - Presets
|
||||||
@@ -620,12 +623,14 @@ final class IOSAudioRouter: ObservableObject {
|
|||||||
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
|
UserDefaults.standard.set(preset.rawValue, forKey: kPreset)
|
||||||
savePreferences()
|
savePreferences()
|
||||||
applyConfiguration()
|
applyConfiguration()
|
||||||
// Sync the core's capture channel count. The core's set_capture_channels handles
|
// Update the core's stored capture channel count (does not restart the engine).
|
||||||
// the engine restart internally.
|
|
||||||
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
if let streamId = AudioSessionManager.shared.activeMicStreamId {
|
||||||
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
_ = AudioSessionManager.shared.client?.setCaptureChannels(
|
||||||
streamId: streamId, channels: preset.captureChannels.channelCount)
|
streamId: streamId, channels: preset.captureChannels.channelCount)
|
||||||
}
|
}
|
||||||
|
// Restart the engine AFTER AVAudioSession routing has settled and the channel
|
||||||
|
// count is stored. Playback opens first (commits A2DP route), then capture.
|
||||||
|
_ = AudioSessionManager.shared.client?.audioRestart()
|
||||||
refreshRoutes()
|
refreshRoutes()
|
||||||
logger.info("applyPreset — \(preset.rawValue)")
|
logger.info("applyPreset — \(preset.rawValue)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,9 +209,11 @@ final class SessionState {
|
|||||||
// applyPreset). Without this, switching stereo→mono leaves the LocalStream's
|
// applyPreset). Without this, switching stereo→mono leaves the LocalStream's
|
||||||
// capture_channels field at 2 and the next engine start still opens stereo.
|
// capture_channels field at 2 and the next engine start still opens stereo.
|
||||||
AudioSessionManager.shared.activeMicStreamId = streamId
|
AudioSessionManager.shared.activeMicStreamId = streamId
|
||||||
// Apply the user's capture channel selection (mono/stereo) from IOSAudioRouter.
|
// Store the user's capture channel selection before the server acknowledges
|
||||||
// The core opens the capture device via miniaudio on the next engine start;
|
// the stream. The engine hasn't started yet at this point (it starts when
|
||||||
// vc_set_capture_channels tells it to open in stereo (2) or mono (1).
|
// handle_stream_announce_result fires), so vc_set_capture_channels just
|
||||||
|
// stores the value — no restart. ensure_audio_running() picks it up when
|
||||||
|
// the stream is confirmed and opens the device with the right channel count.
|
||||||
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
let channels = IOSAudioRouter.shared.captureChannels.channelCount
|
||||||
if channels != 1 {
|
if channels != 1 {
|
||||||
client.setCaptureChannels(streamId: streamId, channels: channels)
|
client.setCaptureChannels(streamId: streamId, channels: channels)
|
||||||
|
|||||||
@@ -397,14 +397,15 @@ VC_API vc_result vc_test_inject_capture(vc_client* c, uint32_t stream_id, const
|
|||||||
size_t samples);
|
size_t samples);
|
||||||
|
|
||||||
/* Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved).
|
/* Set the capture channel count for a local MIC stream (1 = mono, 2 = stereo interleaved).
|
||||||
* Must be called after vc_stream_start; takes effect on the next AudioEngine restart (e.g. when
|
* Must be called after vc_stream_start. Stores the value; it takes effect on the next engine
|
||||||
* joining voice, or immediately if the engine is already running — it stops and restarts the
|
* (re)start. Does NOT restart the engine itself — the caller must follow up with
|
||||||
* capture device with the new channel count). Defaults to 1 (mono). On iOS the Swift
|
* vc_audio_restart() after AVAudioSession routing has settled (iOS) or after any platform
|
||||||
* AVAudioSession routing layer enables stereo built-in mic capture by switching the built-in
|
* audio-session reconfiguration. On iOS the Swift AVAudioSession routing layer enables stereo
|
||||||
* mic's data source to the .stereo polar pattern (setPreferredDataSource +
|
* built-in mic capture by switching the built-in mic's data source to the .stereo polar
|
||||||
* setPreferredPolarPattern(.stereo) + setPreferredInput + setInputDataSource) and then calls
|
* pattern (setPreferredDataSource + setPreferredPolarPattern(.stereo) + setPreferredInput +
|
||||||
* this to tell the core to open the capture device in stereo. VC_ERR_INVALID_ARG if stream_id
|
* setInputDataSource), calls this to record the desired channel count, and then calls
|
||||||
* is unknown, the stream is not a MIC stream, or channels is not 1 or 2. */
|
* vc_audio_restart() so the core reopens capture and playback against the new route.
|
||||||
|
* VC_ERR_INVALID_ARG if stream_id is unknown or channels is not 1 or 2. */
|
||||||
VC_API vc_result vc_set_capture_channels(vc_client* c, uint32_t stream_id, uint32_t channels);
|
VC_API vc_result vc_set_capture_channels(vc_client* c, uint32_t stream_id, uint32_t channels);
|
||||||
|
|
||||||
/* ── Text ─────────────────────────────────────────────────────────────────── */
|
/* ── Text ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -163,32 +163,10 @@ bool AudioEngine::start(const AudioParams& p, CaptureCallback capture_cb) {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#ifdef VOICECAT_HAS_AUDIO
|
#ifdef VOICECAT_HAS_AUDIO
|
||||||
// ── Capture device ──────────────────────────────────────────────────────
|
// ── Playback device (opened first on iOS: commits the output route — e.g. A2DP —
|
||||||
ma_device_id cap_id{};
|
// before the capture device starts. Starting stereo capture can trigger an iOS audio
|
||||||
bool have_cap_id = !p.capture_device_id.empty() &&
|
// route reconfiguration; opening playback first ensures A2DP is already committed
|
||||||
hex_decode_device_id(p.capture_device_id, &cap_id);
|
// and is less likely to be dropped when the capture AudioUnit activates.) ──────────
|
||||||
|
|
||||||
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 ─────────────────────────────────────────────────────
|
|
||||||
ma_device_id pb_id{};
|
ma_device_id pb_id{};
|
||||||
bool have_pb_id = !p.playback_device_id.empty() &&
|
bool have_pb_id = !p.playback_device_id.empty() &&
|
||||||
hex_decode_device_id(p.playback_device_id, &pb_id);
|
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
|
#endif // VOICECAT_HAS_AUDIO
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1320,24 +1320,15 @@ vc_result vc_client::set_input_device(uint32_t stream_id, const char* device_id)
|
|||||||
|
|
||||||
vc_result vc_client::set_capture_channels(uint32_t stream_id, uint32_t channels) {
|
vc_result vc_client::set_capture_channels(uint32_t stream_id, uint32_t channels) {
|
||||||
if (channels != 1 && channels != 2) return VC_ERR_INVALID_ARG;
|
if (channels != 1 && channels != 2) return VC_ERR_INVALID_ARG;
|
||||||
int kind = -1;
|
|
||||||
{
|
|
||||||
std::lock_guard lk(local_streams_mu_);
|
std::lock_guard lk(local_streams_mu_);
|
||||||
LocalStream* ls = find_local_stream_by_id(stream_id);
|
LocalStream* ls = find_local_stream_by_id(stream_id);
|
||||||
if (!ls) return VC_ERR_INVALID_ARG;
|
if (!ls) return VC_ERR_INVALID_ARG;
|
||||||
ls->capture_channels = channels;
|
ls->capture_channels = channels;
|
||||||
for (auto& [k, entry] : local_streams_) {
|
// Engine restart is the caller's responsibility (via vc_audio_restart), issued AFTER
|
||||||
if (&entry == ls) { kind = k; break; }
|
// AVAudioSession routing has settled. The stored channel count is picked up by
|
||||||
}
|
// ensure_audio_running() on the next (re)start. On iOS this avoids the race where
|
||||||
}
|
// starting the stereo capture AudioUnit immediately (before the playback device is
|
||||||
// Only the real capture device (MIC) is affected by channel count — SCREEN_AUDIO uses
|
// committed to its route) collapses the A2DP output.
|
||||||
// loopback capture (channel count driven by the channel's stereo mode, not this setter)
|
|
||||||
// and AUX_DEVICE isn't backed by a real device path yet. Restart the engine so the capture
|
|
||||||
// device re-opens with the new channel count.
|
|
||||||
if (kind == static_cast<int>(VC_STREAM_MIC) && audio_engine_.running()) {
|
|
||||||
audio_engine_.stop();
|
|
||||||
ensure_audio_running();
|
|
||||||
}
|
|
||||||
return VC_OK;
|
return VC_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user