Every rendered still links to the app frame that produced it (song + seed + frame + venue/viz pins + probe warm-up, new window), via shared src/ui/preview.js. Supporting work, each measured: app honors ?frame/?venueSel/?viz/?warm= with pre-build pins (Show.applyPins) and primes deep-link loads; shared pinViz/pinVenueShell paths for gallery, filmstrip and app; probes prime and carry their warm-up; bank probes analyse the bank PCM and file decode is fixed-rate with bit-exact WAV parsing so app and probes see identical samples; materials built mid-update compile synchronously (needsCompile). Round trip verified bit-identical gallery-versus-app.
171 lines
9.6 KiB
JavaScript
171 lines
9.6 KiB
JavaScript
// 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, MockAudioBuffer } from './synth.js';
|
|
|
|
/**
|
|
* The bank's PCM contract: peak-normalise to -1 dBFS, then round to the
|
|
* 16-bit grid — bit-exactly what tools/build-song-bank.js writes into
|
|
* test/songs/*.wav (int16 LE of round(v * 32767), read back as V / 32768).
|
|
*
|
|
* Why this exists: the app decodes the wav while every probe page analyses
|
|
* the synth, and unless both see these exact samples their features differ
|
|
* (measured: 3x loudness and a whole extra drop section on drone) and no
|
|
* preview deep-link can reproduce its image. song() applies it so the bank
|
|
* IS the files; the writer shares it so the files stay the bank.
|
|
*/
|
|
export function bankPcm(samples) {
|
|
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;
|
|
const out = new Float32Array(samples.length);
|
|
for (let i = 0; i < samples.length; i++) {
|
|
const v = Math.max(-1, Math.min(1, samples[i] * gain));
|
|
out[i] = Math.round(v * 32767) / 32768;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* @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, transition: 0.05,
|
|
covers: 'beatless · darkest · most dynamic' },
|
|
{ name: 'air', bpm: 76, arrangement: 'ambient', brightness: 0.90, noise: 0.25, noiseColour: 0.15, dynamics: 0.85, transition: 0.1,
|
|
covers: 'beatless but bright — brightness without a beat' },
|
|
{ name: 'ember', bpm: 68, arrangement: 'club', brightness: 0.20, noise: 0.05, dynamics: 0.55, transition: 0.8,
|
|
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, transition: 0.12,
|
|
covers: 'slow, dark, one build and one payoff' },
|
|
{ name: 'hymn', bpm: 92, arrangement: 'ballad', brightness: 0.80, noise: 0.20, dynamics: 0.70, transition: 0.25,
|
|
covers: 'slow and bright — separates tempo from brightness' },
|
|
{ name: 'still', bpm: 100, arrangement: 'sparse', brightness: 0.35, noise: 0.06, dynamics: 0.75, transition: 0.2,
|
|
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, transition: 0.45,
|
|
covers: 'darkest and most tonal with a full arrangement' },
|
|
{ name: 'centre', bpm: 124, arrangement: 'classic', brightness: 0.50, noise: 0.20, dynamics: 0.55, transition: 0.6,
|
|
covers: 'the middle of every axis — the null hypothesis' },
|
|
{ name: 'glare', bpm: 128, arrangement: 'classic', brightness: 0.95, noise: 0.55, dynamics: 0.50, transition: 0.9,
|
|
covers: 'bright and noisy together' },
|
|
{ name: 'slab', bpm: 126, arrangement: 'club', brightness: 0.45, noise: 0.25, dynamics: 0.05, transition: 0.95,
|
|
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, transition: 0.85,
|
|
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, transition: 0.35,
|
|
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, transition: 0.92,
|
|
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, transition: 0.88,
|
|
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, transition: 0.95,
|
|
covers: 'noisy AND compressed — two extremes at once' },
|
|
{ name: 'runner', bpm: 174, arrangement: 'breaks', brightness: 0.70, noise: 0.40, dynamics: 0.50, transition: 0.9,
|
|
covers: 'fastest with a broken arrangement' },
|
|
{ name: 'flare', bpm: 168, arrangement: 'classic', brightness: 0.95, noise: 0.85, dynamics: 0.25, transition: 0.7,
|
|
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, duration = DURATION } = {}) {
|
|
const cacheKey = `${name}:${fps}:${duration}`;
|
|
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
|
const spec = SONGS.find((s) => s.name === name);
|
|
if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`);
|
|
|
|
// Analysed from the bank PCM, not the raw synth: peak gain, 16-bit
|
|
// grid, mono — the exact samples test/songs/*.wav holds, so this track
|
|
// is the file the app decodes rather than a near-twin of it. See bankPcm.
|
|
const synth = synthesizeSong({
|
|
bpm: spec.bpm,
|
|
duration,
|
|
arrangement: spec.arrangement,
|
|
brightness: spec.brightness,
|
|
noise: spec.noise,
|
|
noiseColour: spec.noiseColour ?? null,
|
|
dynamics: spec.dynamics,
|
|
transition: spec.transition ?? 0.5,
|
|
seed: 1 + SONGS.indexOf(spec),
|
|
});
|
|
const mono = new MockAudioBuffer(
|
|
1, synth.length, synth.sampleRate);
|
|
mono.getChannelData(0).set(bankPcm(synth.getChannelData(0)));
|
|
const entry = { ...spec, track: FeatureTrack.fromAudioBuffer(mono, { fps }) };
|
|
cache.set(cacheKey, 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, duration = DURATION } = {}) {
|
|
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, duration }));
|
|
}
|
|
|
|
/** 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' },
|
|
];
|