// The song bank: a set of tracks that between them exercise every input the // look generator reads. // // Why this has to exist. Everything downstream of the audio is a function of // five summary statistics and a section list, and until now every measurement // in this project was taken on ONE synthetic track — which turned out to have // two sections, both quiet, making half the scene library unreachable and the // resulting numbers wrong. A single song cannot tell you whether the generator // is varied; it can only tell you what it does with that song. // // The bank is built against what the generator actually consumes, not against // what sounds like a reasonable spread of music: // // summary.bpm motion bias, animation rate, personality pace // summary.meanCentroid director odds, line weight, softness, bloom, vignette // summary.meanFlatness director odds, chroma, saturation, surface texture // summary.dynamicRange feedback amount and decay, contrast // summary.meanLoudness per-section energy, temperament intensity // section kinds which families each director opens — the casting gate // // Coverage is a claim, so `tools/song-coverage.js` measures it rather than // trusting this table. Each entry names the corner it is here to occupy; if a // song stops occupying it, that is a bug in the synth, and the coverage tool is // what catches it. // // These are test signals, not music. They exist to be measured. import { FeatureTrack } from './FeatureTrack.js'; import { synthesizeSong } from './synth.js'; /** * @typedef {object} SongSpec * @property {string} name * @property {string} covers the corner of the feature space this one holds */ export const SONGS = [ // --- slow, quiet, spacious ------------------------------------------- { name: 'drone', bpm: 62, arrangement: 'ambient', brightness: 0.05, noise: 0.02, dynamics: 0.95, covers: 'beatless · darkest · most dynamic' }, { name: 'air', bpm: 76, arrangement: 'ambient', brightness: 0.90, noise: 0.25, noiseColour: 0.15, dynamics: 0.85, covers: 'beatless but bright — brightness without a beat' }, { name: 'ember', bpm: 68, arrangement: 'club', brightness: 0.20, noise: 0.05, dynamics: 0.55, covers: 'slowest track with a detectable beat — anchors the low tempo end' }, { name: 'elegy', bpm: 84, arrangement: 'ballad', brightness: 0.10, noise: 0.03, dynamics: 0.90, covers: 'slow, dark, one build and one payoff' }, { name: 'hymn', bpm: 92, arrangement: 'ballad', brightness: 0.80, noise: 0.20, dynamics: 0.70, covers: 'slow and bright — separates tempo from brightness' }, { name: 'still', bpm: 100, arrangement: 'sparse', brightness: 0.35, noise: 0.06, dynamics: 0.75, covers: 'the degenerate two-section case, which must not break' }, // --- mid tempo, the bulk of the space --------------------------------- { name: 'dusk', bpm: 118, arrangement: 'classic', brightness: 0.02, noise: 0.01, dynamics: 0.60, covers: 'darkest and most tonal with a full arrangement' }, { name: 'centre', bpm: 124, arrangement: 'classic', brightness: 0.50, noise: 0.20, dynamics: 0.55, covers: 'the middle of every axis — the null hypothesis' }, { name: 'glare', bpm: 128, arrangement: 'classic', brightness: 0.95, noise: 0.55, dynamics: 0.50, covers: 'bright and noisy together' }, { name: 'slab', bpm: 126, arrangement: 'club', brightness: 0.45, noise: 0.25, dynamics: 0.05, covers: 'limitered — lowest dynamic range, longest sections' }, { name: 'chrome', bpm: 132, arrangement: 'club', brightness: 0.85, noise: 0.06, noiseColour: 0.1, dynamics: 0.35, covers: 'bright and tonal at club tempo' }, { name: 'murk', bpm: 88, arrangement: 'classic', brightness: 0.12, noise: 0.85, noiseColour: 0.95, dynamics: 0.70, covers: 'dark tonal content under bright hiss — the quadrant that breaks the brightness/noise correlation' }, // --- fast, dense, broken ---------------------------------------------- { name: 'lattice', bpm: 140, arrangement: 'breaks', brightness: 0.60, noise: 0.35, dynamics: 0.45, covers: 'most sections — makes rosters actually rotate' }, { name: 'grit', bpm: 150, arrangement: 'breaks', brightness: 0.15, noise: 0.90, noiseColour: 0.85, dynamics: 0.40, covers: "noisiest and dark — the corrupt director's home ground" }, { name: 'plate', bpm: 138, arrangement: 'club', brightness: 0.25, noise: 0.70, noiseColour: 0.9, dynamics: 0.10, covers: 'noisy AND compressed — two extremes at once' }, { name: 'runner', bpm: 174, arrangement: 'breaks', brightness: 0.70, noise: 0.40, dynamics: 0.50, covers: 'fastest with a broken arrangement' }, { name: 'flare', bpm: 168, arrangement: 'classic', brightness: 0.95, noise: 0.85, dynamics: 0.25, covers: 'brightest and noisiest — the far corner' }, ]; const DURATION = 120; // Analysis is CPU-bound seconds per track and every caller wants the same // tracks, so the bank is built once and held. const cache = new Map(); /** One song from the bank, analysed. */ export function song(name, { fps = 60 } = {}) { if (cache.has(name)) return cache.get(name); const spec = SONGS.find((s) => s.name === name); if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`); const entry = { ...spec, track: FeatureTrack.fromAudioBuffer(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), }), { fps }), }; cache.set(name, entry); return entry; } /** * The whole bank, analysed. * * `count` takes an evenly spaced subset rather than the first n, so a cheap run * still spans the space instead of testing five slow ambient tracks. */ export function songBank({ count = null, fps = 60 } = {}) { const specs = count && count < SONGS.length ? Array.from({ length: count }, (_, i) => SONGS[Math.round(i * (SONGS.length - 1) / (count - 1))]) : SONGS; return specs.map((s) => song(s.name, { fps })); } /** Feature axes the bank claims to span, and where each is read. */ export const AXES = [ { key: 'bpm', label: 'tempo', of: (t) => t.summary.bpm, range: [60, 180], reads: 'motion bias · animation rate · personality pace' }, // Not [0,1]: centroid is mapped to a LOG frequency axis, so a track made of // nothing but sub-bass and a 55Hz pad still measures ~0.28 and pure hiss // measures ~0.9. The range here is what the statistic can really take on for // music, and calling it [0,1] would report a permanent 50% gap that no // synth change could close. { key: 'centroid', label: 'brightness', of: (t) => t.summary.meanCentroid, range: [0.25, 0.95], reads: 'director odds · line weight · softness · bloom · vignette' }, { key: 'flatness', label: 'noisiness', of: (t) => t.summary.meanFlatness, range: [0, 1], reads: 'director odds · chroma · saturation · surface texture' }, { key: 'dynamics', label: 'dynamic range', of: (t) => t.summary.dynamicRange, range: [0, 1], reads: 'feedback amount and decay · contrast' }, // Raw mean spectral magnitude, not a normalised 0..1 — biasFor divides // section energy by it, so its scale is whatever the analyser produces. { key: 'loudness', label: 'loudness', of: (t) => t.summary.meanLoudness, range: [0, 0.012], reads: 'section energy · temperament intensity' }, { key: 'sections', label: 'section count', of: (t) => t.sections.length, range: [2, 8], reads: 'roster rotation · how often the image is allowed to change' }, ];