Files
voice-cat/tests/test_jitter_depth.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

145 lines
6.1 KiB
C++

/*
* test_jitter_depth — verifies the bounded-depth playout in AudioEngine::on_playback.
*
* Regression guard for the "latency keeps drifting backward, fixed only by rejoining" bug. The
* sender omits VAD/PTT/DTX silence from its timestamps (a compressed timeline), while the
* receiver's playout clock free-runs in real time. The old logic re-synced the playout clock to
* the *oldest* buffered frame and could only ever *add* standing latency (a reordered/late frame
* snapped the clock backward), with nothing to trim it — so latency ratcheted up across talkspurt
* gaps. The fix keeps the clock a bounded `target` behind the *newest* arrival and frame-skips to
* catch up, so depth stays bounded no matter the trigger.
*
* This drives many talkspurt/silence cycles with a compressed timeline plus a reordered straggler
* each cycle (which previously snapped the clock backward), and asserts the buffered depth
* (newest_ts - playout_ts) stays bounded while audio keeps playing. White-box via mix_for_test
* (no audio hardware needed), same pattern as test_plc_cap.
*/
#include <cmath>
#include <cstdint>
#include <cstdio>
#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);
}
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
voicecat::codec::OpusParams op;
op.sample_rate = 48000;
op.frame_ms = 20;
op.stereo = false;
const int frame_samples = voicecat::codec::opus_frame_samples(op); // 960
// A loud sine, encoded once, reused for every pushed frame.
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];
const 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);
const uint32_t pb_frames = 480; // 10 ms hardware period
const int out_n = static_cast<int>(pb_frames) * 2; // stereo interleaved
std::vector<int16_t> out(static_cast<size_t>(out_n), 0);
auto mix_n = [&](int n) {
for (int i = 0; i < n; ++i) engine.mix_for_test(out.data(), pb_frames);
};
auto push = [&](uint32_t ts, bool marker) {
voicecat::audio::JitterBuffer::Frame f;
f.seq = 0;
f.timestamp = ts;
f.fec_present = false;
f.marker = marker;
f.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(ssrc, std::move(f));
};
uint32_t ts = 1000; // arbitrary non-zero start
int32_t max_depth = 0;
double last_voice_rms = 0.0;
// Seed the stream (first frame is a talkspurt marker, like a real resume).
push(ts, /*marker=*/true);
ts += static_cast<uint32_t>(frame_samples);
mix_n(1);
// Drive the producer FASTER than the consumer: push one 960-sample frame per step but drain
// only 480 samples (one pb_frames callback) — i.e. arrivals outrun playout by ~480 samples a
// step, exactly the clock-drift / bursty-arrival condition that made latency ratchet up. Also
// inject a reordered straggler periodically (the old backward-snap trigger). The bounded-depth
// catch-up must keep the standing latency from growing without limit. Pre-fix (no catch-up,
// snap-to-oldest) the depth would climb to ~hundreds of frames here.
const int kSteps = 400;
const uint32_t kStraggler = 48000u * 250u / 1000u; // 250 ms behind the leading edge
for (int s = 0; s < kSteps; ++s) {
push(ts, /*marker=*/false);
ts += static_cast<uint32_t>(frame_samples);
if (s % 25 == 12) push(ts - kStraggler, /*marker=*/false); // reordered straggler
mix_n(1); // drain only 480 of the 960 produced — producer outruns consumer
int32_t d = engine.stream_playout_depth_samples(ssrc);
if (d > max_depth) max_depth = d;
last_voice_rms = std::max(last_voice_rms, rms(out.data(), out_n));
}
std::printf("jitter_depth: max_depth=%d samples (%.0f ms) voice_rms=%.1f\n", max_depth,
static_cast<double>(max_depth) * 1000.0 / 48000.0, last_voice_rms);
// Bounded: with catch-up the standing latency stays near the adaptive target, well under
// 200 ms even though arrivals outran playout for 400 steps (~4 s of pushed audio).
CHECK(max_depth > 0); // playout ran / depth observed
CHECK(max_depth < static_cast<int32_t>(48000 * 200 / 1000)); // bounded (was unbounded pre-fix)
CHECK(last_voice_rms > 1.0); // audio keeps playing
engine.remove_stream(ssrc);
engine.stop();
enc.destroy();
if (g_failures == 0) {
std::printf("jitter_depth: all checks passed\n");
return 0;
}
std::printf("jitter_depth: %d failure(s)\n", g_failures);
return 1;
}
#else
int main() {
std::printf("jitter_depth: SKIP (VOICECAT_HAS_AUDIO or VOICECAT_HAS_OPUS not defined)\n");
return 0;
}
#endif