zebra-spaces: deploy role-aware playout delays + worklet for all roles
This commit is contained in:
parent
6e42fc180c
commit
f077c01c5e
1 changed files with 118 additions and 26 deletions
|
|
@ -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> · 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue