music-video-gen/flow-state/src/audio/synth.js
Dejvino 0c5b0a8d4d Close out the twenty: what the gates taught
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>
2026-08-17 07:00:45 +02:00

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);
}