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>
190 lines
5.6 KiB
TypeScript
190 lines
5.6 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
}
|