diff --git a/docs/tickets/0001-fedora-chrome-cannot-hear-speakers.md b/docs/tickets/0001-fedora-chrome-cannot-hear-speakers.md
index 6d4a660..843a4f7 100644
--- a/docs/tickets/0001-fedora-chrome-cannot-hear-speakers.md
+++ b/docs/tickets/0001-fedora-chrome-cannot-hear-speakers.md
@@ -88,13 +88,91 @@ telemetry session shows which sink the worklet is routed to.
- `audioCtx without setSinkId support (older browser) does not throw —
silent fallback to default`
+## 2026-06-07 telemetry — sink hypothesis ruled out
+
+Fresh `/var/log/zebra-spaces-signal.log` capture (00:20:01 — fxhp host
+publishing real music, blanka-chrome speaker):
+
+```
+audio via AudioContext 085b target=0.5s ctxState=running sink=default
+mesh stream swapped into worklet for 085b — buffer cushion now applies to mesh too
+...
+aud.recv pkt=14622 lost=1 bytes=9117575 jitter=0.0100 level=0.000 jbuf=? lp=0.1s
+```
+
+Comparison — fxhp-phone in the same room, same window, same publisher:
+
+```
+aud.recv pkt=11161 lost=1 bytes=7154201 jitter=0.0090 level=0.001 jbuf=0.49s lp=0.0s
+```
+
+- sink IS routed (deployed fix `467146a` confirmed live — later capture
+ shows `sink=c38572ec…` = the user-picked deviceId, NOT default).
+- 50 pps RTP, 9.1 MB of stereo Opus arriving on blanka-chrome's SFU sub PC.
+- `jbuf=?` = `jitterBufferEmittedCount === 0` = receiver **decodes
+ nothing**. There is no consumer wired to the SFU audio receiver.
+- fxhp-phone on the same SFU stream has `jbuf=0.49s` + `level=0.001`
+ (native decoder running) — so the SFU is fine, the problem is per-
+ client wiring.
+
+So: **sink routing was not the bug, or was only part of it.** With the
+sink fix applied, fedora chrome still hears silence.
+
+## Real root cause
+
+Two coupled defects, both visible in `handleRemoteSfuTrack` (line 4700)
+and the mesh `pc.ontrack` (line 6693):
+
+**(A) `tracks=0` race at mic ontrack.** Chrome can fire `ontrack` where
+`ev.streams[0]` exists but has 0 tracks at handler-time (the track is
+added a microtask later, or MSID-supplant merges new + dead tracks).
+Screen / camera / game already work around this by constructing a fresh
+`new MediaStream([ev.track])` and passing that. **Mic does not.** It
+caches `ev.streams[0]` directly into `sfuStreamsByPubHex` and hands the
+same reference to `attachSfuTrack`. When that fires before the track
+shows up on the stream, `attachAudioStreamViaWorklet` rejects with
+`liveAudio.length === 0` → falls through to the silent `
diff --git a/web/how-it-works.html b/web/how-it-works.html
index fcc51c2..498b42c 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 53fcd5f..f0db38d 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 01a726b..297925f 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -4838,11 +4838,24 @@ function handleRemoteSfuTrack(ev){
* was handing the dead stream to attachAudioStreamViaWorklet on the
* NEXT peer-joined with matching pubkey, racing the live ontrack.
* Fox 2026-06-05 telemetry: peer-joined u=64b0 fired "audio attach
- * fresh=1 tracks=0" before the real attach. */
- sfuStreamsByPubHex.set(pubHex, ev.streams[0]);
+ * fresh=1 tracks=0" before the real attach.
+ *
+ * Wrap in a FRESH MediaStream containing only ev.track — same shape
+ * screen/camera/game use at lines 4759-4784. Chrome can fire ontrack
+ * where ev.streams[0] is the supplant-merged stream object containing
+ * the dead old track AND nothing else yet (the live track gets added
+ * asynchronously after the handler runs). Caching ev.streams[0]
+ * directly captured the empty/dead version; attachSfuTrack then logged
+ * `fresh=1 tracks=0` + `meter ... MediaStream has no audio track`
+ * and fell through to the silent path. Fox 2026-06-07
+ * telemetry on blanka-chrome confirmed: SFU sub PC carrying real
+ * 50 pps stereo Opus, jbuf=? + level=0 because no decoder consumed
+ * the orphan track. */
+ const s = new MediaStream([ev.track]);
+ sfuStreamsByPubHex.set(pubHex, s);
try {
ev.track.addEventListener('ended', () => {
- if (sfuStreamsByPubHex.get(pubHex) === ev.streams[0]){
+ if (sfuStreamsByPubHex.get(pubHex) === s){
sfuStreamsByPubHex.delete(pubHex);
logLine('', 'sfu cache drop pub='+pubHex.slice(0,4)+' — track ended');
}
@@ -4885,7 +4898,7 @@ function handleRemoteSfuTrack(ev){
logLine('', 'sfu mic taking over for '+uuid+' — mesh state='+meshState);
}
}
- attachSfuTrack(uuid, ev.streams[0]);
+ attachSfuTrack(uuid, s);
return;
}
} catch(_){}
@@ -6700,33 +6713,84 @@ async function connectToPeer(uuid, weOffer){
* cushion (0.5s for speakers). Source swap is seamless because
* the worklet's queue holds 0.5s of decoded samples and both
* sources contain identical content at slightly different
- * network delays. */
+ * network delays.
+ *
+ * Gate the swap on track-not-muted. ev.track.muted=true means
+ * "no RTP yet"; it transitions to false on the first decoded
+ * packet ('unmute' event). Swapping the worklet to a muted mesh
+ * track orphans the SFU receiver (no decoder consumes it →
+ * jbuf=? + level=0 on telemetry) while the worklet plays silence
+ * from mesh — the silent-room state seen on fedora chrome
+ * (ticket 0001, fox 2026-06-07). If the mesh track is already
+ * unmuted at ontrack-time, swap immediately. Otherwise wait for
+ * 'unmute' before touching the worklet. */
const stream = ev.streams[0] || new MediaStream([ev.track]);
- if (!setWorkletStream(uuid, stream)){
- /* fallback: no existing worklet (AudioContext failed at SFU
- * attach time). Create one fresh with mesh as the source.
- * Small initial silence while the buffer fills. */
- attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC);
+ const swapToMesh = () => {
+ if (!setWorkletStream(uuid, stream)){
+ /* fallback: no existing worklet (AudioContext failed at SFU
+ * attach time). Create one fresh with mesh as the source.
+ * Small initial silence while the buffer fills. */
+ attachAudioStreamViaWorklet(uuid, stream, SPEAKER_PLAYOUT_DELAY_SEC);
+ }
+ /* mesh path is peer-to-peer between two speakers (you'd never be
+ * in mesh as a pure listener). Always conversational latency
+ * here — fixed at SPEAKER_PLAYOUT_DELAY_SEC, no role check
+ * needed. */
+ try { ev.receiver.playoutDelayHint = SPEAKER_PLAYOUT_DELAY_SEC; } catch(_){}
+ try { ev.receiver.jitterBufferTarget = SPEAKER_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
+ /* lip-sync: rebind the audio receiver for this publisher to the
+ * MESH receiver since mesh is now what's feeding the worklet.
+ * Its jbuf (~50ms native) + worklet (0.5s) ≈ ~0.55s total audio
+ * delay. Video receivers will re-target to match on the next
+ * worklet 'buffered' message. Fox 2026-06-04: "video would need
+ * to be slid depending on the mode to keep it in sync." */
+ try {
+ const mm = members.get(uuid);
+ const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
+ if (pubHex && ev.receiver) registerLipSyncAudio(pubHex, uuid, ev.receiver);
+ } catch(_){}
+ stopMeter(uuid); startMeter(uuid, stream);
+ logLine('', 'mesh stream swapped into worklet for '+uuid.slice(0,4)+' — buffer cushion now applies to mesh too');
+ };
+ /* Mesh-stale fallback: once swapped in, if the mesh track returns
+ * to muted (RTP stopped flowing) for >MESH_MUTE_WINDOW_MS, swap
+ * the worklet back to the cached SFU stream so the listener keeps
+ * hearing the publisher even if mesh stalls without dropping the
+ * PC's connectionState. Same idea as the connectionState='failed'
+ * restore path (below), but driven by track-level mute instead of
+ * PC-level failure. */
+ const MESH_MUTE_WINDOW_MS = 5000;
+ let muteTimer = null;
+ ev.track.addEventListener('mute', () => {
+ if (muteTimer) return;
+ muteTimer = setTimeout(() => {
+ muteTimer = null;
+ if (!ev.track.muted) return;
+ try {
+ const mm = members.get(uuid);
+ const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
+ const sfuStream = pubHex ? sfuStreamsByPubHex.get(pubHex) : null;
+ if (sfuStream && setWorkletStream(uuid, sfuStream)){
+ const sfuRx = pubHex ? sfuAudioReceivers.get(pubHex) : null;
+ if (sfuRx) registerLipSyncAudio(pubHex, uuid, sfuRx);
+ logLine('', 'mesh muted >'+(MESH_MUTE_WINDOW_MS/1000)+'s for '+uuid.slice(0,4)+' — worklet swapped back to SFU');
+ }
+ } catch(_){}
+ }, MESH_MUTE_WINDOW_MS);
+ });
+ ev.track.addEventListener('unmute', () => {
+ if (muteTimer){ clearTimeout(muteTimer); muteTimer = null; }
+ /* if we were on SFU because mesh went muted, swap forward to
+ * mesh again now that RTP is flowing. swapToMesh is idempotent
+ * (setWorkletStream is a no-op if the same source is already
+ * connected). */
+ swapToMesh();
+ });
+ if (!ev.track.muted){
+ swapToMesh();
+ } else {
+ logLine('', 'mesh track for '+uuid.slice(0,4)+' arrived muted — staying on SFU until unmute');
}
- /* mesh path is peer-to-peer between two speakers (you'd never be
- * in mesh as a pure listener). Always conversational latency
- * here — fixed at SPEAKER_PLAYOUT_DELAY_SEC, no role check
- * needed. */
- try { ev.receiver.playoutDelayHint = SPEAKER_PLAYOUT_DELAY_SEC; } catch(_){}
- try { ev.receiver.jitterBufferTarget = SPEAKER_PLAYOUT_DELAY_SEC * 1000; } catch(_){}
- /* lip-sync: rebind the audio receiver for this publisher to the
- * MESH receiver since mesh is now what's feeding the worklet.
- * Its jbuf (~50ms native) + worklet (0.5s) ≈ ~0.55s total audio
- * delay. Video receivers will re-target to match on the next
- * worklet 'buffered' message. Fox 2026-06-04: "video would need
- * to be slid depending on the mode to keep it in sync." */
- try {
- const mm = members.get(uuid);
- const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
- if (pubHex && ev.receiver) registerLipSyncAudio(pubHex, uuid, ev.receiver);
- } catch(_){}
- stopMeter(uuid); startMeter(uuid, stream);
- logLine('', 'mesh stream swapped into worklet for '+uuid.slice(0,4)+' — buffer cushion now applies to mesh too');
};
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
pc.onconnectionstatechange = () => {
@@ -8084,8 +8148,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');