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

328 lines
16 KiB
JavaScript
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.
/**
* Measures the rendered score against the contract. Nobody can listen, so:
*
* 1. format: 48 kHz, stereo, 5 760 000 frames (ffprobe);
* 2. loudness: integrated LUFS and true peak (ffmpeg ebur128), DC offset,
* clipped samples;
* 3. per chapter (src/timeline.ts): RMS (ffmpeg astats), momentary-max and
* integrated loudness, L/R correlation. The drops must be louder than
* the night;
* 4. kick alignment: each HITS.kick against the mix, by matched filter
* (lag of the best fit with the kick one-shot) and by the low band's
* onset; then a blind scan for low-band transients the score doesn't
* have (bars 1–2, 31–34, 64 must stay kick-free);
* 5. a spectrogram and a waveform PNG in music/ (gitignored) to eyeball.
*
* node music/check-music.mjs [file.wav]
*/
import { build } from "esbuild";
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const file = resolve(process.argv[2] ?? join(root, "public/audio/score.wav"));
// the score's own data and DSP, straight from the TypeScript sources
const entry = `
export { HITS, PINGS, CHIMES } from "../src/score";
export { SECTIONS, at, BAR, DURATION } from "../src/timeline";
export { kick } from "./drums";
export { gridStems } from "./stems";
export { integratedLoudness } from "./master";
export { Biquad, filtfilt } from "./dsp";
`;
const bundle = await build({ stdin: { contents: entry, resolveDir: join(root, "music"), loader: "ts" }, bundle: true, format: "esm", platform: "node", write: false });
const S = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
const { HITS, SECTIONS, at, BAR, DURATION, kick, integratedLoudness, Biquad, filtfilt } = S;
const run = (cmd, args) => execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 << 20 });
const runErr = (cmd, args) => {
try {
return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"], maxBuffer: 64 << 20 });
} catch (e) {
return String(e.stderr ?? e);
}
};
const ffmpeg = (af, extra = []) =>
new Promise((ok) => {
import("node:child_process").then(({ spawn }) => {
const p = spawn("ffmpeg", ["-hide_banner", "-nostats", ...extra, "-i", file, "-af", af, "-f", "null", "-"]);
let err = "";
p.stderr.on("data", (d) => (err += d));
p.on("close", () => ok(err));
});
});
const fmt = (x, d = 2) => (Number.isFinite(x) ? x.toFixed(d) : String(x));
const dbv = (x) => 20 * Math.log10(Math.max(x, 1e-12));
let failures = 0;
const check = (ok, msg) => {
console.log(`${ok ? " ok " : "FAIL "} ${msg}`);
if (!ok) failures++;
};
// ── 1. format ──
const probe = JSON.parse(run("ffprobe", ["-v", "error", "-show_entries", "stream=sample_rate,channels,duration_ts,duration,codec_name,bits_per_sample", "-of", "json", file])).streams[0];
console.log(`\n1. format: ${probe.codec_name}, ${probe.sample_rate} Hz, ${probe.channels} ch, ${probe.duration_ts} frames, ${probe.duration} s`);
check(+probe.sample_rate === 48000 && +probe.channels === 2, "48 kHz stereo");
check(+probe.duration_ts === 5760000 && Math.abs(+probe.duration - 120) < 1e-6, "exactly 5 760 000 frames = 120.000 s");
// ── decode the WAV ourselves (PCM 16/24 or float 32) ──
const buf = readFileSync(file);
let off = 12;
let fmtc;
let data;
while (off < buf.length) {
const id = buf.toString("ascii", off, off + 4);
const size = buf.readUInt32LE(off + 4);
if (id === "fmt ") fmtc = { format: buf.readUInt16LE(off + 8), channels: buf.readUInt16LE(off + 10), bits: buf.readUInt16LE(off + 22) };
if (id === "data") data = { start: off + 8, size };
off += 8 + size + (size & 1);
}
const frames = data.size / (fmtc.channels * (fmtc.bits / 8));
const L = new Float32Array(frames);
const R = new Float32Array(frames);
const bps = fmtc.bits / 8;
for (let i = 0; i < frames; i++) {
for (let c = 0; c < 2; c++) {
const o = data.start + (i * 2 + c) * bps;
const v = fmtc.format === 3 ? buf.readFloatLE(o) : bps === 3 ? buf.readIntLE(o, 3) / 8388608 : buf.readInt16LE(o) / 32768;
(c ? R : L)[i] = v;
}
}
const SR = 48000;
// ── 2. loudness, peaks, DC ──
const eb = await ffmpeg("ebur128=peak=true:framelog=quiet");
const I = +(eb.match(/I:\s+(-?[\d.]+) LUFS/) ?? [])[1];
const TP = +(eb.match(/Peak:\s+(-?[\d.]+) dBFS/) ?? [])[1];
const LRA = +(eb.match(/LRA:\s+(-?[\d.]+) LU/) ?? [])[1];
const st = await ffmpeg("astats=measure_perchannel=none:measure_overall=DC_offset+Peak_level+RMS_level+Flat_factor+Peak_count");
const overall = st.slice(st.lastIndexOf("Overall"));
const astat = (name) => +(overall.match(new RegExp(`${name}:\\s+(-?[\\d.e+-]+)`)) ?? [])[1];
let clipped = 0;
let sumL = 0;
let sumR = 0;
for (let i = 0; i < frames; i++) {
if (Math.abs(L[i]) >= 0.9999 || Math.abs(R[i]) >= 0.9999) clipped++;
sumL += L[i];
sumR += R[i];
}
console.log(`\n2. loudness: ${fmt(I, 1)} LUFS integrated · true peak ${fmt(TP, 2)} dBTP · LRA ${fmt(LRA, 1)} LU · sample peak ${fmt(astat("Peak level dB"))} dBFS · RMS ${fmt(astat("RMS level dB"))} dBFS`);
check(Math.abs(I + 15) <= 1, "integrated loudness −15 LUFS ± 1");
check(TP <= -1, "true peak ≤ −1 dBTP");
check(clipped === 0, `no clipped samples (${clipped})`);
console.log(` DC offset: ffmpeg ${astat("DC offset")} · mean L ${(sumL / frames).toExponential(2)} R ${(sumR / frames).toExponential(2)}`);
check(Math.abs(sumL / frames) < 1e-4 && Math.abs(sumR / frames) < 1e-4, "no DC offset");
let lastNonZero = frames - 1;
while (lastNonZero > 0 && L[lastNonZero] === 0 && R[lastNonZero] === 0) lastNonZero--;
let peakTail = 0;
for (let i = frames - SR / 2; i < frames; i++) peakTail = Math.max(peakTail, Math.abs(L[i]), Math.abs(R[i]));
console.log(` first sample ${L[0]}/${R[0]} · last non-zero sample ${lastNonZero} (${fmt(lastNonZero / SR, 4)} s) · peak over the last 0.5 s ${fmt(dbv(peakTail), 1)} dBFS`);
check(L[frames - 1] === 0 && R[frames - 1] === 0, "ends in digital silence");
// ── 3. per chapter ──
console.log(`\n3. per chapter (RMS from ffmpeg astats; loudness BS.1770; L/R correlation)`);
console.log(` chapter bars seconds RMS dBFS int LUFS max-M LUFS corr`);
const rows = [];
for (const s of SECTIONS) {
const a = at(s.fromBar);
const b = at(s.toBar + 1);
const st2 = await ffmpeg(`atrim=start=${a}:end=${b},astats=measure_perchannel=none:measure_overall=RMS_level`);
const rms = +(st2.slice(st2.lastIndexOf("Overall")).match(/RMS level dB:\s+(-?[\d.]+)/) ?? [])[1];
const i0 = Math.round(a * SR);
const i1 = Math.round(b * SR);
const sl = L.subarray(i0, i1);
const sr = R.subarray(i0, i1);
let maxM = -Infinity;
for (let j = 0; j + 0.4 * SR <= sl.length; j += 0.1 * SR) {
let z = 0;
const wl = sl.slice(j, j + 0.4 * SR);
const wr = sr.slice(j, j + 0.4 * SR);
// momentary loudness via the BS.1770 meter on one 400 ms block
z = integratedLoudness(wl, wr);
if (z > maxM) maxM = z;
}
let sxy = 0;
let sxx = 0;
let syy = 0;
for (let j = 0; j < sl.length; j++) {
sxy += sl[j] * sr[j];
sxx += sl[j] * sl[j];
syy += sr[j] * sr[j];
}
const corr = sxy / Math.sqrt(sxx * syy + 1e-30);
const lufs = integratedLoudness(sl.slice(), sr.slice());
rows.push({ id: s.id, rms, lufs, corr });
console.log(` ${s.id.padEnd(14)} ${String(s.fromBar).padStart(2)}–${String(s.toBar).padEnd(3)} ${fmt(a, 2).padStart(6)}–${fmt(b, 2).padEnd(7)} ${fmt(rms).padStart(8)} ${fmt(lufs, 1).padStart(9)} ${fmt(maxM, 1).padStart(10)} ${fmt(corr, 2).padStart(6)}`);
}
// finer: drops vs night
const span = (a, b) => {
const i0 = Math.round(at(a) * SR);
const i1 = Math.round(at(b + 1) * SR);
return integratedLoudness(L.slice(i0, i1), R.slice(i0, i1));
};
const drops = [span(5, 8), span(39, 44), span(59, 62)];
const nightL = span(31, 34);
console.log(` drops 5–8 / 39–44 / 59–62: ${drops.map((x) => fmt(x, 1)).join(" / ")} LUFS · night 31–34: ${fmt(nightL, 1)} LUFS · ring-out 63–64: ${fmt(span(63, 64), 1)} LUFS`);
check(Math.min(...drops) > nightL + 6, "every drop is ≥ 6 LU louder than the night");
check(rows.every((r) => r.corr > 0.2), "L/R positively correlated in every chapter (not phase-inverted)");
// ── 4. kick alignment ──
console.log(`\n4. kick alignment (${HITS.kick.length} kicks in the score)`);
const mono = new Float32Array(frames);
for (let i = 0; i < frames; i++) mono[i] = (L[i] + R[i]) / 2;
// zero-phase low-passes (no filter delay): the kick band and the sub band
const band400 = filtfilt(mono.slice(), () => Biquad.lp(400, Math.SQRT1_2));
const band150 = filtfilt(mono.slice(), () => Biquad.lp(150, Math.SQRT1_2));
const shot = kick();
const TPL = Math.round(0.06 * SR);
const tpl = filtfilt(shot.slice(0, TPL), () => Biquad.lp(400, Math.SQRT1_2));
let tplNorm = 0;
for (let j = 0; j < TPL; j += 2) tplNorm += tpl[j] * tpl[j];
tplNorm = Math.sqrt(tplNorm);
/** normalised cross-correlation of the kick template with the 400 Hz band at sample c */
const ncc = (c) => {
let s = 0;
let e = 0;
for (let j = 0; j < TPL; j += 2) {
const x = band400[c + j] ?? 0;
s += x * tpl[j];
e += x * x;
}
return { score: s / (Math.sqrt(e) * tplNorm + 1e-12), rms: Math.sqrt((2 * e) / TPL) };
};
// (a) matched filter around each score kick: the lag of the best fit, ±8 ms
const lags = [];
const scores = [];
for (const h of HITS.kick) {
const c = Math.round(h.t * SR);
let best = -Infinity;
let bestLag = 0;
for (let lag = -384; lag <= 384; lag++) {
const { score } = ncc(c + lag);
if (score > best) {
best = score;
bestLag = lag;
}
}
lags.push(bestLag);
scores.push(best);
}
const maxLag = Math.max(...lags.map(Math.abs));
const hist = Object.fromEntries([...new Set(lags)].sort((a, b) => a - b).map((l) => [l, lags.filter((x) => x === l).length]));
console.log(` matched filter (kick one-shot vs the mix's < 400 Hz band): max |lag| ${maxLag} samples = ${fmt((maxLag / SR) * 1000, 3)} ms · fit ${fmt(Math.min(...scores), 2)}…${fmt(Math.max(...scores), 2)} · lags ${JSON.stringify(hist)}`);
check(maxLag / SR < 0.005, "every kick within 5 ms of its HITS.kick time");
// (b) envelopes (quadrature: |x| + |x'|/ω, so a 50–100 Hz sine reads as a smooth envelope)
const envelope = (x, f0) => {
const w = (2 * Math.PI * f0) / SR;
const e = new Float32Array(frames);
for (let i = 1; i < frames - 1; i++) e[i] = Math.hypot(x[i], (x[i + 1] - x[i - 1]) / (2 * w));
return e;
};
const e150 = envelope(band150, 60);
const peaks = [];
for (const h of HITS.kick) {
const c = Math.round(h.t * SR);
let m2 = 0;
let mi2 = c;
for (let k = c; k < c + 0.12 * SR; k++) if (e150[k] > m2) ((m2 = e150[k]), (mi2 = k));
peaks.push((mi2 - c) / SR);
}
const stats = (a) => {
const s = [...a].sort((x, y) => x - y);
return `median ${fmt(s[s.length >> 1] * 1000, 1)} ms, range ${fmt(s[0] * 1000, 1)}…${fmt(s[s.length - 1] * 1000, 1)} ms`;
};
console.log(` the sub "thump" (< 150 Hz envelope peak) comes ${stats(peaks)} after the hit`);
// (c) blind scan: slide the matched filter over the whole film (1 ms hop) — every kick-like event
const kickLevel = [...HITS.kick.map((h) => ncc(Math.round(h.t * SR)).rms)].sort((a, b) => a - b)[HITS.kick.length >> 1];
const found = [];
const hop = 48;
let prevScore = 0;
let prevPrev = 0;
for (let c = 0; c + TPL < frames; c += hop) {
const { score, rms } = ncc(c);
// a local maximum of the fit, on an event at most 20 dB under a typical kick
if (prevScore > 0.75 && prevScore >= prevPrev && prevScore >= score && rms > kickLevel * 0.1) found.push((c - hop) / SR);
prevPrev = prevScore;
prevScore = score;
}
const kickTimes = HITS.kick.map((h) => h.t);
const extras = found.filter((t) => !kickTimes.some((kt) => Math.abs(kt - t) < 0.012));
const missed = kickTimes.filter((kt) => !found.some((t) => Math.abs(kt - t) < 0.012));
console.log(` blind scan: ${found.length} kick-like events (fit > 0.75, within 20 dB of a kick); ${missed.length} score kicks missed; ${extras.length} events off the score${extras.length ? ": " + extras.map((t) => `${fmt(t, 3)} s (bar ${Math.floor(t / BAR) + 1})`).join(", ") : ""}`);
const quiet = [
[1, 2],
[31, 34],
[64, 64],
];
const inQuiet = extras.filter((t) => quiet.some(([a, b]) => t >= at(a) && t < at(b + 1)));
check(missed.length === 0, "the blind scan finds every score kick");
check(inQuiet.length === 0, "no kick-like events in bars 1–2, 31–34, 64");
for (const [a, b] of quiet) {
let m = 0;
for (let i = Math.round(at(a) * SR); i < Math.round(at(b + 1) * SR); i++) m = Math.max(m, e150[i]);
console.log(` bars ${a}–${b}: loudest < 150 Hz envelope ${fmt(dbv(m), 1)} dBFS (a kick's sub peaks at ${fmt(dbv([...HITS.kick.map((h) => { let x = 0; for (let k = Math.round(h.t * SR); k < Math.round((h.t + 0.12) * SR); k++) x = Math.max(x, e150[k]); return x; })].sort((p, q) => p - q)[HITS.kick.length >> 1]), 1)} dBFS)`);
}
// (d) the grid stems (deterministic: rebuilt here) against the finished mix: the lag that best lines each
// stem up with the mix must be 0, so nothing on the way (filters, reverbs, master) moved them
const grid = S.gridStems();
const stemLag = (stem, a, b) => {
const i0 = Math.round(at(a) * SR) + 512;
const i1 = Math.round(at(b + 1) * SR) - 512;
const corr = (lag) => {
let s2 = 0;
for (let i = i0; i < i1; i += 2) s2 += mono[i + lag] * (stem.L[i] + stem.R[i]);
return s2;
};
let best = 0;
let bestV = -Infinity;
for (let lag = -480; lag <= 480; lag += 6) {
const v = corr(lag);
if (v > bestV) ((bestV = v), (best = lag));
}
for (let lag = best - 6; lag <= best + 6; lag++) {
const v = corr(lag);
if (v > bestV) ((bestV = v), (best = lag));
}
return best;
};
const stemLags = {
"kick (bars 5–12)": stemLag(grid.kick, 5, 12),
"pings + chimes (25–35)": stemLag(grid.bells, 25, 35),
"crashes (51–59)": stemLag(grid.crash, 51, 59),
"risers + impacts (35–40)": stemLag(grid.fx, 35, 40),
};
console.log(` grid stems vs the mix, best-fit lag in samples: ${Object.entries(stemLags).map(([k, v]) => `${k} ${v}`).join(" · ")}`);
check(Object.values(stemLags).every((v) => v === 0), "kicks, pings, chimes, crashes, risers and impacts sit on their exact samples in the mix");
// ── energy curve: loudness of every bar (BS.1770 over the bar), to read against the brief's energy map ──
console.log(`\n loudness per bar (LUFS):`);
for (let row = 0; row < 8; row++) {
const cells = [];
for (let k = 1; k <= 8; k++) {
const bar = row * 8 + k;
const i0 = Math.round(at(bar) * SR);
const i1 = Math.round(at(bar + 1) * SR);
const v = integratedLoudness(L.slice(i0, i1), R.slice(i0, i1));
cells.push(`${String(bar).padStart(2)}:${(Number.isFinite(v) ? v.toFixed(1) : "-inf").padStart(6)}`);
}
console.log(" " + cells.join(" "));
}
const gapA = Math.round(at(38, 4, 2) * SR);
const gapB = Math.round(at(39) * SR);
let gapPeak = 0;
for (let i = gapA; i < gapB; i++) gapPeak = Math.max(gapPeak, Math.abs(L[i]), Math.abs(R[i]));
console.log(` the gap (bar 38's last 8th): peak ${fmt(dbv(gapPeak), 1)} dBFS — only the riser sweeping into the drop`);
// ── 5. pictures ──
const png = (name, af) => runErr("ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", "-i", file, "-lavfi", af, "-frames:v", "1", join(root, "music", name)]);
png("score-spectrum.png", "showspectrumpic=s=1920x540:legend=1:color=intensity:scale=log:fscale=log");
png("score-wave.png", "showwavespic=s=1920x300:split_channels=1:colors=#e8743b|#6a994e");
console.log(`\n5. pictures: music/score-spectrum.png, music/score-wave.png`);
console.log(failures ? `\n${failures} check(s) failed` : `\nall checks passed`);
process.exit(failures ? 1 : 0);