diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 3eb798b..adf4e6b 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -3204,15 +3204,32 @@ 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. */
+ * THIS exact stream object, treat as no-op. */
if (existing && existing.stream === stream){
logLine('', 'audio attach no-op uuid='+uuid.slice(0,4)+' — same stream already attached');
return true;
}
+ /* IN-PLACE SOURCE SWAP for different stream on same uuid. The SFU
+ * forwards stale audio transceivers across publisher rejoins — each
+ * ontrack delivers a NEW MediaStream object for the same publisher.
+ * Tearing down the chain and rebuilding leaves the old gain briefly
+ * connected to destination while the new chain is being wired — and
+ * when multiple paths race (peer-joined → flushSfuStreams, sub PC
+ * ontrack, mesh ontrack, mid-spotlight supplant), the rebuilt chains
+ * overlap audibly. fxhp-android-firefox 2026-06-05: "hearing two,
+ * one perfectly synced other is not." That's exactly two
+ * destination-connected gains playing the same source out of phase.
+ *
+ * Instead reuse the existing worklet+gain+destination chain and swap
+ * only the MediaStreamSource — the path setWorkletStream already uses
+ * for mesh ontrack. Single chain per uuid forever. */
if (existing){
+ if (setWorkletStream(uuid, stream)){
+ logLine('', 'audio attach in-place swap uuid='+uuid.slice(0,4));
+ return true;
+ }
+ /* setWorkletStream failed (no audioCtx? no jbuf yet?). Fall
+ * through to the legacy teardown+rebuild as last resort. */
try { existing.src.disconnect(); } catch(_){}
try { if (existing.jbuf) existing.jbuf.disconnect(); } catch(_){}
try { if (existing.capture) existing.capture.disconnect(); } catch(_){}
@@ -7822,8 +7839,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');