#!/usr/bin/env node // Level the shouted hooks by DELIVERED LOUDNESS, not by peak target. // // This re-implements the round-2 method (`mk-variants/README-round2.md`), whose own // tool lived in a job temp directory and did not survive it. The finding it exists to // keep is worth restating: `mk-mix-hooks.mjs` treats the catalogue's `gain` as a PEAK // target, so a hook delivers `gain / crest` — and crest runs 4.34 to 7.92 across the // roster. Identical peak targets therefore produce a 3.38x (10.6 dB) spread in // loudness, and they under-level the sharpest shouts hardest. That spread is exactly // the complaint: "every voice clip other than 'test your might' and 'mortal kombat' // is too quiet to hear." // // Measured on the MIX, not modelled from the files, because punch, sub, echo, carve, // the intro envelope and the limiter all move the delivered level. Two renders give // the two sides: // // SONG_GAIN=0 the hooks alone // HOOK_GAIN=0 the song alone // // A window drawn on the FINISHED mix would contain the ums as well as the shout, so a // variant that ducks the ums less reads hotter even when the shout has not moved. // // node fit-hooks.mjs // TARGET=1.40 delivered hook/body loudness every ordinary hook should reach // DRIVE=1.6 tanh saturation, renormalised to the same peak: it is what buys // the headroom, since a peak target alone tops out before the // quietest hooks reach any sensible target // TYM=0.84,0.88,1.04,1.13 // "Test your might" is held OUT of the flat target and given the // record's own build across its four appearances -- it measured // 1.8x the record's balance and is one of the two you can already // hear, so levelling it up with the rest is the wrong direction. // ITERS=2 how many correction passes. The SPREAD needs more of them the more // hooks there are, because the limiter responds to the correction // itself: 6 hooks close 3.13x -> 1.00x in one, but 28 go // 3.52 -> 1.32 -> 1.11 -> 1.08 and want ITERS=4. The ABSOLUTE ratio // does NOT improve with iterations -- the mix is peak-normalised, so // raising every hook raises the whole file. Reach the target with // DRIVE, close the spread with iterations. import { readFileSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { SONG_DATA } from "./paths.mjs"; const [SONG, OVL, OUT] = process.argv.slice(2); if (!OUT) throw new Error("usage: fit-hooks.mjs "); const DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname)); const TARGET = Number(process.env.TARGET ?? 1.40); const DRIVE = process.env.DRIVE ?? "1.6"; const TYM = (process.env.TYM ?? "0.84,0.88,1.04,1.13").split(",").map(Number); const ITERS = Number(process.env.ITERS ?? 2); const SR = 48000; const abs = (f) => (path.isAbsolute(f) ? f : path.join(SONG_DATA, f)); const overlays = JSON.parse(readFileSync(abs(OVL), "utf8")); const hooks = overlays.map((h) => ({ ...h, id: path.basename(h.file, ".wav"), tym: /test your might/i.test(h.label ?? ""), dur: Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", abs(h.file)]).toString().trim()), })); let tymSeen = 0; for (const h of hooks) h.target = h.tym ? (TYM[tymSeen++] ?? TYM.at(-1)) : TARGET; console.log(`${hooks.length} hooks; ${hooks.filter((h) => h.tym).length} "Test your might" on the record's own build`); function readWav(file) { const b = readFileSync(file); let p = 12, fmt = null, data = null; while (p + 8 <= b.length) { const id = b.toString("latin1", p, p + 4), sz = b.readUInt32LE(p + 4), body = p + 8; if (id === "fmt ") fmt = { ch: b.readUInt16LE(body + 2), sr: b.readUInt32LE(body + 4), bits: b.readUInt16LE(body + 14) }; if (id === "data") { data = b.subarray(body, body + sz); break; } p = body + sz + (sz & 1); } const by = fmt.bits / 8, n = Math.floor(data.length / by / fmt.ch), x = new Float32Array(n); for (let i = 0; i < n; i += 1) { let s = 0; for (let c = 0; c < fmt.ch; c += 1) s += data.readInt16LE((i * fmt.ch + c) * by) / 32768; x[i] = s / fmt.ch; } return x; } const rms = (x, a, b) => { const i0 = Math.max(0, Math.round(a * SR)), i1 = Math.min(x.length, Math.round(b * SR)); if (i1 <= i0) return 0; let s = 0; for (let i = i0; i < i1; i += 1) s += x[i] * x[i]; return Math.sqrt(s / (i1 - i0)); }; // mk-mix-hooks resolves EVERY path against SONG_DATA, so it must be handed the // relative names it was given -- an absolute path comes back doubled // (".../song/home//.../song/mk-overlays.json") and it dies on ENOENT. const mix = (rel, env) => { execFileSync("node", [path.join(DIR, "mk-mix-hooks.mjs"), SONG, OVL, rel], { env: { ...process.env, DRIVE, ...env }, stdio: ["ignore", "ignore", "inherit"] }); return readWav(path.join(SONG_DATA, rel)); }; execFileSync("mkdir", ["-p", path.join(SONG_DATA, "fithooks")]); // The body only has to be measured once: HOOK_GAINS does not touch it. const bodyOnly = mix("fithooks/song-only.wav", { HOOK_GAIN: "0" }); const gains = Object.fromEntries(hooks.map((h) => [h.id, 1])); for (let it = 1; it <= ITERS; it += 1) { const hooksOnly = mix("fithooks/hooks-only.wav", { SONG_GAIN: "0", HOOK_GAINS: JSON.stringify(gains) }); const rows = []; for (const h of hooks) { const a = h.at, b = h.at + h.dur; const hk = rms(hooksOnly, a, b), bd = rms(bodyOnly, a, b); const ratio = bd > 1e-6 ? hk / bd : 0; rows.push({ h, ratio }); if (it < ITERS && ratio > 1e-6) gains[h.id] = +(gains[h.id] * (h.target / ratio)).toFixed(4); } const ord = rows.filter((r) => !r.h.tym).map((r) => r.ratio).sort((a, b) => a - b); console.log(`\niteration ${it}:`); for (const r of rows) { console.log(` ${r.h.id} ratio ${r.ratio.toFixed(3)} target ${r.h.target.toFixed(2)}` + ` gain ${gains[r.h.id]}${r.h.tym ? " (TYM, held out)" : ""}`); } if (ord.length) console.log(` ordinary hooks: ${ord[0].toFixed(3)} -> ${ord.at(-1).toFixed(3)}` + ` spread ${(ord.at(-1) / Math.max(1e-6, ord[0])).toFixed(2)}x`); } writeFileSync(abs(OUT), JSON.stringify(gains, null, 1)); console.log(`\nwrote ${OUT}`);