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>
328 lines
16 KiB
JavaScript
328 lines
16 KiB
JavaScript
/**
|
||
* 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);
|