Files
ai-agent/reel/music/synth.ts
Gabriel Vidal 945976fd2e feat(reel): an original score composed in code
music/ renders public/audio/score.wav (24-bit 48 kHz, exactly 120 s) with
plain TypeScript DSP in Node (`npm run music`): D major on the 王道進行
"royal road" progression (G△9 – A9 – F#m7 – Bm7), a two-bar hook in the yo
scale over the three drops, sidechained supersaws, a pluck arpeggio, FM
bells on the notification pings and glass chimes for the cron jobs. Every
drum hit is scheduled from src/score.ts, so the kicks sit within 0.2 ms of
the grid the picture pulses on. check-music.mjs re-measures loudness
(−15 LUFS, −1.3 dBTP), per-chapter levels and kick alignment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:50:47 +02:00

297 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The score's synthesisers in plain TypeScript.
*
* Oscillators are band-limited wavetables with half-octave mipmaps, the way
* Web Audio's OscillatorNode works. Unison stacks of detuned copies are fanned
* across the stereo field (the supersaws). Envelopes are ADSR, and an
* optional per-note filter envelope gives plucks and basses their snap. Each
* note is rendered on its own and summed in, so polyphony is free and
* nothing is ever stolen or cut.
*/
import { Biquad, SR, rng, toSample, type Stereo } from "./dsp";
export interface Env {
attack: number;
decay: number;
sustain: number;
release: number;
}
export type Wave = "saw" | "square" | "triangle" | "sine";
// ─── band-limited wavetables ──────────────────────────────────────────────
const TABLE = 4096;
const BANDS = 24;
const cache = new Map<Wave, Float32Array[]>();
/** One table per half-octave band k, serving fundamentals up to 20·2^((k+1)/2) Hz with every harmonic below 19.5 kHz. */
function tables(wave: Wave): Float32Array[] {
const hit = cache.get(wave);
if (hit) return hit;
const out: Float32Array[] = [];
const sin = new Float64Array(TABLE);
for (let i = 0; i < TABLE; i++) sin[i] = Math.sin((2 * Math.PI * i) / TABLE);
for (let k = 0; k < BANDS; k++) {
const harmonics = wave === "sine" ? 1 : Math.max(1, Math.floor(19500 / (20 * 2 ** ((k + 1) / 2))));
const tab = new Float32Array(TABLE + 1);
for (let h = 1; h <= harmonics; h++) {
let a = 0;
if (wave === "saw") a = 1 / h;
else if (wave === "square") a = h % 2 ? 1 / h : 0;
else if (wave === "triangle") a = h % 2 ? (((h - 1) / 2) % 2 ? -1 : 1) / (h * h) : 0;
else a = h === 1 ? 1 : 0;
if (a) for (let i = 0; i < TABLE; i++) tab[i] += a * sin[(h * i) % TABLE];
}
let m = 0;
for (let i = 0; i < TABLE; i++) m = Math.max(m, Math.abs(tab[i]));
for (let i = 0; i < TABLE; i++) tab[i] /= m;
tab[TABLE] = tab[0];
out.push(tab);
}
cache.set(wave, out);
return out;
}
const bandOf = (f: number) => Math.min(BANDS - 1, Math.max(0, Math.ceil(2 * Math.log2(Math.max(f, 20) / 20)) - 1));
/**
* Add `n` samples of a unison stack into L/R: copy c at `freqs[c]` Hz from
* phase `phases[c]` (0…1), weighted `gl[c]`/`gr[c]` per side. One pass,
* one table (the band of the highest copy).
*/
export function unison(
L: Float32Array,
R: Float32Array,
n: number,
wave: Wave,
freqs: number[],
phases: number[],
gl: number[],
gr: number[],
vibrato?: Vibrato,
) {
const tab = tables(wave)[bandOf(Math.max(...freqs) * (vibrato ? 2 ** (vibrato.cents / 1200) : 1))];
const k = freqs.length;
const inc = Float64Array.from(freqs, (f) => (f / SR) * TABLE);
const ph = Float64Array.from(phases, (p) => p * TABLE);
const wl = Float64Array.from(gl);
const wr = Float64Array.from(gr);
// vibrato: a pitch LFO (± cents) that fades in after `delay`, updated every 32 samples
const vw = vibrato ? (2 * Math.PI * vibrato.rate) / SR : 0;
const vd = vibrato ? Math.round(vibrato.delay * SR) : 0;
const vr = vibrato ? Math.round(0.25 * SR) : 1;
let bend = 1;
for (let i = 0; i < n; i++) {
if (vibrato && (i & 31) === 0) {
const fade = Math.min(1, Math.max(0, (i - vd) / vr));
bend = 2 ** ((vibrato.cents * fade * Math.sin(vw * i)) / 1200);
}
let l = 0;
let r = 0;
for (let c = 0; c < k; c++) {
let p = ph[c];
const j = p | 0;
const y = tab[j] + (p - j) * (tab[j + 1] - tab[j]);
l += wl[c] * y;
r += wr[c] * y;
p += inc[c] * bend;
ph[c] = p >= TABLE ? p - TABLE : p;
}
L[i] += l;
R[i] += r;
}
}
/** A delayed pitch vibrato: `cents` deep at `rate` Hz, fading in over 250 ms after `delay` seconds. */
export interface Vibrato {
cents: number;
rate: number;
delay: number;
}
/** A unison saw stack gliding exponentially from f0 to f1 over `n` samples (the synth risers). */
export function glide(n: number, f0: number, f1: number, count: number, spread: number, seed: number): Stereo {
const L = new Float32Array(n);
const R = new Float32Array(n);
const r = rng(seed);
const tabs = tables("saw");
for (let c = 0; c < count; c++) {
const x = count === 1 ? 0 : (c / (count - 1)) * 2 - 1;
const ratio = 2 ** ((spread / 2) * x / 1200);
const pan = (c % 2 ? 1 : -1) * Math.abs(x) * 0.8;
const gl = Math.min(1, 1 - pan) / Math.sqrt(count);
const gr = Math.min(1, 1 + pan) / Math.sqrt(count);
let ph = r() * TABLE;
for (let i0 = 0; i0 < n; i0 += 64) {
const f = f0 * (f1 / f0) ** (i0 / n) * ratio;
const tab = tabs[bandOf(f * 1.06)];
const inc = (f / SR) * TABLE;
const end = Math.min(n, i0 + 64);
for (let i = i0; i < end; i++) {
const k = ph | 0;
const y = tab[k] + (ph - k) * (tab[k + 1] - tab[k]);
L[i] += gl * y;
R[i] += gr * y;
ph += inc;
if (ph >= TABLE) ph -= TABLE;
}
}
}
return { L, R };
}
// ─── voices ───────────────────────────────────────────────────────────────
export interface VoiceSpec {
wave: Wave;
/** detuned copies: how many, their total spread in cents, how far they fan out (0 mono … 1 hard left/right) */
unison?: { count: number; spread: number; width: number };
env: Env;
/** per-note low-pass, cutoff = base·2^(octaves·e(t)) with e following `env` */
filter?: { base: number; octaves: number; env: Env; q: number; poles?: 2 | 4 };
vibrato?: Vibrato;
}
interface NoteEvent {
f: number;
t: number;
dur: number;
v: number;
}
/** An instrument: collect notes, then `render()` them all into a stereo buffer. */
export class Synth {
private notes: NoteEvent[] = [];
constructor(
readonly spec: VoiceSpec,
readonly seed: number,
) {}
note(f: number, t: number, dur: number, v: number): this {
this.notes.push({ f, t, dur, v });
return this;
}
chord(fs: number[], t: number, dur: number, v: number): this {
for (const f of fs) this.note(f, t, dur, v);
return this;
}
/**
* Add every note into `out` (a full-length stereo buffer). Identical notes
* (pitch, length, velocity) are rendered once per variant and reused like
* samples. VARIANTS seeds of the unison phases alternate, so a repeated
* stab still breathes a little.
*/
render(out: Stereo): void {
const total = out.L.length;
const cache = new Map<string, Stereo>();
this.notes.forEach((n, j) => {
const s0 = toSample(n.t);
if (s0 >= total) return;
const variant = j % VARIANTS;
const key = `${n.f.toFixed(4)}|${Math.round(n.dur * SR)}|${n.v.toFixed(4)}|${variant}`;
let shot = cache.get(key);
if (!shot) cache.set(key, (shot = this.shot(n, this.seed * 7919 + variant * 104729 + Math.round(n.f * 1000))));
const len = Math.min(shot.L.length, total - s0);
for (let i = 0; i < len; i++) {
out.L[s0 + i] += shot.L[i];
out.R[s0 + i] += shot.R[i];
}
});
}
/** One note, start to silence. */
private shot(n: NoteEvent, seed: number): Stereo {
const { spec } = this;
const len = Math.ceil((n.dur + spec.env.release * 1.6) * SR);
const L = new Float32Array(len);
const R = new Float32Array(len);
const r = rng(seed);
const u = spec.unison ?? { count: 1, spread: 0, width: 0 };
const freqs: number[] = [];
const phases: number[] = [];
const gl: number[] = [];
const gr: number[] = [];
for (let c = 0; c < u.count; c++) {
const x = u.count === 1 ? 0 : (c / (u.count - 1)) * 2 - 1;
const pan = (c % 2 ? 1 : -1) * Math.abs(x) * u.width;
const g = (x === 0 ? 1 : 0.85) / Math.sqrt(u.count);
freqs.push(n.f * 2 ** (((u.spread / 2) * x) / 1200));
phases.push(r());
gl.push(g * Math.min(1, 1 - pan));
gr.push(g * Math.min(1, 1 + pan));
}
unison(L, R, len, spec.wave, freqs, phases, gl, gr, spec.vibrato);
if (spec.filter) noteFilter(L, R, len, n, spec.filter);
applyEnv(L, R, len, n, spec.env);
return { L, R };
}
}
/** Seeds of unison phases per repeated note. */
const VARIANTS = 3;
/** Amplitude ADSR (linear attack, exponential decay and release) × velocity, in place. */
function applyEnv(L: Float32Array, R: Float32Array, len: number, n: NoteEvent, env: Env) {
const A = Math.max(1, Math.round(env.attack * SR));
const off = Math.round(n.dur * SR);
const kd = Math.exp(-1 / ((Math.max(env.decay, 1e-3) / 3) * SR));
const kr = Math.exp(-1 / ((Math.max(env.release, 1e-3) / 4) * SR));
const S = env.sustain;
let dec = 1;
let e = 0;
for (let i = 0; i < len; i++) {
if (i < off) {
if (i < A) e = i / A;
else {
e = S + (1 - S) * dec;
dec *= kd;
}
} else e *= kr;
const g = e * n.v;
L[i] *= g;
R[i] *= g;
}
}
/** The per-note low-pass: coefficients follow the filter envelope, updated every 16 samples. */
function noteFilter(L: Float32Array, R: Float32Array, len: number, n: NoteEvent, fe: NonNullable<VoiceSpec["filter"]>) {
const poles = fe.poles ?? 4;
const fl = [new Biquad(), new Biquad()];
const fr = [new Biquad(), new Biquad()];
const A = Math.max(1, Math.round(fe.env.attack * SR));
const off = Math.round(n.dur * SR);
const kd = Math.exp(-16 / ((Math.max(fe.env.decay, 1e-3) / 3) * SR));
const kr = Math.exp(-16 / ((Math.max(fe.env.release, 1e-3) / 4) * SR));
const S = fe.env.sustain;
let dec = 1;
let e = 0;
for (let i0 = 0; i0 < len; i0 += 16) {
if (i0 < off) {
if (i0 < A) e = i0 / A;
else {
e = S + (1 - S) * dec;
dec *= kd;
}
} else e *= kr;
const f = fe.base * 2 ** (fe.octaves * e);
for (let p = 0; p < poles / 2; p++) {
fl[p].lp(f, fe.q);
fr[p].lp(f, fe.q);
}
const end = Math.min(len, i0 + 16);
for (let i = i0; i < end; i++) {
let a = L[i];
let b = R[i];
for (let p = 0; p < poles / 2; p++) {
a = fl[p].process(a);
b = fr[p].process(b);
}
L[i] = a;
R[i] = b;
}
}
}