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>
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
}
|
||
}
|