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');