From 1e4f0fa23d7f0e74d97d6d790593a1887903002a Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 4 Jun 2026 15:10:38 -0400 Subject: [PATCH] zebra-spaces: jitterBufferTarget enforces 4s buffer (hint isn't honored on FF Android) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fox 2026-06-04 — definitive observation: "the phone as a listener doesn't seem to be 4 secs behind ever." playoutDelayHint is a HINT the browser is free to ignore; Firefox Android apparently does. The phone's actual buffer was near-zero — so every host-side encoder stall propagated audibly to listeners with no cushion. Three changes: 1. Set RTCRtpReceiver.jitterBufferTarget = 4000 (ms) alongside playoutDelayHint. jitterBufferTarget is NOT a hint — it's a target the receiver must aim for. Chromium 113+ (May 2023), Firefox 124+ (2024). Older browsers silently ignore the assignment (try/catch). 2. Applied at all THREE attach sites: - SFU video receiver (screen/camera/game) - SFU mic receiver - mesh peer mic receiver 3. Add `jbuf=Xs` to telemetry, computed from inbound-rtp jitterBufferDelay / jitterBufferEmittedCount. This is the ACTUAL average buffer depth — we can now see whether the receiver is holding ~4s or 0.05s. If jbuf stays small after this deploy, the browser is ignoring the target too and we need a different approach (AudioWorklet manual buffering, or move to HTTP-pull DJ path for listeners). Existing playoutDelayHint setting kept for older browsers that honor it but don't yet support jitterBufferTarget. --- web/zebra-spaces.html | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index 2bebcb9..c6bd0fd 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -2955,6 +2955,7 @@ function handleRemoteSfuTrack(ev){ * is more vital than video — video adapts to audio's delay, never * the other way around. */ try { if (ev.receiver) ev.receiver.playoutDelayHint = RECV_PLAYOUT_DELAY_SEC; } catch(_){} + try { if (ev.receiver) ev.receiver.jitterBufferTarget = RECV_PLAYOUT_DELAY_SEC * 1000; } catch(_){} } /* MSID-supplant safety: the SFU re-uses the same streamID * (`shortPub-kind`) when a publisher supplants themselves. WebRTC @@ -3003,8 +3004,17 @@ function handleRemoteSfuTrack(ev){ } /* mic audio — see RECV_PLAYOUT_DELAY_SEC for the buffer-vs-latency * tradeoff rationale. Bumped 0.7 → 2.0 after fox 2026-06-03 chasing - * persistent chop that survived every SDP-side dial-back. */ + * persistent chop that survived every SDP-side dial-back. + * + * playoutDelayHint is a HINT the browser is free to ignore. Fox + * 2026-06-04: "the phone as a listener doesn't seem to be 4 secs + * behind ever" — Firefox Android wasn't honoring the hint, so the + * jitter buffer stayed near-zero and any encoder stall on the host + * was instantly audible. jitterBufferTarget (Chromium 113+, FF 124+) + * is NOT a hint — it sets a target the receiver MUST aim for. + * Setting both for cross-browser coverage. */ try { if (ev.receiver) ev.receiver.playoutDelayHint = RECV_PLAYOUT_DELAY_SEC; } catch(_){} + try { if (ev.receiver) ev.receiver.jitterBufferTarget = RECV_PLAYOUT_DELAY_SEC * 1000; } catch(_){} /* cache by full pubkey (already resolved above) so it survives the * member's session uuid changing across leave/rejoin */ sfuStreamsByPubHex.set(pubHex, ev.streams[0]); @@ -3900,21 +3910,38 @@ async function dumpTelemetry(){ /* lastPacketReceivedTimestamp pins the EXACT real-time * moment audio RTP stopped — pkt-delta only shows it after * the next tick. Express as 'ago' so a frozen flow stands - * out at a glance ('lp=0.1s' = fine, 'lp=12s' = dead). */ + * out at a glance ('lp=0.1s' = fine, 'lp=12s' = dead). + * + * jbuf is the ACTUAL average jitter-buffer depth in seconds. + * jitterBufferDelay accumulates "total seconds of buffer + * delay applied to emitted samples" and jitterBufferEmittedCount + * counts the emitted samples — ratio is the average. Fox + * 2026-06-04: playoutDelayHint is a hint, not a contract; + * we need to SEE whether the receiver actually holds 4s. If + * jbuf << 4s the receiver is ignoring the hint and any + * upstream stall is instantly audible. */ const lp = r.lastPacketReceivedTimestamp ? ((nowMs - r.lastPacketReceivedTimestamp) / 1000).toFixed(1) + 's' : '?'; + const jbuf = (r.jitterBufferEmittedCount > 0) + ? (r.jitterBufferDelay / r.jitterBufferEmittedCount).toFixed(2) + 's' + : '?'; parts.push('aud.recv pkt=' + (r.packetsReceived|0) + ' lost=' + (r.packetsLost|0) + ' bytes=' + (r.bytesReceived|0) + ' jitter=' + (r.jitter || 0).toFixed(4) + ' level=' + (r.audioLevel || 0).toFixed(3) + + ' jbuf=' + jbuf + ' lp=' + lp); } if (r.type === 'inbound-rtp' && r.kind === 'video'){ const lp = r.lastPacketReceivedTimestamp ? ((nowMs - r.lastPacketReceivedTimestamp) / 1000).toFixed(1) + 's' : '?'; + const jbuf = (r.jitterBufferEmittedCount > 0) + ? (r.jitterBufferDelay / r.jitterBufferEmittedCount).toFixed(2) + 's' + : '?'; parts.push('vid.recv pkt=' + (r.packetsReceived|0) + ' lost=' + (r.packetsLost|0) + ' frames=' + (r.framesDecoded|0) + + ' jbuf=' + jbuf + ' lp=' + lp); } }); @@ -4667,8 +4694,11 @@ async function connectToPeer(uuid, weOffer){ applySinkTo(a); } a.srcObject = ev.streams[0] || new MediaStream([ev.track]); - /* mesh path matches the SFU path — same RECV_PLAYOUT_DELAY_SEC. */ + /* mesh path matches the SFU path — same RECV_PLAYOUT_DELAY_SEC. + * jitterBufferTarget enforces (not hints) the buffer depth — see + * sfu mic-receiver site for rationale. */ try { ev.receiver.playoutDelayHint = RECV_PLAYOUT_DELAY_SEC; } catch(_){} + try { ev.receiver.jitterBufferTarget = RECV_PLAYOUT_DELAY_SEC * 1000; } catch(_){} stopMeter(uuid); startMeter(uuid, a.srcObject); }; pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ }; @@ -5950,8 +5980,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');