fix(audio): seed/re-sync playout clock so VAD/PTT gaps don't silence playback
RemoteStream::playout_ts was seeded to 0 and only advanced inside the decode loop (including on every PLC iteration), so it free-ran at ~1x wall-clock regardless of whether the sender was transmitting. The sender's frame timestamps only advance while it actually sends (the VAD/PTT gate returns before ls.timestamp += samples). Across a late join or any VAD/PTT silence gap the two clocks diverged without bound; once past the jitter buffer's 500 ms late-drop window every real frame was dropped-as-late (clock ahead) or never-due (clock behind) -> permanent silence, while the talk indicator (driven by push_recv_frame, independent of the jitter buffer) stayed lit. Add JitterBuffer::peek_front_ts() (try-lock, RT-safe) and seed/re-sync playout_ts to the earliest buffered frame on the first frame and whenever it has drifted past +/-200/500 ms. This seeds startup and recovers after every silence gap. New regression test test_playout_resync free-runs the clock ~2 s past the drop window, pushes a ts=0 frame, and asserts audible output: fails (energy=0) without the fix, passes with it. ctest --preset m1-dev: 14/14. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,14 @@ int64_t now_ms() {
|
||||
.count();
|
||||
}
|
||||
|
||||
// Playout-clock re-sync thresholds (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
|
||||
|
||||
#ifdef VOICECAT_HAS_AUDIO
|
||||
// device_id encoding (DeviceInfo::id / AudioParams::*_device_id): a hex string of the raw
|
||||
// ma_device_id bytes. Opaque on purpose — names aren't guaranteed unique, and this is the only
|
||||
@@ -103,6 +111,12 @@ std::optional<JitterBuffer::Frame> JitterBuffer::pop(uint32_t playout_ts) {
|
||||
return f;
|
||||
}
|
||||
|
||||
std::optional<uint32_t> JitterBuffer::peek_front_ts() const {
|
||||
std::unique_lock lk(mu_, std::try_to_lock);
|
||||
if (!lk || buf_.empty()) return std::nullopt;
|
||||
return buf_.begin()->first;
|
||||
}
|
||||
|
||||
void JitterBuffer::reset() {
|
||||
std::lock_guard lk(mu_);
|
||||
buf_.clear();
|
||||
@@ -413,6 +427,24 @@ 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;
|
||||
stream.playout_started = true;
|
||||
}
|
||||
}
|
||||
|
||||
while (stream.ring_count < frames && frame_samples > 0) {
|
||||
auto maybe_frame = stream.jitter.pop(stream.playout_ts);
|
||||
int n;
|
||||
|
||||
@@ -55,6 +55,11 @@ class JitterBuffer {
|
||||
// Drops frames that are too old (more than kLateDropSamples late).
|
||||
std::optional<Frame> pop(uint32_t playout_ts);
|
||||
|
||||
// Timestamp of the earliest buffered frame, or nullopt if empty/contended. Lets the playout
|
||||
// clock seed/re-sync itself to the arriving stream rather than free-running (see
|
||||
// AudioEngine::on_playback). Uses try_lock — never blocks the real-time callback.
|
||||
std::optional<uint32_t> peek_front_ts() const;
|
||||
|
||||
uint32_t target_depth_ms()const { return target_depth_ms_.load(); }
|
||||
uint32_t packets_lost() const { return lost_.load(); }
|
||||
void reset();
|
||||
@@ -259,6 +264,11 @@ class AudioEngine {
|
||||
float gain = 1.0f;
|
||||
bool mute = false;
|
||||
uint32_t playout_ts = 0;
|
||||
// playout_ts free-runs (advances every callback via PLC), so it must be seeded from, and
|
||||
// periodically re-synced to, the actual stream timeline — otherwise it drifts past the
|
||||
// jitter buffer's drop window across VAD/PTT gaps and late joins and every frame is
|
||||
// dropped/never-due (silent playback). false until the first frame seeds it (on_playback).
|
||||
bool playout_started = false;
|
||||
|
||||
// M3: listener-chosen, local-only noise reduction (docs/voice.md §10). Lazily
|
||||
// created only when enabled — bounded by how many remote streams this listener
|
||||
|
||||
Reference in New Issue
Block a user