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(' · ')}`);