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

@@ -172,6 +172,111 @@ up instantly. Newest status at the top.
controls the *mic* path which still uses miniaudio's device.
- **Done:** **iOS audio preset fixes — A2DP output muting + broken stereo capture** (2026-06-19).
Two user-reported bugs with the audio presets (esp. with Bluetooth headphones connected),
both root-caused against Apple docs/WWDC20 + dev-forum reports and fixed in
`IOSAudioRouter.applyConfiguration()` / `AudioSessionManager.ensureSessionActive()`:
1. **Output (and VoiceOver) went dead on the Stereo Mic / A2DP presets (BUG — fixed).**
`applyConfiguration()` always inserted `.defaultToSpeaker`, which breaks A2DP routing in
`.playAndRecord` (route ends up muted, taking the shared hardware output — and VoiceOver —
with it). And the A2DP path used `mode = .default`, which iOS 17+ routes to the speaker.
**Fix:** `.defaultToSpeaker` now set *only* for the speaker preset; `.mixWithOthers` kept
always (VoiceOver must stay audible for a blind user); A2DP/stereo paths now use
`mode = .videoRecording` (the documented mode that keeps Bluetooth output and supports
multi-capsule stereo); after `setActive(true)` on an A2DP preset we call
`overrideOutputAudioPort(.none)` to clear any lingering speaker override. NOTE: built-in
mic + A2DP output during active recording is inherently flaky on iOS (A2DP is output-only;
recording wants to collapse BT to mono HFP) — this is best-effort, not guaranteed on every
BT device. Needs on-device testing.
2. **Stereo mic only captured the left channel (BUG — fixed).** Real built-in-mic stereo
needs more than `setPreferredInputNumberOfChannels(2)`: per WWDC20 you must select a data
source whose `supportedPolarPatterns` contains `.stereo`, call
`setPreferredPolarPattern(.stereo)`, set `setPreferredInputOrientation(.portrait)`, then
request 2 channels. The old code set the data source/polar pattern to `nil` for the stereo
preset, so channel 2 was silent. **Fix:** new `configureStereoCapture()` does the full
WWDC20 sequence and falls back to mono if the route has no stereo-capable capsule (BT/wired
mic). New `configureMonoCapture()` resets to 1 channel (so stereo→mono actually takes
effect) and applies the user's chosen mono orientation/polar pattern.
- **Verified:** `xcodebuild -scheme VoiceCatiOS -destination 'generic/platform=iOS Simulator'
ARCHS=arm64` — **BUILD SUCCEEDED**. On-device behavior (BT headphones + VoiceOver + stereo
pickup) still to be confirmed by the user.
- **Follow-up (same day) after device testing.** User reported: default + BT-mono-mic work,
but Stereo Mic still kills output, and remote audio may never be audible.
b. **Remote audio never routed (BUG — fixed).** The AVAudioSession was activated lazily on
the `.streamStarted` event, but the core opens its miniaudio playback device *before*
emitting that event — so playback opened against an inactive session and produced no
sound. **Fix:** activate the session proactively on `.authResult == .ok`
(`AppState.swift`), so it's live before any remote stream opens a device. Mirrors the
macOS "session active while connected" model.
- **Done:** **iOS stereo mic + A2DP output — audio death fix** (2026-06-19). The previous
"fix" (line below, "Stereo mic killed A2DP output") was never verified on-device and still
had the bug: selecting the Stereo Mic preset killed all audio (including VoiceOver) — only
kill & relaunch recovered it. Root cause found by comparing against TeamTalk5
(`Client/iTeamTalk/`), which achieves stereo mic + A2DP output. Five issues fixed:
1. **`configureStereoCapture` was missing `setPreferredInput` + `setInputDataSource`**
(BUG — fixed). The previous fix threw out `setPreferredInput` together with
`setPreferredInputNumberOfChannels(2)`, blaming the *combination* for collapsing A2DP.
WRONG — TeamTalk5 keeps `setPreferredInput` + `setInputDataSource`
(`SoundDevicesModel.selectDataSource:147-148`) and only omits
`setPreferredInputNumberOfChannels(2)`. Without the explicit input anchor, when
`setPreferredPolarPattern(.stereo)` fired, iOS had no session-level input anchor and the
route reconfiguration collapsed the A2DP output. **Fix:** `configureStereoCapture` now
calls `setPreferredInput(builtIn)` + `setInputDataSource(stereoSource)` after the polar
pattern. Also removed `setPreferredInputOrientation(.portrait)` (TeamTalk doesn't use it;
possible route-collapse contributor on iOS 26).
2. **Core devices not restarted around `applyConfiguration`** (BUG — fixed). TeamTalk5's
`setupSoundDevices` calls `closeSoundDevices()` FIRST, then reconfigures, then reopens —
so the audio unit never sees a route change mid-flight. VoiceCat reconfigured
`AVAudioSession` while the core's miniaudio devices were still open, leaving them bound
to the dead route. **Fix:** new C ABI function `vc_audio_restart` (full stop + re-init,
unlike `suspend`/`resume` which only stop/start). `IOSAudioRouter`'s setters now wrap
`applyConfiguration()` with `audioSuspend` → reconfigure → `audioRestart`.
`AudioSessionManager.handleRouteChange` also calls `audioRestart` on device
plug/unplug so the playback device picks up the new route.
3. **Deprecated `.allowBluetooth` instead of `.allowBluetoothHFP`** (modernized).
TeamTalk5 uses `.allowBluetoothHFP` (iOS 17+). VoiceCat was using the deprecated
`.allowBluetooth` alias. Replaced; also added `.bluetoothHighQualityRecording` on
iOS 26+ (mirrors TeamTalk5 `UtilSound.swift:229-231`).
4. **Capture channels not reset when switching stereo→mono** (BUG — fixed). Switching from
stereo to mono only changed the `AVAudioSession` polar pattern, not the `LocalStream`'s
`capture_channels` field — so the next engine start still opened 2 channels. **Fix:**
`AudioSessionManager` now tracks `activeMicStreamId` (set by `SessionState` on
join/leave voice); `selectCaptureChannels` and `applyPreset` call
`setCaptureChannels(streamId, channels.channelCount)` inside the suspend window so the
field is updated before the engine restarts.
5. **Docs corrected.** `docs/voice.md` §8, `docs/tech-stack.md` §2, `docs/architecture.md`
§4 — removed stale `setPreferredInputNumberOfChannels(2)` references; documented the
actual recipe (`.stereo` polar pattern + `setPreferredInput` + `setInputDataSource` +
`vc_set_capture_channels`) and the new `vc_audio_restart` ABI + close→reconfigure→reopen
ordering rule. `voicecat.h` `vc_set_capture_channels` doc comment also corrected.
- **New C ABI:** `vc_result vc_audio_restart(vc_client* c)` — full audio engine restart
(stop + uninit + re-init), append-only addition. Skeleton stub added. Swift wrapper:
`VoiceCatClient.audioRestart()`.
- **Verified:** `cmake --build --preset dev` + `ctest --preset dev` — **21/21 green**
(including `test_stereo_mic_capture`). `xcodebuild -arch arm64 ONLY_ACTIVE_ARCH=YES` —
**BUILD SUCCEEDED** (iOS client compiles + links). **On-device verification still
pending** user test: (a) Voice Chat → Stereo Mic, (b) BT Headphones + Mono Mic → Stereo
Mic, (c) Stereo Mic → Voice Chat, (d) unplug/replug Bluetooth mid-stereo-session.
a. **Stereo mic killed A2DP output (BUG — fixed correctly this time).** My first follow-up
wrongly concluded stereo ⊥ Bluetooth was a hardware limit and forced stereo → speaker.
WRONG — TeamTalk5 (`Client/iTeamTalk/iTeamTalk/UtilSound.swift`) and Ferrite both do
built-in stereo mic + A2DP output. The actual cause was *how* stereo was requested:
`setPreferredInputNumberOfChannels(2)` + `setPreferredInput(builtInMic)` +
`mode=.videoRecording` together collapse the A2DP output route. **Fix (TT5 recipe):**
stereo is now enabled purely by setting the built-in mic data source's `.stereo` polar
pattern (`configureStereoCapture`); NO `setPreferredInputNumberOfChannels`, NO
`setPreferredInput` (with HFP disabled the system already routes input to the built-in
mic); `mode=.default` for stereo (`.voiceChat`/VPIO forces mono). The channel count is
requested by miniaudio at the AU level via `vc_set_capture_channels(2)`. Reverted the
force-to-speaker workaround: `.stereoMic`/`.studio` presets use A2DP again (speaker
fallback when no BT); re-removed `showsStereoForcesSpeakerWarning`; `.allowAirPlay`
added to BT presets. `clearStereoPolarPattern()` resets the capsule when returning to
mono. So stereo mic + A2DP output now coexist.
- **Verified:** rebuilt — **BUILD SUCCEEDED**. On-device confirmation (hearing remote
clients; stereo mic + A2DP output with both channels) still pending user test.
- **Done:** **iOS audio overhaul + Join/Leave Voice + channel-id sync fix** (2026-06-19).
Three problems found while reviewing the iOS client, all fixed:
1. **Mic button permanently dimmed (BUG — fixed).** `VoiceControlsView.swift:26` gated the