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>
215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
/**
|
||
* The drum kit, synthesised sample by sample. Every function returns a
|
||
* one-shot whose first sample IS the hit — no pre-roll, no latency — and whose
|
||
* peak is 1; stems.ts scales it by the hit's velocity and drops it on the grid.
|
||
*/
|
||
import { Biquad, SR, filtfilt, fadeTail, normalize, rng, saturate, white, type Stereo } from "./dsp";
|
||
|
||
/** A sum of exponentially damped sines [Hz, amplitude, τ seconds], via rotating phasors. */
|
||
export function partials(n: number, list: [number, number, number][], phase = 0): Float32Array {
|
||
const out = new Float32Array(n);
|
||
for (const [f, a, tau] of list) {
|
||
if (f >= SR * 0.45) continue;
|
||
const w = (2 * Math.PI * f) / SR;
|
||
const r = Math.exp(-1 / (tau * SR));
|
||
const cr = r * Math.cos(w);
|
||
const ci = r * Math.sin(w);
|
||
let re = Math.cos(phase);
|
||
let im = Math.sin(phase);
|
||
for (let i = 0; i < n; i++) {
|
||
out[i] += a * im;
|
||
const nr = re * cr - im * ci;
|
||
im = re * ci + im * cr;
|
||
re = nr;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Punchy house kick: a sine sweeping ~520 → 160 → 49 Hz with a short hold and
|
||
* a 115 ms tail, a 1 ms band-passed click, soft-clipped for weight on small
|
||
* speakers. `muffleHz` low-passes it (zero-phase, so the onset stays put) —
|
||
* the filtered four-on-the-floor of the intro build.
|
||
*/
|
||
export function kick(muffleHz = 0): Float32Array {
|
||
const n = Math.round(0.44 * SR);
|
||
const out = new Float32Array(n);
|
||
const nz = white(0x51c4);
|
||
const click = Biquad.bp(4200, 0.7);
|
||
let ph = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / SR;
|
||
const f = 49 + 115 * Math.exp(-t / 0.032) + 360 * Math.exp(-t / 0.004);
|
||
ph += (2 * Math.PI * f) / SR;
|
||
const amp = t < 0.014 ? 1 : Math.exp(-(t - 0.014) / 0.115);
|
||
out[i] = Math.sin(ph) * amp + click.process(nz()) * Math.exp(-t / 0.0011) * 1.6;
|
||
}
|
||
saturate(out, 1.9);
|
||
if (muffleHz > 0) filtfilt(out, () => Biquad.lp(muffleHz, 0.8));
|
||
fadeTail(out, 0.03);
|
||
return normalize(out);
|
||
}
|
||
|
||
/**
|
||
* Layered clap: four noise bursts ~9 ms apart (the hands), the last one
|
||
* ringing 65 ms, a 205 Hz body under it, and a short decorrelated room.
|
||
*/
|
||
export function clap(seed: number): Stereo {
|
||
const n = Math.round(0.5 * SR);
|
||
const dry = new Float32Array(n);
|
||
const nz = white(seed);
|
||
const bp = Biquad.bp(1250, 1.1);
|
||
const hp = Biquad.hp(5200, 0.7);
|
||
const bursts = [0, 0.0095, 0.0185, 0.028];
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / SR;
|
||
let env = 0;
|
||
for (let b = 0; b < bursts.length; b++) {
|
||
const dt = t - bursts[b];
|
||
if (dt < 0) continue;
|
||
const tail = b === bursts.length - 1 ? Math.exp(-dt / 0.065) : Math.exp(-dt / 0.0028);
|
||
env = Math.max(env, Math.min(1, dt / 0.0004) * tail * (0.72 + 0.1 * b));
|
||
}
|
||
const x = nz();
|
||
dry[i] = env * (bp.process(x) * 2.4 + hp.process(x) * 0.45);
|
||
dry[i] += Math.sin(2 * Math.PI * 205 * t) * Math.exp(-t / 0.022) * 0.3 * Math.min(1, t / 0.0005);
|
||
}
|
||
normalize(dry);
|
||
const room = (s: number) => {
|
||
const r = new Float32Array(n);
|
||
const rn = white(s);
|
||
const f1 = Biquad.bp(1700, 0.6);
|
||
const f2 = Biquad.lp(6500);
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / SR - 0.004;
|
||
const env = t < 0 ? 0 : Math.min(1, t / 0.008) * Math.exp(-t / 0.085);
|
||
r[i] = f2.process(f1.process(rn())) * env;
|
||
}
|
||
return normalize(r, 0.3);
|
||
};
|
||
const L = room(seed + 1);
|
||
const R = room(seed + 2);
|
||
for (let i = 0; i < n; i++) {
|
||
L[i] += dry[i];
|
||
R[i] += dry[i];
|
||
}
|
||
fadeTail(L, 0.05);
|
||
fadeTail(R, 0.05);
|
||
return { L, R };
|
||
}
|
||
|
||
/** EDM snare for fills and rolls: a pitched body plus bright noise; `pitch` lifts it through a build. */
|
||
export function snare(seed: number, pitch = 1): Float32Array {
|
||
const n = Math.round(0.3 * SR);
|
||
const out = new Float32Array(n);
|
||
const nz = white(seed);
|
||
const hp = Biquad.hp(1600, 0.7);
|
||
const pk = Biquad.peak(5800, 0.9, 4);
|
||
const lp = Biquad.lp(13000);
|
||
let p1 = 0;
|
||
let p2 = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / SR;
|
||
p1 += (2 * Math.PI * 188 * pitch * (1 + 0.6 * Math.exp(-t / 0.01))) / SR;
|
||
p2 += (2 * Math.PI * 330 * pitch) / SR;
|
||
const body = Math.sin(p1) * Math.exp(-t / 0.055) + 0.45 * Math.sin(p2) * Math.exp(-t / 0.035);
|
||
const noise = lp.process(pk.process(hp.process(nz()))) * Math.exp(-t / 0.09) * Math.min(1, t / 0.0003);
|
||
out[i] = 0.62 * body + 1.25 * noise;
|
||
}
|
||
saturate(out, 1.4);
|
||
fadeTail(out, 0.04);
|
||
return normalize(out);
|
||
}
|
||
|
||
/**
|
||
* Hats: bright filtered noise, de-harshed (high-passed above the 3–5 kHz
|
||
* bite, a gentle top). Ghost notes are shorter and duller; the open hat rings.
|
||
*/
|
||
export function hat(seed: number, open: boolean, ghost: boolean): Float32Array {
|
||
const n = Math.round((open ? 0.3 : 0.09) * SR);
|
||
const out = new Float32Array(n);
|
||
const nz = white(seed);
|
||
const hp1 = Biquad.hp(open ? 6400 : 7600, 0.75);
|
||
const hp2 = Biquad.hp(open ? 6400 : 7600, 0.75);
|
||
const pk = Biquad.peak(10500, 1.2, 3);
|
||
const lp = Biquad.lp(ghost ? 11500 : 14500, 0.7);
|
||
const tau = open ? 0.085 : ghost ? 0.011 : 0.019;
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / SR;
|
||
const env = Math.min(1, t / 0.0003) * Math.exp(-t / tau);
|
||
out[i] = lp.process(pk.process(hp2.process(hp1.process(nz())))) * env;
|
||
}
|
||
if (open) {
|
||
const ring = partials(n, [
|
||
[6350, 0.05, 0.12],
|
||
[8420, 0.04, 0.1],
|
||
[10900, 0.03, 0.08],
|
||
]);
|
||
for (let i = 0; i < n; i++) out[i] += ring[i] * Math.min(1, i / 15);
|
||
}
|
||
fadeTail(out, open ? 0.05 : 0.015);
|
||
return normalize(out);
|
||
}
|
||
|
||
/** The cron chapter's clock: a dry wooden tick (accent) or a lower tock (ghost). */
|
||
export function tick(seed: number, tock: boolean): Float32Array {
|
||
const n = Math.round(0.05 * SR);
|
||
const modes: [number, number, number][] = tock
|
||
? [
|
||
[1720, 1, 0.005],
|
||
[2650, 0.5, 0.0032],
|
||
[4050, 0.25, 0.0018],
|
||
]
|
||
: [
|
||
[2380, 1, 0.0042],
|
||
[3710, 0.55, 0.0028],
|
||
[5650, 0.3, 0.0016],
|
||
];
|
||
const out = partials(n, modes);
|
||
const nz = white(seed);
|
||
const hp = Biquad.hp(2000);
|
||
for (let i = 0; i < n; i++) out[i] += hp.process(nz()) * Math.exp(-i / SR / 0.00025) * 0.6;
|
||
fadeTail(out, 0.01);
|
||
return normalize(out);
|
||
}
|
||
|
||
/**
|
||
* Crash: bright decorrelated stereo noise over a bed of metallic partials,
|
||
* a fast initial burst then a ~0.6 s decay (≈ −30 dB at 2 s). `big` (the
|
||
* drops) adds a wash in the mids and rings longer.
|
||
*/
|
||
export function crash(seed: number, big: boolean): Stereo {
|
||
const n = Math.round(3.2 * SR);
|
||
const tau = big ? 0.72 : 0.6;
|
||
const bed: [number, number, number][] = [];
|
||
const r = rng(seed);
|
||
for (let k = 0; k < 28; k++) bed.push([2900 * 2 ** (r() * 2.1), 0.02 + 0.03 * r(), 0.25 + 0.85 * r()]);
|
||
const side = (c: number) => {
|
||
const nz = white(seed * 7 + c);
|
||
const hp = Biquad.hp(3200, 0.7);
|
||
const pk = Biquad.peak(7800, 0.8, 3);
|
||
const lp = Biquad.lp(13500, 0.7);
|
||
const wash = Biquad.bp(950, 0.5);
|
||
const out = partials(n, bed, c * 1.3);
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / SR;
|
||
const env = Math.min(1, t / 0.0015) * (0.45 * Math.exp(-t / 0.08) + 0.55 * Math.exp(-t / tau));
|
||
const x = nz();
|
||
let y = lp.process(pk.process(hp.process(x))) * 1.6;
|
||
if (big) y += wash.process(x) * 0.35 * Math.exp(-t / 0.3);
|
||
out[i] = (out[i] * Math.exp(-t / 1.2) + y) * env;
|
||
}
|
||
return fadeTail(out, 0.4);
|
||
};
|
||
const L = side(1);
|
||
const R = side(2);
|
||
let m = 0;
|
||
for (let i = 0; i < n; i++) m = Math.max(m, Math.abs(L[i]), Math.abs(R[i]));
|
||
for (let i = 0; i < n; i++) {
|
||
L[i] /= m;
|
||
R[i] /= m;
|
||
}
|
||
return { L, R };
|
||
}
|