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

301 lines
9.5 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 mixing desk, offline: automation curves, swept bus filters, the kick
* duck, a partitioned-FFT convolution reverb and a ping-pong delay, all on
* whole-film buffers.
*/
import { BAR } from "../src/timeline";
import { Biquad, SR, db, type Stereo } from "./dsp";
/** An automation point: [bar position (30.75 = bar 30 beat 4), value, "ramp"?]. */
export type Point = [number, number, "ramp"?];
/**
* A per-sample curve from points. A step glides linearly over the 12 ms
* before its time. A ramp is exponential from the previous point (linear
* in dB for gains, in octaves for cutoffs). `map` turns the written value
* into the curve's (dB → gain).
*/
export function curve(points: Point[], length: number, map = (v: number) => v): Float32Array {
const out = new Float32Array(length);
const at = (b: number) => Math.round((b - 1) * BAR * SR);
let prevI = 0;
let prev = map(points[0][1]);
out.fill(prev);
for (const [b, v, kind] of points.slice(1)) {
const i = Math.min(at(b), length);
const x = map(v);
if (kind === "ramp") {
const n = Math.max(1, i - prevI);
for (let k = prevI; k < i; k++) out[k] = prev * (x / prev) ** ((k - prevI) / n);
} else {
const g = Math.max(prevI, i - Math.round(0.012 * SR));
for (let k = g; k < i; k++) out[k] = prev + ((x - prev) * (k - g)) / Math.max(1, i - g);
}
out.fill(x, i);
prevI = i;
prev = x;
}
return out;
}
/** dB → gain for faders; −∞ becomes −80 dB, so exponential ramps can leave it. */
export const fader = (v: number) => db(Math.max(v, -80));
/**
* Filter a stereo buffer in place (RBJ low/high-pass, 2 or 4 poles).
* `cutoff` may be a curve: the coefficients follow it every 32 samples.
* Silent stretches are skipped once the filter has rung out.
*/
export function filter(s: Stereo, type: "lp" | "hp", cutoff: number | Float32Array, q = Math.SQRT1_2, poles: 2 | 4 = 2) {
const coef = (f: number) => {
const w0 = (2 * Math.PI * Math.min(Math.max(f, 5), SR * 0.49)) / SR;
const c = Math.cos(w0);
const al = Math.sin(w0) / (2 * q);
const a0 = 1 + al;
const b0 = type === "lp" ? (1 - c) / 2 / a0 : (1 + c) / 2 / a0;
const b1 = type === "lp" ? (1 - c) / a0 : -(1 + c) / a0;
return [b0, b1, b0, (-2 * c) / a0, (1 - al) / a0];
};
const fixed = typeof cutoff === "number" ? coef(cutoff) : null;
for (const x of [s.L, s.R]) {
for (let stage = 0; stage < poles / 2; stage++) {
let [b0, b1, b2, a1, a2] = fixed ?? coef((cutoff as Float32Array)[0]);
let x1 = 0;
let x2 = 0;
let y1 = 0;
let y2 = 0;
for (let i0 = 0; i0 < x.length; i0 += 32) {
const end = Math.min(x.length, i0 + 32);
if (Math.abs(y1) + Math.abs(y2) + Math.abs(x1) + Math.abs(x2) < 1e-9) {
let silent = true;
for (let i = i0; i < end && silent; i++) silent = x[i] === 0;
if (silent) {
x1 = x2 = y1 = y2 = 0;
continue;
}
}
if (!fixed) [b0, b1, b2, a1, a2] = coef((cutoff as Float32Array)[i0]);
for (let i = i0; i < end; i++) {
const xi = x[i];
const y = b0 * xi + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
x2 = x1;
x1 = xi;
y2 = y1;
y1 = y;
x[i] = y;
}
}
}
}
}
/** A peaking EQ band (RBJ), in place: `gainDb` around `f` Hz. */
export function eq(s: Stereo, f: number, q: number, gainDb: number) {
const w0 = (2 * Math.PI * f) / SR;
const c = Math.cos(w0);
const al = Math.sin(w0) / (2 * q);
const A = 10 ** (gainDb / 40);
const a0 = 1 + al / A;
const b0 = (1 + al * A) / a0;
const b1 = (-2 * c) / a0;
const b2 = (1 - al * A) / a0;
const a2 = (1 - al / A) / a0;
for (const x of [s.L, s.R]) {
let x1 = 0;
let x2 = 0;
let y1 = 0;
let y2 = 0;
for (let i = 0; i < x.length; i++) {
const xi = x[i];
const y = b0 * xi + b1 * x1 + b2 * x2 - b1 * y1 - a2 * y2;
x2 = x1;
x1 = xi;
y2 = y1;
y1 = y;
x[i] = y;
}
}
}
/** Multiply by a gain curve (or a constant). */
export function gain(s: Stereo, g: number | Float32Array) {
for (const x of [s.L, s.R]) {
if (typeof g === "number") for (let i = 0; i < x.length; i++) x[i] *= g;
else for (let i = 0; i < x.length; i++) x[i] *= g[i];
}
}
/** The sidechain: × (1 − depth · duck(t)). */
export function duck(s: Stereo, curve: Float32Array, depth: number) {
for (const x of [s.L, s.R]) for (let i = 0; i < x.length; i++) x[i] *= 1 - depth * curve[i];
}
/** dst += src · g */
export function mixInto(dst: Stereo, src: Stereo, g = 1) {
for (let i = 0; i < dst.L.length; i++) {
dst.L[i] += src.L[i] * g;
dst.R[i] += src.R[i] * g;
}
}
/** mono dst += (L + R)/2 · g */
export function sendInto(dst: Float32Array, src: Stereo, g: number) {
const h = g / 2;
for (let i = 0; i < dst.length; i++) dst[i] += (src.L[i] + src.R[i]) * h;
}
// ─── FFT convolution ──────────────────────────────────────────────────────
/** In-place iterative radix-2 complex FFT of size n (a power of two). */
class FFT {
private cos: Float64Array;
private sin: Float64Array;
private rev: Uint32Array;
constructor(readonly n: number) {
this.cos = new Float64Array(n / 2);
this.sin = new Float64Array(n / 2);
for (let i = 0; i < n / 2; i++) {
this.cos[i] = Math.cos((2 * Math.PI * i) / n);
this.sin[i] = Math.sin((2 * Math.PI * i) / n);
}
const bits = Math.log2(n);
this.rev = new Uint32Array(n);
for (let i = 0; i < n; i++) {
let r = 0;
for (let b = 0; b < bits; b++) r |= ((i >> b) & 1) << (bits - 1 - b);
this.rev[i] = r;
}
}
run(re: Float64Array, im: Float64Array, inverse = false) {
const { n, rev, cos, sin } = this;
for (let i = 0; i < n; i++) {
const j = rev[i];
if (j > i) {
let t = re[i];
re[i] = re[j];
re[j] = t;
t = im[i];
im[i] = im[j];
im[j] = t;
}
}
const sign = inverse ? 1 : -1;
for (let size = 2; size <= n; size *= 2) {
const half = size / 2;
const step = n / size;
for (let i = 0; i < n; i += size) {
for (let j = 0; j < half; j++) {
const c = cos[j * step];
const s = sign * sin[j * step];
const a = i + j;
const b = a + half;
const tr = re[b] * c - im[b] * s;
const ti = re[b] * s + im[b] * c;
re[b] = re[a] - tr;
im[b] = im[a] - ti;
re[a] += tr;
im[a] += ti;
}
}
}
}
}
/**
* Mono-in, stereo-out convolution with a stereo impulse (uniformly
* partitioned, overlap-save). The left and right impulses ride in one
* complex FFT as h_L + i·h_R, so a single inverse transform yields both
* outputs: real = left, imaginary = right.
*/
export function convolve(x: Float32Array, ir: Stereo, B = 16384): Stereo {
const N = 2 * B;
const fft = new FFT(N);
const P = Math.ceil(ir.L.length / B);
const Hre: Float64Array[] = [];
const Him: Float64Array[] = [];
for (let p = 0; p < P; p++) {
const re = new Float64Array(N);
const im = new Float64Array(N);
for (let i = 0; i < B && p * B + i < ir.L.length; i++) {
re[i] = ir.L[p * B + i];
im[i] = ir.R[p * B + i];
}
fft.run(re, im);
Hre.push(re);
Him.push(im);
}
const blocks = Math.ceil(x.length / B);
const Xre = Array.from({ length: P }, () => new Float64Array(N));
const Xim = Array.from({ length: P }, () => new Float64Array(N));
const silent = new Array<boolean>(P).fill(true);
const Yre = new Float64Array(N);
const Yim = new Float64Array(N);
const L = new Float32Array(x.length);
const R = new Float32Array(x.length);
for (let k = 0; k < blocks; k++) {
// the newest input spectrum goes in slot k % P: frame x[kB − B, kB + B)
const slot = k % P;
const re = Xre[slot];
const im = Xim[slot];
let energy = 0;
for (let i = 0; i < N; i++) {
const j = k * B - B + i;
re[i] = j >= 0 && j < x.length ? x[j] : 0;
im[i] = 0;
energy += Math.abs(re[i]);
}
silent[slot] = energy === 0;
if (!silent[slot]) fft.run(re, im);
Yre.fill(0);
Yim.fill(0);
let any = false;
for (let p = 0; p < P && p <= k; p++) {
const s = (k - p) % P;
if (silent[s]) continue;
any = true;
const ar = Xre[s];
const ai = Xim[s];
const br = Hre[p];
const bi = Him[p];
for (let i = 0; i < N; i++) {
Yre[i] += ar[i] * br[i] - ai[i] * bi[i];
Yim[i] += ar[i] * bi[i] + ai[i] * br[i];
}
}
if (!any) continue;
fft.run(Yre, Yim, true);
for (let i = 0; i < B; i++) {
const j = k * B + i;
if (j >= x.length) break;
L[j] = Yre[B + i] / N;
R[j] = Yim[B + i] / N;
}
}
return { L, R };
}
/**
* Ping-pong delay, mono in: the first echo on the left after `delay`
* samples, then right, left… each `feedback` quieter and darker.
*/
export function pingPong(x: Float32Array, delay: number, feedback: number): Stereo {
const n = x.length;
const L = new Float32Array(n);
const R = new Float32Array(n);
const lineL = new Float32Array(delay);
const lineR = new Float32Array(delay);
const damp = Biquad.lp(5200, 0.6);
const thin = Biquad.hp(300, 0.6);
for (let i = 0; i < n; i++) {
const w = i % delay;
const outL = lineL[w];
const outR = lineR[w];
L[i] = outL;
R[i] = outR;
lineL[w] = x[i] + thin.process(damp.process(outR)) * feedback;
lineR[w] = outL * feedback;
}
return { L, R };
}