music-video-gen/flow-state
Dejvino 8f5ff3a239 Side quest 2: give three churning scenes something to develop
Curl Flow, Signal Decay and Circuit Bloom had no parameter that changed their
structure. Each was one process at one scale, statistically identical
everywhere and at every moment — which is the technical statement of "it
looks the same for five minutes". Every pixel moving, the image never
changing.

Each got the large-scale structure it was missing, and the slow axis now has
something real to walk:

- Curl Flow gains a CURRENT: one broad band carries the filaments and the
  rest runs bare. The axis is the band's strength rather than its position,
  and that choice was measured — moving the band preserves the frame's total
  energy, so a ten-second average of it is nearly the same image wherever it
  sits (0.0028 across a full sweep). Opening and closing it changes how much
  of the frame is lit at all. The current gates the final image rather than
  only the vein term, because the feedback trail and the flow tint each fill
  the frame on their own.
- Signal Decay gains a DAMAGE FRONT: lock falls away on one side of a moving
  boundary, so the stack has a shape instead of every lane being an
  independent coin flip. Hiss had to be gated by it too — hiss rises as lock
  falls, so without that a damaged lane simply traded signal for noise and
  carried the same energy, and the front cancelled itself out.
- Circuit Bloom now actually BLOOMS. `reach` was a fixed vignette, so the
  packets ran, the pads blinked and the board never changed. Its range stops
  at 1.6 because the frame's far corner is ~1.4 units out and travel spent
  past that changes nothing — the first attempt wasted most of the axis up
  there.

The gate itself was wrong, and this is the more important half of the commit.
It compared the scene at thirty seconds against the scene at two and a half
minutes and divided by what it did between those points with frozen
parameters. Those are two different places in the song, so the denominator
was full of audio reactivity: it was really asking "does the axis move this
scene MORE than the music does", which no scene should have to pass. Circuit
Bloom failed at 0.92x while its mean luminance moved 5.6x across the axis — a
large structural change scored as nothing.

The experiment now holds time constant and varies only the axis, against a
control that is the same parameters over the next window — the residual churn
the ten-second average failed to cancel, which is the noise floor it has to
beat. With the axis disabled the two parameter sets are identical and the
ratio is 0.00 by construction, so the companion check is a real discriminator
rather than a formality. It reads 0.00x on all six scenes.

Moiré Grid 28.7x · Gate Corridor 14.3x · Truchet Fold 3.3x · Signal Decay
1.9x · Curl Flow 1.7x · Circuit Bloom 1.4x, against a 1.25x bar.

104/104 checks pass including the slow set. Closes SIDE-QUESTS.md §2 and the
open half of EPIC-2.md §3.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 19:51:51 +02:00
..
.claude Make grain a treatment, tempo a governor, and params commit harder 2026-08-06 07:04:27 +02:00
src Side quest 2: give three churning scenes something to develop 2026-08-06 19:51:51 +02:00
test Phase 1: offline audio pipeline 2026-08-05 11:10:01 +02:00
tools Fix export frame loss: reject codec profiles that reorder frames 2026-08-06 13:10:24 +02:00
_audioprobe.html Side quest 1: give four scenes art direction other than grain 2026-08-06 19:35:50 +02:00
_captureprobe.html Side quest 1: give four scenes art direction other than grain 2026-08-06 19:35:50 +02:00
_decodetest.html Side quest 1: give four scenes art direction other than grain 2026-08-06 19:35:50 +02:00
_encprobe.html Side quest 1: give four scenes art direction other than grain 2026-08-06 19:35:50 +02:00
_exporttest.html Side quest 1: give four scenes art direction other than grain 2026-08-06 19:35:50 +02:00
_muxprobe.html Side quest 1: give four scenes art direction other than grain 2026-08-06 19:35:50 +02:00
_playtest.html Side quest 1: give four scenes art direction other than grain 2026-08-06 19:35:50 +02:00
.gitignore Phase 0: determinism spine 2026-08-05 10:59:30 +02:00
checks.html Phase 0: determinism spine 2026-08-05 10:59:30 +02:00
EPIC-2.md Side quest 2: give three churning scenes something to develop 2026-08-06 19:51:51 +02:00
HOWTO-visualizers.md Make grain a treatment, tempo a governor, and params commit harder 2026-08-06 07:04:27 +02:00
index.html Fix export frame loss: reject codec profiles that reorder frames 2026-08-06 13:10:24 +02:00
package-lock.json Phase 6 WIP 2026-08-05 11:56:52 +02:00
package.json Phase 10: variety, twelve scenes, and tooling to write the next one 2026-08-05 23:59:20 +02:00
PLAN.md Make grain a treatment, tempo a governor, and params commit harder 2026-08-06 07:04:27 +02:00
README.md Add OSD with song name 2026-08-05 21:30:46 +02:00
SIDE-QUESTS.md Write up the three side quests Epic 2 left behind 2026-08-06 16:37:12 +02:00
vite.config.js Phase 0: determinism spine 2026-08-05 10:59:30 +02:00

flow-state

Ambient/EDM music video generator. Drop in a track, get a full-length, non-story, music-reactive video. No sourced footage — every frame is generated, and the whole look is derived from the audio.

npm install
npm run dev          # http://localhost:5180

Drop an audio file onto the page (mp3, flac, wav, ogg). Analysis takes a second or two, then the video is ready to preview and export.

How it works

The track is decoded and analysed before the first frame renders, into a table with one row per video frame: band energies, onset flux, spectral centroid and flatness, a phase-locked beat grid, section boundaries, and lookahead fields.

Nothing reads a live AnalyserNode. Realtime preview maps audio.currentTime to a frame index; export counts frames. Both read the same rows, so what you preview is what you export — the exporter has no render path of its own.

Analysing the whole track up front also buys the thing a causal analyser cannot do: a build can anticipate its drop and arrive at the transition already at full tension, instead of reacting once the drop has landed.

Working with it

space play / pause
previous / next section boundary
L loop the current section
D debug HUD
O toggle the corner title plate
, . step one frame

test render exports 20 seconds around the playhead at full export quality. Use it before committing to a full render.

reroll re-seeds the whole track; reroll section changes only the section under the playhead; lock protects a section from further rerolls. Every parameter the generator chose is exposed under the scene tab and can be edited live.

The click track button (look tab) mixes an audible click onto the detected beat grid. If the clicks don't sit on the beat, tempo detection is wrong and everything downstream inherits it — check this first when a track looks off.

Checks

npm test             # audio pipeline against synthetic ground truth
npm run lint:scenes  # determinism grep + scene schema/shader agreement

http://localhost:5180/checks.html runs the GPU gates for every phase. Add ?slow=1 for the full suite, ?phase=5 for one phase.

Adding a scene

Quick walkthrough: HOWTO-visualizers.md.

A scene is a shader plus a params block. Everything else — uniform binding, UI controls, seeded per-track sampling, arc automation — is derived from the schema.

export const myScene = {
    name: 'My Scene',
    family: 'organic',          // flow organic minimal structural geometric glitch
    kind: 'fragment',
    params: {
        density: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_density', bias: 'density' },
        speed:   { type: 'float', range: [0.1, 2], default: 0.5, uniform: 'u_speed', rate: true },
        palette: { type: 'palette', count: 4 },
    },
    reactive: {
        density: { feature: 'bandLow', amount: 0.3 },
    },
    shader: `
vec4 scene(vec2 uv, vec2 p) {
    float t = u_time * u_speed + u_seed;
    return vec4(palRamp(fbm(p * 4.0 + t, 4)), 1.0);
}
`,
};

Register it in src/scenes/registry.js, then run npm run lint:scenes and the Phase 7 checks. Three rules the linter enforces, each of which has already caused a real bug here:

  • Anything multiplying u_time must be rate: true. Phase is elapsed × rate, so modulating a rate jumps the phase by elapsed × delta — a minute in, a small wobble throws the image several whole units between frames. It measured as strobing at twice the accessibility limit.
  • Don't reuse a contract uniform name (u_width, u_time, u_seed, …). It's a GLSL redefinition error, and the only symptom is a black frame.
  • Use pal() / palRamp(), not hardcoded colours, or the look generator can't recolour the scene.

Scenes that composite over a background rather than being one declare role: 'accent'.

Layout

src/
  audio/      decode, STFT analysis, tempo, segmentation, FeatureTrack, click track
  engine/     Timeline, Renderer, Layer, Compositor, passes, seeded rng, flash safety
  look/       palette (OKLCH), LookGenerator, ArcDriver
  params/     declarative schema, validation, serialisation
  scenes/     the library — shader/ and layers3d/
  export/     WebCodecs exporter
  ui/         preview surface
  checks/     phase gates, run from checks.html

PLAN.md has the full design and the reasoning behind each gate.

Forked from party-stage by copying what was useful, then fully detached — there are no imports across the directory boundary in either direction.