Files
voice-cat/tests/test_plc_cap.cpp
Talon ce2035f271
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled
fix(audio): bound playout depth to stop voice latency ratcheting up
Latency between speakers grew to multiple seconds and only reset on
rejoining voice. Root cause was the receiver playout logic, not the
codec settings: the playout clock free-ran in real time while the
sender omitted silence from its timestamps (and set no header flags),
and the only correction snapped the clock to the *oldest* buffered
frame — which could only ever add standing latency. target_depth_ms_
was computed but never enforced, so latency could only grow or reset.

Fix: bound playout against the stream's leading edge (newest frame).
(Re)seed to the leading edge on start/marker/starve (no prebuffer, so
latency stays low), and frame-skip catch-up trims any backlog beyond
target+hysteresis — the missing downward force.

Hardening: sender now stamps kFlagMarker (talkspurt start) and kFlagDtx,
consumed on recv for clean resync; adaptive late-drop window; EWMA
outlier rejection so silence gaps/stragglers don't poison the estimate;
duplicate counting and ring-underrun diagnostics.

New test_jitter_depth asserts depth stays bounded (<200ms) while
arrivals outrun playback. ctest --preset dev green (27/27).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:02:20 +02:00

152 lines
5.7 KiB
C++

/*
* test_plc_cap — verifies the PLC cap in AudioEngine::on_playback.
*
* After ~2s of pure packet-loss concealment (no real packets decoded), the mixer stops
* calling opus_decode(nullptr,0,...) and emits digital silence instead. This bounds the
* Opus comfort-noise hiss so a stale stream left in the mixer can never hiss forever —
* defense-in-depth for the server's UserEvent::LEFT broadcast (the primary fix that
* triggers remove_stream on disconnect). Also verifies that a fresh real packet resets
* the PLC streak and audio resumes.
*
* White-box: drives AudioEngine::mix_for_test directly (no audio hardware needed).
*/
#include <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
#if defined(VOICECAT_HAS_AUDIO) && defined(VOICECAT_HAS_OPUS)
#include "audio/audio_engine.h"
#include "codec/opus_codec.h"
static int g_failures = 0;
#define CHECK(cond) \
do { \
if (!(cond)) { \
std::printf("FAIL [%s:%d]: %s\n", __FILE__, __LINE__, #cond); \
++g_failures; \
} \
} while (0)
static double rms(const int16_t* pcm, int n) {
double sum = 0.0;
for (int i = 0; i < n; ++i) sum += static_cast<double>(pcm[i]) * pcm[i];
return std::sqrt(sum / n);
}
static int64_t abs_energy(const int16_t* pcm, int n) {
int64_t e = 0;
for (int i = 0; i < n; ++i) e += static_cast<int64_t>(std::abs(static_cast<int>(pcm[i])));
return e;
}
int main() {
voicecat::audio::AudioEngine engine;
voicecat::audio::AudioParams p;
p.sample_rate = 48000;
p.capture_channels = 1;
p.playback_channels = 2;
p.frame_ms = 20;
CHECK(engine.start(p)); // no capture_cb — headless safe (devices may fail to init; ok)
voicecat::codec::OpusParams op;
op.sample_rate = 48000;
op.frame_ms = 20;
op.stereo = false;
int frame_samples = voicecat::codec::opus_frame_samples(op); // 960
// Encode a loud sine wave to seed the decoder's PLC state.
voicecat::codec::OpusEncoder enc;
CHECK(enc.init(op));
std::vector<int16_t> sine(static_cast<size_t>(frame_samples));
for (int i = 0; i < frame_samples; ++i) {
float t = static_cast<float>(i) / 48000.0f;
sine[i] = static_cast<int16_t>(std::sin(2.0f * 3.14159265f * 440.0f * t) * 20000.0f);
}
uint8_t opus_buf[1500];
int opus_len = enc.encode(sine.data(), frame_samples, opus_buf, sizeof(opus_buf));
CHECK(opus_len > 0);
const uint32_t ssrc = 1;
engine.init_recv_stream(ssrc, op, /*user_id=*/0, /*stream_id=*/0);
// Push one real frame to seed the decoder.
voicecat::audio::JitterBuffer::Frame f;
f.seq = 0;
f.timestamp = 0;
f.fec_present = false;
f.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(ssrc, std::move(f));
// mix_for_test period — 480 frames @ 48kHz = 10ms (typical WASAPI shared period).
const uint32_t pb_frames = 480;
const int out_n = static_cast<int>(pb_frames) * 2; // stereo interleaved
std::vector<int16_t> out(static_cast<size_t>(out_n), 0);
// 1) Decode the real frame (first mix call) — seeds PLC state.
engine.mix_for_test(out.data(), pb_frames);
// 2) Drive ~50ms of pure PLC — should produce comfort noise (non-zero).
double early_rms = 0.0;
for (int i = 0; i < 5; ++i) {
engine.mix_for_test(out.data(), pb_frames);
early_rms = std::max(early_rms, rms(out.data(), out_n));
}
CHECK(early_rms > 1.0); // PLC of a loud sine is audible, not digital silence
// 3) Drive well past the 2s PLC cap (250 callbacks = 2.5s of output).
// After the cap, on_playback emits silence (memset 0) instead of PLC noise.
for (int i = 0; i < 250; ++i) {
engine.mix_for_test(out.data(), pb_frames);
}
// 4) The output must now be digital silence (all zeros), not comfort noise.
// Drain a couple more callbacks to flush any ring residue, then assert.
int64_t energy = 0;
for (int i = 0; i < 3; ++i) {
engine.mix_for_test(out.data(), pb_frames);
energy = std::max(energy, abs_energy(out.data(), out_n));
}
CHECK(energy == 0); // capped PLC = silence
// 5) Resumption: push a fresh real frame — PLC streak resets, audio returns. marker=true is
// what the real sender stamps on the first frame after a silence (talkspurt restart); it
// makes the playout clock reseed to this leading edge immediately (no prebuffer delay).
voicecat::audio::JitterBuffer::Frame f2;
f2.seq = 1;
f2.timestamp = 200000; // far ahead — playout-clock reseeds to it
f2.fec_present = false;
f2.marker = true;
f2.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(ssrc, std::move(f2));
double resume_rms = 0.0;
for (int i = 0; i < 5; ++i) { // a few calls to flush silence residue + decode real
engine.mix_for_test(out.data(), pb_frames);
resume_rms = std::max(resume_rms, rms(out.data(), out_n));
}
CHECK(resume_rms > 1.0); // real audio is back
engine.remove_stream(ssrc);
engine.stop();
enc.destroy();
if (g_failures == 0) {
std::printf("plc_cap: all checks passed (early_rms=%.1f resume_rms=%.1f)\n",
early_rms, resume_rms);
return 0;
}
std::printf("plc_cap: %d failure(s)\n", g_failures);
return 1;
}
#else
int main() {
std::printf("plc_cap: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
return 0;
}
#endif