zebra-spaces: prefix-match in flushSfuStreams (kill silent fedora-chrome listener)
Fox 2026-06-06: "fedora chrome is flawless besides not able to hear
any mics it was working a few days back and nothing was changed on
the system, only thing we changed was our zebra codes."
The race: on a listener joining a room with existing speakers, the
SFU sub PC ontrack can fire BEFORE the signal-server peer-joined
event populates `members`. handleRemoteSfuTrack already does prefix
resolution at line ~4640:
let pubHex = pubHex16;
for (const [, mm] of members){
if (fh.startsWith(pubHex16)){ pubHex = fh; break; }
}
When the roster is empty, the loop finds nothing, pubHex stays the
16-char streamID prefix, and sfuStreamsByPubHex.set(pubHex, stream)
caches under that short key. peer-joined arrives later,
flushSfuStreams runs to attach what was cached — but its inner match
was strict ===:
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
mm.pubkey decodes to the FULL 64-char hex; pubHex from the cache is
the 16-char prefix; === never matches; listener stays permanently
silent for every speaker who was already in the room.
Pre-cascade this defect was masked: the old guard `!remoteAudio.has(uuid)`
was always true for worklet listeners (remoteAudio is the <audio>
fallback path only), so flushSfuStreams re-attached every cached
stream on every peer-joined — the eventual second ontrack from a
later renegotiation would land with members populated, cache key
became the full pubhex, and === matched. The 2e74b92 fix replaced
the always-true guard with `!listenerAudioNodes.has(uuid)`, which
correctly skipped re-attach but also exposed the strict-equality
matcher in the cold path.
Fix: switch flushSfuStreams' inner match from `=== pubHex` to
`fh.startsWith(pubHex)`. Symmetric with handleRemoteSfuTrack's own
prefix resolution. Works for both cases:
- cache key is full 64-char pubhex → startsWith with a full string
requires equality, so behavior is unchanged when ontrack arrived
after peer-joined (the common case).
- cache key is 16-char prefix → startsWith matches the first 16
chars of any member's full pubhex. 64 bits of prefix entropy =
astronomical collision probability.
Pinned by 29 new assertions in test/listener-audio-attach.test.js,
extracted from the live page so they cannot drift:
- 22 cover the attach FSM (chain reachability, dedup, in-place
swap, jbuf race, idempotent re-attach).
- 7 cover handleRemoteSfuTrack including the failing scenario:
"ontrack ARRIVES BEFORE peer-joined (member roster empty) →
cached + audible after flush" — fails pre-fix, passes post-fix.
Makefile gets test-listener-audio + adds it to test-all.
This commit is contained in:
parent
1c09dacb62
commit
0ce1339f8e
7 changed files with 860 additions and 21 deletions
15
Makefile
15
Makefile
|
|
@ -61,6 +61,17 @@ test-mod-actions:
|
|||
test-self-listener:
|
||||
@node test/self-listener-fsm.test.js
|
||||
|
||||
# Listener audio attach pipeline — pins src→(jbuf)→gain→destination
|
||||
# graph reachability through attachAudioStreamViaWorklet,
|
||||
# setWorkletStream, installJitterBuffer, attachSfuTrack,
|
||||
# flushSfuStreams. Extracts each function from the live page and
|
||||
# replays the cascade scenarios (fresh attach, same-stream no-op,
|
||||
# different-stream in-place swap, dedup by pubkey, publisher rejoin,
|
||||
# zero-live-track reject). Catches silent-listener regressions where
|
||||
# a chain builds but never reaches audioCtx.destination.
|
||||
test-listener-audio:
|
||||
@node test/listener-audio-attach.test.js
|
||||
|
||||
# zebra-spaces JS↔Go protocol parity + vault + ed25519 + (optionally) a live
|
||||
# server flow. The live-server tier auto-runs when proxy.unturf.com sits
|
||||
# alongside this checkout AND has a Go toolchain — we build the relay binary
|
||||
|
|
@ -78,7 +89,7 @@ test-zebra-spaces:
|
|||
ZEBRA_SPACES_BINARY=$$bin node test/zebra-spaces.test.js; \
|
||||
rc=$$?; rm -f /tmp/zspc-signal-test; exit $$rc
|
||||
|
||||
test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-self-listener test-zebra-spaces
|
||||
test-all: test/unit test/integration test/functional test-web test-fsm test-video-removal test-mesh test-mod-actions test-self-listener test-listener-audio test-zebra-spaces
|
||||
@echo "--- unit ---"
|
||||
@./test/unit
|
||||
@echo "--- integration ---"
|
||||
|
|
@ -97,6 +108,8 @@ test-all: test/unit test/integration test/functional test-web test-fsm test-vide
|
|||
@node test/mod-action-serializer.test.js
|
||||
@echo "--- self-listener FSM ---"
|
||||
@node test/self-listener-fsm.test.js
|
||||
@echo "--- listener audio attach ---"
|
||||
@node test/listener-audio-attach.test.js
|
||||
@echo "--- zebra-spaces ---"
|
||||
@$(MAKE) -s test-zebra-spaces
|
||||
|
||||
|
|
|
|||
811
test/listener-audio-attach.test.js
Normal file
811
test/listener-audio-attach.test.js
Normal file
|
|
@ -0,0 +1,811 @@
|
|||
#!/usr/bin/env node
|
||||
/* listener-audio attach pipeline tests.
|
||||
*
|
||||
* node test/listener-audio-attach.test.js
|
||||
*
|
||||
* Pins the audio-attach FSM in web/zebra-spaces.html. The shipped
|
||||
* receive-side audio path is:
|
||||
*
|
||||
* handleRemoteSfuTrack → caches stream by pubHex, attaches per-uuid
|
||||
* attachSfuTrack → role-routes to worklet (listener: 4s buffer;
|
||||
* speaker/cohost/host: 0.5s)
|
||||
* attachListenerStreamViaAudioContext / attachAudioStreamViaWorklet
|
||||
* → builds src→(jbuf?)→gain→destination per uuid
|
||||
* setWorkletStream → in-place source swap on an existing chain
|
||||
* (publisher renegotiates or mesh ontrack)
|
||||
* installJitterBuffer → inserts the AudioWorkletNode between src
|
||||
* and gain once the worklet module is ready
|
||||
* flushSfuStreams → after peer-joined, attach any cached stream
|
||||
* for that uuid if no chain exists yet
|
||||
* detachListenerStream → tear-down of one chain
|
||||
*
|
||||
* The invariants below are pinned because the cascade of Jun 5 fixes
|
||||
* (commits 2e74b92, f479878, 144dd15, 61b21a6) tightened this path
|
||||
* specifically to kill double-audio on rejoin — and fox 2026-06-06
|
||||
* reported "fedora chrome can't hear any mics", a clean silent-output
|
||||
* regression somewhere in that cascade. Each test below pins a single
|
||||
* thread of expected behavior so the failing assertion names the bad
|
||||
* commit.
|
||||
*
|
||||
* No browser, no AudioContext, no MediaStream — pure Node. We replicate
|
||||
* just enough of the Web Audio graph to verify reachability from a
|
||||
* MediaStreamSource node to audioCtx.destination, with no orphan / no
|
||||
* silent split. The page is source-of-truth: every function under test
|
||||
* is extracted from web/zebra-spaces.html at test time so the tests
|
||||
* cannot drift from shipped code. */
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'web', 'zebra-spaces.html'), 'utf8');
|
||||
|
||||
/* lift a top-level function literal out of zebra-spaces.html by
|
||||
* brace-matching the body. */
|
||||
function extractFn(re){
|
||||
const m = src.match(re);
|
||||
if (!m) throw new Error('could not find ' + re);
|
||||
let i = src.indexOf('{', m.index + m[0].length), depth = 0, j = i;
|
||||
for (; j < src.length; j++){
|
||||
if (src[j] === '{') depth++;
|
||||
else if (src[j] === '}') { if (--depth === 0) { j++; break; } }
|
||||
}
|
||||
return src.slice(m.index, j);
|
||||
}
|
||||
|
||||
const attachWorkletSrc = extractFn(/function attachAudioStreamViaWorklet\(/);
|
||||
const attachListenerSrc = extractFn(/function attachListenerStreamViaAudioContext\(/);
|
||||
const setWorkletStreamSrc = extractFn(/function setWorkletStream\(/);
|
||||
const installJBSrc = extractFn(/function installJitterBuffer\(/);
|
||||
const detachListenerSrc = extractFn(/function detachListenerStream\(/);
|
||||
const attachSfuSrc = extractFn(/function attachSfuTrack\(/);
|
||||
const flushSrc = extractFn(/function flushSfuStreams\(/);
|
||||
const playoutDelaySrc = extractFn(/function playoutDelayForRole\(/);
|
||||
const handleTrackSrc = extractFn(/function handleRemoteSfuTrack\(/);
|
||||
|
||||
/* shipped constants — read from source so an accidental dial-back here
|
||||
* shows up as a failing assertion. */
|
||||
const RECV = parseFloat(src.match(/const\s+RECV_PLAYOUT_DELAY_SEC\s*=\s*([0-9.]+)/)[1]);
|
||||
const SPEAKER = parseFloat(src.match(/const\s+SPEAKER_PLAYOUT_DELAY_SEC\s*=\s*([0-9.]+)/)[1]);
|
||||
|
||||
/* =================== fake Web Audio graph =================== */
|
||||
/* Every node carries _outgoing: a Set of nodes it currently feeds. The
|
||||
* graph is otherwise arbitrary — we only care about reachability from
|
||||
* a MediaStreamSource to audioCtx.destination. */
|
||||
function makeAudioCtx(){
|
||||
const ctx = {
|
||||
state: 'running',
|
||||
currentTime: 0,
|
||||
_nodes: [],
|
||||
resume(){ ctx.state = 'running'; return Promise.resolve(); },
|
||||
};
|
||||
function makeNode(kind, extra){
|
||||
const n = {
|
||||
kind,
|
||||
_outgoing: new Set(),
|
||||
connect(target){
|
||||
n._outgoing.add(target);
|
||||
return target; /* chained: a.connect(b).connect(c) */
|
||||
},
|
||||
disconnect(target){
|
||||
if (target === undefined) n._outgoing.clear();
|
||||
else n._outgoing.delete(target);
|
||||
},
|
||||
};
|
||||
Object.assign(n, extra || {});
|
||||
ctx._nodes.push(n);
|
||||
return n;
|
||||
}
|
||||
ctx.destination = makeNode('destination');
|
||||
ctx.createMediaStreamSource = stream => makeNode('source', { stream });
|
||||
ctx.createGain = () => makeNode('gain', {
|
||||
gain: {
|
||||
value: 1.0,
|
||||
cancelScheduledValues(){},
|
||||
setValueAtTime(){},
|
||||
linearRampToValueAtTime(){},
|
||||
},
|
||||
});
|
||||
ctx.createOscillator = () => makeNode('oscillator', {
|
||||
start(){}, stop(){}, onended: null,
|
||||
});
|
||||
ctx.createAnalyser = () => makeNode('analyser', {
|
||||
fftSize: 1024, smoothingTimeConstant: 0,
|
||||
getFloatTimeDomainData(){},
|
||||
});
|
||||
ctx.audioWorklet = { addModule(){ return Promise.resolve(); } };
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/* The shipped attach calls `new AudioWorkletNode(audioCtx, 'jitter-buffer', opts)`.
|
||||
* Our fake supports connect/disconnect like any other node + a port stub. */
|
||||
function makeAudioWorkletNodeClass(){
|
||||
return class FakeAudioWorkletNode {
|
||||
constructor(ctx, name, opts){
|
||||
this.kind = 'worklet:' + name;
|
||||
this._outgoing = new Set();
|
||||
this._opts = opts || {};
|
||||
this.port = {
|
||||
onmessage: null,
|
||||
postMessage: msg => { this._lastPosted = msg; },
|
||||
};
|
||||
ctx._nodes.push(this);
|
||||
}
|
||||
connect(target){ this._outgoing.add(target); return target; }
|
||||
disconnect(target){
|
||||
if (target === undefined) this._outgoing.clear();
|
||||
else this._outgoing.delete(target);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* =================== fake MediaStream / track =================== */
|
||||
let nextStreamId = 0;
|
||||
function makeAudioTrack(opts){
|
||||
opts = opts || {};
|
||||
const listeners = {};
|
||||
let readyState = opts.readyState || 'live';
|
||||
return {
|
||||
kind: 'audio',
|
||||
enabled: opts.enabled !== false,
|
||||
muted: !!opts.muted,
|
||||
get readyState(){ return readyState; },
|
||||
contentHint: '',
|
||||
addEventListener(ev, fn){ (listeners[ev] = listeners[ev] || []).push(fn); },
|
||||
stop(){
|
||||
readyState = 'ended';
|
||||
for (const fn of (listeners.ended || [])) fn();
|
||||
},
|
||||
_fire(ev){ for (const fn of (listeners[ev] || [])) fn(); },
|
||||
};
|
||||
}
|
||||
class FakeMediaStream {
|
||||
constructor(tracks){
|
||||
this.id = 's' + (nextStreamId++);
|
||||
this.tracks = tracks ? [...tracks] : [];
|
||||
}
|
||||
getAudioTracks(){ return this.tracks.filter(t => t.kind === 'audio'); }
|
||||
getTracks(){ return this.tracks.slice(); }
|
||||
}
|
||||
|
||||
/* =================== reachability =================== */
|
||||
function reachable(from, to){
|
||||
if (!from || !to) return false;
|
||||
const seen = new Set([from]);
|
||||
const stack = [from];
|
||||
while (stack.length){
|
||||
const n = stack.pop();
|
||||
if (n === to) return true;
|
||||
for (const next of (n._outgoing || [])){
|
||||
if (!seen.has(next)){
|
||||
seen.add(next);
|
||||
stack.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* count direct edges from `from` whose target is in some node-set */
|
||||
function edgesTo(from, target){
|
||||
let c = 0;
|
||||
for (const n of from._outgoing){
|
||||
if (n === target) c++;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
/* =================== sandboxed browser =================== */
|
||||
const PUB_A = 'a'.repeat(64);
|
||||
const PUB_B = 'b'.repeat(64);
|
||||
const PUB_C = 'c'.repeat(64);
|
||||
const UUID_A = 'uuidaaaa-aaaa-aaaa';
|
||||
const UUID_B = 'uuidbbbb-bbbb-bbbb';
|
||||
const UUID_C = 'uuidcccc-cccc-cccc';
|
||||
const UUID_A2 = 'uuidA222-2222-2222'; /* A rejoined under new uuid */
|
||||
|
||||
function makeBrowser(role){
|
||||
role = role || 'listener';
|
||||
const ctx = makeAudioCtx();
|
||||
const WorkletNodeClass = makeAudioWorkletNodeClass();
|
||||
|
||||
/* logs captured for diagnostic assertion */
|
||||
const logs = [];
|
||||
|
||||
/* helpers the attach code calls — each is a stub specific enough to
|
||||
* not interfere with the connection graph. */
|
||||
const stubs = `
|
||||
let audioCtx = audioCtxInit;
|
||||
let workletReady = false, workletLoading = false;
|
||||
const listenerAudioNodes = new Map();
|
||||
const remoteAudio = new Map();
|
||||
const sfuStreamsByPubHex = new Map();
|
||||
const sfuAudioReceivers = new Map();
|
||||
const members = new Map();
|
||||
const peers = new Map();
|
||||
const lipSync = new Map();
|
||||
const screenStreams = new Map();
|
||||
const cameraStreams = new Map();
|
||||
const gameStreams = new Map();
|
||||
let myKeys = { pubHex: '${'f'.repeat(64)}' };
|
||||
let myRole = '${role}';
|
||||
let transcribeEnabled = false;
|
||||
/* track every audioCtx-create call so we can assert single-ctx behavior */
|
||||
let audioCtxCreates = 0;
|
||||
/* explicit per-test toggles */
|
||||
function triggerWorkletReady(){
|
||||
workletReady = true;
|
||||
for (const [uuid, node] of listenerAudioNodes) installJitterBuffer(uuid, node);
|
||||
}
|
||||
function setRole(r){ myRole = r; }
|
||||
/* no-op worklet loader — tests call triggerWorkletReady manually */
|
||||
function loadJitterWorklet(){ workletLoading = true; }
|
||||
function startCaptureForUuid(){ return Promise.resolve(); }
|
||||
function leaseAudioElement(){
|
||||
const el = {
|
||||
srcObject: null, _attrs: {},
|
||||
play(){ return Promise.resolve(); },
|
||||
pause(){}, remove(){},
|
||||
addEventListener(){}, removeAttribute(){}, load(){},
|
||||
muted: false, autoplay: true, playsInline: true,
|
||||
currentTime: 0,
|
||||
error: null,
|
||||
};
|
||||
return el;
|
||||
}
|
||||
function applySinkTo(){ return Promise.resolve(); }
|
||||
function startMeter(){}
|
||||
function stopMeter(){}
|
||||
function registerLipSyncAudio(){}
|
||||
function refreshLipSyncForUuid(){}
|
||||
function ddNoteWorkletBuffered(){}
|
||||
function onWorkletStarted(){}
|
||||
function canSpeak(r){ return r === 'host' || r === 'cohost' || r === 'speaker'; }
|
||||
function unb64(s){
|
||||
const buf = Buffer.from(s, 'base64');
|
||||
return new Uint8Array(buf);
|
||||
}
|
||||
function hex(u8){
|
||||
if (!(u8 instanceof Uint8Array)) u8 = new Uint8Array(u8);
|
||||
return Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
/* logLine captures everything for assertion */
|
||||
function logLine(kind, msg){ logsBuf.push({ kind, msg }); }
|
||||
const navigator = {}; /* skips the Media Session block cleanly */
|
||||
const window = { AudioContext: function(){ audioCtxCreates++; return makeAudioCtx_(); } };
|
||||
const AudioWorkletNode = AudioWorkletNodeClass;
|
||||
/* SPEAKER_PLAYOUT_DELAY_SEC + RECV_PLAYOUT_DELAY_SEC come in as args */
|
||||
`;
|
||||
|
||||
const factory = new Function(
|
||||
'audioCtxInit',
|
||||
'AudioWorkletNodeClass',
|
||||
'MediaStream',
|
||||
'logsBuf',
|
||||
'RECV_PLAYOUT_DELAY_SEC',
|
||||
'SPEAKER_PLAYOUT_DELAY_SEC',
|
||||
'makeAudioCtx_',
|
||||
stubs + '\n' +
|
||||
attachWorkletSrc + '\n' +
|
||||
attachListenerSrc + '\n' +
|
||||
setWorkletStreamSrc + '\n' +
|
||||
installJBSrc + '\n' +
|
||||
detachListenerSrc + '\n' +
|
||||
attachSfuSrc + '\n' +
|
||||
flushSrc + '\n' +
|
||||
playoutDelaySrc + '\n' +
|
||||
/* handleRemoteSfuTrack needs stubs for non-mic kinds — they don't run on
|
||||
* a mic ontrack but they're referenced in the body via early-return
|
||||
* blocks. We never deliver screen/camera/game to this sandbox so the
|
||||
* shims below are tombstones. */
|
||||
'function renderScreenTile(){}\n' +
|
||||
'function removeScreenTile(){}\n' +
|
||||
'function renderCameraTile(){}\n' +
|
||||
'function removeCameraTile(){}\n' +
|
||||
'function renderVideoTile(){}\n' +
|
||||
'function removeVideoTile(){}\n' +
|
||||
'function watchVideoTrackForRemoval(){}\n' +
|
||||
'function watchFirstFrame(){}\n' +
|
||||
'function handleBlocked(){}\n' +
|
||||
'const VIDEO_REMOVE_MUTE_WINDOW_MS = 5000;\n' +
|
||||
'const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 8000;\n' +
|
||||
handleTrackSrc + '\n' +
|
||||
'return {\n' +
|
||||
' attachAudioStreamViaWorklet, attachListenerStreamViaAudioContext,\n' +
|
||||
' attachSfuTrack, flushSfuStreams, setWorkletStream, detachListenerStream,\n' +
|
||||
' handleRemoteSfuTrack,\n' +
|
||||
' triggerWorkletReady, setRole,\n' +
|
||||
' get audioCtx(){ return audioCtx; },\n' +
|
||||
' get workletReady(){ return workletReady; },\n' +
|
||||
' get audioCtxCreates(){ return audioCtxCreates; },\n' +
|
||||
' listenerAudioNodes, remoteAudio, sfuStreamsByPubHex, members, peers,\n' +
|
||||
'};\n'
|
||||
);
|
||||
const api = factory(
|
||||
ctx,
|
||||
WorkletNodeClass,
|
||||
FakeMediaStream,
|
||||
logs,
|
||||
RECV,
|
||||
SPEAKER,
|
||||
makeAudioCtx,
|
||||
);
|
||||
return { ctx, logs, api };
|
||||
}
|
||||
|
||||
/* add a member to the roster so handleRemoteSfuTrack / attachAudioStreamViaWorklet
|
||||
* can resolve uuid → pubHex via members. The map stores pubkey in base64. */
|
||||
function addMember(b, uuid, pubHex){
|
||||
const pubB64 = Buffer.from(pubHex, 'hex').toString('base64');
|
||||
b.api.members.set(uuid, { uuid, pubkey: pubB64, handle: uuid, role: 'speaker' });
|
||||
}
|
||||
|
||||
function makeStream(opts){
|
||||
opts = opts || {};
|
||||
const tracks = [];
|
||||
const n = opts.tracks !== undefined ? opts.tracks : 1;
|
||||
for (let i = 0; i < n; i++) tracks.push(makeAudioTrack(opts.trackOpts));
|
||||
return new FakeMediaStream(tracks);
|
||||
}
|
||||
|
||||
/* =================== assertions =================== */
|
||||
let pass = 0, fail = 0;
|
||||
function test(name, fn){
|
||||
try { fn(); console.log(' ✓ ' + name); pass++; }
|
||||
catch (e){ console.log(' ✗ ' + name + ' — ' + (e.message || e)); fail++; }
|
||||
}
|
||||
function eq(a, b, msg){
|
||||
if (a !== b) throw new Error((msg || 'expected') + ' — got ' + JSON.stringify(a) + ' want ' + JSON.stringify(b));
|
||||
}
|
||||
function truthy(v, msg){ if (!v) throw new Error(msg || 'expected truthy'); }
|
||||
function falsy(v, msg){ if (v) throw new Error(msg || 'expected falsy'); }
|
||||
|
||||
/* =================== tests =================== */
|
||||
console.log('listener audio attach:');
|
||||
|
||||
/* -------- shipped constants -------- */
|
||||
test('shipped: RECV_PLAYOUT_DELAY_SEC is 4s (fox-approved for cellular music; do not dial back without sign-off)', () => {
|
||||
eq(RECV, 4.0, 'RECV_PLAYOUT_DELAY_SEC');
|
||||
});
|
||||
test('shipped: SPEAKER_PLAYOUT_DELAY_SEC is 0.5s (conversational latency for speakers/cohost/host)', () => {
|
||||
eq(SPEAKER, 0.5, 'SPEAKER_PLAYOUT_DELAY_SEC');
|
||||
});
|
||||
|
||||
/* -------- unit: attachAudioStreamViaWorklet -------- */
|
||||
test('listener: first attach with a healthy stream builds src→gain→destination at gain=1', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const stream = makeStream();
|
||||
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
|
||||
eq(ok, true, 'returns true');
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
truthy(node, 'node created');
|
||||
eq(node.gain.gain.value, 1.0, 'gain at unity');
|
||||
truthy(reachable(node.src, b.ctx.destination), 'src reaches destination');
|
||||
eq(node.stream, stream, 'tracks the source stream');
|
||||
});
|
||||
|
||||
test('listener: stream with zero audio tracks is rejected without building a chain', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const stream = new FakeMediaStream([]); /* no tracks */
|
||||
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
|
||||
eq(ok, false, 'returns false');
|
||||
falsy(b.api.listenerAudioNodes.has(UUID_A), 'no chain created');
|
||||
});
|
||||
|
||||
test('listener: stream whose only track is already ended is rejected', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const t = makeAudioTrack();
|
||||
t.stop();
|
||||
const stream = new FakeMediaStream([t]);
|
||||
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
|
||||
eq(ok, false, 'returns false on dead stream');
|
||||
falsy(b.api.listenerAudioNodes.has(UUID_A), 'no chain created');
|
||||
});
|
||||
|
||||
test('listener: stream with a live but muted track still attaches (chrome listener fresh-join flow — peer arrives with mic muted)', () => {
|
||||
/* Chrome reports a remote audio track as readyState=live + muted=true
|
||||
* during the brief window between negotiation completion and first
|
||||
* RTP arrival. The "no live audio tracks" guard added in f479878
|
||||
* MUST gate on readyState only, never on .muted, or fresh listeners
|
||||
* with quiet speakers see permanent silence. fox 2026-06-06:
|
||||
* "fedora chrome is flawless besides not able to hear any mics". */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const stream = makeStream({ trackOpts: { muted: true } });
|
||||
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
|
||||
eq(ok, true, 'returns true even when track.muted=true');
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built');
|
||||
});
|
||||
|
||||
test('listener: second attach with SAME stream object is a no-op (does not rebuild gain or rewire destination)', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const stream = makeStream();
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
|
||||
const first = b.api.listenerAudioNodes.get(UUID_A);
|
||||
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
|
||||
eq(ok, true, 'returns true (idempotent)');
|
||||
eq(b.api.listenerAudioNodes.get(UUID_A), first, 'same node object — not rebuilt');
|
||||
eq(first.gain.gain.value, 1.0, 'gain still unity');
|
||||
truthy(reachable(first.src, b.ctx.destination), 'src→destination still intact');
|
||||
});
|
||||
|
||||
test('listener: second attach with DIFFERENT stream on same uuid swaps source in-place (single chain, no orphan)', () => {
|
||||
/* Pins 144dd15: in-place source swap. The SFU forwards stale audio
|
||||
* transceivers across publisher rejoins — each ontrack delivers a
|
||||
* NEW MediaStream object even when the publisher is the same. The
|
||||
* old teardown+rebuild path produced "two streams" (fxhp-android-
|
||||
* firefox 2026-06-05). The new path reuses the gain+destination
|
||||
* wiring and only swaps the MediaStreamSource. */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const streamA = makeStream();
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, streamA, RECV);
|
||||
const node1 = b.api.listenerAudioNodes.get(UUID_A);
|
||||
const gain1 = node1.gain;
|
||||
const dest = b.ctx.destination;
|
||||
|
||||
const streamB = makeStream();
|
||||
const ok = b.api.attachAudioStreamViaWorklet(UUID_A, streamB, RECV);
|
||||
eq(ok, true, 'swap returns true');
|
||||
const node2 = b.api.listenerAudioNodes.get(UUID_A);
|
||||
eq(node2, node1, 'same node object reused (no teardown)');
|
||||
eq(node2.gain, gain1, 'same GainNode reused (no rewire to destination)');
|
||||
eq(node2.stream, streamB, 'tracks the new stream');
|
||||
truthy(reachable(node2.src, dest), 'new src reaches destination');
|
||||
});
|
||||
|
||||
test('listener: in-place swap after jbuf installed routes new src through the SAME jbuf (no duplicate jbufs, no orphan src)', () => {
|
||||
/* If installJitterBuffer ran (worklet ready), the chain is
|
||||
* src→jbuf→gain→destination. A subsequent setWorkletStream MUST
|
||||
* reuse the same jbuf — otherwise two jbufs end up parallel and
|
||||
* audio doubles. */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const streamA = makeStream();
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, streamA, RECV);
|
||||
b.api.triggerWorkletReady();
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
const jbuf = node.jbuf;
|
||||
truthy(jbuf, 'jbuf installed');
|
||||
|
||||
const streamB = makeStream();
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, streamB, RECV);
|
||||
eq(node.jbuf, jbuf, 'jbuf reused');
|
||||
truthy(node.src._outgoing.has(jbuf), 'new src feeds the jbuf');
|
||||
truthy(reachable(node.src, b.ctx.destination), 'still reaches destination');
|
||||
|
||||
/* count worklet nodes — exactly one jbuf per uuid */
|
||||
const jbufs = b.ctx._nodes.filter(n => n.kind === 'worklet:jitter-buffer');
|
||||
eq(jbufs.length, 1, 'exactly one jbuf node total');
|
||||
});
|
||||
|
||||
/* -------- unit: dedup by pubkey -------- */
|
||||
test('listener: attaching uuid B for the same publisher as uuid A detaches A (no double-gain to destination)', () => {
|
||||
/* Pins f9736c2 dedup + the upstream guard from 2e74b92. fox 2026-06-05:
|
||||
* "fxhp-android-firefox is playing host audio twice." */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
addMember(b, UUID_A2, PUB_A);
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
|
||||
const node1 = b.api.listenerAudioNodes.get(UUID_A);
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A2, makeStream(), RECV);
|
||||
falsy(b.api.listenerAudioNodes.has(UUID_A), 'A detached');
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A2), 'A2 attached');
|
||||
/* assert A's source no longer reaches destination */
|
||||
falsy(reachable(node1.src, b.ctx.destination), 'A no longer reaches destination');
|
||||
});
|
||||
|
||||
/* -------- unit: detachListenerStream cleans everything -------- */
|
||||
test('detachListenerStream: removes from map and disconnects src, jbuf, gain', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
|
||||
b.api.triggerWorkletReady();
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
b.api.detachListenerStream(UUID_A);
|
||||
falsy(b.api.listenerAudioNodes.has(UUID_A), 'removed from map');
|
||||
eq(node.src._outgoing.size, 0, 'src disconnected');
|
||||
eq(node.jbuf._outgoing.size, 0, 'jbuf disconnected');
|
||||
eq(node.gain._outgoing.size, 0, 'gain disconnected');
|
||||
});
|
||||
|
||||
/* -------- unit: setWorkletStream -------- */
|
||||
test('setWorkletStream: no existing chain returns false (caller should attach fresh)', () => {
|
||||
const b = makeBrowser('listener');
|
||||
const ok = b.api.setWorkletStream(UUID_A, makeStream());
|
||||
eq(ok, false);
|
||||
});
|
||||
|
||||
test('setWorkletStream: when jbuf exists, new source connects to jbuf (not gain)', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
|
||||
b.api.triggerWorkletReady();
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
const jbuf = node.jbuf;
|
||||
b.api.setWorkletStream(UUID_A, makeStream());
|
||||
truthy(node.src._outgoing.has(jbuf), 'src→jbuf edge present');
|
||||
falsy(node.src._outgoing.has(node.gain), 'src does NOT bypass jbuf to gain');
|
||||
});
|
||||
|
||||
test('setWorkletStream: when jbuf does NOT exist (worklet not ready yet), new source connects to gain', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
|
||||
/* deliberately do NOT triggerWorkletReady */
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
falsy(node.jbuf, 'jbuf not installed yet');
|
||||
b.api.setWorkletStream(UUID_A, makeStream());
|
||||
truthy(node.src._outgoing.has(node.gain), 'src→gain edge present');
|
||||
truthy(reachable(node.src, b.ctx.destination), 'still reaches destination');
|
||||
});
|
||||
|
||||
/* -------- installJitterBuffer race -------- */
|
||||
test('installJitterBuffer after setWorkletStream: src (post-swap) gets jbuf inserted, single chain', () => {
|
||||
/* Race: ontrackA → attach → src1+gain wired. ontrackB (same pub,
|
||||
* different stream) → setWorkletStream → src2 connected directly to
|
||||
* gain (jbuf not ready). LATER worklet loads → installJitterBuffer
|
||||
* walks listenerAudioNodes and inserts jbuf between current src
|
||||
* (src2) and gain. Final chain: src2→jbuf→gain→destination. No
|
||||
* orphan, single jbuf. */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
|
||||
b.api.setWorkletStream(UUID_A, makeStream()); /* swap before worklet ready */
|
||||
b.api.triggerWorkletReady(); /* now worklet ready */
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
truthy(node.jbuf, 'jbuf installed');
|
||||
truthy(node.src._outgoing.has(node.jbuf), 'current src feeds jbuf');
|
||||
truthy(node.jbuf._outgoing.has(node.gain), 'jbuf feeds gain');
|
||||
truthy(reachable(node.src, b.ctx.destination), 'reaches destination');
|
||||
});
|
||||
|
||||
/* -------- integration: attachSfuTrack routing by role -------- */
|
||||
test('integration: listener role routes through attachListenerStreamViaAudioContext at 4s target', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.attachSfuTrack(UUID_A, makeStream());
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
truthy(node, 'chain built');
|
||||
eq(node.targetSeconds, RECV, 'target=4s for listener');
|
||||
truthy(reachable(node.src, b.ctx.destination), 'audible path exists');
|
||||
});
|
||||
|
||||
test('integration: speaker role routes through attachAudioStreamViaWorklet at 0.5s target', () => {
|
||||
const b = makeBrowser('speaker');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.attachSfuTrack(UUID_A, makeStream());
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
truthy(node, 'chain built');
|
||||
eq(node.targetSeconds, SPEAKER, 'target=0.5s for speaker');
|
||||
});
|
||||
|
||||
/* -------- integration: flushSfuStreams skip-on-existing -------- */
|
||||
test('flushSfuStreams: when a chain already exists for the uuid, no re-attach (kills the peer-joined re-attach storm from 2e74b92)', () => {
|
||||
/* Pre-2e74b92, the guard only checked remoteAudio (the <audio>
|
||||
* fallback), which is empty on listeners using the worklet path
|
||||
* → every peer-joined re-attached every speaker → audible
|
||||
* teardown/rebuild race. Fix: check listenerAudioNodes too. */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const stream = makeStream();
|
||||
/* prime: stream is cached AND a chain already exists */
|
||||
b.api.sfuStreamsByPubHex.set(PUB_A, stream);
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, stream, RECV);
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
const srcBefore = node.src;
|
||||
b.api.flushSfuStreams();
|
||||
eq(b.api.listenerAudioNodes.get(UUID_A).src, srcBefore, 'src unchanged — no re-attach');
|
||||
});
|
||||
|
||||
test('flushSfuStreams: when a stream is cached but no chain exists yet, it attaches', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const stream = makeStream();
|
||||
b.api.sfuStreamsByPubHex.set(PUB_A, stream);
|
||||
b.api.flushSfuStreams();
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built from cache');
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
truthy(reachable(node.src, b.ctx.destination), 'audible path exists');
|
||||
});
|
||||
|
||||
/* -------- integration: multi-speaker listener -------- */
|
||||
test('integration: listener with three speakers — three independent chains, each reaches destination', () => {
|
||||
/* The fedora-chrome silent-listener regression repro. If any
|
||||
* chain is silently dropped or not reaching destination, this fails. */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
addMember(b, UUID_B, PUB_B);
|
||||
addMember(b, UUID_C, PUB_C);
|
||||
b.api.attachSfuTrack(UUID_A, makeStream());
|
||||
b.api.attachSfuTrack(UUID_B, makeStream());
|
||||
b.api.attachSfuTrack(UUID_C, makeStream());
|
||||
eq(b.api.listenerAudioNodes.size, 3, 'three chains');
|
||||
for (const uuid of [UUID_A, UUID_B, UUID_C]){
|
||||
const node = b.api.listenerAudioNodes.get(uuid);
|
||||
truthy(node, uuid + ' chain present');
|
||||
truthy(reachable(node.src, b.ctx.destination), uuid + ' reaches destination');
|
||||
eq(node.gain.gain.value, 1.0, uuid + ' at unity gain');
|
||||
}
|
||||
});
|
||||
|
||||
test('integration: listener with three speakers, worklet loads AFTER all attached — every chain gets jbuf, every chain reaches destination', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
addMember(b, UUID_B, PUB_B);
|
||||
addMember(b, UUID_C, PUB_C);
|
||||
b.api.attachSfuTrack(UUID_A, makeStream());
|
||||
b.api.attachSfuTrack(UUID_B, makeStream());
|
||||
b.api.attachSfuTrack(UUID_C, makeStream());
|
||||
b.api.triggerWorkletReady(); /* worklet finishes loading now */
|
||||
for (const uuid of [UUID_A, UUID_B, UUID_C]){
|
||||
const node = b.api.listenerAudioNodes.get(uuid);
|
||||
truthy(node.jbuf, uuid + ' jbuf installed');
|
||||
truthy(reachable(node.src, b.ctx.destination), uuid + ' still reaches destination');
|
||||
}
|
||||
});
|
||||
|
||||
/* -------- regression: publisher rejoin under SAME pubHex with NEW stream -------- */
|
||||
test('regression: ontrack streamA, track-ended, peer-joined for same pubHex new uuid — fresh chain, no zero-track skip', () => {
|
||||
/* Pins f479878 cache-drop on track ended. Pre-fix, the dead streamA
|
||||
* was still in sfuStreamsByPubHex → flushSfuStreams handed it to
|
||||
* attach → "no live audio tracks" reject silenced uuid_A2. */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
|
||||
const streamA = makeStream();
|
||||
/* simulate: handleRemoteSfuTrack would set cache + listen for 'ended' */
|
||||
b.api.sfuStreamsByPubHex.set(PUB_A, streamA);
|
||||
const trackA = streamA.getAudioTracks()[0];
|
||||
trackA.addEventListener('ended', () => {
|
||||
if (b.api.sfuStreamsByPubHex.get(PUB_A) === streamA) b.api.sfuStreamsByPubHex.delete(PUB_A);
|
||||
});
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, streamA, RECV);
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'A attached');
|
||||
|
||||
/* publisher leaves: trackA ends + peer-left would detach */
|
||||
trackA.stop();
|
||||
b.api.detachListenerStream(UUID_A);
|
||||
falsy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache dropped on ended');
|
||||
|
||||
/* publisher rejoins under a new uuid, ontrack arrives with streamB */
|
||||
addMember(b, UUID_A2, PUB_A);
|
||||
const streamB = makeStream();
|
||||
b.api.sfuStreamsByPubHex.set(PUB_A, streamB);
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A2, streamB, RECV);
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A2), 'A2 attached');
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A2);
|
||||
truthy(reachable(node.src, b.ctx.destination), 'A2 reaches destination');
|
||||
});
|
||||
|
||||
/* =================== handleRemoteSfuTrack integration =================== */
|
||||
/* These tests drive the ENTIRE receive-side flow from an ontrack event
|
||||
* down through chain build. They catch upstream defects where the
|
||||
* attach FSM is intact but never gets called (member-resolution failure,
|
||||
* cache-only path, etc.). */
|
||||
|
||||
/* Construct a fake ontrack event with the streamID encoding the page
|
||||
* expects: 16-hex-prefix-of-pubHex (+ '-screen' / '-camera' / '-game'
|
||||
* for non-mic). */
|
||||
function fakeOntrack(pubHex, kind, stream){
|
||||
const sid = (kind === 'mic' || !kind) ? pubHex.slice(0, 16)
|
||||
: (pubHex.slice(0, 16) + '-' + kind);
|
||||
stream.id = sid;
|
||||
return {
|
||||
streams: [stream],
|
||||
track: stream.getTracks()[0] || null,
|
||||
receiver: { playoutDelayHint: 0, jitterBufferTarget: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
test('handleRemoteSfuTrack: listener ontrack(mic) for known speaker → chain built + reaches destination', () => {
|
||||
/* The canonical fedora-chrome listener flow: a remote speaker
|
||||
* publishes mic, ontrack arrives on the SFU sub PC, handleRemoteSfuTrack
|
||||
* runs, sfuStreamsByPubHex caches by full pubHex, attachSfuTrack fires,
|
||||
* chain reaches audioCtx.destination. */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const stream = makeStream();
|
||||
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
|
||||
truthy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cached by pubHex');
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built');
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
truthy(reachable(node.src, b.ctx.destination), 'audible path');
|
||||
eq(node.targetSeconds, RECV, '4s target for listener');
|
||||
});
|
||||
|
||||
test('handleRemoteSfuTrack: ontrack ARRIVES BEFORE peer-joined (member roster empty) → cached + audible after flush', () => {
|
||||
/* The fedora-chrome silent-listener race: on a real listener, the SFU
|
||||
* sub PC ontrack can fire BEFORE the signal-server peer-joined event
|
||||
* populates `members`. handleRemoteSfuTrack must cache the stream by
|
||||
* SOMETHING that flushSfuStreams can later resolve to the publisher's
|
||||
* uuid — otherwise the listener stays silent forever even after
|
||||
* peer-joined arrives. The exact cache key is an implementation
|
||||
* detail; the test pins the end-state contract: after addMember +
|
||||
* flushSfuStreams, the chain MUST exist and reach destination. */
|
||||
const b = makeBrowser('listener');
|
||||
/* deliberately do NOT addMember(UUID_A) yet */
|
||||
const stream = makeStream();
|
||||
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
|
||||
truthy(b.api.sfuStreamsByPubHex.size > 0, 'cached under SOME key (prefix or full)');
|
||||
falsy(b.api.listenerAudioNodes.has(UUID_A), 'no attach yet — uuid unknown');
|
||||
|
||||
/* peer-joined event lands: add to roster, then flush */
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.flushSfuStreams();
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built on flush');
|
||||
const node = b.api.listenerAudioNodes.get(UUID_A);
|
||||
truthy(reachable(node.src, b.ctx.destination), 'audible path');
|
||||
});
|
||||
|
||||
test('handleRemoteSfuTrack: listener does NOT skip SFU when a stale peers map entry is present (kills the "promoted but nobody hears" regression)', () => {
|
||||
/* The mesh-skip branch ("speakers get peers via mesh") is gated by
|
||||
* canSpeak(myRole). Listeners must NEVER skip SFU mic even if peers
|
||||
* happens to have a stale entry — they have no mesh PC. */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
b.api.peers.set(UUID_A, { connectionState: 'failed' }); /* stale, irrelevant for listener */
|
||||
const stream = makeStream();
|
||||
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
|
||||
truthy(b.api.listenerAudioNodes.has(UUID_A), 'chain built, mesh check skipped for listener');
|
||||
});
|
||||
|
||||
test('handleRemoteSfuTrack: track-ended drops the cache entry (so next peer-joined with same pubHex does NOT get a dead stream)', () => {
|
||||
/* Pins the f479878 cache-drop. fox 2026-06-05 signal log:
|
||||
* "peer-joined u=64b0 fired audio attach fresh=1 tracks=0" — that
|
||||
* was flushSfuStreams handing the dead cached stream to attach
|
||||
* after the publisher had left. */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
const stream = makeStream();
|
||||
b.api.handleRemoteSfuTrack(fakeOntrack(PUB_A, 'mic', stream));
|
||||
truthy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache primed');
|
||||
stream.getAudioTracks()[0].stop();
|
||||
falsy(b.api.sfuStreamsByPubHex.has(PUB_A), 'cache dropped on track ended');
|
||||
});
|
||||
|
||||
test('handleRemoteSfuTrack: own publish echo is skipped (sid prefix matches myKeys.pubHex)', () => {
|
||||
const b = makeBrowser('listener');
|
||||
/* myKeys.pubHex = 'f'*64 in our stub */
|
||||
const myPub = 'f'.repeat(64);
|
||||
const stream = makeStream();
|
||||
b.api.handleRemoteSfuTrack(fakeOntrack(myPub, 'mic', stream));
|
||||
falsy(b.api.sfuStreamsByPubHex.has(myPub), 'own echo not cached');
|
||||
eq(b.api.listenerAudioNodes.size, 0, 'no chain for self');
|
||||
});
|
||||
|
||||
/* =================== audioCtx state regression coverage =================== */
|
||||
|
||||
test('listener: attach when audioCtx starts SUSPENDED → resume() is called (Chrome autoplay policy)', () => {
|
||||
/* Chrome (Fedora especially) leaves audioCtx suspended until a real
|
||||
* gesture. The attach code calls audioCtx.resume() fire-and-forget.
|
||||
* This test pins that the call happens. If a future refactor drops
|
||||
* the resume() call, fedora chrome listeners go silent because
|
||||
* destination won't emit while suspended. fox 2026-06-05:
|
||||
* "speaker on firefox chrome was hard restarted and cannot hear host." */
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
let resumeCalled = false;
|
||||
b.ctx.state = 'suspended';
|
||||
const origResume = b.ctx.resume;
|
||||
b.ctx.resume = function(){ resumeCalled = true; return origResume.call(b.ctx); };
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
|
||||
truthy(resumeCalled, 'resume() called when audioCtx was suspended');
|
||||
});
|
||||
|
||||
test('listener: attach when audioCtx is already RUNNING does not call resume()', () => {
|
||||
const b = makeBrowser('listener');
|
||||
addMember(b, UUID_A, PUB_A);
|
||||
let resumeCalled = false;
|
||||
b.ctx.state = 'running';
|
||||
b.ctx.resume = function(){ resumeCalled = true; return Promise.resolve(); };
|
||||
b.api.attachAudioStreamViaWorklet(UUID_A, makeStream(), RECV);
|
||||
falsy(resumeCalled, 'resume() not called on running ctx');
|
||||
});
|
||||
|
||||
/* -------- summary -------- */
|
||||
console.log('');
|
||||
console.log((fail === 0 ? 'PASS' : 'FAIL') + ' — ' + pass + ' passed, ' + fail + ' failed');
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
|
|
@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
|
|||
})();
|
||||
</script>
|
||||
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">b7b1b1b9c612636a17adf94eca5835b5</span><br>
|
||||
sha256 <span class="stamp-sha">7dff2ae15621b8c56d4c0c5a50afe5b3305317a161b281d66c2417c3b1cbf1a6</span><br>
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-06</span><br>
|
||||
md5 <span class="stamp-md5">e9fb81e6d445184e8a4967dfe56883f9</span><br>
|
||||
sha256 <span class="stamp-sha">a44a19223961e6a8e4f6010d073ff1e95246d908eb3b27d322fcd7bb17e5ad39</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 it against these hashes; point it 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>
|
||||
|
|
|
|||
|
|
@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
|
|||
</p>
|
||||
|
||||
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">e4e8e782260db19486c05c042f4f1afc</span><br>
|
||||
sha256 <span class="stamp-sha">0d6678f32e1c4dcc0dd3adcb052b4b0cc5dc86fb6f2dbc5c9c79775e3feb683e</span><br>
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-06</span><br>
|
||||
md5 <span class="stamp-md5">f3900aa0c841baa7c61599ff247f8fe1</span><br>
|
||||
sha256 <span class="stamp-sha">496b0dee4967745d5502d549c30278370cbd228ae75c3020025f9912fa9773d7</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 it against these hashes; point it at your own servers with ?signal= and ?turncred=</span>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -341,9 +341,9 @@ try {
|
|||
</p>
|
||||
|
||||
<footer style="max-width:820px;margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">9870bd4e4dae3439f3238f6c7bfbb0e1</span><br>
|
||||
sha256 <span class="stamp-sha">7bf54074bc06b6a9378d75cfc678aadf626c2c2e921149db5610a7e0ed86ef6a</span><br>
|
||||
<span style="color:#777">page integrity</span> · built <span class="stamp-date">2026-06-06</span><br>
|
||||
md5 <span class="stamp-md5">ea02f55584d8042b03fcbd999e1da240</span><br>
|
||||
sha256 <span class="stamp-sha">c7cb5f8be8241d4634cca84acd3343c1fbfd0c7053b260bcee05084aa7bd83aa</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 it against these hashes; point it 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>
|
||||
|
|
|
|||
|
|
@ -747,9 +747,9 @@ else wirePuppet();
|
|||
</section>
|
||||
|
||||
<footer style="max-width:560px;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">704bc6f8c294b01704add1b59ec23d69</span><br>
|
||||
sha256 <span class="stamp-sha">06b739c56ebdfab90591ea4c5e8d8cb6b5cd854c02f526753f724857c518a64e</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-06</span><br>
|
||||
md5 <span class="stamp-md5">ba6a5714f597469b0aa61b32657f50f4</span><br>
|
||||
sha256 <span class="stamp-sha">78b5fb863ec406ec39081a0231fb909eef3abf332e84c0a5f25942f62f49de1b</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 it against these hashes; point it 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>
|
||||
|
|
|
|||
|
|
@ -3507,15 +3507,30 @@ function flushSfuStreams(){
|
|||
* fresh-attach on the same uuid and producing audible overlap.
|
||||
* Fox 2026-06-05 telemetry: "fxhp-android-firefox is hearing two
|
||||
* streams now as listener" — fix the wrong guard, not double-audio
|
||||
* via dedup. */
|
||||
* via dedup.
|
||||
*
|
||||
* Match by prefix: the cache key from handleRemoteSfuTrack is whatever
|
||||
* resolution returned at ontrack time — full pubhex if the member
|
||||
* roster already had the publisher, or the 16-char streamID prefix
|
||||
* otherwise (ontrack races peer-joined; SFU sub PC ontrack can fire
|
||||
* before signal-server announces existing speakers). Strict === on
|
||||
* the full-decoded mm pubkey would never match the prefix key, so a
|
||||
* listener that joined a room with N existing speakers stayed
|
||||
* permanently silent. Symmetry with the startsWith resolution in
|
||||
* handleRemoteSfuTrack (line ~4640). fox 2026-06-06: "fedora chrome
|
||||
* is flawless besides not able to hear any mics, was working a few
|
||||
* days back, only zebra code changed." */
|
||||
for (const [pubHex, stream] of sfuStreamsByPubHex){
|
||||
for (const [uuid, mm] of members){
|
||||
try {
|
||||
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
|
||||
if (!remoteAudio.has(uuid) && !listenerAudioNodes.has(uuid)){
|
||||
attachSfuTrack(uuid, stream);
|
||||
if (mm.pubkey){
|
||||
const fh = hex(unb64(mm.pubkey));
|
||||
if (fh.startsWith(pubHex)){
|
||||
if (!remoteAudio.has(uuid) && !listenerAudioNodes.has(uuid)){
|
||||
attachSfuTrack(uuid, stream);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch(_){}
|
||||
}
|
||||
|
|
@ -7875,9 +7890,9 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
|||
</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">
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-05</span><br>
|
||||
md5 <span class="stamp-md5">96bc1aa9b23545fe92f4dfe02dfda74c</span><br>
|
||||
sha256 <span class="stamp-sha">df6d1806da9b5844d6b9779ce84ae41fc21feeb4e2352a747a09662741bab08f</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-06</span><br>
|
||||
md5 <span class="stamp-md5">cf7bb5c3111551561745c6432932a659</span><br>
|
||||
sha256 <span class="stamp-sha">fe4edd4a17f457275f1b73dfa3fe61d538a1e0d1b2593efca50b1147a68b71fa</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