Three lessons worth more than the scenes: a slow axis has to move a large low-frequency area or the camera's own drift beats it, whole-frame luminance on the kick is the strobe the flash gate exists for and it arrives by accident, and a style trait expressed only as grain measures as no style at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
407 lines
18 KiB
JavaScript
407 lines
18 KiB
JavaScript
// 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);
|
|
}
|