openvibers/openvibe.live Commits

Strip the reorder buffer and the player jitter-buffer override

OP OpenVibers committed 18 Aug 2026 · c1fa40f691 · 3 files, +8 −80

Both added latency to solve problems that were not real.

Relay: RTP over UDP reorders constantly and every WebRTC receiver — RS's
mediasoup included — has a jitter buffer built to absorb exactly that.
Reordering is normal, not damage, so a relay has no business "fixing" it. The
buffer added up to 80ms of latency for nothing, and worse, it DROPPED packets:
anything arriving past the emit cursor was discarded (tooLate was counting ~16
per 10s) where before it was forwarded late but intact. That turned harmless
reordering into real loss — a regression, and the likely cause of the stream
getting worse rather than better.

Player: swapping a forced 0 for a forced 120ms just traded one hardcoded guess
for another. Set neither and let the browser manage both buffers; its adaptive
jitter buffer already targets the minimum the network allows and grows only
when it must, which beats any constant picked here and costs no latency on a
clean connection. The actual desync fix was never the 120ms — it was not
forcing 0 on the AUDIO receiver, and that still holds.

The relay data path is now a true passthrough again: payload type, cleared
extensions, write. With injection off _seqOffset is always 0, so sequence
numbers pass through untouched — in fact more faithfully than before my
changes, which renumbered contiguously in arrival order and erased gaps.

What remains from this session: the deep NACK ring (passive, only fires when RS
actually asks), the keyframe-backstop dead-code fix, SPS/PPS injection gated
off, and read-only diagnostics.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/public/index.html b/public/index.html
iindex a5ff58d9..a52fef31 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -4971,7 +4971,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/app.js?v=123" defer></script>
--<script src="/js/stream-player.js?v=19" defer></script>
++<script src="/js/stream-player.js?v=20" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/gif-picker.js?v=1" defer></script>
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 8e90bd58..09b66e38 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -2034,18 +2034,10 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
// The old comment claimed the receiver still grows the buffer adaptively; a hard 0
// target fights that adaptation rather than cooperating with it.
//
-- // So: leave AUDIO on the browser's adaptive buffer — that is the thing doing the
-- // syncing — and give VIDEO a small but non-zero target. Still low latency, with
-- // enough headroom to stay smooth and aligned. Raise this if streams still stutter.
-- const VIDEO_JITTER_TARGET_MS = 120;
-- try {
-- const receiver = consumer.rtpReceiver;
-- if (receiver && consumer.kind === 'video') {
-- // jitterBufferTarget is in milliseconds; playoutDelayHint is in seconds.
-- if ('jitterBufferTarget' in receiver) receiver.jitterBufferTarget = VIDEO_JITTER_TARGET_MS;
-- if ('playoutDelayHint' in receiver) receiver.playoutDelayHint = VIDEO_JITTER_TARGET_MS / 1000;
-- }
-- } catch { /* non-fatal — playback still works at default latency */ }
++ // So we now set NEITHER and let the browser manage both buffers. Its adaptive
++ // jitter buffer already targets the minimum delay the network allows and grows
++ // only when it has to — that is strictly better than any constant we could pick
++ // here, and unlike a hardcoded target it costs no latency on a clean connection.
// Keep a handle on the VIDEO receiver so the freeze watchdog can read framesDecoded
// when requestVideoFrameCallback isn't available.
ddiff --git a/server/integrations/rs-passthrough-relay.js b/server/integrations/rs-passthrough-relay.js
iindex 0405ceab..c84f0887 100644
---- a/server/integrations/rs-passthrough-relay.js
++++ b/server/integrations/rs-passthrough-relay.js
@@@ -345,7 +345,6 @@ class RsPassthroughRelay {
_teardown(session) {
if (session.statsTimer) { clearInterval(session.statsTimer); session.statsTimer = null; }
-- if (session.reorderTimer) { clearInterval(session.reorderTimer); session.reorderTimer = null; }
const sfu = require('../streaming/webrtc-sfu');
for (const ing of session.ingests) {
try { ing.socket.removeAllListeners('message'); ing.socket.close(); } catch {}
@@@ -462,7 +461,7 @@ class RsPassthroughRelay {
// 9) Forward encoded RTP straight through (strip our-router ext ids, match declared PT).
// Each UDP datagram from the PlainTransport consumer is one RTP packet.
-- const stats = { v: 0, a: 0, pli: 0, lostIn: 0, kf: 0, maxKfGap: 0, inj: 0, rtxDeep: 0, rtxMiss: 0, gopDamaged: 0, lateFill: 0, reorderSkip: 0, tooLate: 0 };
++ const stats = { v: 0, a: 0, pli: 0, lostIn: 0, kf: 0, maxKfGap: 0, inj: 0, rtxDeep: 0, rtxMiss: 0, gopDamaged: 0, lateFill: 0 };
const vPt = +vMedia.pts[0];
// ── Deep retransmit cache ────────────────────────────────────────────────────────
@@@ -643,72 +642,9 @@ class RsPassthroughRelay {
// forwarded for KF_STALE_MS, pull one so a freeze can never outlast ~2s.
const kfStale = (now - _lastKfAt) > KF_STALE_MS;
if (timeGap || burst || kfStale) pullKeyframe(now);
-- enqueue(p);
++ emitVideo(p);
});
-- // ── Reorder buffer ──────────────────────────────────────────────────────────────
-- // THE bug behind "video freezes, audio fine". Measured on the live stream: lateFill
-- // equals lossIN exactly, every interval — nothing is actually lost on the way in. Every
-- // "missing" packet does arrive, just out of order, because mediasoup NACK-recovers it
-- // from OBS and hands us the repair a few ms late. We were forwarding in ARRIVAL order,
-- // so RS received a shuffled stream.
-- //
-- // Audio shrugs that off (one self-contained Opus packet per 20ms frame). Video cannot:
-- // an H264 frame is split across many packets and its FU-A fragments must be reassembled
-- // in order, so one late packet corrupts the whole frame and the picture stays broken
-- // until the next keyframe. It also made RS NACK packets that were merely still in
-- // flight to us — which is why rtxMiss climbed while deepRtx stayed at zero.
-- //
-- // So: hold packets briefly and release them strictly in sequence. A hole waits up to
-- // REORDER_MS for its straggler before we give up and skip past it, which bounds the
-- // added latency. This is what a relay is supposed to do and nothing downstream can
-- // substitute for it.
-- const REORDER_MS = 80;
-- const _pending = new Map(); // seq -> { pkt, at }
-- let _emitNext = -1;
--
-- function drain() {
-- while (_emitNext >= 0) {
-- const hit = _pending.get(_emitNext);
-- if (!hit) break;
-- _pending.delete(_emitNext);
-- emitVideo(hit.pkt);
-- _emitNext = (_emitNext + 1) & 0xffff;
-- }
-- }
--
-- // Give up on a hole once its successor has waited long enough, then jump the cursor to
-- // the oldest packet we are still holding and continue in order from there.
-- function flushStale(nowMs) {
-- if (!_pending.size) return;
-- let lowest = null, bestDist = Infinity;
-- for (const sq of _pending.keys()) {
-- const d = (sq - _emitNext) & 0xffff;
-- if (d < bestDist) { bestDist = d; lowest = sq; }
-- }
-- if (lowest === null) return;
-- const held = _pending.get(lowest);
-- if (held && (nowMs - held.at) >= REORDER_MS) {
-- stats.reorderSkip += bestDist;
-- _emitNext = lowest;
-- drain();
-- }
-- }
--
-- function enqueue(p) {
-- const seq = p.header.sequenceNumber;
-- if (_emitNext < 0) _emitNext = seq; // first packet sets the cursor
-- const dist = (seq - _emitNext) & 0xffff;
-- if (dist >= 30000) { stats.tooLate++; return; } // already emitted past this one
-- _pending.set(seq, { pkt: p, at: Date.now() });
-- if (_pending.size > 2048) { _emitNext = seq; _pending.clear(); _pending.set(seq, { pkt: p, at: Date.now() }); }
-- drain();
-- }
--
-- session.reorderTimer = setInterval(() => flushStale(Date.now()), 20);
-- session.reorderTimer.unref?.();
--
-- // Emit one packet downstream, in sequence order.
function emitVideo(p) {
p.header.payloadType = vPt; p.header.extensions = [];
// For H264 shift the sequence by the running injection offset so the injected
@@@ -737,7 +673,7 @@ class RsPassthroughRelay {
const rsLostPct = typeof s.remoteFractionLost === 'number' ? ((s.remoteFractionLost / 256) * 100).toFixed(1) : '?';
log(sid, `flow: v ${Math.round((stats.v - lastV) / 10)}/s a ${Math.round((stats.a - lastA) / 10)}/s ` +
`| kf ${stats.kf - lastKf}/10s maxKfGap ${stats.maxKfGap}ms injSPS/PPS=${stats.inj}${INJECT_PARAMS ? '' : '(off)'} gopDmg=${stats.gopDamaged} ` +
-- `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay) lateFill ${stats.lateFill - lastLateFill} reorderSkip=${stats.reorderSkip} tooLate=${stats.tooLate} ` +
++ `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay) lateFill ${stats.lateFill - lastLateFill} ` +
`| RS lost=${rsLostPct}% pktsLost=${s.remotePacketsLost ?? '?'} pliRx=${s.pliCount ?? '?'} firRx=${s.firCount ?? '?'} nackRx=${s.nackCount ?? '?'} retx=${s.retransmittedPacketsSent ?? '?'} deepRtx=${stats.rtxDeep} rtxMiss=${stats.rtxMiss} rtt=${Math.round((s.rtt || 0) * 1000)}ms ` +
`| keyReq=${stats.pli} werift=${pc.connectionState}`);
lastV = stats.v; lastA = stats.a; lastLostIn = stats.lostIn; lastKf = stats.kf; lastLateFill = stats.lateFill; stats.maxKfGap = 0;