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>
210 lines
7.1 KiB
TypeScript
210 lines
7.1 KiB
TypeScript
/**
|
||
* The grid-locked effects and bells — impacts, risers, downlifters, reverse
|
||
* swells, the notification pings and the cron chimes — synthesised sample by
|
||
* sample like the drums, so each one starts (or ends) on its exact sample.
|
||
*/
|
||
import { crash } from "./drums";
|
||
import { Biquad, SR, fadeTail, mtof, normalize, rng, saturate, white, type Stereo } from "./dsp";
|
||
|
||
/** The drop's sub impact: a sine diving 110 → 34 Hz under a low thump, ~2 s. */
|
||
export function impact(seed: number): Float32Array {
|
||
const n = Math.round(2.6 * SR);
|
||
const out = new Float32Array(n);
|
||
const nz = white(seed);
|
||
const lp1 = Biquad.lp(180);
|
||
const lp2 = Biquad.lp(180);
|
||
let ph = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / SR;
|
||
ph += (2 * Math.PI * (34 + 76 * Math.exp(-t / 0.25))) / SR;
|
||
const body = Math.sin(ph) * Math.min(1, t / 0.004) * Math.exp(-t / 0.6);
|
||
const thump = lp2.process(lp1.process(nz())) * Math.exp(-t / 0.07) * 6;
|
||
out[i] = body + thump;
|
||
}
|
||
saturate(out, 1.3);
|
||
fadeTail(out, 0.4);
|
||
return normalize(out);
|
||
}
|
||
|
||
/**
|
||
* White-noise riser: a resonant band sweeping 350 Hz → 11 kHz, swelling from
|
||
* −32 dB and widening from mono to wide, cut dead on its last sample (the
|
||
* downbeat it leads into).
|
||
*/
|
||
export function noiseRiser(dur: number, seed: number): Stereo {
|
||
const n = Math.round(dur * SR);
|
||
const L = new Float32Array(n);
|
||
const R = new Float32Array(n);
|
||
const mid = white(seed);
|
||
const nl = white(seed + 1);
|
||
const nr = white(seed + 2);
|
||
const bl = new Biquad();
|
||
const br = new Biquad();
|
||
const hl = new Biquad();
|
||
const hr = new Biquad();
|
||
for (let i = 0; i < n; i++) {
|
||
const p = i / n;
|
||
if (i % 16 === 0) {
|
||
const f = 350 * (11000 / 350) ** (p ** 1.3);
|
||
bl.bp(f, 1.6);
|
||
br.bp(f, 1.6);
|
||
hl.lp(f * 2.2, 0.7);
|
||
hr.lp(f * 2.2, 0.7);
|
||
}
|
||
const w = 0.15 + 0.75 * p;
|
||
const m = mid();
|
||
const xl = m * (1 - w) + nl() * w;
|
||
const xr = m * (1 - w) + nr() * w;
|
||
const g = 10 ** ((-32 + 32 * p ** 0.7) / 20);
|
||
L[i] = (bl.process(xl) * 2 + hl.process(xl) * 0.35) * g;
|
||
R[i] = (br.process(xr) * 2 + hr.process(xr) * 0.35) * g;
|
||
}
|
||
fadeTail(L, 0.002);
|
||
fadeTail(R, 0.002);
|
||
return peakStereo({ L, R });
|
||
}
|
||
|
||
/** Downlifter: noise falling from bright to dark (never below 250 Hz: no rumble), and a faint falling tone, fading out over `dur`. */
|
||
export function downlifter(dur: number, seed: number): Stereo {
|
||
const n = Math.round(dur * SR);
|
||
const L = new Float32Array(n);
|
||
const R = new Float32Array(n);
|
||
const nl = white(seed);
|
||
const nr = white(seed + 1);
|
||
const fl = new Biquad();
|
||
const fr = new Biquad();
|
||
const hl = Biquad.hp(250, 0.7);
|
||
const hr = Biquad.hp(250, 0.7);
|
||
let ph = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
const p = i / n;
|
||
if (i % 16 === 0) {
|
||
const f = 9000 * (500 / 9000) ** (p ** 0.7);
|
||
fl.lp(f, 1.3);
|
||
fr.lp(f, 1.3);
|
||
}
|
||
const env = Math.min(1, i / (0.003 * SR)) * (1 - p) ** 2.2;
|
||
ph += (2 * Math.PI * 900 * (220 / 900) ** p) / SR;
|
||
const tone = Math.sin(ph) * 0.12 * (1 - p) ** 3;
|
||
L[i] = (hl.process(fl.process(nl())) + tone) * env;
|
||
R[i] = (hr.process(fr.process(nr())) + tone) * env;
|
||
}
|
||
return peakStereo({ L, R });
|
||
}
|
||
|
||
/** Reverse swell: a cymbal played backwards, its attack landing on the buffer's last sample. */
|
||
export function reverseSwell(dur: number, seed: number): Stereo {
|
||
const c = crash(seed, false);
|
||
const n = Math.round(dur * SR);
|
||
const L = c.L.slice(0, n).reverse();
|
||
const R = c.R.slice(0, n).reverse();
|
||
for (let i = 0; i < n; i++) {
|
||
const g = (i / n) ** 1.5;
|
||
L[i] *= g;
|
||
R[i] *= g;
|
||
}
|
||
return peakStereo({ L, R });
|
||
}
|
||
|
||
/**
|
||
* The notification ping: a bright two-operator FM bell — a ratio-3.5
|
||
* shimmer that dies in ~70 ms over a ratio-1 body and an octave sparkle.
|
||
* The shimmer's index shrinks on high notes so no sideband folds over.
|
||
*/
|
||
export function bell(midi: number): Float32Array {
|
||
const f = mtof(midi);
|
||
const n = Math.round(2.2 * SR);
|
||
const out = new Float32Array(n);
|
||
const fm = 3.5 * f;
|
||
const idx = Math.min(3.2, Math.max(0.3, (19000 - f) / fm - 1));
|
||
const tauBody = 0.75 * (880 / f) ** 0.35;
|
||
const w = (2 * Math.PI * f) / SR;
|
||
const wm = (2 * Math.PI * fm) / SR;
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / SR;
|
||
const shimmer = Math.sin(w * i + idx * Math.exp(-t / 0.07) * Math.sin(wm * i)) * Math.exp(-t / 0.12) * 0.45;
|
||
const body = Math.sin(w * i + 1.2 * Math.exp(-t / 0.35) * Math.sin(w * i)) * Math.exp(-t / tauBody) * 0.8;
|
||
const sparkle = 2 * f < 18000 ? Math.sin(2 * w * i) * Math.exp(-t / 0.25) * 0.15 : 0;
|
||
out[i] = (shimmer + body + sparkle) * Math.min(1, t / 0.0006);
|
||
}
|
||
fadeTail(out, 0.2);
|
||
return normalize(out);
|
||
}
|
||
|
||
/**
|
||
* A soft glass chime: free-bar partials (1 : 2.756 : 5.404) with a 3 ms
|
||
* attack; left and right are detuned by ±0.7 Hz, so it shimmers in stereo.
|
||
*/
|
||
export function chime(midi: number): Stereo {
|
||
const f = mtof(midi);
|
||
const n = Math.round(3.5 * SR);
|
||
const side = (df: number) => {
|
||
const out = new Float32Array(n);
|
||
const modes: [number, number, number][] = [
|
||
[f + df, 1, 1.6],
|
||
[f * 2.756, 0.32, 0.6],
|
||
[f * 5.404, 0.1, 0.25],
|
||
];
|
||
for (const [fr, a, tau] of modes) {
|
||
if (fr > 18000) continue;
|
||
const w = (2 * Math.PI * fr) / SR;
|
||
for (let i = 0; i < n; i++) out[i] += a * Math.sin(w * i) * Math.exp(-i / SR / tau);
|
||
}
|
||
for (let i = 0; i < n; i++) out[i] *= Math.min(1, i / (0.003 * SR));
|
||
return fadeTail(out, 0.3);
|
||
};
|
||
return peakStereo({ L: side(-0.7), R: side(0.7) });
|
||
}
|
||
|
||
/**
|
||
* A synthetic stereo reverb impulse: a few early reflections, then
|
||
* decorrelated noise decaying at `rt60`, low-passed harder as it decays (the
|
||
* air takes the highs first, down to `darkHz`); energy-normalised to 1, so a
|
||
* send's level is the wet level.
|
||
*/
|
||
export function reverbIR(rt60: number, preDelay: number, darkHz: number, seed: number): Stereo {
|
||
const n = Math.round((preDelay + rt60) * SR);
|
||
const p0 = Math.round(preDelay * SR);
|
||
const side = (s: number) => {
|
||
const out = new Float32Array(n);
|
||
const nz = white(s);
|
||
const r = rng(s + 100);
|
||
let lp = 0;
|
||
for (let i = p0; i < n; i++) {
|
||
const t = (i - p0) / SR;
|
||
const fc = darkHz + (14000 - darkHz) * Math.exp(-t / (rt60 * 0.25));
|
||
const a = Math.exp((-2 * Math.PI * fc) / SR);
|
||
lp = (1 - a) * nz() + a * lp;
|
||
out[i] = lp * Math.exp((-6.91 * t) / rt60) * Math.min(1, t / 0.008);
|
||
}
|
||
for (let k = 0; k < 7; k++) {
|
||
const d = p0 + Math.round((0.003 + r() * 0.045) * SR);
|
||
out[d] += (r() < 0.5 ? -1 : 1) * (0.35 - k * 0.04);
|
||
}
|
||
return fadeTail(out, 0.05);
|
||
};
|
||
const L = side(seed);
|
||
const R = side(seed + 1);
|
||
let e = 0;
|
||
for (let i = 0; i < n; i++) e += L[i] * L[i] + R[i] * R[i];
|
||
const k = 1 / Math.sqrt(e / 2);
|
||
for (let i = 0; i < n; i++) {
|
||
L[i] *= k;
|
||
R[i] *= k;
|
||
}
|
||
return { L, R };
|
||
}
|
||
|
||
/** Normalise a stereo pair to a joint peak of 1. */
|
||
export function peakStereo(s: Stereo): Stereo {
|
||
let m = 0;
|
||
for (let i = 0; i < s.L.length; i++) m = Math.max(m, Math.abs(s.L[i]), Math.abs(s.R[i]));
|
||
if (m > 0) {
|
||
for (let i = 0; i < s.L.length; i++) {
|
||
s.L[i] /= m;
|
||
s.R[i] /= m;
|
||
}
|
||
}
|
||
return s;
|
||
}
|