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

210 lines
7.1 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 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;
}