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>
301 lines
9.5 KiB
TypeScript
301 lines
9.5 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|