diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html
index 6781985..de02a8e 100644
--- a/web/zebra-spaces.html
+++ b/web/zebra-spaces.html
@@ -1996,14 +1996,13 @@ function installJitterBuffer(uuid, node){
refreshLipSyncForUuid(uuid);
}
};
- /* Listener-role worklets never time-stretch — music quality on the
- * deep cushion is paramount, and the buffer drifts gradually with
- * clock drift. Speaker / cohost / host worklets DO stretch
- * adaptively to drive the per-publisher target the Double Dragon
- * controller sets. */
- if (myRole === 'listener'){
- try { jbuf.port.postMessage({ cmd: 'lock_rate', rate: 1.0 }); } catch(_){}
- }
+ /* All roles now allow time-stretching — listeners benefit too
+ * (their 4s cushion shrinks toward 2s when feed is clean, grows
+ * back to 4s on instability). The ±8% rate cap means the brief
+ * pitch shift during adaptation is ≤1 semitone — barely audible
+ * on speech, briefly audible on sustained music tones. Fox
+ * 2026-06-05: "the 4 sec delay doesn't get faster with a better
+ * feed for listeners." */
try { node.src.disconnect(node.gain); } catch(_){}
node.src.connect(jbuf).connect(node.gain);
node.jbuf = jbuf;
@@ -2186,15 +2185,23 @@ 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; /* normal conversational cushion */
-const DD_MAX_TARGET_SEC = 4.0; /* maximum cushion under instability */
+const DD_BASE_TARGET_SEC = 0.5; /* speaker normal conversational cushion */
+const DD_BASE_LISTENER_TARGET_SEC = 2.0; /* listener clean-feed cushion — lower than initial 4s so latency feels less laggy when feed is good; controller grows back to MAX on any instability */
+const DD_MAX_TARGET_SEC = 4.0; /* maximum cushion under instability — same ceiling for everyone */
+function ddBaseTargetForRole(role){
+ return role === 'listener' ? DD_BASE_LISTENER_TARGET_SEC : DD_BASE_TARGET_SEC;
+}
function ddEntry(pubHex){
let e = doubleDragon.get(pubHex);
if (!e){
+ /* Initial currentTargetSec matches what attachAudioStreamViaWorklet
+ * actually set as the worklet's target — playoutDelayForRole(myRole).
+ * Listeners start at 4s (RECV_PLAYOUT_DELAY_SEC), speakers at 0.5s.
+ * Controller shrinks listener toward 2s on sustained clean stats. */
e = { state: 'auto', unstableStreak: 0, cleanStreak: 0,
lastLost: 0, lastSeenStatsAt: 0,
- currentTargetSec: DD_BASE_TARGET_SEC };
+ currentTargetSec: playoutDelayForRole(myRole) };
doubleDragon.set(pubHex, e);
}
return e;
@@ -2254,6 +2261,7 @@ async function evaluateDoubleDragonForPub(pubHex){
}
});
} catch(_){ return; }
+ const baseTarget = ddBaseTargetForRole(myRole);
const unstable = (lossRate > DD_LOSS_PER_SEC_THRESH) ||
(jitter > DD_JITTER_THRESH_SEC);
if (unstable){
@@ -2269,12 +2277,12 @@ async function evaluateDoubleDragonForPub(pubHex){
} else {
e.cleanStreak++; e.unstableStreak = 0;
if (e.cleanStreak >= DD_CLEAN_SAMPLES_NEEDED &&
- e.currentTargetSec > DD_BASE_TARGET_SEC){
- e.currentTargetSec = DD_BASE_TARGET_SEC;
- ddSetTargetForPub(pubHex, DD_BASE_TARGET_SEC);
+ e.currentTargetSec > baseTarget){
+ e.currentTargetSec = baseTarget;
+ ddSetTargetForPub(pubHex, baseTarget);
logLine('', 'double-dragon pub='+pubHex.slice(0,4)+
- ' SHRINK target → '+DD_BASE_TARGET_SEC+'s (clean '+
- e.cleanStreak+' samples)');
+ ' SHRINK target → '+baseTarget+'s (clean '+
+ e.cleanStreak+' samples, role='+myRole+')');
}
}
}
@@ -6789,9 +6797,9 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');