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:
105
PROGRESS.md
105
PROGRESS.md
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user