zebra-spaces: AudioWorklet manual jitter buffer for listener role (4s)
Browser-native jitterBufferTarget didn't help on the music stream — Firefox Android holds it at 0.06–0.21s on a high-bitrate stereo Opus receiver while honoring 4s on voice and video receivers on the same PC. Per-codec implementation gap in the receiver-side jitter buffer. This adds a userland buffer in Web Audio. The listener path already ran through AudioContext (source → gain → destination); now an AudioWorkletNode sits between source and gain, queues incoming 128-sample blocks until targetSamples (4 × sampleRate) have arrived, then emits with a constant delay. Bounded at maxSamples (6 × sampleRate) so clock drift can't grow the queue unbounded. If the queue fully drains, the buffer re-arms — a hiccup doesn't lock us silent. Worklet code lives inline as a Blob URL (single-file app: no separate JS file shipped). loadJitterWorklet is fire-and-forget on first attach; existing direct-connected streams get swapped through the buffer the moment the worklet module finishes loading. Fallback on worklet creation failure: existing source → gain path stays live. Speaker / cohost / host paths untouched — they need conversational latency, can't sit on a 4s cushion. Listener role test plan: rejoin, watch the new "jitter-buffer installed" log line, observe that the listener is now 4s behind the host's speech. Wiggle the host (X11) — listeners should hear no disruption while the buffer is full.
This commit is contained in:
parent
1e4f0fa23d
commit
6302e9978a
1 changed files with 124 additions and 6 deletions
|
|
@ -1749,7 +1749,116 @@ const sfuStreamsByPubHex = new Map(); // pubHex -> MediaStream
|
|||
* setSinkId requirements that still want <audio> elements. Listeners
|
||||
* don't pick speaker output devices (no UI for it) and don't talk —
|
||||
* the AudioContext path is simpler and survives Firefox Android. */
|
||||
const listenerAudioNodes = new Map(); /* uuid -> { src, gain, stream } */
|
||||
const listenerAudioNodes = new Map(); /* uuid -> { src, gain, jbuf?, stream } */
|
||||
|
||||
/* Inline AudioWorklet processor — a manual jitter buffer.
|
||||
*
|
||||
* Browser-native jitterBufferTarget is a target the receiver "must aim
|
||||
* for" per spec — but Firefox Android's audio path for high-bitrate
|
||||
* stereo Opus apparently doesn't yet wire that target into its
|
||||
* decoder. Observed 2026-06-04: voice tracks hit 1.8s, video hit 4s,
|
||||
* but a 256 kbps stereo Opus music stream stayed at 0.06–0.21s on
|
||||
* the same phone with the same setting. So we buffer ourselves —
|
||||
* 128-sample blocks pile up in `queue`, we don't start emitting
|
||||
* until `targetSamples` are buffered, and we cap at `maxSamples` to
|
||||
* absorb clock drift without growing unbounded.
|
||||
*
|
||||
* Loaded as a Blob URL because this is a single-file app — no
|
||||
* separate JS file shipped. */
|
||||
const JITTER_BUFFER_WORKLET_CODE = `
|
||||
class JitterBufferProcessor extends AudioWorkletProcessor {
|
||||
constructor(opts){
|
||||
super();
|
||||
const o = (opts && opts.processorOptions) || {};
|
||||
this.targetSeconds = o.targetSeconds || 4.0;
|
||||
this.maxSeconds = o.maxSeconds || (this.targetSeconds * 1.5);
|
||||
this.targetSamples = Math.round(this.targetSeconds * sampleRate);
|
||||
this.maxSamples = Math.round(this.maxSeconds * sampleRate);
|
||||
this.queue = [];
|
||||
this.buffered = 0;
|
||||
this.started = false;
|
||||
this.dropped = 0;
|
||||
this.starved = 0;
|
||||
}
|
||||
process(inputs, outputs){
|
||||
const inBlk = inputs[0];
|
||||
const outBlk = outputs[0];
|
||||
if (!outBlk || outBlk.length === 0) return true;
|
||||
const nch = outBlk.length;
|
||||
/* push the incoming block (must copy — host may reuse the buffer
|
||||
* after process() returns) */
|
||||
if (inBlk && inBlk.length > 0 && inBlk[0] && inBlk[0].length > 0){
|
||||
const copy = [];
|
||||
for (let c = 0; c < inBlk.length; c++) copy.push(new Float32Array(inBlk[c]));
|
||||
this.queue.push(copy);
|
||||
this.buffered += copy[0].length;
|
||||
/* overflow guard — drop oldest if clock drift or network surge
|
||||
* pushes us above the cap */
|
||||
while (this.buffered > this.maxSamples && this.queue.length > 0){
|
||||
const drop = this.queue.shift();
|
||||
this.buffered -= drop[0].length;
|
||||
this.dropped += drop[0].length;
|
||||
}
|
||||
}
|
||||
/* lock onto the buffer once it fills; re-arm if we ever fully
|
||||
* drain so a brief upstream outage doesn't lock us into a
|
||||
* silent state */
|
||||
if (!this.started && this.buffered >= this.targetSamples) this.started = true;
|
||||
else if (this.started && this.queue.length === 0) this.started = false;
|
||||
if (this.started && this.queue.length > 0){
|
||||
const head = this.queue.shift();
|
||||
this.buffered -= head[0].length;
|
||||
for (let c = 0; c < nch; c++){
|
||||
const srcCh = head[c] || head[0]; /* mono → stereo: dup L→R */
|
||||
outBlk[c].set(srcCh.subarray(0, outBlk[c].length));
|
||||
}
|
||||
} else {
|
||||
for (let c = 0; c < nch; c++) outBlk[c].fill(0);
|
||||
if (!this.started && this.buffered > 0) this.starved++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('jitter-buffer', JitterBufferProcessor);
|
||||
`;
|
||||
|
||||
let workletReady = false, workletLoading = false;
|
||||
function loadJitterWorklet(ctx){
|
||||
if (workletReady || workletLoading) return;
|
||||
workletLoading = true;
|
||||
const blob = new Blob([JITTER_BUFFER_WORKLET_CODE], { type: 'application/javascript' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
ctx.audioWorklet.addModule(url).then(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
workletReady = true; workletLoading = false;
|
||||
/* swap every existing listener stream through the buffer */
|
||||
for (const [uuid, node] of listenerAudioNodes) installJitterBuffer(uuid, node);
|
||||
logLine('', 'jitter-buffer worklet ready (target='+RECV_PLAYOUT_DELAY_SEC+'s)');
|
||||
}).catch(e => {
|
||||
URL.revokeObjectURL(url);
|
||||
workletLoading = false;
|
||||
logLine('err', 'jitter-buffer worklet load: '+e.message+' — listener audio direct');
|
||||
});
|
||||
}
|
||||
function installJitterBuffer(uuid, node){
|
||||
if (!node || node.jbuf || !workletReady) return;
|
||||
try {
|
||||
const jbuf = new AudioWorkletNode(audioCtx, 'jitter-buffer', {
|
||||
processorOptions: {
|
||||
targetSeconds: RECV_PLAYOUT_DELAY_SEC,
|
||||
maxSeconds: RECV_PLAYOUT_DELAY_SEC * 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');
|
||||
} catch (e) {
|
||||
logLine('err', 'jitter-buffer install '+uuid.slice(0,4)+': '+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function attachListenerStreamViaAudioContext(uuid, stream){
|
||||
if (!audioCtx){
|
||||
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
|
||||
|
|
@ -1760,7 +1869,9 @@ function attachListenerStreamViaAudioContext(uuid, stream){
|
|||
}
|
||||
const existing = listenerAudioNodes.get(uuid);
|
||||
if (existing){
|
||||
try { existing.src.disconnect(); existing.gain.disconnect(); } catch(_){}
|
||||
try { existing.src.disconnect(); } catch(_){}
|
||||
try { if (existing.jbuf) existing.jbuf.disconnect(); } catch(_){}
|
||||
try { existing.gain.disconnect(); } catch(_){}
|
||||
listenerAudioNodes.delete(uuid);
|
||||
}
|
||||
let src;
|
||||
|
|
@ -1770,7 +1881,12 @@ function attachListenerStreamViaAudioContext(uuid, stream){
|
|||
gain.gain.value = 1.0;
|
||||
src.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
listenerAudioNodes.set(uuid, { src, gain, stream });
|
||||
const node = { src, gain, stream };
|
||||
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);
|
||||
/* Media Session API — tell the OS this tab is playing media. On
|
||||
* Android Firefox + iOS Safari, this:
|
||||
|
|
@ -1804,7 +1920,9 @@ function attachListenerStreamViaAudioContext(uuid, stream){
|
|||
function detachListenerStream(uuid){
|
||||
const node = listenerAudioNodes.get(uuid);
|
||||
if (!node) return;
|
||||
try { node.src.disconnect(); node.gain.disconnect(); } catch(_){}
|
||||
try { node.src.disconnect(); } catch(_){}
|
||||
try { if (node.jbuf) node.jbuf.disconnect(); } catch(_){}
|
||||
try { node.gain.disconnect(); } catch(_){}
|
||||
listenerAudioNodes.delete(uuid);
|
||||
}
|
||||
function attachSfuTrack(uuid, stream){
|
||||
|
|
@ -5980,8 +6098,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">25a1c73043470ab0e7e990747635161c</span><br>
|
||||
sha256 <span class="stamp-sha">a91fefd9648e7b76a8aaca85f73cd9a7fb2eb9bc2ca0b27303a233ef43cdb57c</span><br>
|
||||
md5 <span class="stamp-md5">545d1105afc28939ffd80b0ab81b3181</span><br>
|
||||
sha256 <span class="stamp-sha">1dbf38c720551b0dd746ca2467a1e9b936fe08dca1291de0a392381f6b3417d6</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