diff --git a/zebra-report/host-your-own.html b/zebra-report/host-your-own.html
index f7fe228..8e9971c 100644
--- a/zebra-report/host-your-own.html
+++ b/zebra-report/host-your-own.html
@@ -507,9 +507,9 @@ handle /zebra-spaces-sfu* { reverse_proxy localhost:8092 { flush_interval -1
- page integrity · built 2026-06-06
- md5 f3900aa0c841baa7c61599ff247f8fe1
- sha256 496b0dee4967745d5502d549c30278370cbd228ae75c3020025f9912fa9773d7
+ page integrity · built 2026-06-07
+ md5 97108d6b240474c7e1b3bea7ae889cc4
+ sha256 9ac307a6b7bdedc95d44e5395a984be9ffd68d726459f96432373eb9bb67497f
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/zebra-report/how-it-works.html b/zebra-report/how-it-works.html
index fcc51c2..498b42c 100644
--- a/zebra-report/how-it-works.html
+++ b/zebra-report/how-it-works.html
@@ -341,9 +341,9 @@ try {
- page integrity · built 2026-06-06
- md5 ea02f55584d8042b03fcbd999e1da240
- sha256 c7cb5f8be8241d4634cca84acd3343c1fbfd0c7053b260bcee05084aa7bd83aa
+ page integrity · built 2026-06-07
+ md5 402c3b4d6e72b8cc6692166fc4683978
+ sha256 5f0a1a8dafd2074081c9189d12455797157901f9e52bb7c4899d4c7b7dd7bf95
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/zebra-report/index.html b/zebra-report/index.html
index edccd83..3683a81 100644
--- a/zebra-report/index.html
+++ b/zebra-report/index.html
@@ -1747,9 +1747,9 @@ logLine('sys', 'chat content lives in encrypted SRTP audio. no IP packets carry
})();
- page integrity · built 2026-06-06
- md5 e9fb81e6d445184e8a4967dfe56883f9
- sha256 a44a19223961e6a8e4f6010d073ff1e95246d908eb3b27d322fcd7bb17e5ad39
+ page integrity · built 2026-06-07
+ md5 c9fee1030b030fb1277cb89607ef7d8d
+ sha256 12c4070297b0d4d671e84d18891451289968f14b12fc2309f4dc85bef47528be
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/zebra-report/zebra-audio.html b/zebra-report/zebra-audio.html
index 53fcd5f..f0db38d 100644
--- a/zebra-report/zebra-audio.html
+++ b/zebra-report/zebra-audio.html
@@ -747,9 +747,9 @@ else wirePuppet();
- page integrity · built 2026-06-06
- md5 ba6a5714f597469b0aa61b32657f50f4
- sha256 78b5fb863ec406ec39081a0231fb909eef3abf332e84c0a5f25942f62f49de1b
+ page integrity · built 2026-06-07
+ md5 92054b1353cdbde054776ac5ed46b531
+ sha256 6a3dc7206ba425b7f36e8d04b2fd78a1ad53abb7033ba5a39656071591b33a34
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/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 01a726b..297925f 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/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');
page integrity · built 2026-06-07
- md5 14af06bd5893feb70229dded7e6c5497
- sha256 e603642628f264fa919f55be33e6578d77d2d42858566c2d708beefce091aed5
+ md5 59cd5e54142e3880cfe784df09204e52
+ sha256 9ba89654f5a18538ee2e4ed6d614b8901e1088c4892fcbfc41d0b5a991692517
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