music-video-gen/flow-state/tools/build-song-bank.js
Dejvino 905c217193 Three glitch scenes: a press, a loop and a tape
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>
2026-08-17 06:55:43 +02:00

179 lines
7.7 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,
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('');
}