Completes the bank whose first half went in with the scene commits. The remaining work was all in what "covered" means. Two axis ranges were wrong, and being wrong made the coverage report lie in both directions. Loudness is a raw mean spectral magnitude, not a normalised 0..1, so every song read as 0.00 and the axis looked dead. Centroid is mapped to a log frequency axis, so a track of nothing but sub-bass and a 55Hz pad still measures 0.28 and pure hiss measures 0.93 — against a nominal [0,1] the bank would have reported a permanent 50% gap that no synth change could close. Both now carry the range the statistic can really take on, with the reason. Spread on every axis is not coverage of the space, so the tool also measures correlation between axes. It immediately found brightness and noisiness moving together at r=0.95: noise colour had been tied to brightness, one axis wearing two names, leaving the dark-and-noisy quadrant unreachable. Noise colour is now its own parameter — hiss over a sub-bass pad is an ordinary record. That got r to 0.94, and no further. Flatness is the geometric mean of the spectrum over its arithmetic mean across the whole band, so a signal only measures flat if it has energy everywhere, which is the same thing as measuring bright; band-limiting the noise to hold the centroid down empties the top and drops the flatness with it. It is a property of the analyser and real material does the same, so it is allowlisted with its reason. Anything not on that list still fails — the check is there to catch a bank that has collapsed, not to relitigate physics every run. The output is gitignored. It is 172MB of deterministic audio derived from a table that will keep changing: rebuild it, do not carry it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
10 KiB
JavaScript
230 lines
10 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.
|
||
//
|
||
// One correlation here is a property of the ANALYSER and cannot be engineered
|
||
// away. Flatness is the geometric mean of the spectrum over its arithmetic
|
||
// mean, taken across the whole band, so a signal only measures flat if it has
|
||
// energy everywhere — which is the same thing as measuring bright. Adding a
|
||
// noise bed raises both, and band-limiting it to keep the centroid down empties
|
||
// the top of the spectrum and drops the flatness with it. Two attempts at
|
||
// decoupling (a steeper noise filter, then noise colour as its own axis) moved
|
||
// r from 0.95 to 0.94. Real material behaves the same way.
|
||
//
|
||
// So it is allowlisted with its reason rather than failed on, and anything NOT
|
||
// on the list still fails — the check is here to catch a bank that has
|
||
// collapsed, not to relitigate physics on every run.
|
||
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])]));
|
||
|
||
const EXPECTED = {
|
||
'brightness↔noisiness':
|
||
'spectral flatness is measured across the whole band, so flat implies bright',
|
||
};
|
||
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;
|
||
const key = `${AXES[i].label}↔${AXES[j].label}`;
|
||
const expected = EXPECTED[key];
|
||
console.log(` ${AXES[i].label} ↔ ${AXES[j].label}: r = ${r.toFixed(2)}` +
|
||
(expected ? ` expected — ${expected}` : Math.abs(r) > 0.85 ? ' ← effectively one axis' : ''));
|
||
if (Math.abs(r) > 0.85 && !expected) {
|
||
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('');
|
||
}
|