From fa0666f7ccae13b3db28a2bface3686c81831044 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Thu, 4 Jun 2026 19:46:24 -0400 Subject: [PATCH] zebra-spaces: deploy Phase 3 adaptive time-stretching --- zebra-report/zebra-spaces.html | 306 ++++++++++++++++++++++----------- 1 file changed, 201 insertions(+), 105 deletions(-) diff --git a/zebra-report/zebra-spaces.html b/zebra-report/zebra-spaces.html index 8848b55..6781985 100644 --- a/zebra-report/zebra-spaces.html +++ b/zebra-report/zebra-spaces.html @@ -1766,6 +1766,24 @@ const listenerAudioNodes = new Map(); /* uuid -> { src, gain, jbuf?, stream } */ * Loaded as a Blob URL because this is a single-file app — no * separate JS file shipped. */ const JITTER_BUFFER_WORKLET_CODE = ` +/* Adaptive jitter buffer with variable playback rate (Phase 3 of Double + * Dragon). The buffer's target depth is set from JS; the worklet + * automatically tunes its INTERNAL playback rate to drive the actual + * buffer level toward target. + * + * - buffered < target → stretchFactor > 1.0 (slow playback → buffer grows) + * - buffered > target → stretchFactor < 1.0 (fast playback → buffer shrinks) + * - buffered ≈ target → stretchFactor = 1.0 (normal) + * + * Rate change is capped at ±8% and ramped smoothly per-block so the + * pitch shift during adaptation is ≤1 semitone, brief, and centred. + * Listeners can disable stretching entirely via the 'lock_rate' command + * so music never gets resampled. + * + * Resampling: linear interpolation between adjacent input samples. Pure + * pitch-preserving WSOLA grain processing is ~10x more code; ship this + * first and upgrade if the brief pitch shift is audible enough to + * matter. */ class JitterBufferProcessor extends AudioWorkletProcessor { constructor(opts){ super(); @@ -1774,18 +1792,16 @@ class JitterBufferProcessor extends AudioWorkletProcessor { this.maxSeconds = o.maxSeconds || (this.targetSeconds * 1.5); this.targetSamples = Math.round(this.targetSeconds * sampleRate); this.maxSamples = Math.round(this.maxSeconds * sampleRate); - /* re-arm only after this many consecutive empty blocks. 128 samples - * per block at 48 kHz = 2.67 ms; 100 blocks ≈ 267 ms of silence. - * brief upstream drains (a single empty process() tick) MUST NOT - * tear down playback, or a 4s re-buffer kicks in every time — - * which is what made the phone choppy. */ this.rearmThresholdBlocks = 100; - /* periodic buffer-depth report so JS can drive the lip-sync - * algorithm (video.playoutDelayHint must match audio total delay - * including this worklet's cushion, else mouths move ~4s ahead - * of the words on listeners). 256 blocks * 128 samples / 48000Hz - * ≈ 683ms — frequent enough to track real changes, sparse enough - * to not flood the port. */ + /* Variable-rate playback state. cursor is the fractional sample + * index into the head of the queue. stretchFactor controls how + * fast the cursor advances per output sample. */ + this.cursor = 0; + this.stretchFactor = 1.0; + this.targetStretch = 1.0; + this.stretchRampPerBlock = 0.0002; /* ~0.075/sec ramp */ + this.maxStretch = 1.08; /* cap rate at ±8% */ + this.minStretch = 0.92; this.bufferedReportEvery = 256; this.bufferedReportCounter = 0; this.queue = []; @@ -1793,21 +1809,28 @@ class JitterBufferProcessor extends AudioWorkletProcessor { this.started = false; this.emptyStreak = 0; this.dropped = 0; - /* role-change retarget — JS posts {cmd:'retarget', targetSeconds} - * when the user is promoted/demoted; we recompute the sample - * targets and shrink the queue if the new max is smaller. */ this.port.onmessage = (e) => { - if (!e.data || e.data.cmd !== 'retarget') return; - const t = +e.data.targetSeconds; - if (!isFinite(t) || t <= 0) return; - 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){ - const drop = this.queue.shift(); - this.buffered -= drop[0].length; - this.dropped += drop[0].length; + if (!e.data) return; + if (e.data.cmd === 'retarget'){ + const t = +e.data.targetSeconds; + if (!isFinite(t) || t <= 0) return; + 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){ + const drop = this.queue.shift(); + this.buffered -= drop[0].length; + this.dropped += drop[0].length; + } + } else if (e.data.cmd === 'lock_rate'){ + /* listeners get this — explicitly forbid time-stretching so + * music playback stays at exactly 1.0 always. */ + const r = +e.data.rate; + if (isFinite(r) && r > 0){ + this.stretchFactor = this.targetStretch = r; + this.minStretch = this.maxStretch = r; + } } }; } @@ -1816,38 +1839,84 @@ class JitterBufferProcessor extends AudioWorkletProcessor { const outBlk = outputs[0]; if (!outBlk || outBlk.length === 0) return true; const nch = outBlk.length; - /* push the incoming block (must copy — host may reuse the buffer + const blkLen = outBlk[0].length; + /* push the incoming block (copy — host may reuse the buffer * after process() returns) */ if (inBlk && inBlk.length > 0 && inBlk[0] && inBlk[0].length > 0){ const copy = []; for (let c = 0; c < inBlk.length; c++) copy.push(new Float32Array(inBlk[c])); this.queue.push(copy); this.buffered += copy[0].length; - /* overflow guard — drop oldest if clock drift or network surge - * pushes us above the cap */ while (this.buffered > this.maxSamples && this.queue.length > 0){ const drop = this.queue.shift(); this.buffered -= drop[0].length; this.dropped += drop[0].length; } } - /* lock onto the buffer once it fills. Do NOT un-lock on a single - * empty queue tick — that's what made the phone choppy: any - * 2.67ms drain forced a full 4s re-buffer. emptyStreak tracks - * sustained silence and only re-arms after ~267ms. */ + /* Adaptive rate: target stretch from buffer depth ratio. */ + if (this.maxStretch !== this.minStretch){ + const ratio = this.buffered / Math.max(1, this.targetSamples); + if (ratio < 0.9){ + this.targetStretch = this.maxStretch; /* slow → grow */ + } else if (ratio > 1.1){ + this.targetStretch = this.minStretch; /* fast → shrink */ + } else { + this.targetStretch = 1.0; + } + if (this.stretchFactor < this.targetStretch){ + this.stretchFactor = Math.min(this.targetStretch, this.stretchFactor + this.stretchRampPerBlock); + } else if (this.stretchFactor > this.targetStretch){ + this.stretchFactor = Math.max(this.targetStretch, this.stretchFactor - this.stretchRampPerBlock); + } + } + /* lock onto buffer once it fills */ if (!this.started && this.buffered >= this.targetSamples){ this.started = true; - /* notify JS — listener UI stays in "buffering" state until the - * first started message arrives. */ try { this.port.postMessage({ cmd: 'started', targetSeconds: this.targetSeconds }); } catch(_){} } if (this.started && this.queue.length > 0){ - const head = this.queue.shift(); - this.buffered -= head[0].length; + const invStretch = 1.0 / this.stretchFactor; this.emptyStreak = 0; - for (let c = 0; c < nch; c++){ - const srcCh = head[c] || head[0]; /* mono → stereo: dup L→R */ - outBlk[c].set(srcCh.subarray(0, outBlk[c].length)); + /* generate blkLen output samples by reading at fractional cursor + * positions with linear interpolation between adjacent input + * samples. */ + for (let i = 0; i < blkLen; i++){ + const inFloat = this.cursor + i * invStretch; + const inIdx = Math.floor(inFloat); + const frac = inFloat - inIdx; + /* find which block + offset inIdx falls into */ + let offsetInBlock = inIdx; + let blockIdx = -1; + for (let b = 0; b < this.queue.length; b++){ + if (offsetInBlock < this.queue[b][0].length){ blockIdx = b; break; } + offsetInBlock -= this.queue[b][0].length; + } + if (blockIdx < 0){ + for (let c = 0; c < nch; c++) outBlk[c][i] = 0; + continue; + } + for (let c = 0; c < nch; c++){ + const srcCh = this.queue[blockIdx][c] || this.queue[blockIdx][0]; + const s1 = srcCh[offsetInBlock]; + let s2; + if (offsetInBlock + 1 < srcCh.length){ + s2 = srcCh[offsetInBlock + 1]; + } else if (blockIdx + 1 < this.queue.length){ + const nb = this.queue[blockIdx + 1]; + s2 = (nb[c] || nb[0])[0]; + } else { + s2 = s1; + } + outBlk[c][i] = s1 * (1 - frac) + s2 * frac; + } + } + this.cursor += blkLen * invStretch; + /* drop fully-consumed blocks from queue front, bring cursor back + * into the new head block's index space */ + while (this.queue.length > 0 && this.cursor >= this.queue[0][0].length){ + this.cursor -= this.queue[0][0].length; + this.buffered -= this.queue[0][0].length; + this.queue.shift(); } } else { for (let c = 0; c < nch; c++) outBlk[c].fill(0); @@ -1856,12 +1925,17 @@ class JitterBufferProcessor extends AudioWorkletProcessor { if (this.emptyStreak >= this.rearmThresholdBlocks){ this.started = false; this.emptyStreak = 0; + this.cursor = 0; } } } if (++this.bufferedReportCounter >= this.bufferedReportEvery){ this.bufferedReportCounter = 0; - try { this.port.postMessage({ cmd: 'buffered', seconds: this.buffered / sampleRate }); } catch(_){} + try { this.port.postMessage({ + cmd: 'buffered', + seconds: this.buffered / sampleRate, + stretchFactor: this.stretchFactor, + }); } catch(_){} } return true; } @@ -1911,12 +1985,25 @@ function installJitterBuffer(uuid, node){ onWorkletStarted(uuid); } else if (e.data.cmd === 'buffered'){ /* worklet's current buffer depth (seconds). Used by lip-sync - * to make video receivers track the audio's total delay. */ + * to make video receivers track the audio's total delay. + * stretchFactor is reported too — JS controller in Phase 3 + * uses it to drive the per-publisher target. */ const n = listenerAudioNodes.get(uuid); - if (n) n.bufferedSeconds = e.data.seconds; + if (n){ + n.bufferedSeconds = e.data.seconds; + n.stretchFactor = e.data.stretchFactor; + } 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(_){} + } try { node.src.disconnect(node.gain); } catch(_){} node.src.connect(jbuf).connect(node.gain); node.jbuf = jbuf; @@ -2061,49 +2148,53 @@ async function refreshLipSyncForUuid(uuid){ } /* ================================================================== - * Double Dragon — twin-stream auto-engage controller. + * Double Dragon — adaptive time-stretching controller (Phase 3). * * Fox 2026-06-04 framing: "think of it like a CD that is literally - * being skipped in a physical disc man and solve it with two lasers - * one moving fast mesh as fast as possible and one for the broadcast - * also as fast as possible … double headed hydra! double dragon!" + * being skipped in a physical disc man … two lasers one moving fast + * mesh as fast as possible and one for the broadcast also as fast as + * possible dynamic based on … hardware performance and network + * performance and feed performance double headed hydra!" * - * Per-publisher health monitor. Every telemetry tick (5s), we look - * at the active mesh receiver's stats. If the publisher's network or - * encoder is hitting the listener with loss/jitter, auto-engage the - * HTTP /stream path (deeper buffer, glitch-free, ~2.5s behind). When - * the publisher's mesh path is clean for sustained time, auto- - * disengage and return to low-latency mesh. + * Per-publisher controller. Every 5s telemetry tick, we look at the + * active audio receiver's stats. If the publisher's network/encoder + * is hitting the listener with loss/jitter, we GROW the worklet's + * target buffer depth from DD_BASE_TARGET (0.5s, conversational) up + * toward DD_MAX_TARGET (4s, full wiggle cushion). The worklet's + * built-in adaptive resampling (±8% rate cap) slowly stretches + * playback to grow the actual buffer toward the new target without + * audible click or gap — pitch shifts by ≤1 semitone during the + * adaptation, returns to normal once the target is reached. + * + * When the publisher's mesh path is clean for sustained time, we + * SHRINK the target back to DD_BASE_TARGET. Worklet compresses + * playback (≤8% faster) to shrink the buffer. * * State per pubHex: - * 'mesh' — listening via worklet (mesh source), HTTP off - * 'http' — auto-engaged HTTP, worklet muted - * 'manual-on' — user toggled HTTP manually; we don't touch it - * 'manual-off' — user toggled HTTP off; we don't auto-engage + * 'auto' — controller-managed; target adjusts with stats + * 'manual-on' — user toggled HTTP /stream; controller hands off + * 'manual-off' — user toggled HTTP off; controller hands off * - * Transitions (auto only): - * mesh → http: 2 consecutive samples show instability - * http → mesh: 6 consecutive clean samples (~30s) + * Manual state is sticky — clicking the toggle overrides the + * controller until the user leaves + re-enters. * - * Manual state is sticky — if the user clicks the toggle, that - * intent overrides the controller until they leave + re-enter. - * - * The switch is audibly a ~2.5s time-jump (mesh latency vs HTTP - * latency). Listener hears past content briefly. Phase 3 (deferred) - * does sample-aligned dual-decode via cross-correlation to remove - * the jump. */ + * Phase 4 (deferred): grain-based PSOLA/WSOLA pitch-preservation in + * the worklet to remove the ≤1 semitone shift during adaptation. */ const doubleDragon = new Map(); /* pubHex → state */ const DD_LOSS_PER_SEC_THRESH = 2; /* > 2 loss/sec = unstable */ 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 */ function ddEntry(pubHex){ let e = doubleDragon.get(pubHex); if (!e){ - e = { state: 'mesh', unstableStreak: 0, cleanStreak: 0, - lastLost: 0, lastSeenStatsAt: 0 }; + e = { state: 'auto', unstableStreak: 0, cleanStreak: 0, + lastLost: 0, lastSeenStatsAt: 0, + currentTargetSec: DD_BASE_TARGET_SEC }; doubleDragon.set(pubHex, e); } return e; @@ -2115,6 +2206,26 @@ function ddNoteManualToggle(pubHex, isOn){ logLine('', 'double-dragon pub='+pubHex.slice(0,4)+' → '+e.state+' (manual)'); } +/* Find the listener-audio node uuid for a publisher pubHex and post a + * retarget message to its worklet. The worklet's built-in rate-control + * loop will then stretch playback to drive its actual buffer toward + * the new target — no audible click, just a gradual time-stretch over + * ~5-10 seconds. */ +function ddSetTargetForPub(pubHex, targetSec){ + for (const [u, mm] of members){ + try { + if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ + const node = listenerAudioNodes.get(u); + if (node && node.jbuf && node.jbuf.port){ + node.targetSeconds = targetSec; + try { node.jbuf.port.postMessage({ cmd: 'retarget', targetSeconds: targetSec }); } catch(_){} + } + break; + } + } catch(_){} + } +} + async function evaluateDoubleDragonForPub(pubHex){ const e = doubleDragon.get(pubHex) || ddEntry(pubHex); /* manual state: hands off */ @@ -2125,14 +2236,6 @@ async function evaluateDoubleDragonForPub(pubHex){ const ls = lipSync.get(pubHex); const audRx = (ls && ls.audioReceiver) || sfuAudioReceivers.get(pubHex); if (!audRx) return; - /* find the uuid for the publisher (needed for start/stopStream) */ - let uuid = null; - for (const [u, mm] of members){ - try { - if (mm.pubkey && hex(unb64(mm.pubkey)) === pubHex){ uuid = u; break; } - } catch(_){} - } - if (!uuid) return; /* read stats */ let lossRate = 0, jitter = 0; try { @@ -2155,24 +2258,23 @@ async function evaluateDoubleDragonForPub(pubHex){ (jitter > DD_JITTER_THRESH_SEC); if (unstable){ e.unstableStreak++; e.cleanStreak = 0; - if (e.state === 'mesh' && e.unstableStreak >= DD_UNSTABLE_SAMPLES_NEEDED){ - e.state = 'http'; + if (e.unstableStreak >= DD_UNSTABLE_SAMPLES_NEEDED && + e.currentTargetSec < DD_MAX_TARGET_SEC){ + e.currentTargetSec = DD_MAX_TARGET_SEC; + ddSetTargetForPub(pubHex, DD_MAX_TARGET_SEC); logLine('', 'double-dragon pub='+pubHex.slice(0,4)+ - ' AUTO-ENGAGE HTTP (loss/s='+lossRate.toFixed(1)+ - ' jitter='+(jitter*1000).toFixed(0)+'ms)'); - streamMode.add(pubHex); - try { await startStream(uuid, pubHex); } catch(_){} - try { renderRoom(); } catch(_){} + ' GROW target → '+DD_MAX_TARGET_SEC+'s (loss/s='+ + lossRate.toFixed(1)+' jitter='+(jitter*1000).toFixed(0)+'ms)'); } } else { e.cleanStreak++; e.unstableStreak = 0; - if (e.state === 'http' && e.cleanStreak >= DD_CLEAN_SAMPLES_NEEDED){ - e.state = 'mesh'; + 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); logLine('', 'double-dragon pub='+pubHex.slice(0,4)+ - ' AUTO-DISENGAGE (clean '+e.cleanStreak+' samples)'); - streamMode.delete(pubHex); - try { stopStream(uuid); } catch(_){} - try { renderRoom(); } catch(_){} + ' SHRINK target → '+DD_BASE_TARGET_SEC+'s (clean '+ + e.cleanStreak+' samples)'); } } } @@ -2487,20 +2589,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 cushion — empirically validated at 4s for - * 20-second wiggle absorption. Previous 0.5s was conversation-friendly - * but cohost-on-Fedora-Chrome glitched during host's X11 wiggles; - * smaller buffers untested. 4s pays a conversation-latency cost but - * gives same wiggle-immunity as listeners. Fox 2026-06-04: "we needed - * 4 secs before for the wiggle. it was at least 4 secs for 20 sec - * wiggles; we didn't test less." - * - * Future: twin-stream double-dragon controller (mesh + HTTP /stream - * running in parallel, adaptive switching based on mesh loss/jitter - * stats + hardware/network/feed performance). Until that lands, this - * single-source buffer is the floor that keeps everyone glitch-free - * under the most common stalls. */ -const SPEAKER_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) + * during sustained clean periods. The worklet's ±8% 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; /* 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 @@ -6694,8 +6790,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');