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

215 lines
7.4 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 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 };
}