zebra-spaces: chromium decoder anchor — muted <audio> alongside MediaStreamSource
Chromium will not run its WebRTC audio decoder for a remote track that is only consumed by a MediaStreamAudioSourceNode. The track must also be attached to an HTMLMediaElement to "kick" the decoder into running. Without the anchor the worklet's queue fills with zeros — the 'started' event fires, ctxState=running, RTP packets arrive at 50 pps, yet PulseAudio Playback shows the chrome stream present with the level meter dead at 0. Fox 2026-06-07 fedora chrome silent for days; firefox unaffected (its decoder runs unconditionally for MediaStreamSource consumers). Each attach now creates a hidden muted <audio> bound to the same MediaStream. The element makes no audible output (muted=true); its sole purpose is to keep chromium's decoder running so the worklet's MediaStreamSource sees real samples. setWorkletStream swaps the anchor's srcObject alongside the source node so an in-place stream swap doesn't strand the anchor on a dead track. detachListenerStream tears the anchor down with the rest of the chain. Tests pin the contract: anchor created on attach, anchor follows the stream on setWorkletStream, anchor torn down on detach.
This commit is contained in:
parent
1984bb1d41
commit
dad697d141
2 changed files with 92 additions and 4 deletions
|
|
@ -281,6 +281,23 @@ function makeBrowser(role){
|
||||||
const navigator = {}; /* skips the Media Session block cleanly */
|
const navigator = {}; /* skips the Media Session block cleanly */
|
||||||
const window = { AudioContext: function(){ audioCtxCreates++; return makeAudioCtx_(); } };
|
const window = { AudioContext: function(){ audioCtxCreates++; return makeAudioCtx_(); } };
|
||||||
const AudioWorkletNode = AudioWorkletNodeClass;
|
const AudioWorkletNode = AudioWorkletNodeClass;
|
||||||
|
/* minimal document for createElement('audio') — the decoder-anchor
|
||||||
|
* code in attachAudioStreamViaWorklet creates a hidden <audio>
|
||||||
|
* bound to the stream. Tests need a stub that returns an object
|
||||||
|
* with srcObject, play(), pause(), remove(), and style. */
|
||||||
|
const document = {
|
||||||
|
createElement(tag){
|
||||||
|
return {
|
||||||
|
tagName: String(tag).toUpperCase(),
|
||||||
|
srcObject: null, muted: false, autoplay: false, playsInline: false,
|
||||||
|
style: {},
|
||||||
|
play(){ return Promise.resolve(); },
|
||||||
|
pause(){},
|
||||||
|
remove(){},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
body: { appendChild(){} },
|
||||||
|
};
|
||||||
/* SPEAKER_PLAYOUT_DELAY_SEC + RECV_PLAYOUT_DELAY_SEC come in as args */
|
/* SPEAKER_PLAYOUT_DELAY_SEC + RECV_PLAYOUT_DELAY_SEC come in as args */
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -516,6 +533,44 @@ test('listener: attaching uuid B for the same publisher as uuid A detaches A (no
|
||||||
});
|
});
|
||||||
|
|
||||||
/* -------- unit: detachListenerStream cleans everything -------- */
|
/* -------- unit: detachListenerStream cleans everything -------- */
|
||||||
|
test('attach creates a muted decoder-anchor <audio> element bound to the same stream (chromium decoder kick)', () => {
|
||||||
|
/* Chromium will not run its WebRTC audio decoder for a remote track
|
||||||
|
* that is only consumed by a MediaStreamAudioSourceNode. The anchor
|
||||||
|
* element forces the decoder to run; without it the worklet sees
|
||||||
|
* silence even though packets arrive. */
|
||||||
|
const b = makeBrowser('listener');
|
||||||
|
addMember(b, UUID_A, PUB_A);
|
||||||
|
const stream = makeStream();
|
||||||
|
b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
|
||||||
|
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||||
|
truthy(node.anchor, 'anchor element created');
|
||||||
|
eq(node.anchor.muted, true, 'anchor is muted (no audible output)');
|
||||||
|
eq(node.anchor.srcObject, stream, 'anchor bound to the same stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setWorkletStream swaps the anchor srcObject too (keeps decoder kick on the new stream)', () => {
|
||||||
|
const b = makeBrowser('listener');
|
||||||
|
addMember(b, UUID_A, PUB_A);
|
||||||
|
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
|
||||||
|
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||||
|
const anchor = node.anchor;
|
||||||
|
const newStream = makeStream();
|
||||||
|
b.api.setWorkletStream(UUID_A, newStream);
|
||||||
|
eq(node.anchor, anchor, 'same anchor element reused');
|
||||||
|
eq(node.anchor.srcObject, newStream, 'anchor follows the new stream');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detachListenerStream removes the anchor element', () => {
|
||||||
|
const b = makeBrowser('listener');
|
||||||
|
addMember(b, UUID_A, PUB_A);
|
||||||
|
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
|
||||||
|
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||||
|
const anchor = node.anchor;
|
||||||
|
truthy(anchor, 'anchor exists');
|
||||||
|
b.api.detachListenerStream(UUID_A);
|
||||||
|
eq(anchor.srcObject, null, 'anchor srcObject cleared');
|
||||||
|
});
|
||||||
|
|
||||||
test('detachListenerStream: removes from map and disconnects src, jbuf, gain', () => {
|
test('detachListenerStream: removes from map and disconnects src, jbuf, gain', () => {
|
||||||
const b = makeBrowser('listener');
|
const b = makeBrowser('listener');
|
||||||
addMember(b, UUID_A, PUB_A);
|
addMember(b, UUID_A, PUB_A);
|
||||||
|
|
|
||||||
|
|
@ -3365,7 +3365,31 @@ function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){
|
||||||
gain.gain.value = 1.0;
|
gain.gain.value = 1.0;
|
||||||
src.connect(gain);
|
src.connect(gain);
|
||||||
gain.connect(audioCtx.destination);
|
gain.connect(audioCtx.destination);
|
||||||
const node = { src, gain, stream, targetSeconds };
|
/* Chrome decoder anchor: chromium's WebRTC decoder will not run for
|
||||||
|
* a remote audio track that is only consumed by a MediaStreamAudioSourceNode.
|
||||||
|
* The track must also be attached to an HTMLMediaElement consumer to
|
||||||
|
* "kick" the decoder; otherwise the MediaStreamSource produces silence
|
||||||
|
* even though packets arrive and the worklet 'started' event fires
|
||||||
|
* (queue fills with zeros). The element is created muted so it makes
|
||||||
|
* no sound on its own — its only job is to keep chrome's decoder
|
||||||
|
* running so the worklet sees real samples. Firefox doesn't have
|
||||||
|
* this limitation but the same anchor is harmless there.
|
||||||
|
* Fox 2026-06-07 blanka-chrome: PulseAudio Playback showed the chrome
|
||||||
|
* stream present with zero level meter movement until this anchor
|
||||||
|
* landed. */
|
||||||
|
let anchor = null;
|
||||||
|
try {
|
||||||
|
anchor = document.createElement('audio');
|
||||||
|
anchor.muted = true;
|
||||||
|
anchor.autoplay = true;
|
||||||
|
anchor.playsInline = true;
|
||||||
|
anchor.srcObject = stream;
|
||||||
|
anchor.style.display = 'none';
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
const p = anchor.play();
|
||||||
|
if (p && p.catch) p.catch(()=>{}); /* muted autoplay should never reject, but swallow defensively */
|
||||||
|
} catch(e){ logLine('err','decoder anchor '+uuid.slice(0,4)+': '+e.message); }
|
||||||
|
const node = { src, gain, stream, targetSeconds, anchor };
|
||||||
listenerAudioNodes.set(uuid, node);
|
listenerAudioNodes.set(uuid, node);
|
||||||
/* fire-and-forget worklet load on first use; once ready, every
|
/* fire-and-forget worklet load on first use; once ready, every
|
||||||
* existing stream is swapped through the buffer (see loadJitterWorklet) */
|
* existing stream is swapped through the buffer (see loadJitterWorklet) */
|
||||||
|
|
@ -3421,6 +3445,9 @@ function detachListenerStream(uuid){
|
||||||
try { if (node.jbuf) node.jbuf.disconnect(); } catch(_){}
|
try { if (node.jbuf) node.jbuf.disconnect(); } catch(_){}
|
||||||
try { if (node.capture) node.capture.disconnect(); } catch(_){}
|
try { if (node.capture) node.capture.disconnect(); } catch(_){}
|
||||||
try { node.gain.disconnect(); } catch(_){}
|
try { node.gain.disconnect(); } catch(_){}
|
||||||
|
if (node.anchor){
|
||||||
|
try { node.anchor.srcObject = null; node.anchor.pause(); node.anchor.remove(); } catch(_){}
|
||||||
|
}
|
||||||
listenerAudioNodes.delete(uuid);
|
listenerAudioNodes.delete(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3451,6 +3478,12 @@ function setWorkletStream(uuid, newStream){
|
||||||
else newSrc.connect(node.gain);
|
else newSrc.connect(node.gain);
|
||||||
node.src = newSrc;
|
node.src = newSrc;
|
||||||
node.stream = newStream;
|
node.stream = newStream;
|
||||||
|
/* swap the decoder anchor's srcObject so chrome's decoder keeps
|
||||||
|
* running for the new track (see attachAudioStreamViaWorklet for
|
||||||
|
* the anchor's role). */
|
||||||
|
if (node.anchor){
|
||||||
|
try { node.anchor.srcObject = newStream; } catch(_){}
|
||||||
|
}
|
||||||
logLine('', 'worklet source swapped for '+uuid.slice(0,4));
|
logLine('', 'worklet source swapped for '+uuid.slice(0,4));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -8091,9 +8124,9 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
<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-07</span><br>
|
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-08</span><br>
|
||||||
md5 <span class="stamp-md5">9e0930862e9720838a0d0928578590f7</span><br>
|
md5 <span class="stamp-md5">c9d8e28deae17fddf6b3c7ac7f6160a1</span><br>
|
||||||
sha256 <span class="stamp-sha">680c406db618bcc53fc029f073347c4b921af2dde9a97563ea308c23ea8451d3</span><br>
|
sha256 <span class="stamp-sha">941f26894b17cc6c5797d28fd305affff9ffb72fee8b4bb2ac93c4be46dbab4c</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">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>
|
<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>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue