Halftone Misprint is a printing fault rather than an electronic one — the image is never damaged, only separated and reassembled out of register, and nothing else in the library is made of dots. Droste Feedback scales the previous frame where Time Smear translates it, so the image never clears. Analog Wow is the continuous, wet counterpart to Scan Tear and Block Mosh: the error varies smoothly down the frame because every line was written at a different moment. Three things that had to be got right. The halftone ruling is a count of dots across the frame, not a pixel pitch, or a 4K export is the same dot on a bigger sheet. The Droste loop has to CONTRACT — expanding pushes every copy off the edge and leaves a plume instead of a corridor. And all three expressed style only through grain at first, which is the Side Quest 1 complaint: they now put the track's line weight and edge softness into the dot, the ring and the band boundary, taking the measured style response from 24/11/18 to 86/255/88. Neither feedback scene declares a slow axis, with the numbers recorded in the files: their own history moves the ten-second average further than any parameter does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
7.7 KiB
JavaScript
179 lines
7.7 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,
|
||
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('');
|
||
}
|