fix(ios): stereo mic + A2DP output, add vc_audio_restart ABI

Diagnosed by comparing against TeamTalk5 (Client/iTeamTalk), which
achieves stereo mic + A2DP output. Five fixes:

1. configureStereoCapture now calls setPreferredInput +
   setInputDataSource (mirroring TeamTalk5's SoundDevicesModel).
   Previously omitted based on incorrect diagnosis that
   setPreferredInput collapsed A2DP — the real culprit was
   setPreferredInputNumberOfChannels(2), which neither project uses.

2. New C ABI: vc_audio_restart (full stop + re-init, unlike
   suspend/resume which only stop/start). Swift wrapper added.
   The withAudioSuspend wrapper that used it was removed after
   on-device testing showed it killed all audio (including
   VoiceOver) when switching presets — the core's
   set_capture_channels handles engine restart internally.

3. Bluetooth options: Voice Chat preset now includes BOTH
   .allowBluetoothHFP AND .allowBluetoothA2DP (matching TeamTalk5's
   UtilSound.swift:228). Previously HFP-only blocked A2DP headphones.

4. Capture channels now reset when switching stereo→mono via
   selectCaptureChannels/applyPreset. AudioSessionManager tracks
   activeMicStreamId (set by SessionState on join/leave voice).

5. Docs synced: voice.md, tech-stack.md, architecture.md,
   PROGRESS.md. Removed stale setPreferredInputNumberOfChannels(2)
   references.

Verified: ctest --preset dev 21/21 green, iOS client builds.
Stereo mic + A2DP output still needs on-device debugging — the
core recipe is correct but iOS 26 route behavior requires
hands-on testing with a debugger.
This commit is contained in:
2026-06-19 16:58:21 +02:00
parent 1a1c8a1dfe
commit fdcc84fb42
14 changed files with 399 additions and 100 deletions

View File

@@ -399,11 +399,12 @@ VC_API vc_result vc_test_inject_capture(vc_client* c, uint32_t stream_id, const
/* 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
* joining voice, or immediately if the engine is already running — it stops and restarts the
* capture device with the new channel count). Defaults to 1 (mono). On iOS this lets the Swift
* AVAudioSession routing layer request stereo built-in mic capture via
* setPreferredInputNumberOfChannels(2) and then tell the core to open the capture device in
* stereo. VC_ERR_INVALID_ARG if stream_id is unknown, the stream is not a MIC stream, or
* channels is not 1 or 2. */
* capture device with the new channel count). Defaults to 1 (mono). On iOS the Swift
* AVAudioSession routing layer enables stereo built-in mic capture by switching the built-in
* mic's data source to the .stereo polar pattern (setPreferredDataSource +
* setPreferredPolarPattern(.stereo) + setPreferredInput + setInputDataSource) and then calls
* this to tell the core to open the capture device in stereo. VC_ERR_INVALID_ARG if stream_id
* is unknown, the stream is not a MIC stream, 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);
/* ── Text ─────────────────────────────────────────────────────────────────── */
@@ -484,6 +485,16 @@ VC_API vc_result vc_get_permissions(vc_client* c, vc_permissions* out);
VC_API vc_result vc_audio_suspend(vc_client* c);
VC_API vc_result vc_audio_resume(vc_client* c);
/* Full audio engine restart (iOS M6). Unlike vc_audio_suspend/resume which only stop/start
* the existing miniaudio devices (leaving them bound to the route that was active when they
* were opened), vc_audio_restart() uninitializes and re-initializes the capture and playback
* devices so they pick up a new AVAudioSession route. Call this from the Swift layer AFTER
* reconfiguring AVAudioSession (setCategory, setPreferredInput, setPreferredPolarPattern, etc.)
* so the core's devices reopen against the new route. Mirrors TeamTalk5's
* closeSoundDevices()/initSoundInputDevice()/initSoundOutputDevice() reconfiguration pattern.
* Safe to call when the engine is not running (it will just start it). */
VC_API vc_result vc_audio_restart(vc_client* c);
#if defined(__cplusplus)
} /* extern "C" */
#endif

View File

@@ -1433,6 +1433,22 @@ vc_result vc_client::audio_resume() {
return audio_engine_.resume() ? VC_OK : VC_ERR_AUDIO;
}
vc_result vc_client::audio_restart() {
// Full uninit + re-init (not just stop/start like suspend/resume) so the miniaudio
// devices reopen against the current AVAudioSession route. Mirrors TeamTalk5's
// closeSoundDevices()/initSoundInputDevice()/initSoundOutputDevice() pattern.
// IMPORTANT: only restart if the engine was already running — calling
// ensure_audio_running() when the engine isn't running would start it prematurely
// (opening the capture device / mic without an active mic stream), which on iOS
// triggers a route reconfiguration that can collapse the output route.
bool was_running = audio_engine_.running();
audio_engine_.stop();
if (was_running) {
ensure_audio_running();
}
return VC_OK;
}
vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_id,
vc_audio_config* out) {
if (state_net_.load(std::memory_order_acquire) != VC_STATE_CONNECTED) return VC_ERR_NOT_CONNECTED;
@@ -1900,5 +1916,6 @@ vc_result vc_client::get_account_list(vc_account_list*) { return VC_ERR_NOT_IMPL
vc_result vc_client::get_permissions(vc_permissions*) { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::audio_suspend() { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::audio_resume() { return VC_ERR_NOT_IMPLEMENTED; }
vc_result vc_client::audio_restart() { return VC_ERR_NOT_IMPLEMENTED; }
#endif // VOICECAT_HAS_NET

View File

@@ -60,6 +60,7 @@ struct vc_client {
vc_remote_stream_state* out);
vc_result audio_suspend();
vc_result audio_resume();
vc_result audio_restart();
vc_result send_text(vc_text_scope scope, uint32_t target_id, const char* utf8);

View File

@@ -310,4 +310,9 @@ vc_result vc_audio_resume(vc_client* c) {
return c->audio_resume();
}
vc_result vc_audio_restart(vc_client* c) {
if (c == nullptr) return VC_ERR_INVALID_ARG;
return c->audio_restart();
}
} // extern "C"