diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index 8ba8adc..d0d8787 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -3036,7 +3036,7 @@ const DD_JITTER_THRESH_SEC = 0.030; /* > 30ms = unstable */
const DD_UNSTABLE_SAMPLES_NEEDED = 2;
const DD_CLEAN_SAMPLES_NEEDED = 6; /* 6 × 5s = 30s clean → recover */
const DD_TELEMETRY_TICK_SEC = 5;
-const DD_BASE_TARGET_SEC = 0.5; /* speaker minimum on stable feed */
+const DD_BASE_TARGET_SEC = 0.7; /* speaker minimum on stable feed */
const DD_BASE_LISTENER_TARGET_SEC = 1.3; /* listener minimum on stable high-quality feed — fox 2026-06-05 */
const DD_MAX_TARGET_SEC = 4.0; /* maximum cushion under instability — same ceiling for everyone */
/* Self-calibrating floor: after a wiggle, the floor for THAT publisher
@@ -3675,14 +3675,14 @@ const VIDEO_REMOVE_MUTE_WINDOW_SCREEN_MS = 120000;
* out wiggle-stalls without a glitch. Big enough to survive any
* realistic publisher-side hiccup. */
const RECV_PLAYOUT_DELAY_SEC = 4.0;
-/* Speaker/cohost/host INITIAL cushion — 0.5s for conversational
- * latency, then the Double Dragon adaptive controller grows it to
- * DD_MAX_TARGET_SEC (4s) when the publisher's network or encoder
- * shows instability and shrinks it back to DD_BASE_TARGET_SEC (0.5s)
+/* Speaker/cohost/host INITIAL cushion — 0.7s. Enough to absorb a
+ * 200ms host wiggle through SFU's worklet unaided. Double Dragon
+ * adaptive controller grows it to DD_MAX_TARGET_SEC (4s) under
+ * publisher instability and shrinks it back to DD_BASE_TARGET_SEC
* during sustained clean periods. The worklet's ±6% rate-limited
* resampling makes the transition smooth: brief ≤1 semitone pitch
* shift during the ramp, silence-free, click-free. */
-const SPEAKER_PLAYOUT_DELAY_SEC = 0.5;
+const SPEAKER_PLAYOUT_DELAY_SEC = 0.7;
/* HTTP /stream estimated end-to-end delay used for lip-sync when the
* per-speaker stream toggle is ON for a publisher. The actual delay
* varies (~1-3s depending on browser buffer + network), so this is a
@@ -4893,24 +4893,8 @@ function handleRemoteSfuTrack(ev){
registerLipSyncAudio(pubHex, uuid, ev.receiver);
sfuAudioReceivers.set(pubHex, ev.receiver);
}
- /* speakers get their peers' audio via mesh (lower latency)
- * AT THE TIMES THE MESH PC IS CONNECTED. A stale or failing
- * mesh PC must NOT block the SFU fallback — that's how the
- * 'phone re-promoted but nobody hears them' regression
- * appeared: the peers map still had an entry whose state
- * was 'failed', so we skipped SFU and the receiver got no
- * audio at all. */
- if (canSpeak(myRole)){
- const meshPC = peers.get(uuid);
- const meshState = meshPC ? meshPC.connectionState : 'none';
- if (meshPC && meshState === 'connected'){
- logLine('', 'sfu mic skipped for '+uuid+' — mesh peer connected');
- return;
- }
- if (meshPC){
- logLine('', 'sfu mic taking over for '+uuid+' — mesh state='+meshState);
- }
- }
+ /* SFU is the only receive-audio path for every role,
+ * including speakers. Mesh PCs carry our outbound mic only. */
attachSfuTrack(uuid, s);
return;
}
@@ -6769,93 +6753,10 @@ async function connectToPeer(uuid, weOffer){
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
pc.ontrack = (ev) => {
- /* SFU and mesh both feed the SAME worklet now. The SFU stream
- * was already routed there by attachSfuTrack; we swap in the
- * mesh stream so the worklet's buffer absorbs wiggle-glitches
- * for mesh too. Without this, mesh's native ~50ms buffer was no
- * cushion at all — a 200ms host stall caused mesh listeners to
- * glitch while SFU listeners didn't. Now both share the worklet
- * 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.
- *
- * 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]);
- 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 inbound audio is ignored — the SFU-fed worklet is the
+ * single receive-audio path. The mesh PC carries our outbound
+ * mic only. */
+ logLine('', 'mesh ontrack for '+uuid.slice(0,4)+' — SFU is the receive path');
};
pc.onicecandidate = (ev) => { /* using waitForIceGathering pattern, candidates ignored */ };
pc.onconnectionstatechange = () => {
@@ -6870,28 +6771,6 @@ async function connectToPeer(uuid, weOffer){
const attempts = (peerMeshRetries.get(uuid) || 0) + 1;
peerMeshRetries.set(uuid, attempts);
tearPeer(uuid);
- /* Mesh PC died. Swap the worklet's source back to the cached
- * SFU stream so the user keeps hearing the publisher without a
- * hiccup. The worklet's existing queue covers the swap latency
- * — by the time the queue drains 0.5s of (now-stale) mesh
- * samples, SFU samples are flowing in. attachCachedSfuStreamFor
- * is kept as a fallback for the no-worklet case (AudioContext
- * failed earlier). */
- 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)){
- /* lip-sync: rebind back to the SFU audio receiver. Worklet
- * source is SFU again → video should target SFU's native
- * jbuf + worklet (~0.5s + 0.5s ≈ 1s) instead of mesh's. */
- const sfuRx = pubHex ? sfuAudioReceivers.get(pubHex) : null;
- if (sfuRx) registerLipSyncAudio(pubHex, uuid, sfuRx);
- logLine('', 'mesh failed → worklet swapped back to SFU stream for '+uuid.slice(0,4));
- } else {
- attachCachedSfuStreamFor(uuid);
- }
- } catch(_){ try { attachCachedSfuStreamFor(uuid); } catch(_){} }
if (attempts >= PEER_MESH_MAX_RETRIES){
peerMeshGiveUp.add(uuid);
@@ -8213,8 +8092,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');