Phase 4: arc driver ("C" brain)

Three timescales now stack: per-frame reactivity, per-section seeded LFO
drift, and whole-song scene changes with lookahead. Layer instances are
cached per section and reused across crossfades — rebuilding them per frame
would recompile shaders every transition.

Crossfades run forward from a boundary: the outgoing scene holds while the
incoming one fades in over it.

Three real bugs, each found by a check that had to be rewritten first:

1. A pop exactly at every transition. buildSlope is discontinuous by
   construction (~1 before a boundary, 0 after), and the outgoing layer is
   still on screen when it flips — collapsing its lookahead ramp in one
   frame. It now holds the slope it had entering the boundary.

2. FeatureTrack.at() returns a REUSED row object, and _boundarySlope()
   called at() again mid-render, rewriting the features the layer was about
   to read. Symptom: a frame correct on every repeat and wrong the first
   time — invisible to fresh-vs-fresh comparison, and wrong in every export,
   since export renders each frame exactly once. Now indexes the typed array
   directly, with the aliasing hazard documented on at(), and a new check
   covers the whole bug class.

3. Warm-up converged to 1%, leaving a visible 0.015 difference at heavy
   feedback settings. Now targets 0.1%.

Two checks were themselves wrong and were rebuilt: a raw delta threshold
and an outlier-vs-local-median test both flag beat flashes as pops, and a
control window taken from a different scene reads an ordinary busy scene as
a 9x spike. The working formulation A/Bs each boundary against the interior
of the two scenes adjacent to it.

PLAN.md §6 corrected: boundary seeks are NOT exact for free. Layer state is
re-seeded there but the feedback buffer is global and carries across.
Clearing it at boundaries would buy exactness for a visible flash at every
transition; warm-up is the better trade and applies everywhere.

Gate 9/9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-05 11:30:50 +02:00
co-authored by Claude Opus 5
parent 022c267888
commit 5f25437b89
7 changed files with 812 additions and 13 deletions
+9 -1
View File
@@ -18,7 +18,15 @@ export class FeatureTrack {
this._row = {};
}
/** @returns {object} the feature row for a frame, clamped to range. */
/**
* The feature row for a frame, clamped to range.
*
* WARNING: the returned object is REUSED between calls — at() is called every
* frame and allocating for it is pointless. The consequence is that you must
* never call at() again while still holding a previous result, and in
* particular never inside a render pass that is using one. Index the typed
* arrays in `raw`/`tracks` directly for incidental lookups.
*/
at(frame) {
const f = Math.max(0, Math.min(this.frameCount - 1, frame | 0));
const row = this._row;