fix(audio): bound playout depth to stop voice latency ratcheting up
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

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>
This commit is contained in:
2026-06-22 20:02:20 +02:00
parent e155e342f4
commit ce2035f271
10 changed files with 384 additions and 49 deletions

View File

@@ -18,13 +18,18 @@ int64_t now_ms() {
.count();
}
// Playout-clock re-sync thresholds (samples @ 48 kHz). The playout clock advances every callback
// Bounded-depth playout targeting (samples @ 48 kHz). The playout clock advances every callback
// via the PLC path in on_playback, while the sender's frame timestamps only advance while it is
// actually transmitting — so they drift across VAD/PTT silence gaps and late joins. Both are kept
// under JitterBuffer::kLateDropSamples (500 ms) so the clock is snapped back before frames would
// begin to be dropped-as-late, which is what produced the "talk indicator lit, no audio" silence.
constexpr int32_t kResyncAheadSamples = 48000 * 200 / 1000; // clock 200 ms ahead → re-seed
constexpr int32_t kResyncBehindSamples = 48000 * 500 / 1000; // clock 500 ms behind → re-seed
// actually transmitting (silence is omitted) — so the clock drifts away from the stream timeline
// across VAD/PTT/DTX gaps and late joins. Rather than snap the clock to the *oldest* buffered
// frame (which could only add standing latency and never trim it — the cause of the "latency keeps
// drifting backward, fixed by rejoin" bug), on_playback keeps the clock a bounded `target` behind
// the *newest* arrival and frame-skips to catch up when the backlog grows. These are the floor and
// hysteresis for that target; the steady-state target itself comes from JitterBuffer's adaptive
// estimate (target_depth_samples()).
constexpr int32_t kMinDepthSamples = 48000 * 40 / 1000; // floor for the catch-up target depth
constexpr int32_t kCatchupSamples = 48000 * 60 / 1000; // skip when depth > target + 60 ms
constexpr int32_t kStarveSamples = 48000 * 120 / 1000; // reseed when clock 120 ms past newest
// PLC cap: after this many consecutive samples of pure packet-loss concealment (no real
// packet decoded), stop calling opus_decode(nullptr,0,...) and emit silence instead. Opus
@@ -79,22 +84,37 @@ bool hex_decode_device_id(const std::string& hex, ma_device_id* out) {
void JitterBuffer::push(Frame f) {
std::lock_guard lk(mu_);
uint32_t ts = f.timestamp;
uint32_t ts = f.timestamp;
bool marker = f.marker;
// Jitter estimation (EWMA of inter-arrival gap vs expected gap).
// Jitter estimation (EWMA of inter-arrival gap vs the expected per-frame gap). Skip
// silence-gap outliers — a talkspurt restart (marker) or any gap far larger than a frame
// (DTX/VAD/PTT silence) is not jitter; counting it would spike the estimate and inflate the
// target depth for the rest of the call. The sender omits silence from its timestamp, so a
// restart can also arrive "behind" (negative gap) — also an outlier.
if (!first_push_) {
uint32_t arrived_gap = ts - last_push_ts_;
uint32_t expected_gap = 960; // 20 ms @48k; TODO: derive from params
uint32_t diff = (arrived_gap > expected_gap) ? (arrived_gap - expected_gap)
: (expected_gap - arrived_gap);
jitter_est_ = (jitter_est_ * 7 + diff) / 8;
uint32_t depth = std::clamp(jitter_est_ * 2 + 960u, 960u, 48000u * 200u / 1000u);
target_depth_ms_.store(depth * 1000u / 48000u, std::memory_order_relaxed);
int32_t gap = static_cast<int32_t>(ts - last_push_ts_);
bool outlier = marker || gap <= 0 ||
gap > static_cast<int32_t>(expected_gap_samples_ * 8);
if (!outlier) {
uint32_t diff = (static_cast<uint32_t>(gap) > expected_gap_samples_)
? (static_cast<uint32_t>(gap) - expected_gap_samples_)
: (expected_gap_samples_ - static_cast<uint32_t>(gap));
jitter_est_ = (jitter_est_ * 7 + diff) / 8;
uint32_t depth = std::clamp(jitter_est_ * 2 + expected_gap_samples_,
expected_gap_samples_, 48000u * 200u / 1000u);
target_depth_ms_.store(depth * 1000u / 48000u, std::memory_order_relaxed);
}
}
last_push_ts_ = ts;
first_push_ = false;
buf_.emplace(ts, std::move(f));
// Track the leading edge (newest timestamp), wrap-safe.
if (!have_newest_ || static_cast<int32_t>(ts - newest_ts_) > 0) newest_ts_ = ts;
have_newest_ = true;
auto res = buf_.emplace(ts, std::move(f));
if (!res.second) dup_.fetch_add(1, std::memory_order_relaxed); // duplicate timestamp
}
std::optional<JitterBuffer::Frame> JitterBuffer::pop(uint32_t playout_ts) {
@@ -106,8 +126,14 @@ std::optional<JitterBuffer::Frame> JitterBuffer::pop(uint32_t playout_ts) {
auto it = buf_.begin();
uint32_t ts = it->first;
// Drop frames that are too old (> 500 ms late).
if (static_cast<int32_t>(playout_ts - ts) > static_cast<int32_t>(kLateDropSamples)) {
// Drop frames that are too old to play. The window tracks the (adaptive) target depth plus a
// margin so it never undercuts DRED's next-packet lookahead, floored/capped at the fixed
// 500 ms bound. Without this the only downward force on latency was a snap that *added* it.
constexpr uint32_t kLateMarginSamples = 48000u * 200u / 1000u; // +200 ms over target depth
constexpr uint32_t kLateDropFloor = 48000u * 200u / 1000u; // never drop earlier than 200 ms
uint32_t late_window = std::clamp(target_depth_samples() + kLateMarginSamples,
kLateDropFloor, kLateDropSamples);
if (static_cast<int32_t>(playout_ts - ts) > static_cast<int32_t>(late_window)) {
lost_.fetch_add(1, std::memory_order_relaxed);
buf_.erase(it);
return std::nullopt;
@@ -127,6 +153,23 @@ std::optional<uint32_t> JitterBuffer::peek_front_ts() const {
return buf_.begin()->first;
}
std::optional<uint32_t> JitterBuffer::peek_back_ts() const {
std::unique_lock lk(mu_, std::try_to_lock);
if (!lk || !have_newest_) return std::nullopt;
return newest_ts_;
}
void JitterBuffer::drop_before(uint32_t ts) {
std::lock_guard lk(mu_);
while (!buf_.empty()) {
auto it = buf_.begin(); // oldest
if (static_cast<int32_t>(ts - it->first) > 0)
buf_.erase(it); // strictly before the new playout point — stale
else
break;
}
}
size_t JitterBuffer::try_copy_front_payload(uint32_t expected_ts, uint8_t* out, size_t max_sz) {
std::unique_lock lk(mu_, std::try_to_lock);
if (!lk || buf_.empty()) return 0;
@@ -142,7 +185,10 @@ void JitterBuffer::reset() {
std::lock_guard lk(mu_);
buf_.clear();
lost_.store(0);
first_push_ = true;
dup_.store(0);
first_push_ = true;
have_newest_ = false;
jitter_est_ = 0;
}
// ── AudioEngine ──────────────────────────────────────────────────────────────
@@ -444,6 +490,7 @@ void AudioEngine::push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f) {
std::lock_guard lk(streams_mu_);
auto& s = streams_[ssrc];
s.last_voice_ms.store(now_ms(), std::memory_order_relaxed);
if (f.marker) s.pending_marker = true; // talkspurt start → force playout reseed
s.jitter.push(std::move(f));
}
@@ -520,6 +567,29 @@ uint32_t AudioEngine::stream_target_depth_ms(uint32_t ssrc) const {
return (it != streams_.end()) ? it->second.jitter.target_depth_ms() : 40;
}
uint32_t AudioEngine::stream_duplicates(uint32_t ssrc) const {
std::lock_guard lk(streams_mu_);
auto it = streams_.find(ssrc);
return (it != streams_.end()) ? it->second.jitter.duplicates() : 0;
}
uint64_t AudioEngine::stream_underruns(uint32_t ssrc) const {
std::lock_guard lk(streams_mu_);
auto it = streams_.find(ssrc);
return (it != streams_.end())
? it->second.underruns.load(std::memory_order_relaxed)
: 0;
}
int32_t AudioEngine::stream_playout_depth_samples(uint32_t ssrc) const {
std::lock_guard lk(streams_mu_);
auto it = streams_.find(ssrc);
if (it == streams_.end() || !it->second.playout_started) return 0;
auto newest = it->second.jitter.peek_back_ts();
if (!newest) return 0;
return static_cast<int32_t>(*newest - it->second.playout_ts);
}
#ifdef VOICECAT_HAS_OPUS
void AudioEngine::init_recv_stream(uint32_t ssrc, const codec::OpusParams& p,
uint32_t user_id, uint32_t stream_id) {
@@ -534,6 +604,9 @@ void AudioEngine::init_recv_stream(uint32_t ssrc, const codec::OpusParams& p,
int frame_samples = stream.decoder.frame_samples();
if (frame_samples <= 0) frame_samples = static_cast<int>(p.sample_rate / 1000 * p.frame_ms);
stream.init_ring(channels, frame_samples);
// The expected inter-arrival gap = the sender's frame size in samples @48 kHz; the jitter
// EWMA and silence-gap outlier rejection key off it (defaults to 20 ms otherwise).
stream.jitter.set_expected_gap(static_cast<uint32_t>(frame_samples));
// DRED: pre-allocate per-stream scratch (no RT-thread allocation). 4000 bytes > max Opus pkt.
stream.dred_payload_scratch_.assign(4000, 0);
if (!stream.dred_state_) {
@@ -620,21 +693,31 @@ void AudioEngine::on_playback(int16_t* out, ma_uint32 frames) {
const int dec_channels = std::max(1, stream.decoder.channels());
const int frame_samples = stream.decoder.frame_samples();
// Seed / re-sync the playout clock to the arriving stream. playout_ts advances every
// callback (via the PLC path below) independently of whether the sender is transmitting,
// so across a late join or any VAD/PTT silence gap it drifts away from the sender's frame
// timestamps without bound. Left uncorrected, the divergence eventually exceeds the jitter
// buffer's late-drop window and every real frame is dropped-as-late (clock ahead) or
// never-due (clock behind) — permanent silence even though frames keep arriving (the talk
// indicator, driven by push_recv_frame, stays lit). Snap to the earliest buffered frame on
// the first frame and whenever the clock has drifted too far; this both seeds startup and
// recovers after every silence gap. u32 subtraction via int32_t handles timestamp wrap.
if (auto front_ts = stream.jitter.peek_front_ts()) {
int32_t drift = static_cast<int32_t>(stream.playout_ts - *front_ts);
if (!stream.playout_started || drift > kResyncAheadSamples ||
drift < -kResyncBehindSamples) {
stream.playout_ts = *front_ts;
// Bound the playout latency against the stream's leading edge (newest buffered frame).
// playout_ts free-runs every callback (PLC path below) while the sender omits silence from
// its timestamps, so the two diverge across late joins and VAD/PTT/DTX gaps. Two cases,
// both measured against the *newest* frame — never the oldest, whose snap could only ever
// *add* standing latency (the ratchet that caused the growing-latency bug):
// • (re)seed to the leading edge when starting, on a talkspurt marker, or when the clock
// has run past the newest frame (starved after silence). depth becomes ~0 — no
// artificial prebuffer, so latency stays as low as the old path; buffered frames still
// play in order (pop() returns oldest-first), they just stop being held back.
// • frame-skip catch-up: if the backlog has grown beyond target+hysteresis (clock drift,
// bursty arrival, reordering), fast-forward to leave exactly `target` buffered and drop
// the now-stale frames. This is the missing downward force that bounds latency.
// u32 subtraction via int32_t handles timestamp wrap.
const int32_t target =
std::max<int32_t>(static_cast<int32_t>(stream.jitter.target_depth_samples()),
kMinDepthSamples);
if (auto newest = stream.jitter.peek_back_ts()) {
int32_t depth = static_cast<int32_t>(*newest - stream.playout_ts); // wrap-safe
if (!stream.playout_started || stream.pending_marker || depth < -kStarveSamples) {
stream.playout_ts = *newest;
stream.playout_started = true;
stream.pending_marker = false;
} else if (depth > target + kCatchupSamples) {
stream.playout_ts = *newest - static_cast<uint32_t>(target);
stream.jitter.drop_before(stream.playout_ts);
}
}
@@ -713,6 +796,12 @@ void AudioEngine::on_playback(int16_t* out, ma_uint32 frames) {
stream.playout_ts += static_cast<uint32_t>(n);
}
// Diagnostic: the decode loop couldn't keep the ring fed for this hardware period while
// the stream was actively playing out — a genuine underrun (decoder error / exhausted
// PLC), distinct from ordinary single-packet loss the loop conceals in place.
if (stream.playout_started && stream.ring_count < frames)
stream.underruns.fetch_add(1, std::memory_order_relaxed);
const float g = stream.gain;
int16_t frame_buf[2];
for (ma_uint32 i = 0; i < frames; ++i) {

View File

@@ -46,6 +46,7 @@ class JitterBuffer {
uint64_t seq;
uint32_t timestamp;
bool fec_present;
bool marker = false; // kFlagMarker: first frame of a talkspurt
std::vector<uint8_t> payload;
};
@@ -61,28 +62,49 @@ class JitterBuffer {
// AudioEngine::on_playback). Uses try_lock — never blocks the real-time callback.
std::optional<uint32_t> peek_front_ts() const;
// Timestamp of the newest (latest) buffered frame — the stream's leading edge. Lets the
// playout clock keep a bounded depth behind the arriving stream and catch up (frame-skip)
// when the backlog grows; see AudioEngine::on_playback. try_lock — never blocks the RT thread.
std::optional<uint32_t> peek_back_ts() const;
// If the front frame's timestamp == expected_ts, copies its raw Opus payload into out
// (caller-allocated, max_sz bytes). Returns bytes copied, or 0 (lock miss / wrong ts /
// empty). Caller pre-allocates out to avoid RT-thread allocation. Uses try_lock.
size_t try_copy_front_payload(uint32_t expected_ts, uint8_t* out, size_t max_sz);
uint32_t target_depth_ms()const { return target_depth_ms_.load(); }
uint32_t packets_lost() const { return lost_.load(); }
// Erase all buffered frames whose timestamp is strictly before `ts` (oldest-first). Used by
// the playout catch-up so a forward clock jump doesn't flood the decode loop with stale frames.
void drop_before(uint32_t ts);
uint32_t target_depth_ms() const { return target_depth_ms_.load(); }
uint32_t target_depth_samples() const { return target_depth_ms_.load() * 48; } // @48 kHz
uint32_t packets_lost() const { return lost_.load(); }
uint32_t duplicates() const { return dup_.load(); }
// Set the expected inter-arrival gap (= sender frame size in samples @48 kHz) so the jitter
// EWMA and silence-gap outlier rejection are correct for non-20 ms channels. Call at init.
void set_expected_gap(uint32_t samples) {
if (samples > 0) expected_gap_samples_ = samples;
}
void reset();
private:
static constexpr uint32_t kLateDropSamples = 48000 / 2; // 500 ms @48 kHz
static constexpr uint32_t kLateDropSamples = 48000 / 2; // 500 ms @48 kHz (hard floor/cap)
mutable std::mutex mu_;
std::map<uint32_t, Frame> buf_; // keyed by timestamp (u32 wraps are handled below)
std::atomic<uint32_t> target_depth_ms_{40};
std::atomic<uint32_t> lost_{0};
std::atomic<uint32_t> dup_{0};
// Jitter estimation (EWMA).
uint32_t last_push_ts_ = 0; // local clock estimate on last push
uint32_t jitter_est_ = 0; // EWMA jitter in samples
bool first_push_ = true;
uint32_t last_push_ts_ = 0; // sender timestamp on last push
uint32_t jitter_est_ = 0; // EWMA jitter in samples
uint32_t expected_gap_samples_ = 960; // expected inter-arrival gap (frame size @48 kHz)
uint32_t newest_ts_ = 0; // latest buffered timestamp (leading edge)
bool have_newest_ = false;
bool first_push_ = true;
};
// ── AudioParams ──────────────────────────────────────────────────────────────
@@ -192,6 +214,13 @@ class AudioEngine {
// Get stats for a remote stream's jitter buffer.
uint32_t stream_packets_lost(uint32_t ssrc) const;
uint32_t stream_target_depth_ms(uint32_t ssrc) const;
uint32_t stream_duplicates(uint32_t ssrc) const;
uint64_t stream_underruns(uint32_t ssrc) const;
// TEST-ONLY: current playout depth in samples (newest buffered ts - playout_ts), i.e. the
// standing latency held in the jitter buffer. Used by test_jitter_depth to assert the
// bounded-depth invariant. Returns 0 if the stream is unknown or not yet playing out.
int32_t stream_playout_depth_samples(uint32_t ssrc) const;
#ifdef VOICECAT_HAS_OPUS
// Configure the Opus decoder for an incoming ssrc (must be called before
@@ -422,6 +451,16 @@ class AudioEngine {
// dropped/never-due (silent playback). false until the first frame seeds it (on_playback).
bool playout_started = false;
// Set by push_recv_frame when a kFlagMarker (talkspurt-start) frame arrives; consumed by
// on_playback to force an immediate playout-clock reseed at the new talkspurt, so the
// bounded-depth target is re-established cleanly across silence gaps. See on_playback.
bool pending_marker = false;
// Diagnostic: times the decode/playback ring underran (produced silence because the
// jitter buffer had nothing due) while the stream was actively playing out — i.e. the
// "frames arriving but silent / latency starved" signal. Polled via stream_underruns().
std::atomic<uint64_t> underruns{0};
// PLC cap (defense-in-depth): consecutive samples produced by packet-loss
// concealment since the last real decoded frame. Reset to 0 on every real frame.
// When it exceeds kPlcCapSamples (audio_engine.cpp), on_playback stops calling