diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 10af972..37fc6f5 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -1975,6 +1975,7 @@ function resetListenerBufferReady(){
* the new median differs by more than LIP_SYNC_THRESHOLD from the
* last applied value. */
const lipSync = new Map(); /* pubHex → state */
+const sfuAudioReceivers = new Map(); /* pubHex → RTCRtpReceiver (SFU audio) — cached so we can restore as the lip-sync source after mesh fail */
const LIP_SYNC_HISTORY = 5;
const LIP_SYNC_THRESHOLD = 0.05; /* 50ms — below this is within perception noise */
@@ -2119,21 +2120,44 @@ function detachListenerStream(uuid){
listenerAudioNodes.delete(uuid);
}
-/* Smooth crossfade for the SFU worklet path. The worklet stays running
+/* In-place stream swap on an existing worklet. Both the SFU stream
+ * and any mesh stream from the same publisher contain the same
+ * encoded content at slightly different network delays — so when we
+ * disconnect the old source from the worklet and connect a new one,
+ * the worklet's queued samples continue emitting seamlessly while
+ * the new source fills the queue. No buffer drain, no audible gap.
+ *
+ * This is how we make a mesh peer's audio benefit from the worklet's
+ * cushion: route mesh through the same worklet the SFU was using, so
+ * the 0.5s buffer absorbs the same wiggle-glitches it does for SFU.
+ * Fox 2026-06-04: "the cohost on fedora chrome has the wiggle issue
+ * when host is messing with tabs … can we sync the other stream and
+ * recover the glitches assuming it is still buffering where the low-
+ * latency version is glitched."
+ *
+ * Returns true on successful swap, false if no existing worklet. */
+function setWorkletStream(uuid, newStream){
+ const node = listenerAudioNodes.get(uuid);
+ if (!node || !audioCtx) return false;
+ let newSrc;
+ try { newSrc = audioCtx.createMediaStreamSource(newStream); }
+ catch(e){ logLine('err', 'setWorkletStream src '+uuid.slice(0,4)+': '+e.message); return false; }
+ try { node.src.disconnect(); } catch(_){}
+ if (node.jbuf) newSrc.connect(node.jbuf);
+ else newSrc.connect(node.gain);
+ node.src = newSrc;
+ node.stream = newStream;
+ logLine('', 'worklet source swapped for '+uuid.slice(0,4));
+ return true;
+}
+
+/* Smooth crossfade for the worklet path. The worklet stays running
* (continues decoding samples at small CPU cost); only its GainNode
- * value moves. We use Web Audio's linearRampToValueAtTime for click-
- * free transitions — settings audio.volume directly causes audible
- * zipper noise on Android.
+ * value moves. Used by the HTTP /stream toggle to cleanly cut the
+ * worklet while the HTTP