fix(audio): decouple Opus decode cadence from playback callback period

on_playback() was passing miniaudio's hardware playback-callback frame
count to opus_decode()'s max_samples, instead of the decoder's fixed
frame size (960 samples @ 20ms/48kHz). Since real packets decode to
more samples than the (often smaller, e.g. ~480 on default low-latency
WASAPI) hardware period, opus_decode returned OPUS_BUFFER_TOO_SMALL on
nearly every callback -- packets were received/decrypted/jitter-buffered
correctly but never decoded into audible PCM. Result: control-plane
events and VAD worked, but zero audio in headphones.

mix_for_test()'s white-box test masked this since it always called
on_playback with frames == frame_samples, the one case where the bug
is invisible.

Fix: RemoteStream gained a small ring buffer (init_ring/push_ring/
pop_ring) that decouples decode cadence from playback-callback cadence.
on_playback now tops the ring up by decoding whole Opus frames (always
decoder.frame_samples(), never the hardware frame count) and drains
exactly what the callback asks for, silence-padding (PLC) on underrun.

Side effect: also fixes playout_ts, which was advancing by the wrong
unit (hardware frames instead of decoded samples) -- it now tracks
correctly against jitter-buffer timestamps.

ctest --test-dir build/m1-dev: 12/12 green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 17:39:49 +02:00
parent 845f995826
commit 5be869c61a
3 changed files with 123 additions and 35 deletions

View File

@@ -10,6 +10,26 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action
- **Done:** **Fixed silent-playback bug in `AudioEngine::on_playback`** (2026-06-16, found via
live manual test: two `vccli --voice` clients, control-plane events and VAD all correct, but
zero audible output). Root cause: `opus_decode()`'s `max_samples` was being passed the
*hardware playback callback's* frame count (miniaudio's own choice, frequently smaller than
one Opus frame — e.g. ~480 samples on default low-latency WASAPI periods), instead of the
decoder's fixed frame size (960 @ 20ms/48kHz). Since the real packet almost always decodes to
more samples than that, `opus_decode` returned `OPUS_BUFFER_TOO_SMALL` on nearly every
callback — frames were correctly received/decrypted/jitter-buffered, just never decoded into
audible PCM. `mix_for_test()`'s white-box test masked this because it always called
`on_playback` with `frames == frame_samples`, the one case where the bug is invisible.
Fix: `RemoteStream` (`core/src/audio/audio_engine.h`) gained a small ring buffer
(`init_ring`/`push_ring`/`pop_ring`) that decouples decode cadence from playback-callback
cadence — `on_playback` (`core/src/audio/audio_engine.cpp`) now tops the ring up by decoding
whole Opus frames (`decoder.frame_samples()`, never the hardware `frames`) and drains exactly
`frames` samples-per-channel from it each callback, silence-padding (PLC) on underrun. Also
fixes a latent `playout_ts` bug: it now advances by the actual decoded sample count per Opus
frame, not by the hardware callback's (unrelated) frame count, which was the wrong unit for
jitter-buffer timestamp comparisons. `ctest --test-dir build/m1-dev` — 12/12 green (run via
PowerShell; Git Bash exec gotcha for these binaries, see `docs/building.md`). **Not yet
confirmed audible by ear** — pending the user re-running their live two-`vccli` test.
- **Done:** **Post-M3 follow-up — device enumeration, VAD/PTT gate, stereo playback, WASAPI
loopback** ✓ complete (2026-06-16). Closes all three items M3 explicitly carried forward as
out of scope (see the dated section below for the full file-by-file change list).