music-video-gen/flow-state/src/audio/synth.js
Dejvino 022c267888 Phase 3: look generation ("A" complete)
A track now yields a complete, coherent look with no input: palette,
per-section scene assignments, parameter sets, post and feedback settings.
Seeded from a hash of the decoded PCM, so a file always renders identically.

- palette.js builds in OKLCH, not HSL. HSL lightness is not perceptual, so
  evenly-stepped HSL palettes have colours that vanish and colours that
  dominate — which matters when nobody is supervising the choice.
  Regenerates until the contrast floor is cleared.
- Scenes are assigned per section KIND, not per section: a track's drops
  share a scene and the video reads as one piece instead of a shuffle.
- Family preference per kind keeps breakdowns off strobing glitch scenes.
- Section bias (energy/density/motion) carries track character into params
  without scenes knowing anything about audio.
- PaletteSource is the seam for cover art later; no scene would change.

Gate 9/9, including the look-space spread measurement (mean pairwise
distance 0.168 against a 0.08 floor) — the one check that catches a
generator that is deterministic and valid but visually collapsed.

Known gap, not a regression: all four battery tracks currently choose the
same two scenes. There are no 'minimal' family scenes yet, so intro and
outro sections fall through to flow/organic. Differentiation is presently
carried by palette alone. Phase 7 grows the library to fix it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:18:37 +02:00

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