Files
ai-agent/reel/music/dsp.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

190 lines
5.6 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.
/**
* A tiny offline DSP kit for the score's hand-made sounds (drums, bells,
* risers) and the master bus — plain Float32Array math, no Web Audio, so a
* sample lands exactly where it is written and every render is identical.
*/
export const SR = 48_000;
/** Seconds → a whole sample index (all grid times are exact on 48 kHz). */
export const toSample = (sec: number) => Math.round(sec * SR);
/** dB → linear gain. */
export const db = (x: number) => 10 ** (x / 20);
/** MIDI note → Hz. */
export const mtof = (m: number) => 440 * 2 ** ((m - 69) / 12);
/** mulberry32 — a seeded PRNG in [0, 1). */
export function rng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Seeded white noise in [-1, 1). */
export function white(seed: number): () => number {
const r = rng(seed);
return () => r() * 2 - 1;
}
/** RBJ-cookbook biquad (direct form I); coefficients can be re-set while running. */
export class Biquad {
private b0 = 1;
private b1 = 0;
private b2 = 0;
private a1 = 0;
private a2 = 0;
private x1 = 0;
private x2 = 0;
private y1 = 0;
private y2 = 0;
static lp(f: number, q = Math.SQRT1_2) {
return new Biquad().lp(f, q);
}
static hp(f: number, q = Math.SQRT1_2) {
return new Biquad().hp(f, q);
}
static bp(f: number, q = 1) {
return new Biquad().bp(f, q);
}
static peak(f: number, q: number, gainDb: number) {
return new Biquad().peak(f, q, gainDb);
}
static raw(b: [number, number, number], a: [number, number, number]) {
const bq = new Biquad();
bq.set(b[0] / a[0], b[1] / a[0], b[2] / a[0], a[1] / a[0], a[2] / a[0]);
return bq;
}
private set(b0: number, b1: number, b2: number, a1: number, a2: number) {
this.b0 = b0;
this.b1 = b1;
this.b2 = b2;
this.a1 = a1;
this.a2 = a2;
}
private static w(f: number, q: number) {
const w0 = (2 * Math.PI * Math.min(Math.max(f, 5), SR * 0.49)) / SR;
return { c: Math.cos(w0), al: Math.sin(w0) / (2 * q) };
}
lp(f: number, q = Math.SQRT1_2): this {
const { c, al } = Biquad.w(f, q);
const a0 = 1 + al;
this.set((1 - c) / 2 / a0, (1 - c) / a0, (1 - c) / 2 / a0, (-2 * c) / a0, (1 - al) / a0);
return this;
}
hp(f: number, q = Math.SQRT1_2): this {
const { c, al } = Biquad.w(f, q);
const a0 = 1 + al;
this.set((1 + c) / 2 / a0, -(1 + c) / a0, (1 + c) / 2 / a0, (-2 * c) / a0, (1 - al) / a0);
return this;
}
/** Band-pass with a 0 dB peak. */
bp(f: number, q = 1): this {
const { c, al } = Biquad.w(f, q);
const a0 = 1 + al;
this.set(al / a0, 0, -al / a0, (-2 * c) / a0, (1 - al) / a0);
return this;
}
peak(f: number, q: number, gainDb: number): this {
const { c, al } = Biquad.w(f, q);
const A = 10 ** (gainDb / 40);
const a0 = 1 + al / A;
this.set((1 + al * A) / a0, (-2 * c) / a0, (1 - al * A) / a0, (-2 * c) / a0, (1 - al / A) / a0);
return this;
}
process(x: number): number {
const y = this.b0 * x + this.b1 * this.x1 + this.b2 * this.x2 - this.a1 * this.y1 - this.a2 * this.y2;
this.x2 = this.x1;
this.x1 = x;
this.y2 = this.y1;
this.y1 = y;
return y;
}
/** Filter `buf` in place. */
run(buf: Float32Array): Float32Array {
for (let i = 0; i < buf.length; i++) buf[i] = this.process(buf[i]);
return buf;
}
}
/** Zero-phase filtering: forward then backward (no delay, squared magnitude). */
export function filtfilt(buf: Float32Array, make: () => Biquad): Float32Array {
make().run(buf);
buf.reverse();
make().run(buf);
buf.reverse();
return buf;
}
/** Soft clip normalised so that `drive` only shapes, never boosts, a full-scale peak. */
export function saturate(buf: Float32Array, drive: number): Float32Array {
const k = Math.tanh(drive);
for (let i = 0; i < buf.length; i++) buf[i] = Math.tanh(buf[i] * drive) / k;
return buf;
}
/** Linear fade-out over the last `sec` seconds (one-shots must end at zero). */
export function fadeTail(buf: Float32Array, sec: number): Float32Array {
const n = Math.min(buf.length, Math.round(sec * SR));
for (let i = 0; i < n; i++) buf[buf.length - 1 - i] *= i / n;
return buf;
}
/** Peak-normalise to `peak`. */
export function normalize(buf: Float32Array, peak = 1): Float32Array {
let m = 0;
for (let i = 0; i < buf.length; i++) m = Math.max(m, Math.abs(buf[i]));
if (m > 0) for (let i = 0; i < buf.length; i++) buf[i] *= peak / m;
return buf;
}
/** A stereo pair. */
export interface Stereo {
L: Float32Array;
R: Float32Array;
}
/**
* A full-length stereo stem that sounds are mixed into at exact sample
* offsets — the drums and every grid-locked event go through one of these.
*/
export class Stem implements Stereo {
L: Float32Array;
R: Float32Array;
constructor(readonly length: number) {
this.L = new Float32Array(length);
this.R = new Float32Array(length);
}
/** Mix `src` in starting at `sec` (sample-exact), scaled by `gain`, panned −1…1. */
add(sec: number, src: Float32Array | Stereo, gain = 1, pan = 0): this {
const start = toSample(sec);
const L = src instanceof Float32Array ? src : src.L;
const R = src instanceof Float32Array ? src : src.R;
// balance pan: unity in the centre, the far side turned down
const gl = gain * Math.min(1, 1 - pan);
const gr = gain * Math.min(1, 1 + pan);
const n = Math.min(L.length, this.length - start);
for (let i = Math.max(0, -start); i < n; i++) {
this.L[start + i] += L[i] * gl;
this.R[start + i] += R[i] * gr;
}
return this;
}
}