diff --git a/flow-state/debug.html b/flow-state/debug.html new file mode 100644 index 0000000..e2175a2 --- /dev/null +++ b/flow-state/debug.html @@ -0,0 +1,183 @@ + + +
+ ++ Everything here answers a question the app itself cannot. The app shows you + one video; these show you whether the generator is doing its job across + many. +
+ +Every visualizer, six times, on six different songs' content — cast, + ink, lattice, palette and parameters all varying. Sorted least-varied + first, so the scenes that always look the same come to the top.
+ +Drop a track and watch it. test/songs/*.wav holds the
+ synthetic bank if you want something with known properties.
Every acceptance gate in PLAN.md, run for real. This is
+ the one to check before committing.
The whole per-scene battery for one visualizer: renders, animates, + deterministic, distinct, param sweep, flash rate, and every declared + trait and artifact.
+ ++ These measure whether two videos differ, which no other gate can see — a + generator that ignores its input passes determinism and liveness perfectly. + Read the caution below before trusting a single run. +
+Identity against container, measured apart: hold the scene fixed and + vary the song's content, then the reverse. The most + trustworthy number here — it compares renders directly, and + its noise floor is exactly zero.
+ +Twelve songs from the bank, each with its own audio-derived seed. Also + reports coupling — whether songs that sound different come + out looking different, which is still unsolved.
+ +Three pools compared over repeated runs. The template for any A/B + here: it prints the difference against its own noise band and says + outright when the two are indistinguishable.
+ +How many scenes a track should draw on. Swept rather than guessed — + and the answer turned out to be that it does not matter between 4 and + 32.
+ +Which scenes draw the song's content, which still draw their own, and
+ what each remaining one needs. Recipe in MIGRATION.md.
node tools/migration-status.js+
Which visualizers actually get cast, across songs and seeds — and for + the ones that do not, which of the four gates killed them.
+node tools/cast-census.js 12 40+
How far apart the songs' identities are before anything is rendered. + No GPU. Run this before blaming the visualizers.
+node tools/identity-census.js 12+
Rebuild the test songs and verify they still span every feature the + generator reads. Fails loudly if an axis has collapsed.
+npm run build:songs +npm run check:songs+
Determinism grep, schema/shader agreement both ways, and the backtick + check that guards the shader template literals.
+npm run lint:scenes +npm test+
+ This harness has two classes of measurement and only one of them is safe to
+ steer by. Direct render comparisons — the gallery score,
+ decompose, the per-scene gate — have a noise floor of zero and
+ mean what they say. Aggregate ratios — chiefly
+ separation, which divides one small difference by another —
+ swing wildly between runs: three runs of the same measurement gave 0.31,
+ 0.56 and 0.07. Four conclusions were drawn and withdrawn during this epic
+ for exactly that reason. Anything under about 0.01 of spread needs
+ &repeats=3 before it is believed, and a difference that
+ changes sign with the sample is not a difference.
+
+ A module that fails to load takes the whole graph with it: the page renders,
+ every control is present, and nothing is wired to anything. The app carries a
+ boot guard that says so. The usual causes are a content blocker — a file
+ named like tracking will be treated as tracking, which cost this project a
+ session over a file called clicktrack.js — or a dev server
+ caught mid-restart.
+
+ Every visualizer rendered six times, once per song, each with that song's + complete identity — its cast, ink, lattice, palette and sampled parameters. + A scene whose six frames are interchangeable is one the song cannot change. + Sorted least-varied first, scored on the same structural descriptor the + variety harness uses, with colour excluded so six palettes cannot disguise + one image. ← all debug tools +
+ + + + + + + + diff --git a/flow-state/src/checks/gallery.js b/flow-state/src/checks/gallery.js new file mode 100644 index 0000000..da0d487 --- /dev/null +++ b/flow-state/src/checks/gallery.js @@ -0,0 +1,156 @@ +// The gallery: every visualizer, six times, on six different songs' content. +// +// The complaint this answers is one a metric could not raise — watching a few +// videos, certain scenes announce themselves. You have seen that one before. +// You cannot tell which ones from a number, because the harness measures whole +// videos and a scene that always looks like itself is averaged in with +// everything around it. +// +// So this renders each scene under six complete identities — six casts, six ink +// treatments, six lattices, six palettes, six parameter draws — and puts them +// side by side. A scene whose six thumbnails are interchangeable is a scene the +// song cannot change, and that is the definition of the problem. +// +// Then it scores them, using the same structural descriptor the variety harness +// runs on, so the gallery can be sorted worst-first. Browsing sixty-six scenes +// looking for the repetitive ones is exactly the job a sort order should do. + +import { Engine } from '../engine/Engine.js'; +import { scenes } from '../scenes/registry.js'; +import { sampleValues } from '../params/schema.js'; +import { Rng, hashString } from '../engine/rng.js'; +import { featureProviderFor } from '../audio/FeatureTrack.js'; +import { songBank } from '../audio/songbank.js'; +import { generateLook } from '../look/LookGenerator.js'; +import { describeIdentity } from '../look/Identity.js'; +import { frameDescriptor } from './variety/descriptors.js'; +import { descriptorDistance, STRUCTURAL } from './variety/signature.js'; + +const THUMB = { width: 256, height: 144 }; + +/** + * Six contexts, one per song: everything a scene is handed when it is cast. + * + * Built from real bank entries rather than invented, so what the gallery shows + * is what the generator would actually produce — the same identities, palettes + * and section biases, only with the scene held fixed instead of chosen. + */ +export function galleryContexts(count = 6) { + return songBank({ count }).map((entry) => { + const look = generateLook(entry.track, { seed: hashString(entry.name) }); + // The busiest section, because that is where a scene is asked for the + // most and where two scenes are most likely to converge. + const section = look.sections.reduce( + (best, s) => (s.bias.energy > best.bias.energy ? s : best), look.sections[0]); + return { + name: entry.name, + track: entry.track, + palette: look.palette, + personality: look.personality, + bias: section.bias, + frame: section.startFrame + Math.floor((section.endFrame - section.startFrame) * 0.5), + identity: describeIdentity(look.personality.identity), + }; + }); +} + +/** Bottom-up WebGL pixels into a canvas the right way up. */ +function blit(canvas, pixels, width, height) { + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + const image = ctx.createImageData(width, height); + const row = width * 4; + for (let y = 0; y < height; y++) { + const src = (height - 1 - y) * row; + image.data.set(pixels.subarray(src, src + row), y * row); + } + ctx.putImageData(image, 0, 0); +} + +/** + * Render one scene across every context. + * + * @returns {{thumbs: Uint8Array[], variety: number, byBlock: object}} + */ +export function renderScene(engine, module, contexts) { + const thumbs = []; + const descriptors = []; + + for (const ctx of contexts) { + engine.timeline.setDuration(ctx.track.duration); + engine.setFeatureProvider(featureProviderFor(ctx.track)); + + const rng = new Rng(hashString(`${module.name}:${ctx.name}`)); + const params = sampleValues(module, rng, ctx.bias, ctx.personality.temperament); + + engine.setLayerSpecs([{ + module, + params, + seed: rng.int(0, 0x7fffffff), + opacity: 1, + blend: 'normal', + palette: ctx.palette, + personality: ctx.personality, + }]); + engine.compositor.reset(); + // A few frames of warm-up so anything with state is past its first frame. + for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f); + const pixels = Uint8Array.from(engine.readPixels(engine.renderFrame(ctx.frame))); + + thumbs.push(pixels); + descriptors.push(frameDescriptor(pixels, THUMB.width, THUMB.height)); + } + + // How different this scene's six outputs are from each other, on the same + // structural descriptor the variety harness uses — colour excluded, because + // six palettes would otherwise make every scene look varied. + const byBlock = {}; + let total = 0, pairs = 0; + for (let i = 0; i < descriptors.length; i++) { + for (let j = i + 1; j < descriptors.length; j++) { + const d = descriptorDistance(descriptors[i], descriptors[j]); + for (const b of [...STRUCTURAL, 'colour']) byBlock[b] = (byBlock[b] || 0) + (d[b] || 0); + total += STRUCTURAL.reduce((a, b) => a + (d[b] || 0), 0) / STRUCTURAL.length; + pairs++; + } + } + for (const b of Object.keys(byBlock)) byBlock[b] /= pairs || 1; + + return { thumbs, variety: pairs ? total / pairs : 0, byBlock }; +} + +/** + * Build the whole gallery, reporting progress as it goes. + * + * @param {(done:number, of:number, name:string, row:object) => void} onScene + */ +export async function buildGallery({ contexts, onScene, only = null } = {}) { + const list = only + ? scenes.filter((m) => only.includes(m.name)) + : scenes.filter((m) => m.kind === 'fragment'); + + const engine = new Engine({ ...THUMB }); + const rows = []; + try { + for (let i = 0; i < list.length; i++) { + const module = list[i]; + let row; + try { + row = { module, ...renderScene(engine, module, contexts) }; + } catch (err) { + row = { module, thumbs: [], variety: 0, byBlock: {}, error: err.message }; + } + rows.push(row); + if (onScene) onScene(i + 1, list.length, module.name, row); + // Yield so the page can paint each row as it lands rather than + // freezing for a minute and then showing everything at once. + await new Promise((r) => setTimeout(r, 0)); + } + } finally { + engine.dispose(); + } + return rows; +} + +export { blit, THUMB }; diff --git a/flow-state/vite.config.js b/flow-state/vite.config.js index ed51d1c..226e567 100644 --- a/flow-state/vite.config.js +++ b/flow-state/vite.config.js @@ -11,6 +11,8 @@ export default defineConfig({ input: { main: resolve(process.cwd(), 'index.html'), checks: resolve(process.cwd(), 'checks.html'), + debug: resolve(process.cwd(), 'debug.html'), + gallery: resolve(process.cwd(), 'gallery.html'), }, }, },