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>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|