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