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>
213 lines
9.3 KiB
JavaScript
213 lines
9.3 KiB
JavaScript
// 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('');
|
||
}
|