// 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; } /** Silence, for degenerate-input checks. */ export function synthesizeSilence({ duration = 10, sampleRate = 44100 } = {}) { return new MockAudioBuffer(2, Math.round(duration * sampleRate), sampleRate); }