From 91e74ff1675565cd5306da0f12ceaa81349ac76e Mon Sep 17 00:00:00 2001 From: Dejvino Date: Mon, 17 Aug 2026 06:45:42 +0200 Subject: [PATCH] A cast census, and the arrangement it needed to be true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Twenty-three of sixty-one scenes are never cast" was an artefact of the song it was measured on. synthesizeSectioned has one change point, so it segments into exactly two sections and both are quiet kinds — and intro, breakdown and outro are restricted to the restful families for every director. Half the library was unreachable before a seed was drawn, and the measurement reported that as a casting failure. synthesizeArrangement builds a real one: intro, build, drop, breakdown, drop, outro, shaped to hit the segmenter's own classifier rather than to sound like anything. Measured against a bank of those, two scenes out of sixty-one are never a background, not twenty-three. The census exists because "never cast" without a reason is unactionable. A scene can die at the signature gate, at the director's family table, or in the roster draw, and those are three different repairs — a trait declaration, a table, and arithmetic that neither fixes. It reports which one, and it separates being cast as a background from appearing as a translucent overlay, because a library can look fully used while nine scenes carry every frame. What it finds is that eligibility is almost entirely a function of how many traits a scene declares. Four-trait scenes are eligible for every track and open half of all videos; two-trait scenes are eligible for one track in fourteen; the single scene declaring one trait is eligible for none, ever. Co-Authored-By: Claude Opus 5 --- flow-state/src/audio/synth.js | 65 +++++++++++ flow-state/tools/cast-census.js | 198 ++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 flow-state/tools/cast-census.js diff --git a/flow-state/src/audio/synth.js b/flow-state/src/audio/synth.js index 4399739..a8c8b60 100644 --- a/flow-state/src/audio/synth.js +++ b/flow-state/src/audio/synth.js @@ -119,6 +119,71 @@ export function synthesizeSectioned({ return buffer; } +/** + * A track with a full ARRANGEMENT — intro, build, drop, breakdown, drop, outro. + * + * `synthesizeSectioned` has one change point, so it segments into exactly two + * sections and both of them are quiet kinds. That is fine for testing that the + * segmenter finds a boundary, and it was quietly useless for anything that + * measures what the generator DOES with a song: intro, breakdown and outro are + * restricted to the restful families for every director, so a two-section track + * cannot reach geometric, glitch or structural scenes at all. Half the library + * is unreachable before the seed is even drawn, and a test built on it will + * report that as a casting failure. + * + * The stages here are shaped to hit the segmenter's own classifier: a build + * needs a rising energy slope, a drop needs energy and flux together, and a + * breakdown needs to fall well below the median. + */ +export function synthesizeArrangement({ + bpm = 128, + duration = 120, + sampleRate = 44100, + brightness = 1, + density = 1, +} = {}) { + const length = Math.round(duration * sampleRate); + const buffer = new MockAudioBuffer(2, length, sampleRate); + const left = buffer.getChannelData(0); + const right = buffer.getChannelData(1); + + // Proportions of the track, in order. Kick gain, hat gain, pad root, pad gain. + const stages = [ + { span: 0.12, kick: 0.00, hat: 0.00, root: 110, pad: 0.09 }, // intro + { span: 0.16, kick: 0.55, hat: 0.20, root: 165, pad: 0.14, ramp: true }, // build + { span: 0.22, kick: 1.00, hat: 0.50, root: 440, pad: 0.24 }, // drop + { span: 0.14, kick: 0.05, hat: 0.02, root: 110, pad: 0.07 }, // breakdown + { span: 0.24, kick: 1.00, hat: 0.55, root: 440, pad: 0.26 }, // drop + { span: 0.12, kick: 0.25, hat: 0.08, root: 110, pad: 0.08 }, // outro + ]; + + const beat = 60 / bpm; + let at = 0; + for (const stage of stages) { + const from = at; + const to = Math.min(duration, at + stage.span * duration); + at = to; + + let index = Math.round(from / beat); + for (let t = from; t < to; t += beat, index++) { + // A build ramps across its own span so the energy slope is positive + // enough for the classifier to call it one. + const ramp = stage.ramp ? (t - from) / Math.max(1e-6, to - from) : 1; + const gain = stage.kick * density * (0.25 + ramp * 0.75); + if (gain > 0.02) addKick(left, sampleRate, t, gain * (index % 4 === 0 ? 1 : 0.8)); + if (stage.hat > 0.02) { + const h = stage.hat * density * ramp; + addHat(left, sampleRate, t + beat / 2, h, index + 1); + if (h > 0.3) addHat(left, sampleRate, t + beat / 4, h * 0.6, index + 7); + } + } + addPad(left, sampleRate, from, to, stage.pad, stage.root * brightness); + } + + for (let i = 0; i < length; i++) right[i] = left[i] * 0.98; + return buffer; +} + /** Silence, for degenerate-input checks. */ export function synthesizeSilence({ duration = 10, sampleRate = 44100 } = {}) { return new MockAudioBuffer(2, Math.round(duration * sampleRate), sampleRate); diff --git a/flow-state/tools/cast-census.js b/flow-state/tools/cast-census.js new file mode 100644 index 0000000..e328b25 --- /dev/null +++ b/flow-state/tools/cast-census.js @@ -0,0 +1,198 @@ +// Which visualizations does the generator actually reach, and why not the rest? +// +// A library entry that no song ever sees is not a library entry. But "never cast +// on one track across many seeds" and "never cast at all" are completely +// different findings, and the first is what the variety report measures, so this +// varies the SONG as well as the seed and separates the two. +// +// The casting funnel has four gates, and a scene can die at any of them: +// +// 1. role accents are never a section's primary scene, by design. +// 2. signature the track picks one or two traits and a scene that cannot +// honour both is disqualified for that whole track. +// 3. director the track's point of view maps each section KIND to three +// families; a scene in none of them is not a candidate. +// 4. roster a kind takes only three or four scenes, weighted. +// +// Reporting a scene as "never cast" without saying which gate killed it is +// unactionable — gate 2 is a trait declaration to fix on the scene, gate 3 is a +// director table to widen, gate 4 is arithmetic that no amount of either fixes. +// +// node tools/cast-census.js 12 songs x 40 seeds +// node tools/cast-census.js 24 80 more of both + +import { FeatureTrack } from '../src/audio/FeatureTrack.js'; +import { synthesizeArrangement } from '../src/audio/synth.js'; +import { generateLook } from '../src/look/LookGenerator.js'; +import { scenes } from '../src/scenes/registry.js'; +import { sceneHonours, TRAITS } from '../src/look/Personality.js'; +import { DIRECTORS } from '../src/look/directors.js'; + +const SONGS = Number(process.argv[2]) || 12; +const SEEDS = Number(process.argv[3]) || 40; + +/** + * A spread of songs, not a spread of seeds. + * + * Tempo, density and brightness are the three things the generator actually + * reads, so the bank spans them deliberately rather than randomly — a random + * bank would cluster in the middle and under-report exactly the scenes that only + * a fast, bright, noisy track can reach. + * + * Arrangements, not `synthesizeSectioned`. A two-section track is all quiet + * kinds, and quiet kinds are restricted to the restful families for every + * director, so half the library would be unreachable for reasons that have + * nothing to do with casting. + */ +function songBank(count) { + const out = []; + for (let i = 0; i < count; i++) { + const t = count === 1 ? 0.5 : i / (count - 1); + const bpm = 70 + t * 104; + out.push({ + label: `bpm ${Math.round(bpm)}`, + track: FeatureTrack.fromAudioBuffer(synthesizeArrangement({ + bpm, + duration: 120, + brightness: 0.6 + ((i * 7) % 5) / 5 * 1.2, + density: 0.55 + ((i * 3) % 4) / 4 * 0.8, + }), { fps: 60 }), + }); + } + return out; +} + +const pool = scenes.filter((m) => m.role !== 'accent'); +const stat = new Map(pool.map((m) => [m.name, { + module: m, eligible: 0, candidate: 0, cast: 0, background: 0, songs: new Set(), +}])); +const kindTally = new Map(); + +const songs = songBank(SONGS); +let looks = 0; + +for (const { track } of songs) { + for (let s = 0; s < SEEDS; s++) { + const seed = (0x51ee7 + s * 2654435761) >>> 0; + const look = generateLook(track, { seed }); + looks++; + + const signature = look.personality.signature; + const director = DIRECTORS.find((d) => d.name === look.director); + const kinds = [...new Set(look.sections.map((x) => x.kind))]; + for (const k of kinds) kindTally.set(k, (kindTally.get(k) || 0) + 1); + const families = new Set(kinds.flatMap((k) => director.families[k] || [])); + + for (const m of pool) { + const st = stat.get(m.name); + const honours = sceneHonours(m, signature); + if (honours) st.eligible++; + // A candidate is a scene that survived BOTH the signature and the + // director for at least one kind this track actually has. + if (honours && families.has(m.family)) st.candidate++; + } + + // Background vs any-layer, kept apart. A scene that only ever appears as + // a translucent overlay is not "in the video" in the sense that matters: + // it is texture over someone else's shot, and counting it as a cast is + // how a library can look fully used while the same nine scenes carry + // every frame. Overlays are also drawn from the whole signature-eligible + // library rather than the director's families, so counting them together + // hides which gate is doing the work. + const usedHere = new Set(look.sections.flatMap( + (sec) => sec.variants.flatMap((v) => v.map((l) => l.module.name)))); + const backgroundsHere = new Set(look.sections.flatMap( + (sec) => sec.variants.map((v) => v[0].module.name))); + for (const name of usedHere) { + const st = stat.get(name); + if (!st) continue; // an accent layer + st.cast++; + st.songs.add(track); + } + for (const name of backgroundsHere) { + const st = stat.get(name); + if (st) st.background++; + } + } +} + +const rows = [...stat.values()].map((st) => ({ + name: st.module.name, + family: st.module.family, + traits: (st.module.traits || []).map((t) => t[0]).join(''), + eligible: st.eligible / looks, + candidate: st.candidate / looks, + cast: st.cast / looks, + background: st.background / looks, + songs: st.songs.size, + // The conditional is the whole point: a scene that is a candidate often and + // cast never is losing the roster draw, which is a different bug from a + // scene that is never a candidate at all. + given: st.candidate ? st.cast / st.candidate : 0, +})).sort((a, b) => a.background - b.background || a.cast - b.cast); + +const pc = (x) => `${(x * 100).toFixed(1)}%`.padStart(6); + +console.log(`\n${looks} looks · ${SONGS} songs x ${SEEDS} seeds · ${pool.length} castable scenes\n`); +console.log(' scene family traits eligible candidate as bg any layer songs'); +console.log(' ' + '-'.repeat(96)); +for (const r of rows) { + const flag = r.background === 0 && r.cast === 0 ? ' ← NEVER' + : r.background === 0 ? ' ← overlay only' + : r.background < 0.02 ? ' ← rare' : ''; + console.log(` ${r.name.padEnd(24)} ${r.family.padEnd(11)} ${r.traits.padEnd(6)} ` + + `${pc(r.eligible)} ${pc(r.candidate)} ${pc(r.background)} ${pc(r.cast)} ` + + `${String(r.songs).padStart(3)}/${SONGS}${flag}`); +} + +console.log(`\n section kinds the bank produced: ` + + [...kindTally.entries()].sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${k} ${(v / looks * 100).toFixed(0)}%`).join(' · ')); + +// --- where the losses happen --------------------------------------------- +const never = rows.filter((r) => r.background === 0); +const neverCandidate = never.filter((r) => r.candidate === 0); +const neverEligible = never.filter((r) => r.eligible === 0); +const lostInRoster = never.filter((r) => r.candidate > 0); + +console.log(`\n never cast as a background: ${never.length}/${pool.length}` + + ` (of those, ${never.filter((r) => r.cast > 0).length} appear only as overlays)`); +console.log(` disqualified by the signature gate, always: ${neverEligible.length}` + + (neverEligible.length ? ` (${neverEligible.map((r) => r.name).join(', ')})` : '')); +console.log(` eligible but never a candidate (director): ${neverCandidate.length - neverEligible.length}` + + (neverCandidate.length - neverEligible.length + ? ` (${neverCandidate.filter((r) => r.eligible > 0).map((r) => r.name).join(', ')})` : '')); +console.log(` a candidate but never won a roster slot: ${lostInRoster.length}` + + (lostInRoster.length ? ` (${lostInRoster.map((r) => r.name).join(', ')})` : '')); + +// --- the trait declarations behind gate 2 --------------------------------- +console.log('\n trait coverage across the library (a signature is one or two of these):'); +for (const trait of TRAITS) { + const n = pool.filter((m) => (m.traits || []).includes(trait)).length; + console.log(` ${trait.padEnd(7)} ${String(n).padStart(3)}/${pool.length} scenes declare it`); +} +const byCount = new Map(); +for (const m of pool) { + const k = (m.traits || []).length; + byCount.set(k, (byCount.get(k) || 0) + 1); +} +console.log('\n scenes by number of traits declared (fewer traits = harder to cast):'); +for (const k of [...byCount.keys()].sort()) { + console.log(` ${k} traits ${String(byCount.get(k)).padStart(3)} scenes` + + (k <= 1 ? ' ← can only survive a signature it happens to match' : '')); +} + +// --- family reachability per director ------------------------------------- +console.log('\n family demand — how many of the 6 section kinds each director opens to a family:'); +const families = [...new Set(pool.map((m) => m.family))].sort(); +const head = families.map((f) => f.slice(0, 6).padStart(7)).join(''); +console.log(` ${'director'.padEnd(12)}${head}`); +for (const d of DIRECTORS) { + const counts = families.map((f) => { + const n = Object.values(d.families).filter((list) => list.includes(f)).length; + return String(n).padStart(7); + }).join(''); + console.log(` ${d.name.padEnd(12)}${counts}`); +} +const sizes = families.map((f) => `${f} ${pool.filter((m) => m.family === f).length}`); +console.log(` library holds: ${sizes.join(' · ')}`);