diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html
index bf86062..f5b802f 100644
--- a/zebra-report/zebra-spaces.html
+++ b/zebra-report/zebra-spaces.html
@@ -1878,14 +1878,37 @@ class JitterBufferProcessor extends AudioWorkletProcessor {
if (e.data.cmd === 'retarget'){
const t = +e.data.targetSeconds;
if (!isFinite(t) || t <= 0) return;
+ const prevTargetSamples = this.targetSamples;
this.targetSeconds = t;
this.maxSeconds = t * 1.5;
this.targetSamples = Math.round(this.targetSeconds * sampleRate);
this.maxSamples = Math.round(this.maxSeconds * sampleRate);
- while (this.buffered > this.maxSamples && this.queue.length > 0){
+ /* On a SHRINKING retarget (listener-to-speaker promotion: 4s
+ * → 0.5s), drop down to targetSamples directly instead of
+ * maxSamples. The maxSamples threshold leaves a 1.5× target
+ * overhang that triggers an audible 6%-cap speed-up phase
+ * lasting ~4 seconds — what fox 2026-06-06 called "janky"
+ * during promotion. One hard skip-forward, then normal
+ * playback — no residual adjustment.
+ *
+ * Report the skip back to JS so paired video receivers can
+ * advance the same amount: an audio jump without a matching
+ * video jump = broken lip-sync for the duration of the
+ * native video jbuf's gradual drain. fox 2026-06-06: "if we
+ * skip ahead from whatever listener is at to speaker speed,
+ * we need to make sure the video skips ahead the same amount
+ * or rate to keep the lips synced". */
+ const shrinking = this.targetSamples < prevTargetSamples;
+ const dropTo = shrinking ? this.targetSamples : this.maxSamples;
+ let skipped = 0;
+ while (this.buffered > dropTo && this.queue.length > 0){
const drop = this.queue.shift();
this.buffered -= drop[0].length;
this.dropped += drop[0].length;
+ skipped += drop[0].length;
+ }
+ if (skipped > 0){
+ try { this.port.postMessage({ cmd: 'dropped', samples: skipped }); } catch(_){}
}
} else if (e.data.cmd === 'lock_rate'){
/* listeners get this — explicitly forbid time-stretching so
@@ -2542,6 +2565,43 @@ function installJitterBuffer(uuid, node){
* enough to catch a wiggle within ~2s of it starting,
* vs the 5s tick path which can be 5-10s late. */
ddNoteWorkletBuffered(uuid, e.data.seconds);
+ } else if (e.data.cmd === 'dropped'){
+ /* The worklet just skipped audio forward (shrinking retarget
+ * — listener→speaker promotion drops 4s→0.5s buffer). Drag
+ * the paired video receivers forward by the same amount so
+ * lip-sync survives the jump. fox 2026-06-06: "if we skip
+ * ahead from whatever listener is at to speaker speed, we
+ * need to make sure the video skips ahead the same amount
+ * or rate to keep the lips synced".
+ *
+ * No native "skip ahead" API exists for RTCRtpReceiver, but
+ * jitterBufferTarget is a hard target the browser converges
+ * to. Setting it to 0 forces aggressive frame-drop until the
+ * native video jbuf drains; the next refreshLipSyncForUuid
+ * 'buffered' tick (~2s) restores the proper role-appropriate
+ * target. Net: audio jumps instantly + video jumps almost
+ * instantly (browser frame-drop is fast) = lip-sync stays
+ * within ~tens of ms. */
+ try {
+ const mm = members.get(uuid);
+ const pubHex = mm && mm.pubkey ? hex(unb64(mm.pubkey)) : null;
+ if (pubHex){
+ const ls = lipSync.get(pubHex);
+ if (ls && ls.videoReceivers){
+ for (const [, rx] of ls.videoReceivers){
+ try { rx.jitterBufferTarget = 0; } catch(_){}
+ try { rx.playoutDelayHint = 0; } catch(_){}
+ }
+ /* clear lastApplied so the next refreshLipSyncForUuid
+ * actually re-applies the role's target — without this
+ * the threshold check could see "no significant change"
+ * and leave video stuck at 0. */
+ ls.lastApplied = 0;
+ }
+ }
+ const skippedSec = e.data.samples / (audioCtx ? audioCtx.sampleRate : 48000);
+ logLine('', 'jitter-buffer skipped '+skippedSec.toFixed(2)+'s uuid='+uuid.slice(0,4)+' — video re-targeting');
+ } catch(_){}
}
};
/* All roles now allow time-stretching — listeners benefit too
@@ -7904,8 +7964,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');