music-video-gen/flow-state
Dejvino 75e0f0ef01 Make the whole library reachable, and measure what that did and did not buy
Three changes, from the list the measurements pointed at.

The personality's values now centre on the audio and the seed picks within that
centre, rather than every value being drawn from the seed alone. A bright track
sits high in its frame, a dynamic one has depth, noise earns corners where tonal
material stays round. The signature's own choice is tilted by the music too — it
decides which scenes a track can cast, so leaving it to the seed meant the most
consequential decision in the generator had no relationship to the song.

The signature stopped being a hard filter. As a filter it was the single largest
cause of sameness: a scene declaring all four traits was eligible for every track
and opened half of all videos, one declaring two was eligible for one track in
fourteen, and eleven scenes out of sixty-one carried nearly everything. It is a
weight now, worth six times at full honour.

That fix alone made things worse, which is worth recording. The filter was doing
two jobs — collapsing the library, and giving each track a DIFFERENT pool to cast
from — and removing it kept the second loss. Every track drew from the same
weighted library and measured song separation fell. So each track now draws its
own pool of about a third of the library, weighted by the signature but sampled
without replacement, keeping the differentiation and dropping the bias.

Motion became a character rather than a rate. Tempo was the only lever and tempo
compresses; stillness is a separate question from speed, and it can now halve the
animation rate or raise it by a third.

What it bought, measured against the same instrument: every scene in the library
is now cast, where sixteen were never reached; identical casts across seeds went
from four pairs to none; and the raw structural distances all rose — motion by
57%, layout by 22%, scale by 23%.

What it did not buy: videos also became more varied INTERNALLY, by more than they
became different from each other. Two songs are no more distinguishable relative
to how much one video already changes over its own length than before, and the
coupling between musical distance and visual distance is still indistinguishable
from noise at this sample size. The ceiling reference now sits below real
outputs, so the separation ratio is reported as not computable rather than as a
large number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 20:12:30 +02:00
..
.claude Make grain a treatment, tempo a governor, and params commit harder 2026-08-06 07:04:27 +02:00
src Make the whole library reachable, and measure what that did and did not buy 2026-08-17 20:12:30 +02:00
test Phase 1: offline audio pipeline 2026-08-05 11:10:01 +02:00
tools Give the sections shapes, and the songs an opinion about their own edges 2026-08-17 07:43:59 +02:00
.gitignore Finish the song bank: axis ranges that are real, and a collinearity check 2026-08-17 07:03:02 +02:00
checks.html Phase 0: determinism spine 2026-08-05 10:59:30 +02:00
EPIC-2.md Epic 2.5.2: make the framing actually reach the image 2026-08-06 20:18:08 +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
LIBRARY-20.md Close out the twenty: what the gates taught 2026-08-17 07:00:45 +02:00
package-lock.json Phase 6 WIP 2026-08-05 11:56:52 +02:00
package.json Finish the song bank: axis ranges that are real, and a collinearity check 2026-08-17 07:03:02 +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 A seed variety test, and a metric that had to be gated before it was believed 2026-08-17 06:40:04 +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.

Seed variety test

Every other phase asks whether one video is correct. Phase 12 asks whether two videos are different — the one failure the rest of the suite cannot see, since a generator that ignores its seed passes determinism and liveness perfectly.

http://localhost:5180/checks.html?variety=1

Frames are reduced to a structural descriptor that is deliberately blind to brightness, contrast, hue and rotation, and sensitive to feature scale, orientation structure, composition, texture statistics and motion. Colour is measured but never counted — its job is to expose the case where two seeds differ only in palette. The score sits between two references the same instrument produced: the floor is how far one video travels from itself across its own sections, the ceiling is the same pipeline with every layer recast at random. Two checks gate the instrument itself before any number from it is trusted.

The report also sweeps all visualizations in the library, every pair, and names the structural twins — different scenes that are the same look in different colours, which the per-scene distinct gate cannot catch because it compares raw pixels.

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.