import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { CACHE_DIR, labelFor } from "./paths"; import type { Loudness } from "./loudness-types"; export * from "./loudness-types"; // --------------------------------------------------------------------------- // How loud is it, actually. // // Nothing in this repo measured loudness. Every LUFS figure quoted in the specs // came from a hand-run of ffmpeg that is not written down anywhere -- a number // nobody can reproduce, which is worse than no number. // // WHY THE FILTER AND NOT A JS RE-IMPLEMENTATION. lib/media.ts decodes to f32 // and does its analysis in JavaScript, and that was the right call there: it // came out of `signalstats` silently returning 0.0, and an envelope is a thing // you can define yourself. Loudness is not. BS.1770-4 is a standardised gated // measurement -- K-weighting, 400 ms blocks at 75% overlap, an absolute gate at // -70 LUFS and a relative one 10 LU below the ungated mean -- and a hand-rolled // gate is exactly how you get a figure that is precise and wrong. // // The failure mode that motivated the JS preference does not apply here either: // `ebur128` prints a Summary block that either parses or does not. If it is // absent this throws. It never returns a zero. // // The same filter is what `ffmpeg -af ebur128` prints on the command line and // what `loudnorm` measures against, so a figure here can be checked by hand // against both -- which is the property the hand-run figures never had. // --------------------------------------------------------------------------- /** A 4-minute 66 MB render decodes in a few seconds; an hour of audio does not. */ const MEASURE_TIMEOUT_MS = 10 * 60 * 1000; const num = (v: string | undefined): number | null => { if (!v) return null; if (/^-?inf$/i.test(v.trim())) return null; const n = Number(v); return Number.isFinite(n) ? n : null; }; /** Pull the figures out of ebur128's Summary block. Throws if there is none. */ export function parseEbur128(stderr: string, label: string, duration: number, tookMs: number): Loudness { const at = stderr.lastIndexOf("Summary:"); if (at < 0) { // No summary means the filter never ran to completion -- a file with no // audio stream, a decode error, an ffmpeg without the filter. Saying so // beats reporting a plausible number for something that was not measured. throw new Error("ebur128 printed no summary — is there an audio stream?"); } const tail = stderr.slice(at); const grab = (re: RegExp): number | null => num(re.exec(tail)?.[1]); const integrated = grab(/^\s*I:\s*(-?[\d.]+|-?inf)\s*LUFS/m); const lra = grab(/^\s*LRA:\s*(-?[\d.]+|-?inf)\s*LU\b/m); const truePeak = grab(/True peak:[\s\S]*?Peak:\s*(-?[\d.]+|-?inf)\s*dBFS/); // The per-frame log carries the momentary and short-term windows, which the // summary does not. Absent (quiet ffmpeg, very short file) they stay null // rather than being invented from the integrated figure. let maxMomentary: number | null = null; let maxShortTerm: number | null = null; for (const m of stderr.matchAll(/\bM:\s*(-?[\d.]+|-?inf)\s+S:\s*(-?[\d.]+|-?inf)/g)) { const mm = num(m[1]); const ss = num(m[2]); if (mm !== null && (maxMomentary === null || mm > maxMomentary)) maxMomentary = mm; if (ss !== null && (maxShortTerm === null || ss > maxShortTerm)) maxShortTerm = ss; } return { label, duration, integrated, lra, truePeak, maxMomentary, maxShortTerm, tookMs }; } async function runEbur128(abs: string): Promise<{ stderr: string; tookMs: number }> { const started = Date.now(); const stderr = await new Promise((resolve, reject) => { const ff = spawn("ffmpeg", [ "-nostdin", // The frame log is on stderr at `info`, which is where M: and S: live. "-v", "info", "-i", abs, "-map", "a:0?", "-af", "ebur128=peak=true", "-f", "null", "-", ]); let err = ""; ff.stderr.on("data", (b: Buffer) => { err += b.toString(); }); const timer = setTimeout(() => { ff.kill("SIGKILL"); reject(new Error(`ebur128 on ${labelFor(abs)} took longer than ${MEASURE_TIMEOUT_MS / 60000} minutes`)); }, MEASURE_TIMEOUT_MS); ff.on("error", (e) => { clearTimeout(timer); reject(e); }); ff.on("close", (code) => { clearTimeout(timer); if (code === 0) resolve(err); else reject(new Error(err.trim().split("\n").slice(-3).join(" ") || `ffmpeg exited ${code}`)); }); }); return { stderr, tookMs: Date.now() - started }; } const DURATION_RE = /Duration:\s*(\d+):(\d+):([\d.]+)/; const cacheKey = (abs: string, mtimeMs: number, size: number) => createHash("sha1").update(`${abs}|${Math.round(mtimeMs)}|${size}|loud-v1`).digest("hex").slice(0, 16); const LOUD_CACHE = path.join(CACHE_DIR, "loudness"); const memo = new Map(); async function cacheFileFor(abs: string): Promise<{ key: string; file: string } | null> { let st; try { st = await stat(abs); } catch { return null; } const key = cacheKey(abs, st.mtimeMs, st.size); return { key, file: path.join(LOUD_CACHE, `${key}.json`) }; } /** * What has already been measured, without measuring anything. * * The page-load path. A four-minute render takes seconds to decode, and * twenty of them behind a force-dynamic route is the "never probes" rule in * lib/browse.ts broken by a heavier operation. */ export async function cachedLoudness(abs: string): Promise { const c = await cacheFileFor(abs); if (!c) return null; const hit = memo.get(c.key); if (hit) return hit; try { const cached = JSON.parse(await readFile(c.file, "utf8")) as Loudness; memo.set(c.key, cached); return cached; } catch { return null; } } /** Measure one file, or return what was already measured. Keyed by mtime+size. */ export async function measureLoudness(abs: string): Promise { const hit = await cachedLoudness(abs); if (hit) return hit; const { stderr, tookMs } = await runEbur128(abs); const d = DURATION_RE.exec(stderr); const duration = d ? +(Number(d[1]) * 3600 + Number(d[2]) * 60 + Number(d[3])).toFixed(3) : 0; const out = parseEbur128(stderr, labelFor(abs), duration, tookMs); const c = await cacheFileFor(abs); if (c) { await mkdir(LOUD_CACHE, { recursive: true }); const tmp = `${c.file}.tmp-${process.pid}`; await writeFile(tmp, JSON.stringify(out)); await rename(tmp, c.file).catch(() => {}); memo.set(c.key, out); } return out; }