// Synthetic audio with known ground truth, so tempo and segmentation can be // tested against an exact answer instead of "sounds about right". Real music // goes through the click track and the battery; this catches regressions in CI // speed and without a GPU. /** Minimal stand-in for AudioBuffer — the analysis code only needs this surface. */ export class MockAudioBuffer { constructor(channels, length, sampleRate) { this.numberOfChannels = channels; this.length = length; this.sampleRate = sampleRate; this.duration = length / sampleRate; this._data = Array.from({ length: channels }, () => new Float32Array(length)); } getChannelData(i) { return this._data[i]; } } function addKick(data, sampleRate, at, gain = 1) { const start = Math.round(at * sampleRate); const length = Math.round(0.12 * sampleRate); for (let i = 0; i < length; i++) { const s = start + i; if (s < 0 || s >= data.length) continue; const t = i / sampleRate; const env = Math.exp(-t * 30); const freq = 55 * Math.exp(-t * 20) + 40; // pitch drop, like a real kick data[s] += Math.sin(2 * Math.PI * freq * t) * env * gain; } } function addHat(data, sampleRate, at, gain = 0.3, seed = 1) { const start = Math.round(at * sampleRate); const length = Math.round(0.04 * sampleRate); let s0 = seed >>> 0; const rnd = () => { s0 = (Math.imul(s0 ^ (s0 >>> 15), s0 | 1) + 0x6d2b79f5) >>> 0; return ((s0 >>> 14) & 0xffff) / 0xffff - 0.5; }; for (let i = 0; i < length; i++) { const s = start + i; if (s < 0 || s >= data.length) continue; const env = Math.exp(-(i / sampleRate) * 90); data[s] += rnd() * env * gain; } } function addPad(data, sampleRate, from, to, gain = 0.15, root = 110) { const start = Math.round(from * sampleRate); const end = Math.min(data.length, Math.round(to * sampleRate)); for (let s = start; s < end; s++) { const t = s / sampleRate; data[s] += (Math.sin(2 * Math.PI * root * t) + Math.sin(2 * Math.PI * root * 1.5 * t)) * gain * 0.5; } } /** * A four-to-the-floor track at a known BPM. * @param {object} opts * @returns {MockAudioBuffer} */ export function synthesizeBeat({ bpm = 128, duration = 40, sampleRate = 44100, hats = true, pad = true, kickGain = 1, hatGain = 0.25, padRoot = 110, padGain = 0.12, } = {}) { const length = Math.round(duration * sampleRate); const buffer = new MockAudioBuffer(2, length, sampleRate); const left = buffer.getChannelData(0); const right = buffer.getChannelData(1); const beat = 60 / bpm; let index = 0; for (let t = 0; t < duration; t += beat, index++) { // Accent the downbeat so the bar phase is detectable. addKick(left, sampleRate, t, kickGain * (index % 4 === 0 ? 1.0 : 0.8)); if (hats) addHat(left, sampleRate, t + beat / 2, hatGain, index + 1); } if (pad) addPad(left, sampleRate, 0, duration, padGain, padRoot); for (let i = 0; i < length; i++) right[i] = left[i] * 0.98; return buffer; } /** * A track with a deliberate structural change at `changeAt` seconds: sparse and * dark before, dense and bright after. Segmentation must find that boundary. */ export function synthesizeSectioned({ bpm = 128, duration = 120, changeAt = 60, sampleRate = 44100, } = {}) { const length = Math.round(duration * sampleRate); const buffer = new MockAudioBuffer(2, length, sampleRate); const left = buffer.getChannelData(0); const right = buffer.getChannelData(1); const beat = 60 / bpm; let index = 0; for (let t = 0; t < duration; t += beat, index++) { const after = t >= changeAt; addKick(left, sampleRate, t, after ? 1.0 : 0.35); if (after) { addHat(left, sampleRate, t + beat / 2, 0.45, index + 1); addHat(left, sampleRate, t + beat / 4, 0.25, index + 7); } } addPad(left, sampleRate, 0, changeAt, 0.10, 110); addPad(left, sampleRate, changeAt, duration, 0.22, 440); // brighter after for (let i = 0; i < length; i++) right[i] = left[i] * 0.98; return buffer; } /** * A broadband noise bed. This is the only way to move spectral FLATNESS, which * the generator reads to decide how noisy a track is — it picks the director * partly on it, and the whole grade follows. Kicks, hats and pads are all * tonal or transient, so without this the bank could only ever produce tonal * tracks and half the director table would be unreachable. * * `tilt` shapes it with a one-pole filter: 0 is dark rumble, 1 is bright hiss. * Brightness and noisiness have to be independently controllable or the bank * cannot tell the two apart when it reports coverage. */ function addNoise(data, sampleRate, from, to, gain = 0.05, tilt = 0.5, seed = 12345) { const start = Math.max(0, Math.round(from * sampleRate)); const end = Math.min(data.length, Math.round(to * sampleRate)); let s0 = seed >>> 0; const rnd = () => { s0 = (Math.imul(s0 ^ (s0 >>> 15), s0 | 1) + 0x6d2b79f5) >>> 0; return ((s0 >>> 14) & 0xffff) / 0xffff - 0.5; }; // Two cascaded one-poles rather than one. A single pole is a 6dB/octave // slope, which leaves so much top on "dark" noise that a noisy track always // measured bright — the dark-and-noisy corner, which is the corrupt // director's entire home ground, was not reachable at all. const a = 0.004 + tilt * 0.9; let lp1 = 0, lp2 = 0; for (let s = start; s < end; s++) { const white = rnd(); lp1 += a * (white - lp1); lp2 += a * (lp1 - lp2); // Below half tilt the lowpass IS the signal; above it, what the lowpass // removed is. data[s] += (tilt > 0.5 ? white - lp2 : lp2 * 3) * gain * 2; } } /** A pad with controllable harmonic content — the other half of brightness. */ function addRichPad(data, sampleRate, from, to, gain, root, harmonics = 2) { const start = Math.max(0, Math.round(from * sampleRate)); const end = Math.min(data.length, Math.round(to * sampleRate)); for (let s = start; s < end; s++) { const t = s / sampleRate; let v = 0; for (let h = 1; h <= harmonics; h++) { v += Math.sin(2 * Math.PI * root * h * t) / h; } data[s] += v * gain / Math.log2(harmonics + 1); } } /** * Arrangement shapes, as stage lists. * * A song's SHAPE is a feature the generator reads as directly as its tempo — * section kinds decide which families each director opens, so a bank that only * contains one arrangement cannot exercise the casting logic no matter how * widely it spreads tempo and brightness. Spans are fractions of the track. * * Stage fields: span, kick, hat, noise, pad, root multiplier, ramp. */ export const ARRANGEMENTS = { // Beatless. Segments into quiet kinds only — the case that must stay // representable, since it is what an actual ambient track looks like. ambient: [ { span: 0.30, kick: 0, hat: 0, pad: 0.10, root: 1.0 }, { span: 0.25, kick: 0, hat: 0, pad: 0.20, root: 1.5, ramp: true }, { span: 0.25, kick: 0, hat: 0, pad: 0.12, root: 1.0 }, { span: 0.20, kick: 0, hat: 0, pad: 0.07, root: 0.75 }, ], // The standard shape: everything present, once. classic: [ { span: 0.12, kick: 0.00, hat: 0.00, pad: 0.09, root: 1.0 }, { span: 0.16, kick: 0.55, hat: 0.20, pad: 0.14, root: 1.5, ramp: true }, { span: 0.22, kick: 1.00, hat: 0.50, pad: 0.24, root: 4.0 }, { span: 0.14, kick: 0.05, hat: 0.02, pad: 0.07, root: 1.0 }, { span: 0.24, kick: 1.00, hat: 0.55, pad: 0.26, root: 4.0 }, { span: 0.12, kick: 0.25, hat: 0.08, pad: 0.08, root: 1.0 }, ], // Long and level: a club tool that states its groove and stays there. The // case that produces `sustain`, which the classic shape barely reaches. club: [ { span: 0.10, kick: 0.45, hat: 0.15, pad: 0.10, root: 1.0 }, { span: 0.30, kick: 0.90, hat: 0.45, pad: 0.20, root: 2.0 }, { span: 0.12, kick: 0.35, hat: 0.15, pad: 0.12, root: 1.0 }, { span: 0.34, kick: 1.00, hat: 0.55, pad: 0.22, root: 3.0 }, { span: 0.14, kick: 0.50, hat: 0.20, pad: 0.10, root: 1.0 }, ], // Many short sections. Stresses the segmenter and produces the highest // section counts, which is what makes rosters rotate. breaks: [ { span: 0.10, kick: 0.30, hat: 0.10, pad: 0.08, root: 1.0 }, { span: 0.12, kick: 0.95, hat: 0.55, pad: 0.20, root: 3.0 }, { span: 0.10, kick: 0.05, hat: 0.02, pad: 0.06, root: 1.0 }, { span: 0.14, kick: 1.00, hat: 0.60, pad: 0.24, root: 4.0 }, { span: 0.10, kick: 0.10, hat: 0.03, pad: 0.07, root: 0.75 }, { span: 0.16, kick: 0.60, hat: 0.30, pad: 0.16, root: 2.0, ramp: true }, { span: 0.16, kick: 1.00, hat: 0.65, pad: 0.26, root: 4.0 }, { span: 0.12, kick: 0.20, hat: 0.05, pad: 0.08, root: 1.0 }, ], // One long rise into one payoff, then gone. Slow material's shape. ballad: [ { span: 0.28, kick: 0.00, hat: 0.00, pad: 0.10, root: 1.0 }, { span: 0.30, kick: 0.40, hat: 0.10, pad: 0.18, root: 1.5, ramp: true }, { span: 0.24, kick: 0.85, hat: 0.35, pad: 0.26, root: 3.0 }, { span: 0.18, kick: 0.15, hat: 0.03, pad: 0.09, root: 1.0 }, ], // Two sections, both quiet. Kept because it is the degenerate case the old // bank produced by accident, and the generator must not break on it. sparse: [ { span: 0.5, kick: 0.10, hat: 0.02, pad: 0.08, root: 1.0 }, { span: 0.5, kick: 0.35, hat: 0.15, pad: 0.16, root: 2.0 }, ], }; /** * One song, from a spec. * * The four continuous knobs map onto exactly the four summary statistics the * look generator reads, so a bank built on them can be checked for coverage * against what the generator actually consumes rather than against what seemed * like a reasonable spread of audio. * * bpm → summary.bpm * brightness → summary.meanCentroid (pad register, harmonics, noise tilt) * noise → summary.meanFlatness (broadband bed) * dynamics → summary.dynamicRange (how far quiet stages fall below loud) */ export function synthesizeSong({ bpm = 128, duration = 120, sampleRate = 44100, arrangement = 'classic', brightness = 0.5, noise = 0.15, noiseColour = null, dynamics = 0.6, seed = 1, } = {}) { const stages = ARRANGEMENTS[arrangement] || ARRANGEMENTS.classic; const length = Math.round(duration * sampleRate); const buffer = new MockAudioBuffer(2, length, sampleRate); const left = buffer.getChannelData(0); const right = buffer.getChannelData(1); const beat = 60 / bpm; // Dynamic range is measured as a crest factor — the 40th percentile of // frame RMS against the 95th — so moving it means changing the RATIO // between the quiet stages and the loud ones, not the overall level. // // A gamma on each stage's level relative to the loudest stage does that. // The loudest stage is pinned at its original value and everything below it // is pushed down (high dynamics) or pulled up (limitered), so a dynamic // track does not simply come out quieter — which is what a straight floor // offset did, and it moved the measured range by 0.2 across the whole bank. const gamma = 0.25 + dynamics * 3.2; // Percussion is transient, so a kick-driven track has a low RMS floor // between hits no matter how hard it is limitered — the gamma alone could // not get the measured range below 0.67. What actually fills the gaps is // SUSTAINED content, so the pad and the noise bed swell as dynamics falls. // That is also what a limitered master really sounds like. const sustainBoost = 1 + (1 - dynamics) ** 2 * 6; const peakOf = (field) => Math.max(...stages.map((s) => s[field] || 0), 1e-6); const peaks = { kick: peakOf('kick'), hat: peakOf('hat'), pad: peakOf('pad') }; const level = (x, field) => (x <= 0 ? 0 : peaks[field] * (x / peaks[field]) ** gamma); // Brightness has to reach genuinely dark, and a hat is broadband: leaving // any hat in at brightness 0 held the measured centroid above 0.65 no matter // what the pad did. Below a quarter brightness the hats go entirely. const baseRoot = 55 + brightness * 260; const harmonics = Math.max(1, Math.round(1 + brightness ** 1.5 * 9)); const hatPresence = Math.max(0, (brightness - 0.22) / 0.78); // The noise bed's COLOUR is its own axis, defaulting to the track's // brightness but separable from it. Tying the two together made spectral // flatness a function of centroid — measured across the bank they came out // at r = 0.95, one axis wearing two names, and the whole dark-but-noisy // quadrant was unreachable. Hiss over a sub-bass pad is an ordinary record. const tilt = 0.05 + (noiseColour === null ? brightness : noiseColour) * 0.9; let at = 0; let stageIndex = 0; for (const stage of stages) { const from = at; const to = Math.min(duration, at + stage.span * duration); at = to; stageIndex++; let index = Math.round(from / beat); for (let t = from; t < to; t += beat, index++) { const ramp = stage.ramp ? (t - from) / Math.max(1e-6, to - from) : 1; const gain = level(stage.kick, 'kick') * (0.25 + ramp * 0.75); if (stage.kick > 0.02 && gain > 0.005) { addKick(left, sampleRate, t, gain * (index % 4 === 0 ? 1 : 0.8)); } if (stage.hat > 0.02 && hatPresence > 0) { const h = level(stage.hat, 'hat') * ramp * hatPresence * 1.4; addHat(left, sampleRate, t + beat / 2, h, index + seed); if (h > 0.3) addHat(left, sampleRate, t + beat / 4, h * 0.6, index + seed + 7); } } addRichPad(left, sampleRate, from, to, level(stage.pad, 'pad') * sustainBoost, baseRoot * stage.root, harmonics); if (noise > 0.01) { // The bed follows the arrangement so it cannot flatten the dynamics // it is layered over. addNoise(left, sampleRate, from, to, noise * level(stage.pad, 'pad') * 3 * sustainBoost, tilt, (seed * 7919 + stageIndex) >>> 0); } } for (let i = 0; i < length; i++) right[i] = left[i] * 0.98; return buffer; } /** * A track with a full ARRANGEMENT — intro, build, drop, breakdown, drop, outro. * * `synthesizeSectioned` has one change point, so it segments into exactly two * sections and both of them are quiet kinds. That is fine for testing that the * segmenter finds a boundary, and it was quietly useless for anything that * measures what the generator DOES with a song: intro, breakdown and outro are * restricted to the restful families for every director, so a two-section track * cannot reach geometric, glitch or structural scenes at all. Half the library * is unreachable before the seed is even drawn, and a test built on it will * report that as a casting failure. * * The stages here are shaped to hit the segmenter's own classifier: a build * needs a rising energy slope, a drop needs energy and flux together, and a * breakdown needs to fall well below the median. */ export function synthesizeArrangement({ bpm = 128, duration = 120, sampleRate = 44100, brightness = 1, density = 1, } = {}) { const length = Math.round(duration * sampleRate); const buffer = new MockAudioBuffer(2, length, sampleRate); const left = buffer.getChannelData(0); const right = buffer.getChannelData(1); // Proportions of the track, in order. Kick gain, hat gain, pad root, pad gain. const stages = [ { span: 0.12, kick: 0.00, hat: 0.00, root: 110, pad: 0.09 }, // intro { span: 0.16, kick: 0.55, hat: 0.20, root: 165, pad: 0.14, ramp: true }, // build { span: 0.22, kick: 1.00, hat: 0.50, root: 440, pad: 0.24 }, // drop { span: 0.14, kick: 0.05, hat: 0.02, root: 110, pad: 0.07 }, // breakdown { span: 0.24, kick: 1.00, hat: 0.55, root: 440, pad: 0.26 }, // drop { span: 0.12, kick: 0.25, hat: 0.08, root: 110, pad: 0.08 }, // outro ]; const beat = 60 / bpm; let at = 0; for (const stage of stages) { const from = at; const to = Math.min(duration, at + stage.span * duration); at = to; let index = Math.round(from / beat); for (let t = from; t < to; t += beat, index++) { // A build ramps across its own span so the energy slope is positive // enough for the classifier to call it one. const ramp = stage.ramp ? (t - from) / Math.max(1e-6, to - from) : 1; const gain = stage.kick * density * (0.25 + ramp * 0.75); if (gain > 0.02) addKick(left, sampleRate, t, gain * (index % 4 === 0 ? 1 : 0.8)); if (stage.hat > 0.02) { const h = stage.hat * density * ramp; addHat(left, sampleRate, t + beat / 2, h, index + 1); if (h > 0.3) addHat(left, sampleRate, t + beat / 4, h * 0.6, index + 7); } } addPad(left, sampleRate, from, to, stage.pad, stage.root * brightness); } for (let i = 0; i < length; i++) right[i] = left[i] * 0.98; return buffer; } /** Silence, for degenerate-input checks. */ export function synthesizeSilence({ duration = 10, sampleRate = 44100 } = {}) { return new MockAudioBuffer(2, Math.round(duration * sampleRate), sampleRate); }