diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 9d23ccb..9fea2d1 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -2965,9 +2965,35 @@ function attachAudioStreamViaWorklet(uuid, stream, targetSeconds){
if (existing){
try { existing.src.disconnect(); } catch(_){}
try { if (existing.jbuf) existing.jbuf.disconnect(); } catch(_){}
+ try { if (existing.capture) existing.capture.disconnect(); } catch(_){}
+ try { if (existing.captureLpf) existing.captureLpf.disconnect(); } catch(_){}
try { existing.gain.disconnect(); } catch(_){}
listenerAudioNodes.delete(uuid);
}
+ /* DEDUP by publisher pubkey: if a different uuid in
+ * listenerAudioNodes belongs to the SAME publisher (e.g. they
+ * rejoined with a new session uuid before the old session's
+ * peer-left fired), detach the older entry. Two worklets+gains
+ * connected to destination = host audio played twice. Fox
+ * 2026-06-05: "fxhp-android-firefox is playing host audio
+ * twice." */
+ try {
+ const mm = members.get(uuid);
+ if (mm && mm.pubkey){
+ const myPubHex = hex(unb64(mm.pubkey));
+ for (const [otherUuid] of [...listenerAudioNodes]){
+ if (otherUuid === uuid) continue;
+ const otherMm = members.get(otherUuid);
+ if (!otherMm || !otherMm.pubkey) continue;
+ try {
+ if (hex(unb64(otherMm.pubkey)) === myPubHex){
+ logLine('', 'audio dedup: detaching stale uuid='+otherUuid.slice(0,4)+' for pub='+myPubHex.slice(0,4));
+ detachListenerStream(otherUuid);
+ }
+ } catch(_){}
+ }
+ }
+ } catch(_){}
let src;
try { src = audioCtx.createMediaStreamSource(stream); }
catch(e){ logLine('err','createMediaStreamSource '+uuid.slice(0,4)+': '+e.message); return false; }
@@ -7477,8 +7503,8 @@ logLine('', 'ready โ pick a handle, type a rendezvous code, enter the space');