diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 8c48c75..3eb798b 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -3180,6 +3180,20 @@ function applyLipSyncForPub(pubHex){
* applied only when role==='listener' — speakers don't need lock-
* screen transport controls. */
function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){
+ /* Reject streams that have no live audio tracks. flushSfuStreams hands
+ * us whatever sfuStreamsByPubHex cached for the publisher's pubkey —
+ * after a publisher leaves and rejoins with the SAME pubkey, the old
+ * cached stream's tracks are ended even though the cache entry still
+ * exists. Without this guard, attach creates a silent MediaStreamSource
+ * → fallback to path → double-audio when the real ontrack
+ * arrives moments later. Fox 2026-06-05 telemetry: "host hearing two"
+ * after peer-joined u=64b0 fired three attaches in 1s. */
+ const liveAudio = (stream && stream.getAudioTracks ? stream.getAudioTracks() : [])
+ .filter(t => t.readyState === 'live');
+ if (liveAudio.length === 0){
+ logLine('', 'audio attach skip uuid='+uuid.slice(0,4)+' — no live audio tracks');
+ return false;
+ }
if (!audioCtx){
try { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
catch(e){ logLine('err','audioCtx create: '+e.message); return false; }
@@ -3189,6 +3203,15 @@ function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){
}
logLine('', 'audio attach uuid='+uuid.slice(0,4)+' target='+targetSeconds+'s (current pool='+listenerAudioNodes.size+')');
const existing = listenerAudioNodes.get(uuid);
+ /* IDEMPOTENT: if we already have a chain for this uuid AND it points at
+ * THIS exact stream object, treat as no-op. Multiple paths (peer-joined
+ * → flushSfuStreams, sub PC ontrack, mid-spotlight supplant) can each
+ * race to attach the same stream within the same tick — without this,
+ * each rebuilds the chain and two of them overlap audibly. */
+ if (existing && existing.stream === stream){
+ logLine('', 'audio attach no-op uuid='+uuid.slice(0,4)+' — same stream already attached');
+ return true;
+ }
if (existing){
try { existing.src.disconnect(); } catch(_){}
try { if (existing.jbuf) existing.jbuf.disconnect(); } catch(_){}
@@ -4644,8 +4667,21 @@ function handleRemoteSfuTrack(ev){
try { if (ev.receiver) ev.receiver.playoutDelayHint = playoutDelayForRole(myRole); } catch(_){}
try { if (ev.receiver) ev.receiver.jitterBufferTarget = playoutDelayForRole(myRole) * 1000; } catch(_){}
/* cache by full pubkey (already resolved above) so it survives the
- * member's session uuid changing across leave/rejoin */
+ * member's session uuid changing across leave/rejoin. When the cached
+ * track ends (publisher leaves), drop the cache entry — flushSfuStreams
+ * 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]);
+ try {
+ ev.track.addEventListener('ended', () => {
+ if (sfuStreamsByPubHex.get(pubHex) === ev.streams[0]){
+ sfuStreamsByPubHex.delete(pubHex);
+ logLine('', 'sfu cache drop pub='+pubHex.slice(0,4)+' — track ended');
+ }
+ }, { once: true });
+ } catch(_){}
for (const [uuid, mm] of members){
try {
if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){
@@ -7786,8 +7822,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
page integrity · built 2026-06-05
- md5 00e6a52b14259a52ff7edda1c7617906
- sha256 98d5e1f1fe606f4fe3261ea116278d0a584440bdd91a9a01472b07c07e71d78e
+ md5 69650ff2e837141b237588617887f238
+ sha256 a99232ece059b34d8e726ed246ada3b54c5a9c75d1f1a2daadb1f9cdac72950f
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