fix(audio): buffer capture frames to Opus encoder's fixed frame size

AudioEngine::on_capture() was passing miniaudio's hardware callback period
(commonly 480 samples / 10 ms on WASAPI shared mode) directly to opus_encode(),
which requires exactly frame_samples_ (960 for 20 ms @ 48 kHz). The mismatch
returned OPUS_BAD_ARG and silently dropped every real mic frame, while screen
share and injected test frames happened to be correctly sized and worked fine.

Fix: accumulate PCM in a pre-allocated CaptureAccum buffer (mirroring the
existing RemoteStream::ring fix on the playback side) and only call capture_cb_
when a full frame_samples_ chunk is ready. Same pattern applied to on_loopback().

Add test_capture_frame_accumulation() to verify the accumulator fires exactly the
right number of callbacks for misaligned chunk sizes (480, 240+720, 1920 samples).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 01:51:50 +02:00
parent 45d87bde67
commit 7da0a02b3a
3 changed files with 155 additions and 13 deletions

View File

@@ -231,6 +231,60 @@ static void test_stereo_mix() {
}
#endif // VOICECAT_HAS_AUDIO && VOICECAT_HAS_OPUS
// ── 5. Capture-frame accumulation (white-box, no audio hardware needed) ──────────
// Regression for the capture-side analogue of the playback ring fix: miniaudio's capture
// callback fires at the hardware period (commonly 480 samples on WASAPI shared mode), while
// opus_encode() requires exactly frame_samples_ (960). Sub-frame chunks must be accumulated;
// the callback must receive exactly 960-sample frames regardless of input chunk size.
#ifdef VOICECAT_HAS_AUDIO
static void test_capture_frame_accumulation() {
voicecat::audio::AudioEngine engine;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1;
p.frame_ms = 20; // frame_samples_ = 960
std::atomic<int> call_count{0};
std::atomic<bool> wrong_size{false};
constexpr int kExpected = 960;
// Start WITHOUT a capture callback: the real mic (if any) fires on_capture(), but
// on_capture() returns immediately when capture_cb_ is null, so capture_accum_ is
// never touched by the hardware thread. feed_capture_for_test() bypasses capture_cb_
// and drives the same accumulator directly with the explicit `cb` below — no races.
CHECK(engine.start(p));
auto cb = [&](int /*kind*/, const int16_t* /*pcm*/, int samples) {
++call_count;
if (samples != kExpected) wrong_size.store(true);
};
// 480-sample (10 ms) input — WASAPI's common hardware period on modern Windows.
// Two 480-chunk inputs → exactly one callback at 960.
std::vector<int16_t> h(480, 1000);
engine.feed_capture_for_test(h.data(), 480, cb);
CHECK(call_count.load() == 0); // half a frame — no callback yet
engine.feed_capture_for_test(h.data(), 480, cb);
CHECK(call_count.load() == 1); // one full frame — callback fired once
// Mis-aligned split: 240 then 720 → still exactly one callback.
std::vector<int16_t> s(240, 500), l(720, 500);
engine.feed_capture_for_test(s.data(), 240, cb);
CHECK(call_count.load() == 1);
engine.feed_capture_for_test(l.data(), 720, cb);
CHECK(call_count.load() == 2);
// 1920-sample input (two Opus frames) → exactly two callbacks.
std::vector<int16_t> d(1920, 800);
engine.feed_capture_for_test(d.data(), 1920, cb);
CHECK(call_count.load() == 4);
CHECK(!wrong_size.load());
engine.stop();
std::printf("test_capture_frame_accumulation: ok (callbacks=%d)\n", call_count.load());
}
#endif // VOICECAT_HAS_AUDIO
// ── 2/3. VAD + PTT gate, through the real ABI against a real server ─────────────
static void test_vad_and_ptt_gate() {
auto tmp = std::filesystem::temp_directory_path() /
@@ -385,6 +439,9 @@ int main() {
test_device_enumeration();
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
test_stereo_mix();
#endif
#ifdef VOICECAT_HAS_AUDIO
test_capture_frame_accumulation();
#endif
test_vad_and_ptt_gate();