From d905146b13ed7c5c3b594ef802f3003cb0d53ac2 Mon Sep 17 00:00:00 2001 From: luciano Date: Fri, 31 Jul 2026 10:53:01 +0200 Subject: [PATCH] Zero-lag beat lock: feed-forward timeline + latency lead The pure exponential follower trailed the beat clock by its time constant (~110ms), which read as the dancer being late on the kick. The clip timeline now advances by itself at the metronome rate (feed-forward, zero structural lag) while a gentler follower only corrects drift, with a 50ms lead compensating beat-detection/display latency and a hard resync when the error exceeds 1.5 beats. Co-Authored-By: Claude Fable 5 --- src/model3d.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/model3d.js b/src/model3d.js index 708c4a5..62b1d4f 100644 --- a/src/model3d.js +++ b/src/model3d.js @@ -595,7 +595,7 @@ class ModelSim { // linear phase, allowed to run past 1 while waiting for the next beat: // motion must never stall (the callers smooth any re-sync jumps) const p = db.last >= 0 ? Math.max(0, (t - db.last)/db.period) : 0; - return { n: db.n, p }; + return { n: db.n, p, period: db.period }; } // Dance director for multi-clip GLBs: the clip timeline is PHASE-LOCKED to @@ -616,17 +616,24 @@ class ModelSim { d.clip = next; d.n0 = bc.n; d.p0 = bc.p; d.sb = 0; d.lastSwitch = t; } const SPB = 0.5*speed; // clip-seconds per music beat - // fluid chase of the beat-locked timeline: absorbs metronome re-syncs - // without the stop-and-go feel of hard phase clamping - const k = 1 - Math.exp(-dt*9); - const tgt = Math.max(0, (bc.n - d.n0) + (bc.p - d.p0)); + // Feed-forward + soft correction: the timeline advances by itself at the + // metronome rate (zero structural lag — a pure follower trailed the beat + // by its time constant), the follower only corrects the drift. LEAD + // compensates beat-detection/display latency. + const LEAD = 0.05; + const rate = 1/bc.period; // beats per second + const k = 1 - Math.exp(-dt*4); + const tgt = Math.max(0, (bc.n - d.n0) + (bc.p - d.p0) + LEAD*rate); + d.sb += dt*rate; d.sb += (tgt - d.sb)*k; + if (Math.abs(tgt - d.sb) > 1.5) d.sb = tgt; // hard resync if way off const cur = this._sampleAnim(this.anims[d.clip], d.sb*SPB); const FADE = 0.45; const f = (t - d.lastSwitch)/FADE; if (f >= 1 || d.prev < 0) return cur; // crossfade with the previous move (same beat-locked, chased timeline) - const ptgt = Math.max(0, (bc.n - d.pn0) + (bc.p - d.pp0)); + const ptgt = Math.max(0, (bc.n - d.pn0) + (bc.p - d.pp0) + LEAD*rate); + d.psb += dt*rate; d.psb += (ptgt - d.psb)*k; const old = this._sampleAnim(this.anims[d.prev], d.psb*SPB); const w = f*f*(3 - 2*f);