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

@@ -8,7 +8,7 @@ and what's next* read [`PROGRESS.md`](PROGRESS.md); for *design* read [`docs/`](
> server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows > server-mute, channel CRUD, in-app account management, disconnect/keepalive/reaper. Windows
> WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj` > WinForms C# client shipped (M4). **macOS AppKit client shipped** — `VoiceCatMac.xcodeproj`
> at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at > at `clients/apple/macOS/`. **iOS SwiftUI client shipped** — `VoiceCatiOS.xcodeproj` at
> `clients/apple/iOS/`. `ctest --preset dev` green — 26/26 tests. > `clients/apple/iOS/`. `ctest --preset dev` green — 27/27 tests.
> External PCM feed/tap API (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) shipped. > External PCM feed/tap API (`vc_stream_feed_pcm` + `vc_set_pcm_sink`) shipped.
> **Screen-audio sharing shipped on macOS (ScreenCaptureKit) and iOS (ReplayKit Broadcast > **Screen-audio sharing shipped on macOS (ScreenCaptureKit) and iOS (ReplayKit Broadcast
> Upload Extension → host App Group ring → `vc_stream_feed_pcm`).** See [`PROGRESS.md`](PROGRESS.md). > Upload Extension → host App Group ring → `vc_stream_feed_pcm`).** See [`PROGRESS.md`](PROGRESS.md).

View File

@@ -10,6 +10,25 @@ up instantly. Newest status at the top.
## ▶ Where we left off / next action ## ▶ Where we left off / next action
- **Done (2026-06-22):** **Fixed growing voice latency (jitter-buffer depth ratchet).** Symptom:
end-to-end latency grew to multiple seconds and "drifted backward," reset only by leaving/
rejoining voice (DTX/FEC/DRED on, 10% loss). Root cause was **not** the codec settings (10% loss
is just an `OPUS_SET_PACKET_LOSS_PERC` encoder hint; FEC/DRED add no standing latency) but the
receiver playout logic in `core/src/audio/audio_engine.cpp`: the playout clock free-ran in real
time while the sender omitted silence from its timestamps and set **no header flags at all**, and
the only correction snapped the clock to the *oldest* buffered frame (could only *add* latency) —
with `target_depth_ms_` computed but never enforced, so latency could only grow or be reset.
**Fix:** bounded-depth playout — (re)seed to the *leading edge* (newest frame) on start/marker/
starve, and **frame-skip catch-up** that trims a backlog beyond `target + hysteresis` (the missing
downward force). Plus hardening: adaptive late-drop window, talkspurt `kFlagMarker`/`kFlagDtx`
now actually stamped by the sender (`client.cpp` send path) and consumed on recv, EWMA outlier
rejection (silence gaps/stragglers no longer poison the estimate), duplicate counting, ring-
underrun diagnostics (`stream_underruns`/`stream_duplicates`). New regression test
`tests/test_jitter_depth.cpp` asserts depth stays bounded (<200 ms) while arrivals outrun playout
for ~4 s. `ctest --preset dev` green **27/27**. Docs: `docs/voice.md` §5 rewritten.
- **Next (manual E2E):** two clients in a channel, DTX/FEC/DRED on talk in alternating bursts
for several minutes and confirm latency stays low/stable (no backward drift, no rejoin needed).
- **Windows done / Apple awaiting Mac build (2026-06-22):** **Event sound effects + optional - **Windows done / Apple awaiting Mac build (2026-06-22):** **Event sound effects + optional
text-to-speech for all clients.** Clients now play a cue per session event and can optionally text-to-speech for all clients.** Clients now play a cue per session event and can optionally
speak it (TTS off by default; when on it announces joins/leaves and reads message/PM bodies). speak it (TTS off by default; when on it announces joins/leaves and reads message/PM bodies).

View File

@@ -18,13 +18,18 @@ int64_t now_ms() {
.count(); .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 // 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 // actually transmitting (silence is omitted) — so the clock drifts away from the stream timeline
// under JitterBuffer::kLateDropSamples (500 ms) so the clock is snapped back before frames would // across VAD/PTT/DTX gaps and late joins. Rather than snap the clock to the *oldest* buffered
// begin to be dropped-as-late, which is what produced the "talk indicator lit, no audio" silence. // frame (which could only add standing latency and never trim it — the cause of the "latency keeps
constexpr int32_t kResyncAheadSamples = 48000 * 200 / 1000; // clock 200 ms ahead → re-seed // drifting backward, fixed by rejoin" bug), on_playback keeps the clock a bounded `target` behind
constexpr int32_t kResyncBehindSamples = 48000 * 500 / 1000; // clock 500 ms behind → re-seed // 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 // 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 // packet decoded), stop calling opus_decode(nullptr,0,...) and emit silence instead. Opus
@@ -80,21 +85,36 @@ void JitterBuffer::push(Frame f) {
std::lock_guard lk(mu_); 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_) { if (!first_push_) {
uint32_t arrived_gap = ts - last_push_ts_; int32_t gap = static_cast<int32_t>(ts - last_push_ts_);
uint32_t expected_gap = 960; // 20 ms @48k; TODO: derive from params bool outlier = marker || gap <= 0 ||
uint32_t diff = (arrived_gap > expected_gap) ? (arrived_gap - expected_gap) gap > static_cast<int32_t>(expected_gap_samples_ * 8);
: (expected_gap - arrived_gap); 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; jitter_est_ = (jitter_est_ * 7 + diff) / 8;
uint32_t depth = std::clamp(jitter_est_ * 2 + 960u, 960u, 48000u * 200u / 1000u); 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); target_depth_ms_.store(depth * 1000u / 48000u, std::memory_order_relaxed);
} }
}
last_push_ts_ = ts; last_push_ts_ = ts;
first_push_ = false; 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) { 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(); auto it = buf_.begin();
uint32_t ts = it->first; uint32_t ts = it->first;
// Drop frames that are too old (> 500 ms late). // Drop frames that are too old to play. The window tracks the (adaptive) target depth plus a
if (static_cast<int32_t>(playout_ts - ts) > static_cast<int32_t>(kLateDropSamples)) { // 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); lost_.fetch_add(1, std::memory_order_relaxed);
buf_.erase(it); buf_.erase(it);
return std::nullopt; return std::nullopt;
@@ -127,6 +153,23 @@ std::optional<uint32_t> JitterBuffer::peek_front_ts() const {
return buf_.begin()->first; 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) { 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); std::unique_lock lk(mu_, std::try_to_lock);
if (!lk || buf_.empty()) return 0; if (!lk || buf_.empty()) return 0;
@@ -142,7 +185,10 @@ void JitterBuffer::reset() {
std::lock_guard lk(mu_); std::lock_guard lk(mu_);
buf_.clear(); buf_.clear();
lost_.store(0); lost_.store(0);
dup_.store(0);
first_push_ = true; first_push_ = true;
have_newest_ = false;
jitter_est_ = 0;
} }
// ── AudioEngine ────────────────────────────────────────────────────────────── // ── AudioEngine ──────────────────────────────────────────────────────────────
@@ -444,6 +490,7 @@ void AudioEngine::push_recv_frame(uint32_t ssrc, JitterBuffer::Frame f) {
std::lock_guard lk(streams_mu_); std::lock_guard lk(streams_mu_);
auto& s = streams_[ssrc]; auto& s = streams_[ssrc];
s.last_voice_ms.store(now_ms(), std::memory_order_relaxed); 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)); 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; 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 #ifdef VOICECAT_HAS_OPUS
void AudioEngine::init_recv_stream(uint32_t ssrc, const codec::OpusParams& p, void AudioEngine::init_recv_stream(uint32_t ssrc, const codec::OpusParams& p,
uint32_t user_id, uint32_t stream_id) { 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(); int frame_samples = stream.decoder.frame_samples();
if (frame_samples <= 0) frame_samples = static_cast<int>(p.sample_rate / 1000 * p.frame_ms); if (frame_samples <= 0) frame_samples = static_cast<int>(p.sample_rate / 1000 * p.frame_ms);
stream.init_ring(channels, frame_samples); 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. // DRED: pre-allocate per-stream scratch (no RT-thread allocation). 4000 bytes > max Opus pkt.
stream.dred_payload_scratch_.assign(4000, 0); stream.dred_payload_scratch_.assign(4000, 0);
if (!stream.dred_state_) { 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 dec_channels = std::max(1, stream.decoder.channels());
const int frame_samples = stream.decoder.frame_samples(); const int frame_samples = stream.decoder.frame_samples();
// Seed / re-sync the playout clock to the arriving stream. playout_ts advances every // Bound the playout latency against the stream's leading edge (newest buffered frame).
// callback (via the PLC path below) independently of whether the sender is transmitting, // playout_ts free-runs every callback (PLC path below) while the sender omits silence from
// so across a late join or any VAD/PTT silence gap it drifts away from the sender's frame // its timestamps, so the two diverge across late joins and VAD/PTT/DTX gaps. Two cases,
// timestamps without bound. Left uncorrected, the divergence eventually exceeds the jitter // both measured against the *newest* frame — never the oldest, whose snap could only ever
// buffer's late-drop window and every real frame is dropped-as-late (clock ahead) or // *add* standing latency (the ratchet that caused the growing-latency bug):
// never-due (clock behind) — permanent silence even though frames keep arriving (the talk // • (re)seed to the leading edge when starting, on a talkspurt marker, or when the clock
// indicator, driven by push_recv_frame, stays lit). Snap to the earliest buffered frame on // has run past the newest frame (starved after silence). depth becomes ~0 — no
// the first frame and whenever the clock has drifted too far; this both seeds startup and // artificial prebuffer, so latency stays as low as the old path; buffered frames still
// recovers after every silence gap. u32 subtraction via int32_t handles timestamp wrap. // play in order (pop() returns oldest-first), they just stop being held back.
if (auto front_ts = stream.jitter.peek_front_ts()) { // • frame-skip catch-up: if the backlog has grown beyond target+hysteresis (clock drift,
int32_t drift = static_cast<int32_t>(stream.playout_ts - *front_ts); // bursty arrival, reordering), fast-forward to leave exactly `target` buffered and drop
if (!stream.playout_started || drift > kResyncAheadSamples || // the now-stale frames. This is the missing downward force that bounds latency.
drift < -kResyncBehindSamples) { // u32 subtraction via int32_t handles timestamp wrap.
stream.playout_ts = *front_ts; 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.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); 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; const float g = stream.gain;
int16_t frame_buf[2]; int16_t frame_buf[2];
for (ma_uint32 i = 0; i < frames; ++i) { for (ma_uint32 i = 0; i < frames; ++i) {

View File

@@ -46,6 +46,7 @@ class JitterBuffer {
uint64_t seq; uint64_t seq;
uint32_t timestamp; uint32_t timestamp;
bool fec_present; bool fec_present;
bool marker = false; // kFlagMarker: first frame of a talkspurt
std::vector<uint8_t> payload; std::vector<uint8_t> payload;
}; };
@@ -61,27 +62,48 @@ class JitterBuffer {
// AudioEngine::on_playback). Uses try_lock — never blocks the real-time callback. // AudioEngine::on_playback). Uses try_lock — never blocks the real-time callback.
std::optional<uint32_t> peek_front_ts() const; 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 // 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 / // (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. // 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); 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(); } // 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 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(); void reset();
private: 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_; mutable std::mutex mu_;
std::map<uint32_t, Frame> buf_; // keyed by timestamp (u32 wraps are handled below) 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> target_depth_ms_{40};
std::atomic<uint32_t> lost_{0}; std::atomic<uint32_t> lost_{0};
std::atomic<uint32_t> dup_{0};
// Jitter estimation (EWMA). // Jitter estimation (EWMA).
uint32_t last_push_ts_ = 0; // local clock estimate on last push uint32_t last_push_ts_ = 0; // sender timestamp on last push
uint32_t jitter_est_ = 0; // EWMA jitter in samples 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; bool first_push_ = true;
}; };
@@ -192,6 +214,13 @@ class AudioEngine {
// Get stats for a remote stream's jitter buffer. // Get stats for a remote stream's jitter buffer.
uint32_t stream_packets_lost(uint32_t ssrc) const; uint32_t stream_packets_lost(uint32_t ssrc) const;
uint32_t stream_target_depth_ms(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 #ifdef VOICECAT_HAS_OPUS
// Configure the Opus decoder for an incoming ssrc (must be called before // 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). // dropped/never-due (silent playback). false until the first frame seeds it (on_playback).
bool playout_started = false; 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 // 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. // 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 // When it exceeds kPlcCapSamples (audio_engine.cpp), on_playback stops calling

View File

@@ -904,6 +904,7 @@ void vc_client::run_udp_recv() {
jf.seq = hdr.seq; jf.seq = hdr.seq;
jf.timestamp = hdr.timestamp; jf.timestamp = hdr.timestamp;
jf.fec_present = (hdr.flags & voicecat::net::kFlagFecPresent) != 0; jf.fec_present = (hdr.flags & voicecat::net::kFlagFecPresent) != 0;
jf.marker = (hdr.flags & voicecat::net::kFlagMarker) != 0;
jf.payload = std::move(plain); jf.payload = std::move(plain);
audio_engine_.push_recv_frame(hdr.ssrc, std::move(jf)); audio_engine_.push_recv_frame(hdr.ssrc, std::move(jf));
} }
@@ -1097,6 +1098,18 @@ void vc_client::encode_and_send_frame(LocalStream& ls, const int16_t* pcm, int s
hdr.timestamp = ls.timestamp; hdr.timestamp = ls.timestamp;
ls.timestamp += static_cast<uint32_t>(samples); ls.timestamp += static_cast<uint32_t>(samples);
// Talkspurt marker: first frame overall, or the first after a transmission gap longer than a
// few frame intervals (VAD/PTT closed, or DTX silence). The sender omits silence from the
// timestamp, so this is how the receiver knows to reseed its playout clock (see on_playback).
const int64_t now_ms = client_now_ms();
const int64_t frame_ms = std::max<int64_t>(1, samples / 48); // @48 kHz
if (ls.last_send_ms < 0 || (now_ms - ls.last_send_ms) > frame_ms * 3)
hdr.flags |= voicecat::net::kFlagMarker;
ls.last_send_ms = now_ms;
// DTX: Opus emits a 12 byte comfort-noise packet when it gates silence. Flag it so the
// receiver can treat it as such (informational; the bounded-depth playout handles timing).
if (opus_len <= 2) hdr.flags |= voicecat::net::kFlagDtx;
uint8_t header_bytes[voicecat::net::kVoiceHeaderSize]; uint8_t header_bytes[voicecat::net::kVoiceHeaderSize];
voicecat::net::serialize_header(hdr, header_bytes); voicecat::net::serialize_header(hdr, header_bytes);

View File

@@ -240,6 +240,13 @@ struct vc_client {
std::atomic<int64_t> last_capture_ms{0}; std::atomic<int64_t> last_capture_ms{0};
bool talking = false; bool talking = false;
// Talkspurt marker: the sender's `timestamp` omits VAD/PTT/DTX silence, so the receiver
// can't tell a continuation from a post-silence restart. encode_and_send_frame stamps
// kFlagMarker on the first frame after a transmission gap (detected via last_send_ms) so
// the receiver reseeds its playout clock cleanly. -1 = no frame sent yet (first frame is
// always a marker).
int64_t last_send_ms = -1;
// Device-enumeration follow-up: the device this stream's capture should use ("" = // Device-enumeration follow-up: the device this stream's capture should use ("" =
// default). Only meaningful for VC_STREAM_MIC today (the real capture device); set via // default). Only meaningful for VC_STREAM_MIC today (the real capture device); set via
// vc_set_input_device. Opaque id from AudioEngine::enumerate_devices — see // vc_set_input_device. Opaque id from AudioEngine::enumerate_devices — see

View File

@@ -143,16 +143,28 @@ Layered, all configurable per channel:
## 5. Jitter buffer ## 5. Jitter buffer
Each receiver keeps an **adaptive jitter buffer per ssrc**. Each receiver keeps an **adaptive jitter buffer per ssrc** with **bounded-depth playout**
(`core/src/audio/audio_engine.cpp``JitterBuffer` + `AudioEngine::on_playback`).
- Frames are inserted by `timestamp`; playback reads in order at the device callback rate. - Frames are inserted by `timestamp`; playback reads in order at the device callback rate.
- Target depth adapts to observed network jitter between a configurable **min/max latency** - **The playout clock is always bounded against the stream's *leading edge* (newest buffered
(channel-level "stability vs latency" knob). A "low-latency" channel runs a shallow frame), never re-synced to the oldest.** The clock free-runs at the playback hardware rate,
buffer; a "stable" channel runs deeper. while the sender omits VAD/PTT/DTX silence from its timestamps, so the two diverge across gaps
- Late frames past the playout point are dropped; gaps are filled by FEC (if the next frame and late joins. Two corrections keep latency bounded:
- **(Re)seed to the leading edge** on first frame, on a talkspurt `marker`, or when the clock
has run past the newest frame (starved after silence). No artificial prebuffer — latency
starts as low as possible; buffered frames still play oldest-first.
- **Frame-skip catch-up:** when the backlog grows past `target + hysteresis` (clock drift,
bursty arrival, reordering), fast-forward the clock to leave `target` buffered and drop the
now-stale frames. This is the downward force that prevents latency from ratcheting upward.
- `target` is the adaptive jitter estimate (EWMA of inter-arrival gap vs. the per-frame gap),
floored; silence gaps and reordered stragglers are rejected as outliers so they don't inflate
it. The late-drop window tracks `target` (floored/capped at 500 ms).
- Late frames past the playout point are dropped; gaps are filled by DRED (if the next frame
arrived) or PLC. arrived) or PLC.
- The `marker` flag (start of talkspurt) lets the buffer resynchronize cleanly after - The `marker` flag (start of talkspurt) — set by the sender on the first frame after a
silence/DTX without accumulating drift. transmission gap — lets the buffer reseed cleanly after silence/DTX without accumulating drift.
- Diagnostics per stream: `packets_lost`, `duplicates`, `underruns`, `target_depth_ms`.
``` ```
incoming (out of order) ──▶ [ reorder by ts | adaptive depth ] ──▶ Opus decode ──▶ mixer incoming (out of order) ──▶ [ reorder by ts | adaptive depth ] ──▶ Opus decode ──▶ mixer

View File

@@ -69,6 +69,15 @@ if(VOICECAT_USE_VCPKG_DEPS)
target_include_directories(test_plc_cap PRIVATE ${VC_TEST_INTERNAL_INCLUDES}) target_include_directories(test_plc_cap PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME plc_cap COMMAND test_plc_cap) add_test(NAME plc_cap COMMAND test_plc_cap)
# Jitter-buffer bounded depth: across many talkspurt/silence cycles with a compressed sender
# timeline + reordered stragglers, playout latency must stay bounded (no backward drift /
# ratchet). White-box AudioEngine test, no server needed.
add_executable(test_jitter_depth test_jitter_depth.cpp)
target_link_libraries(test_jitter_depth PRIVATE voicecat::voicecat)
target_compile_features(test_jitter_depth PRIVATE cxx_std_20)
target_include_directories(test_jitter_depth PRIVATE ${VC_TEST_INTERNAL_INCLUDES})
add_test(NAME jitter_depth COMMAND test_jitter_depth)
# External playback (iOS VPIO): the mixer-timer thread drives decode+mix with NO hardware # External playback (iOS VPIO): the mixer-timer thread drives decode+mix with NO hardware
# device and delivers the final mix to the mixed-output sink. White-box AudioEngine test. # device and delivers the final mix to the mixed-output sink. White-box AudioEngine test.
add_executable(test_external_playback test_external_playback.cpp) add_executable(test_external_playback test_external_playback.cpp)

144
tests/test_jitter_depth.cpp Normal file
View File

@@ -0,0 +1,144 @@
/*
* 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

View File

@@ -110,11 +110,14 @@ int main() {
} }
CHECK(energy == 0); // capped PLC = silence CHECK(energy == 0); // capped PLC = silence
// 5) Resumption: push a fresh real frame — PLC streak resets, audio returns. // 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; voicecat::audio::JitterBuffer::Frame f2;
f2.seq = 1; f2.seq = 1;
f2.timestamp = 200000; // far ahead — playout-clock re-sync snaps to it f2.timestamp = 200000; // far ahead — playout-clock reseeds to it
f2.fec_present = false; f2.fec_present = false;
f2.marker = true;
f2.payload.assign(opus_buf, opus_buf + opus_len); f2.payload.assign(opus_buf, opus_buf + opus_len);
engine.push_recv_frame(ssrc, std::move(f2)); engine.push_recv_frame(ssrc, std::move(f2));