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