From f077c01c5eb89be15f92cff568104b963a0906d6 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 4 Jun 2026 15:59:01 -0400 Subject: [PATCH] zebra-spaces: deploy role-aware playout delays + worklet for all roles --- zebra-report/zebra-spaces.html | 144 +++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 26 deletions(-) diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index 8cf4b9a..f4fb041 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -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;