zebra-spaces: jitterBufferTarget enforces 4s buffer (hint isn't honored on FF Android)

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.
This commit is contained in:
Russell Ballestrini 2026-06-04 15:10:38 -04:00
parent e3f91b1e55
commit 1e4f0fa23d
No known key found for this signature in database

View file

@ -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');
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> &nbsp;·&nbsp; built <span class="stamp-date">2026-06-04</span><br>
md5 <span class="stamp-md5">e3c7120739d2e4ef52d4f95860be5eaf</span><br>
sha256 <span class="stamp-sha">b4416e44b5dca523fe5c9f32160d8f4d219c585b02d90e84e8d8129646ae27c6</span><br>
md5 <span class="stamp-md5">25a1c73043470ab0e7e990747635161c</span><br>
sha256 <span class="stamp-sha">a91fefd9648e7b76a8aaca85f73cd9a7fb2eb9bc2ca0b27303a233ef43cdb57c</span><br>
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
</footer>