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 window = { AudioContext: function(){ audioCtxCreates++; return makeAudioCtx_(); } };
|
||||
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 */
|
||||
`;
|
||||
|
||||
|
|
@ -516,6 +533,44 @@ test('listener: attaching uuid B for the same publisher as uuid A detaches A (no
|
|||
});
|
||||
|
||||
/* -------- 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', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue