diff --git a/Makefile b/Makefile index 82b8e17..fe33665 100644 --- a/Makefile +++ b/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 diff --git a/test/listener-audio-attach.test.js b/test/listener-audio-attach.test.js new file mode 100644 index 0000000..6b47e91 --- /dev/null +++ b/test/listener-audio-attach.test.js @@ -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
diff --git a/web/how-it-works.html b/web/how-it-works.html index d04e551..fcc51c2 100644 --- a/web/how-it-works.html +++ b/web/how-it-works.html @@ -341,9 +341,9 @@ try { diff --git a/web/zebra-audio.html b/web/zebra-audio.html index 3acec16..53fcd5f 100644 --- a/web/zebra-audio.html +++ b/web/zebra-audio.html @@ -747,9 +747,9 @@ else wirePuppet(); diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index e02f966..e603d84 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -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');