A cast census, and the arrangement it needed to be true

"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 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-17 06:45:42 +02:00
parent 656e062069
commit 91e74ff167
2 changed files with 263 additions and 0 deletions

View File

@ -119,6 +119,71 @@ export function synthesizeSectioned({
return buffer; 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. */ /** Silence, for degenerate-input checks. */
export function synthesizeSilence({ duration = 10, sampleRate = 44100 } = {}) { export function synthesizeSilence({ duration = 10, sampleRate = 44100 } = {}) {
return new MockAudioBuffer(2, Math.round(duration * sampleRate), sampleRate); return new MockAudioBuffer(2, Math.round(duration * sampleRate), sampleRate);

View File

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