music-video-gen/flow-state/tools/build-song-bank.js
Dejvino 0c5b0a8d4d Close out the twenty: what the gates taught
Three lessons worth more than the scenes: a slow axis has to move a large
low-frequency area or the camera's own drift beats it, whole-frame luminance on
the kick is the strobe the flash gate exists for and it arrives by accident, and
a style trait expressed only as grain measures as no style at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:00:45 +02:00

213 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Build the test song bank: synthesize every song in src/audio/songbank.js,
// write it out as audio, and verify that the bank covers what it claims to.
//
// The bank is generated rather than stored. It is deterministic, it is a few
// hundred megabytes as audio, and it is derived from a table that will keep
// changing — all three say "rebuild it" rather than "commit it". The output
// directory is gitignored; this tool is the thing that is versioned.
//
// node tools/build-song-bank.js → test/songs/, 120s each
// node tools/build-song-bank.js --duration 60 → shorter, for a quick pass
// node tools/build-song-bank.js --check → verify coverage, write nothing
// node tools/build-song-bank.js --out /tmp/bank
//
// Coverage is measured, never assumed. Each song declares the corner of the
// feature space it exists to occupy; this analyses what was actually produced
// and fails loudly if an axis has collapsed — a synth change that quietly stops
// producing noisy tracks would otherwise leave every downstream measurement
// looking fine while testing half the space.
import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { synthesizeSong } from '../src/audio/synth.js';
import { FeatureTrack } from '../src/audio/FeatureTrack.js';
import { SONGS, AXES } from '../src/audio/songbank.js';
const here = dirname(fileURLToPath(import.meta.url));
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')
? process.argv[i + 1] : fallback;
}
const has = (name) => process.argv.includes(`--${name}`);
const DURATION = Number(arg('duration', 120));
const OUT = resolve(arg('out', resolve(here, '../test/songs')));
const CHECK_ONLY = has('check');
/**
* 16-bit PCM WAV, mono.
*
* Mono halves the size and loses nothing: the analyser sums to mono before it
* does anything, and the synth writes the same signal to both channels anyway.
*/
function wav(samples, sampleRate) {
const bytes = samples.length * 2;
const buffer = Buffer.alloc(44 + bytes);
buffer.write('RIFF', 0);
buffer.writeUInt32LE(36 + bytes, 4);
buffer.write('WAVE', 8);
buffer.write('fmt ', 12);
buffer.writeUInt32LE(16, 16); // PCM chunk size
buffer.writeUInt16LE(1, 20); // PCM
buffer.writeUInt16LE(1, 22); // mono
buffer.writeUInt32LE(sampleRate, 24);
buffer.writeUInt32LE(sampleRate * 2, 28);
buffer.writeUInt16LE(2, 32); // block align
buffer.writeUInt16LE(16, 34); // bits
buffer.write('data', 36);
buffer.writeUInt32LE(bytes, 40);
// Peak-normalise to -1 dBFS. Without it the quiet ambient entries clip down
// into 16-bit noise and their measured flatness stops being what the spec
// asked for — the bank would then be testing the encoder, not the generator.
let peak = 0;
for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i]));
const gain = peak > 1e-9 ? 0.89 / peak : 1;
for (let i = 0; i < samples.length; i++) {
const v = Math.max(-1, Math.min(1, samples[i] * gain));
buffer.writeInt16LE(Math.round(v * 32767), 44 + i * 2);
}
return buffer;
}
if (!CHECK_ONLY && !existsSync(OUT)) mkdirSync(OUT, { recursive: true });
console.log(`\nbuilding ${SONGS.length} songs · ${DURATION}s each` +
(CHECK_ONLY ? ' · check only, writing nothing' : ` · → ${OUT}`) + '\n');
const built = [];
for (const spec of SONGS) {
const buffer = synthesizeSong({
bpm: spec.bpm,
duration: DURATION,
arrangement: spec.arrangement,
brightness: spec.brightness,
noise: spec.noise,
noiseColour: spec.noiseColour ?? null,
dynamics: spec.dynamics,
seed: 1 + SONGS.indexOf(spec),
});
const track = FeatureTrack.fromAudioBuffer(buffer, { fps: 60 });
if (!CHECK_ONLY) {
writeFileSync(resolve(OUT, `${spec.name}.wav`),
wav(buffer.getChannelData(0), buffer.sampleRate));
}
built.push({
...spec,
file: `${spec.name}.wav`,
measured: {
bpm: track.summary.bpm,
centroid: track.summary.meanCentroid,
flatness: track.summary.meanFlatness,
dynamics: track.summary.dynamicRange,
loudness: track.summary.meanLoudness,
sections: track.sections.length,
kinds: track.sections.map((s) => s.kind),
},
});
process.stdout.write(` ${spec.name.padEnd(10)} ${built.at(-1).measured.kinds.join(' ')}\n`);
}
// --- what was actually produced -------------------------------------------
const n2 = (x) => (typeof x === 'number' ? x.toFixed(2) : String(x)).padStart(7);
console.log('\n MEASURED — what the generator will actually read\n');
console.log(' song tempo bright noisy dynamic loud sections arrangement');
console.log(' ' + '-'.repeat(86));
for (const b of built) {
console.log(` ${b.name.padEnd(11)}${n2(b.measured.bpm)}${n2(b.measured.centroid)}` +
`${n2(b.measured.flatness)}${n2(b.measured.dynamics)}${n2(b.measured.loudness)}` +
`${String(b.measured.sections).padStart(10)} ${b.arrangement}`);
}
// --- coverage --------------------------------------------------------------
// An axis is covered when the bank spreads across it AND reaches both ends. A
// bank can have a wide range and still be two clusters with a hole in the
// middle, so occupancy is measured in quintiles rather than as min-to-max.
console.log('\n COVERAGE — measured, not claimed\n');
const problems = [];
for (const axis of AXES) {
const values = built.map((b) => b.measured[axis.key === 'bpm' ? 'bpm' : axis.key]);
const lo = Math.min(...values), hi = Math.max(...values);
const [rLo, rHi] = axis.range;
const bins = new Set(values.map(
(v) => Math.max(0, Math.min(4, Math.floor((v - rLo) / (rHi - rLo) * 5)))));
const filled = [0, 1, 2, 3, 4].map((i) => (bins.has(i) ? '█' : '·')).join('');
const span = (hi - lo) / (rHi - rLo);
console.log(` ${axis.label.padEnd(15)} ${filled} ${lo.toFixed(2)}${hi.toFixed(2)}` +
` spans ${(span * 100).toFixed(0)}% of ${rLo}${rHi}`);
console.log(` ${''.padEnd(15)} reads: ${axis.reads}`);
if (bins.size < 3) problems.push(`${axis.label} occupies only ${bins.size}/5 of its range`);
}
// --- what the bank CANNOT decouple ----------------------------------------
// Spread on every axis is not the same as covering the space. Two axes that
// move together mean whole combinations are unrepresentable, and a downstream
// test would then attribute to the generator what is really a hole in the bank.
//
// The big one here is physical rather than fixable: spectral flatness measures
// how noise-like a spectrum is, and a dark signal is one whose spectrum is
// tilted, so "dark and very noisy" is only weakly reachable. Real material has
// the same correlation. It is reported rather than engineered away.
console.log('\n AXIS CORRELATION — |r| near 1 means the bank cannot vary these independently\n');
const corr = (a, b) => {
const ma = a.reduce((x, y) => x + y, 0) / a.length;
const mb = b.reduce((x, y) => x + y, 0) / b.length;
let num = 0, da = 0, db = 0;
for (let i = 0; i < a.length; i++) {
num += (a[i] - ma) * (b[i] - mb);
da += (a[i] - ma) ** 2;
db += (b[i] - mb) ** 2;
}
return num / (Math.sqrt(da * db) || 1e-9);
};
const series = Object.fromEntries(AXES.map(
(a) => [a.key, built.map((b) => b.measured[a.key])]));
for (let i = 0; i < AXES.length; i++) {
for (let j = i + 1; j < AXES.length; j++) {
const r = corr(series[AXES[i].key], series[AXES[j].key]);
if (Math.abs(r) < 0.6) continue;
console.log(` ${AXES[i].label}${AXES[j].label}: r = ${r.toFixed(2)}` +
(Math.abs(r) > 0.85 ? ' ← effectively one axis' : ''));
if (Math.abs(r) > 0.85) problems.push(`${AXES[i].label} and ${AXES[j].label} are collinear (r ${r.toFixed(2)})`);
}
}
// Section kinds are the casting gate, so coverage of them is not optional: a
// bank with no drop cannot reach the geometric, glitch or structural families
// no matter how widely its tempo spreads.
const kinds = new Map();
for (const b of built) for (const k of b.measured.kinds) kinds.set(k, (kinds.get(k) || 0) + 1);
const ALL_KINDS = ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro'];
console.log(`\n section kinds ${ALL_KINDS.map((k) => (kinds.get(k) ? '█' : '·')).join('')} ` +
ALL_KINDS.map((k) => `${k} ${kinds.get(k) || 0}`).join(' · '));
const missing = ALL_KINDS.filter((k) => !kinds.get(k));
if (missing.length) problems.push(`no song produces: ${missing.join(', ')}`);
if (!CHECK_ONLY) {
writeFileSync(resolve(OUT, 'manifest.json'), JSON.stringify({
built: new Date().toISOString(),
duration: DURATION,
songs: built,
}, null, 2));
}
console.log('');
if (problems.length) {
console.log(' GAPS');
for (const p of problems) console.log(` ${p}`);
console.log('');
process.exitCode = 1;
} else {
console.log(` all ${AXES.length} axes and all ${ALL_KINDS.length} section kinds covered`);
if (!CHECK_ONLY) console.log(` wrote ${built.length} wav files + manifest.json to ${OUT}`);
console.log('');
}