zebra-spaces: deploy role-aware playout delays + worklet for all roles

This commit is contained in:
russell@unturf.com 2026-06-04 15:59:01 -04:00
parent 6e42fc180c
commit f077c01c5e
No known key found for this signature in database

View file

@ -1785,6 +1785,23 @@ class JitterBufferProcessor extends AudioWorkletProcessor {
this.started = false;
this.emptyStreak = 0;
this.dropped = 0;
/* role-change retarget — JS posts {cmd:'retarget', targetSeconds}
* when the user is promoted/demoted; we recompute the sample
* targets and shrink the queue if the new max is smaller. */
this.port.onmessage = (e) => {
if (!e.data || e.data.cmd !== 'retarget') return;
const t = +e.data.targetSeconds;
if (!isFinite(t) || t <= 0) return;
this.targetSeconds = t;
this.maxSeconds = t * 1.5;
this.targetSamples = Math.round(this.targetSeconds * sampleRate);
this.maxSamples = Math.round(this.maxSeconds * sampleRate);
while (this.buffered > this.maxSamples && this.queue.length > 0){
const drop = this.queue.shift();
this.buffered -= drop[0].length;
this.dropped += drop[0].length;
}
};
}
process(inputs, outputs){
const inBlk = inputs[0];
@ -1855,27 +1872,35 @@ function loadJitterWorklet(ctx){
}
function installJitterBuffer(uuid, node){
if (!node || node.jbuf || !workletReady) return;
const target = node.targetSeconds || RECV_PLAYOUT_DELAY_SEC;
try {
const jbuf = new AudioWorkletNode(audioCtx, 'jitter-buffer', {
processorOptions: {
targetSeconds: RECV_PLAYOUT_DELAY_SEC,
maxSeconds: RECV_PLAYOUT_DELAY_SEC * 1.5,
targetSeconds: target,
maxSeconds: target * 1.5,
},
outputChannelCount: [2],
});
try { node.src.disconnect(node.gain); } catch(_){}
node.src.connect(jbuf).connect(node.gain);
node.jbuf = jbuf;
logLine('', 'jitter-buffer installed uuid='+uuid.slice(0,4)+' target='+RECV_PLAYOUT_DELAY_SEC+'s');
logLine('', 'jitter-buffer installed uuid='+uuid.slice(0,4)+' target='+target+'s');
} catch (e) {
logLine('err', 'jitter-buffer install '+uuid.slice(0,4)+': '+e.message);
}
}
function attachListenerStreamViaAudioContext(uuid, stream){
/* Shared audio attach path. Every role routes through here now so the
* worklet can apply role-appropriate buffer depth uniformly. Listener
* gets a fat 4s cushion (lean-back, latency doesn't matter); speakers
* / cohosts / hosts get ~0.5s (small enough for conversation, big
* enough to smooth ordinary jitter). The OS Media-Session hook is
* applied only when role==='listener' — speakers don't need lock-
* screen transport controls. */
function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){
if (!audioCtx){
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch(e){ logLine('err','listener audioCtx create: '+e.message); return false; }
catch(e){ logLine('err','audioCtx create: '+e.message); return false; }
}
if (audioCtx.state === 'suspended'){
audioCtx.resume().catch(()=>{});
@ -1889,18 +1914,24 @@ function attachListenerStreamViaAudioContext(uuid, stream){
}
let src;
try { src = audioCtx.createMediaStreamSource(stream); }
catch(e){ logLine('err','listener createMediaStreamSource '+uuid.slice(0,4)+': '+e.message); return false; }
catch(e){ logLine('err','createMediaStreamSource '+uuid.slice(0,4)+': '+e.message); return false; }
const gain = audioCtx.createGain();
gain.gain.value = 1.0;
src.connect(gain);
gain.connect(audioCtx.destination);
const node = { src, gain, stream };
const node = { src, gain, stream, targetSeconds };
listenerAudioNodes.set(uuid, node);
/* fire-and-forget worklet load on first use; once ready, every
* existing stream is swapped through the buffer (see loadJitterWorklet) */
loadJitterWorklet(audioCtx);
if (workletReady) installJitterBuffer(uuid, node);
logLine('', 'listener audio via AudioContext '+uuid.slice(0,4)+' ctxState='+audioCtx.state);
logLine('', 'audio via AudioContext '+uuid.slice(0,4)+' target='+targetSeconds+'s ctxState='+audioCtx.state);
return true;
}
function attachListenerStreamViaAudioContext(uuid, stream){
const ok = attachAudioStreamViaWorklet(uuid, stream, RECV_PLAYOUT_DELAY_SEC);
if (!ok) return false;
/* Media Session API — tell the OS this tab is playing media. On
* Android Firefox + iOS Safari, this:
* - keeps the tab in media-priority mode (less aggressive JS
@ -1939,19 +1970,31 @@ function detachListenerStream(uuid){
listenerAudioNodes.delete(uuid);
}
function attachSfuTrack(uuid, stream){
/* Listener role: route audio through AudioContext (Firefox Android
* autoplay survives this path; <audio>.play() doesn't). Meter still
* uses the existing startMeter() path which separately creates its
* own analyser source from the same stream — that's fine, multiple
* MediaStreamSource nodes per stream is allowed. */
/* Every role routes through the AudioContext + worklet path now.
* Listener gets 4s buffer (lean-back, latency doesn't matter, ride
* out wiggle-stalls); speakers/cohosts/hosts get 0.5s (small enough
* for conversation, big enough to smooth jitter and let music
* decode cleanly on browsers that don't honor jitterBufferTarget on
* high-bitrate stereo Opus). Meter still uses startMeter()'s own
* analyser source from the same stream — multiple MediaStreamSource
* nodes per stream is allowed. Firefox Android autoplay survives
* AudioContext where <audio>.play() doesn't. */
if (myRole === 'listener'){
if (attachListenerStreamViaAudioContext(uuid, stream)){
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx)');
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx 4s)');
return;
}
/* AudioContext failed — fall through to <audio> path as last resort */
logLine('err','listener audioctx attach failed, falling back to <audio>');
} else {
/* speaker / cohost / host: smaller buffer, same path. */
if (attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC)){
stopMeter(uuid); startMeter(uuid, stream);
logLine('', 'sfu: receiving '+((members.get(uuid)||{}).handle || uuid)+' (audioctx '+SPEAKER_PLAYOUT_DELAY_SEC+'s)');
return;
}
logLine('err', myRole+' audioctx attach failed, falling back to <audio>');
}
let a = remoteAudio.get(uuid);
const fresh = !a;
@ -2072,7 +2115,19 @@ const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
* consistent. If you need lower latency for actual
* conversation, drop this back to 0.7 and accept the
* occasional under-run. */
/* Listener cushion — they lean back, latency doesn't matter, ride
* out wiggle-stalls without a glitch. Big enough to survive any
* realistic publisher-side hiccup. */
const RECV_PLAYOUT_DELAY_SEC = 4.0;
/* Speaker/cohost/host cushion — they're in active conversation, so
* the buffer trades a tiny bit of jitter smoothing for sub-second
* round-trip. ~500ms feels natural; 4s would make every back-and-forth
* impossible. Native receiver jitterBufferTarget honors this for
* voice-rate Opus and the worklet matches it for music-rate. */
const SPEAKER_PLAYOUT_DELAY_SEC = 0.5;
function playoutDelayForRole(role){
return role === 'listener' ? RECV_PLAYOUT_DELAY_SEC : SPEAKER_PLAYOUT_DELAY_SEC;
}
function watchVideoTrackForRemoval(track, removeFn, windowMs){
if (!track) return;
if (typeof windowMs !== 'number' || !isFinite(windowMs) || windowMs <= 0){
@ -3085,8 +3140,8 @@ function handleRemoteSfuTrack(ev){
* clicks / mouth movement / keystrokes lead the voice by ~4s. Audio
* 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(_){}
try { if (ev.receiver) ev.receiver.playoutDelayHint = playoutDelayForRole(myRole); } catch(_){}
try { if (ev.receiver) ev.receiver.jitterBufferTarget = playoutDelayForRole(myRole) * 1000; } catch(_){}
}
/* MSID-supplant safety: the SFU re-uses the same streamID
* (`shortPub-kind`) when a publisher supplants themselves. WebRTC
@ -3143,9 +3198,12 @@ function handleRemoteSfuTrack(ev){
* 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(_){}
* Setting both for cross-browser coverage. Role-aware now —
* listener gets 4s, others get 0.5s (conversational latency). The
* userland AudioWorklet downstream of this receiver gives the real
* cushion regardless of what the native buffer honors. */
try { if (ev.receiver) ev.receiver.playoutDelayHint = playoutDelayForRole(myRole); } catch(_){}
try { if (ev.receiver) ev.receiver.jitterBufferTarget = playoutDelayForRole(myRole) * 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]);
@ -4686,7 +4744,40 @@ async function onRoleEntered(){
* initial play() got swallowed. */
}
let inRoleTransition = false;
/* Walk every live audio + video receiver and push the new playout
* target into it. Called on role change so a listener-just-promoted
* to speaker drops from 4s lean-back to 0.5s conversational latency
* (and back the other way on demote) without leaving + rejoining.
* The worklet gets a postMessage; native receivers get jitterBufferTarget +
* playoutDelayHint reassignment.
*
* Mesh peers are left at SPEAKER_PLAYOUT_DELAY_SEC unconditionally —
* mesh is always peer-to-peer conversation. */
function retargetAllReceivers(role){
const target = playoutDelayForRole(role);
/* worklet-buffered audio receivers (every role uses these now) */
for (const [uuid, node] of listenerAudioNodes){
node.targetSeconds = target;
if (node.jbuf && node.jbuf.port){
try { node.jbuf.port.postMessage({ cmd: 'retarget', targetSeconds: target }); } catch(_){}
}
}
/* SFU sub PC receivers — audio (the native side, downstream of which
* the worklet sits) AND video (which sits directly on the receiver). */
if (sfuSubPC && typeof sfuSubPC.getReceivers === 'function'){
for (const r of sfuSubPC.getReceivers()){
try { r.playoutDelayHint = target; } catch(_){}
try { r.jitterBufferTarget = target * 1000; } catch(_){}
}
}
logLine('', 'retarget all receivers → '+target+'s (role='+role+')');
}
async function onRoleChanged(prev, next){
/* re-target every live receiver to the new role's playout delay
* BEFORE we start dropping/grabbing mics — the audio path stays
* continuous; only the buffer depth adjusts. */
retargetAllReceivers(next);
/* suppress spotlight broadcasts triggered by tile cleanup during the
* role transition — otherwise removeScreenTile / removeCameraTile
* fires pickNextSpotlight which broadcasts an empty spotlight key,
@ -4825,11 +4916,12 @@ 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.
* 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(_){}
/* mesh path is peer-to-peer between two speakers (you'd never be
* in mesh as a pure listener). Always conversational latency
* here — fixed at SPEAKER_PLAYOUT_DELAY_SEC, no role check
* needed. */
try { ev.receiver.playoutDelayHint = SPEAKER_PLAYOUT_DELAY_SEC; } catch(_){}
try { ev.receiver.jitterBufferTarget = SPEAKER_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
stopMeter(uuid); startMeter(uuid, a.srcObject);
};
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
@ -6111,8 +6203,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">40b0bf67548bfa739a92c09566384731</span><br>
sha256 <span class="stamp-sha">498399317ffdf93d814ccc98e4e5f413b249e91ccfa15e7d7767a81f34e31eb1</span><br>
md5 <span class="stamp-md5">7be62e61e3cf7651d652430c128cf977</span><br>
sha256 <span class="stamp-sha">f4f882629980a9f21d34a4a0d5e2d7a0ed786a205d3f778cacfcc7b659e17807</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>