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 <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Fable 5 committed 2026-07-31 10:53:01 +02:00
1 parent d17cd42958
commit d905146b13
1 file changed
+13 -6
+13 -6
View File
@@ -595,7 +595,7 @@ class ModelSim {
// linear phase, allowed to run past 1 while waiting for the next beat: // linear phase, allowed to run past 1 while waiting for the next beat:
// motion must never stall (the callers smooth any re-sync jumps) // 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; 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 // 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; 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 const SPB = 0.5*speed; // clip-seconds per music beat
// fluid chase of the beat-locked timeline: absorbs metronome re-syncs // Feed-forward + soft correction: the timeline advances by itself at the
// without the stop-and-go feel of hard phase clamping // metronome rate (zero structural lag — a pure follower trailed the beat
const k = 1 - Math.exp(-dt*9); // by its time constant), the follower only corrects the drift. LEAD
const tgt = Math.max(0, (bc.n - d.n0) + (bc.p - d.p0)); // 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; 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 cur = this._sampleAnim(this.anims[d.clip], d.sb*SPB);
const FADE = 0.45; const FADE = 0.45;
const f = (t - d.lastSwitch)/FADE; const f = (t - d.lastSwitch)/FADE;
if (f >= 1 || d.prev < 0) return cur; if (f >= 1 || d.prev < 0) return cur;
// crossfade with the previous move (same beat-locked, chased timeline) // 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; d.psb += (ptgt - d.psb)*k;
const old = this._sampleAnim(this.anims[d.prev], d.psb*SPB); const old = this._sampleAnim(this.anims[d.prev], d.psb*SPB);
const w = f*f*(3 - 2*f); const w = f*f*(3 - 2*f);