From 0ce1339f8e69713478b62e9b94390b0a61ca4f4c Mon Sep 17 00:00:00 2001
From: Russell Ballestrini
Date: Sat, 6 Jun 2026 14:37:18 -0400
Subject: [PATCH] zebra-spaces: prefix-match in flushSfuStreams (kill silent
fedora-chrome listener)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
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.
---
Makefile | 15 +-
test/listener-audio-attach.test.js | 811 +++++++++++++++++++++++++++++
web/chat.html | 6 +-
web/host-your-own.html | 6 +-
web/how-it-works.html | 6 +-
web/zebra-audio.html | 6 +-
web/zebra-spaces.html | 31 +-
7 files changed, 860 insertions(+), 21 deletions(-)
create mode 100644 test/listener-audio-attach.test.js
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
+ * 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);
diff --git a/web/chat.html b/web/chat.html
index ddce7e2..edccd83 100644
--- a/web/chat.html
+++ b/web/chat.html
@@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})();
- page integrity · built 2026-06-04
- md5 b7b1b1b9c612636a17adf94eca5835b5
- sha256 7dff2ae15621b8c56d4c0c5a50afe5b3305317a161b281d66c2417c3b1cbf1a6
+ page integrity · built 2026-06-06
+ md5 e9fb81e6d445184e8a4967dfe56883f9
+ sha256 a44a19223961e6a8e4f6010d073ff1e95246d908eb3b27d322fcd7bb17e5ad39
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
diff --git a/web/host-your-own.html b/web/host-your-own.html
index 4355e63..f7fe228 100644
--- a/web/host-your-own.html
+++ b/web/host-your-own.html
@@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
- page integrity · built 2026-06-04
- md5 e4e8e782260db19486c05c042f4f1afc
- sha256 0d6678f32e1c4dcc0dd3adcb052b4b0cc5dc86fb6f2dbc5c9c79775e3feb683e
+ page integrity · built 2026-06-06
+ md5 f3900aa0c841baa7c61599ff247f8fe1
+ sha256 496b0dee4967745d5502d549c30278370cbd228ae75c3020025f9912fa9773d7
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=
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 {
- page integrity · built 2026-06-04
- md5 9870bd4e4dae3439f3238f6c7bfbb0e1
- sha256 7bf54074bc06b6a9378d75cfc678aadf626c2c2e921149db5610a7e0ed86ef6a
+ page integrity · built 2026-06-06
+ md5 ea02f55584d8042b03fcbd999e1da240
+ sha256 c7cb5f8be8241d4634cca84acd3343c1fbfd0c7053b260bcee05084aa7bd83aa
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
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();
- page integrity · built 2026-06-04
- md5 704bc6f8c294b01704add1b59ec23d69
- sha256 06b739c56ebdfab90591ea4c5e8d8cb6b5cd854c02f526753f724857c518a64e
+ page integrity · built 2026-06-06
+ md5 ba6a5714f597469b0aa61b32657f50f4
+ sha256 78b5fb863ec406ec39081a0231fb909eef3abf332e84c0a5f25942f62f49de1b
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify it against these hashes; point it at your own servers with ?signal= and ?turncred=, or host your own community
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');
- page integrity · built 2026-06-05
- md5 96bc1aa9b23545fe92f4dfe02dfda74c
- sha256 df6d1806da9b5844d6b9779ce84ae41fc21feeb4e2352a747a09662741bab08f
+ page integrity · built 2026-06-06
+ md5 cf7bb5c3111551561745c6432932a659
+ sha256 fe4edd4a17f457275f1b73dfa3fe61d538a1e0d1b2593efca50b1147a68b71fa
hashes are of this page with these two fields zeroed — to verify, blank them and re-hash
one self-contained file — save a copy and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or host your own community