zebra-spaces: role-aware playout delay everywhere — speaker 0.5s, listener 4s
Conversation latency for speakers / cohosts / hosts; lean-back cushion
for listeners. Every receiver type updated together so audio + video
stay in sync (the existing memory rule: video must match audio's
playout delay).
Changes:
1. SPEAKER_PLAYOUT_DELAY_SEC = 0.5; playoutDelayForRole(role) — listener
gets 4s, all others get 0.5s.
2. attachListenerStreamViaAudioContext → attachAudioStreamViaWorklet
(generalized). Listener wrapper just calls into it with 4s + the
Media Session hook. Every role now routes SFU mic audio through the
AudioWorklet + per-role buffer — so a speaker hearing high-bitrate
stereo Opus music STILL gets a 0.5s cushion that Firefox's native
jitter buffer would have ignored.
3. attachSfuTrack: speakers / cohosts / hosts route through the worklet
path with 0.5s buffer; <audio>-element fallback only on
AudioContext failure.
4. SFU sub PC video + mic native receiver: jitterBufferTarget +
playoutDelayHint = playoutDelayForRole(myRole). Listener=4s, others=0.5s.
5. Mesh peer receivers: SPEAKER_PLAYOUT_DELAY_SEC always (mesh is
always peer-to-peer conversation, no role-mixed case).
6. AudioWorklet handles a {cmd:'retarget', targetSeconds} message —
recomputes targetSamples / maxSamples and shrinks the queue if the
new cap is smaller. No reconstruction needed across role changes.
7. retargetAllReceivers(role) called from onRoleChanged before mic
acquisition starts. Walks listenerAudioNodes (worklet) and
sfuSubPC.getReceivers() (native audio + video) and applies the new
target. Mesh peers stay at 0.5s unconditionally.
Speakers were previously running the same 4s setting as listeners. The
native buffer was honoring it for voice (ramping to ~3s) which meant
back-and-forth conversation was effectively impossible — they were
hearing each other 3 seconds late and didn't notice because they were
mostly publishing. This brings conversational latency back to ~500ms
while keeping the listener cushion intact.
No new SDP / signaling — all changes are receiver-side at attach. Role
transition refreshes targets on the existing PC without a renegotiation.
This commit is contained in:
parent
fd2d927b5e
commit
4d48df96e4
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