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

305 lines
11 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 master bus, offline in plain JS: a zero-phase rumble filter, gentle
* stereo-linked glue compression, then gain into a look-ahead true-peak
* limiter. The gain is iterated until the integrated loudness
* (ITU-R BS.1770-4) hits the target under the ceiling, then the mix fades to
* digital silence.
*
* Because the whole buffer is processed at once, look-ahead costs no latency
* and the output stays sample-aligned with the drum grid. (A Web Audio
* DynamicsCompressorNode would delay everything by Chrome's 6 ms pre-delay.)
*/
import { Biquad, SR, db, filtfilt } from "./dsp";
export interface MasterOptions {
/** integrated loudness target, LUFS */
lufs: number;
/** true-peak ceiling, dBTP */
ceiling: number;
/** fade to silence over the last `fadeOut` seconds */
fadeOut: number;
}
export interface MasterReport {
lufs: number;
truePeak: number;
samplePeak: number;
gainDb: number;
maxLimitDb: number;
maxGlueDb: number;
}
// ─── loudness (BS.1770-4) ─────────────────────────────────────────────────
/** K-weighted energy of each 100 ms segment of `x`. */
function segmentEnergies(x: Float32Array): Float64Array {
const shelf = Biquad.raw([1.53512485958697, -2.69169618940638, 1.19839281085285], [1, -1.69065929318241, 0.73248077421585]);
const rlb = Biquad.raw([1, -2, 1], [1, -1.99004745483398, 0.99007225036621]);
const step = SR / 10;
const out = new Float64Array(Math.floor(x.length / step));
for (let k = 0; k < out.length; k++) {
let acc = 0;
for (let i = k * step; i < (k + 1) * step; i++) {
const y = rlb.process(shelf.process(x[i]));
acc += y * y;
}
out[k] = acc;
}
return out;
}
const lk = (z: number) => -0.691 + 10 * Math.log10(z);
/** Integrated loudness in LUFS: 400 ms blocks every 100 ms, −70 LUFS absolute and −10 LU relative gates. */
export function integratedLoudness(L: Float32Array, R: Float32Array): number {
const eL = segmentEnergies(L);
const eR = segmentEnergies(R);
const blocks: number[] = [];
for (let j = 0; j + 4 <= eL.length; j++) {
let z = 0;
for (let k = j; k < j + 4; k++) z += eL[k] + eR[k];
blocks.push(z / (0.4 * SR));
}
const mean = (a: number[]) => a.reduce((s, v) => s + v, 0) / a.length;
const abs = blocks.filter((z) => lk(z) > -70);
const rel = lk(mean(abs)) - 10;
return lk(mean(abs.filter((z) => lk(z) > rel)));
}
// ─── true peak ────────────────────────────────────────────────────────────
/** 4× interpolation: Blackman-windowed sinc, 16 taps per phase, at +¼, +½, +¾ of a sample. */
const TP_M = 8;
const TP_PHASES = [0.25, 0.5, 0.75].map((frac) => {
const h = new Float64Array(2 * TP_M);
let sum = 0;
for (let j = 0; j < 2 * TP_M; j++) {
const u = j - (TP_M - 1) - frac; // tap x[n − M + 1 + j] sits u samples from the point n + frac
const sinc = u === 0 ? 1 : Math.sin(Math.PI * u) / (Math.PI * u);
const win = 0.42 + 0.5 * Math.cos((Math.PI * u) / TP_M) + 0.08 * Math.cos((2 * Math.PI * u) / TP_M);
h[j] = sinc * win;
sum += h[j];
}
return h.map((v) => v / sum);
});
/**
* Per-sample true-peak envelope: the largest |value| between n−1 and n+1,
* both channels, 4× oversampled. Interpolation only runs where a neighbour
* is within 14 dB of the loudest sample: quieter stretches can't reach the
* ceiling, so they keep their sample peaks.
*/
export function truePeakEnvelope(L: Float32Array, R: Float32Array): Float32Array {
const n = L.length;
let top = 0;
for (let i = 0; i < n; i++) top = Math.max(top, Math.abs(L[i]), Math.abs(R[i]));
const thr = top * 0.2;
const [h1, h2, h3] = TP_PHASES;
const seg = new Float32Array(n); // peak of the interval [n, n+1)
for (const x of [L, R]) {
for (let i = 0; i < n; i++) {
const a = Math.abs(x[i]);
let p = a;
if (i >= TP_M - 1 && i + TP_M < n && (a > thr || Math.abs(x[i + 1]) > thr)) {
const base = i - TP_M + 1;
let y1 = 0;
let y2 = 0;
let y3 = 0;
for (let j = 0; j < 2 * TP_M; j++) {
const v = x[base + j];
y1 += h1[j] * v;
y2 += h2[j] * v;
y3 += h3[j] * v;
}
p = Math.max(p, Math.abs(y1), Math.abs(y2), Math.abs(y3));
}
if (p > seg[i]) seg[i] = p;
}
}
const env = new Float32Array(n);
for (let i = 0; i < n; i++) env[i] = Math.max(seg[i], i > 0 ? seg[i - 1] : 0);
return env;
}
const toDb = (x: number) => 20 * Math.log10(Math.max(x, 1e-12));
export function truePeakDb(L: Float32Array, R: Float32Array): number {
return toDb(truePeakEnvelope(L, R).reduce((m, v) => (v > m ? v : m), 0));
}
// ─── dynamics ─────────────────────────────────────────────────────────────
/**
* Glue: stereo-linked RMS compressor (soft knee) whose detector ignores
* the sub (high-passed at 100 Hz), so the kick doesn't pump the whole mix.
* Returns the deepest gain reduction in dB.
*/
function glue(L: Float32Array, R: Float32Array, threshold = -17, ratio = 1.8, knee = 6, attack = 0.02, release = 0.22): number {
const hl = Biquad.hp(100);
const hr = Biquad.hp(100);
const ad = Math.exp(-1 / (0.006 * SR));
const aa = Math.exp(-1 / (attack * SR));
const ar = Math.exp(-1 / (release * SR));
const slope = 1 / ratio - 1;
let env = 0;
let gr = 0;
let deepest = 0;
// the gain computer runs every 8 samples (its smoothing is in ms anyway); the gain is interpolated between
const aa8 = aa ** 8;
const ar8 = ar ** 8;
let g0 = 1;
for (let i0 = 0; i0 < L.length; i0 += 8) {
const end = Math.min(L.length, i0 + 8);
for (let i = i0; i < end; i++) {
const xl = hl.process(L[i]);
const xr = hr.process(R[i]);
env = ad * env + (1 - ad) * Math.max(xl * xl, xr * xr);
}
const over = 10 * Math.log10(env + 1e-12) + 3 - threshold; // +3: mean square of a peak-1 sine → its peak level
const target = 2 * over < -knee ? 0 : 2 * Math.abs(over) <= knee ? (slope * (over + knee / 2) ** 2) / (2 * knee) : slope * over;
gr = target < gr ? aa8 * gr + (1 - aa8) * target : ar8 * gr + (1 - ar8) * target;
if (gr < deepest) deepest = gr;
const g1 = 10 ** (gr / 20);
for (let i = i0; i < end; i++) {
const g = g0 + ((g1 - g0) * (i - i0 + 1)) / (end - i0);
L[i] *= g;
R[i] *= g;
}
g0 = g1;
}
return deepest;
}
/** Sliding minimum of `x` over [i, i + w] (values past the end count as 1). */
function minAhead(x: Float32Array, w: number): Float32Array {
const n = x.length;
const out = new Float32Array(n);
const dq = new Int32Array(n + w + 1);
let head = 0;
let tail = 0;
for (let j = n - 1; j >= 0; j--) {
while (tail > head && x[dq[tail - 1]] >= x[j]) tail--;
dq[tail++] = j;
while (dq[head] > j + w) head++;
out[j] = Math.min(x[dq[head]], j + w >= n ? 1 : Infinity);
}
return out;
}
/**
* Look-ahead limiter gain for `peak` × `gain` under `ceiling`: the required
* gain, its minimum `attack` samples ahead, a moving average over the same
* span (so the gain glides down before each peak and never exceeds what it
* needs), then an exponential release.
*/
function limiterGain(peak: Float32Array, gain: number, ceiling: number, attack: number, release: number): Float32Array {
const n = peak.length;
const need = new Float32Array(n);
for (let i = 0; i < n; i++) {
const p = peak[i] * gain;
need[i] = p > ceiling ? ceiling / p : 1;
}
const ahead = minAhead(need, attack);
const out = new Float32Array(n);
const k = Math.exp(-1 / (release * SR));
const w = attack + 1;
let sum = w; // the window [i − attack, i]; samples before 0 count as 1
let g = 1;
for (let i = 0; i < n; i++) {
sum += ahead[i] - (i >= w ? ahead[i - w] : 1);
g = Math.min(sum / w, 1 - (1 - g) * k);
out[i] = g;
}
return out;
}
/** Master `L`/`R` in place. */
export function master(L: Float32Array, R: Float32Array, opts: MasterOptions): MasterReport {
const n = L.length;
filtfilt(L, () => Biquad.hp(22));
filtfilt(R, () => Biquad.hp(22));
// the glue works on a reference level (−20 LUFS), whatever the raw mix's level
const ref = db(-20 - integratedLoudness(L, R));
for (let i = 0; i < n; i++) {
L[i] *= ref;
R[i] *= ref;
}
const maxGlueDb = glue(L, R);
const peak = truePeakEnvelope(L, R);
const ceiling = db(opts.ceiling);
const outL = new Float32Array(n);
const outR = new Float32Array(n);
let gain = 1;
let lufs = 0;
let g = new Float32Array(n);
for (let iter = 0; iter < 10; iter++) {
g = limiterGain(peak, gain, ceiling, Math.round(0.0015 * SR), 0.09);
for (let i = 0; i < n; i++) {
outL[i] = L[i] * gain * g[i];
outR[i] = R[i] * gain * g[i];
}
lufs = integratedLoudness(outL, outR);
if (Math.abs(lufs - opts.lufs) < 0.02) break;
gain *= db(opts.lufs - lufs);
}
// fade to digital silence: cos² over the last `fadeOut` seconds, exactly 0 on the last sample
const f = Math.round(opts.fadeOut * SR);
for (let i = 0; i < f; i++) {
const w = Math.cos(((i + 1) / f) * (Math.PI / 2)) ** 2;
outL[n - f + i] *= w;
outR[n - f + i] *= w;
}
let samplePeak = 0;
for (let i = 0; i < n; i++) samplePeak = Math.max(samplePeak, Math.abs(outL[i]), Math.abs(outR[i]));
L.set(outL);
R.set(outR);
return {
lufs,
truePeak: truePeakDb(L, R),
samplePeak: toDb(samplePeak),
gainDb: toDb(gain),
maxLimitDb: toDb(g.reduce((m, v) => (v < m ? v : m), 1)),
maxGlueDb,
};
}
// ─── WAV ──────────────────────────────────────────────────────────────────
/** Stereo WAV bytes: 24-bit PCM, or 32-bit float for unmastered stems. */
export function wav(L: Float32Array, R: Float32Array, float = false): Uint8Array {
const n = L.length;
const bytes = float ? 4 : 3;
const data = n * 2 * bytes;
const out = new Uint8Array(44 + data);
const dv = new DataView(out.buffer);
const str = (o: number, s: string) => [...s].forEach((c, i) => dv.setUint8(o + i, c.charCodeAt(0)));
str(0, "RIFF");
dv.setUint32(4, 36 + data, true);
str(8, "WAVE");
str(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, float ? 3 : 1, true);
dv.setUint16(22, 2, true);
dv.setUint32(24, SR, true);
dv.setUint32(28, SR * 2 * bytes, true);
dv.setUint16(32, 2 * bytes, true);
dv.setUint16(34, bytes * 8, true);
str(36, "data");
dv.setUint32(40, data, true);
let o = 44;
for (let i = 0; i < n; i++) {
for (const x of [L[i], R[i]]) {
if (float) {
dv.setFloat32(o, x, true);
o += 4;
} else {
const v = Math.round(Math.max(-1, Math.min(1, x)) * 8388607);
out[o++] = v & 0xff;
out[o++] = (v >> 8) & 0xff;
out[o++] = (v >> 16) & 0xff;
}
}
}
return out;
}