#!/usr/bin/env node // Rebuild the Mortal Kombat hooks from the ORIGINAL GAME's samples. // // The shipped hooks are UVR separations of the record, and the stem has no // energy below ~400 Hz: every "Test your might" and fighter name arrives thin, // and mk-mix-hooks.mjs grew SUB_MIX, SUB2_MIX, BOOM and PRESENCE to synthesise // back what the separator took. But the record sampled all of those lines from // the 1992 arcade announcer -- so the clean, full-bodied originals exist, and // the hooks can simply be re-made from them. align-game-hooks.py measured where // the record plays each one (mk-game-hooks.json); this writes the audio. // // node build-game-hooks.mjs ... // // For each overlays file it writes a hook directory BESIDE the one the overlays // name ("hooks-best" -> "hooks-game", "hooks-best-short" -> "hooks-game-short") // and an overlays file beside the input ("-best" -> "-game"), in SONG_DATA. // // Every occurrence keeps its own id (mk-mix-hooks keys HOOK_GAINS by it, so a // shared file would collapse the levelling -- mortal-kombat.md, "Swapping the // FILE collapses the gains"), and its file begins at the overlay's `at`, the // catalogue window's start mapped into the song. The one exception is a line // that starts BEFORE its window (hook-22's opens 0.5 s into "Scorpion!"): that // file starts earlier and the -game overlays carry the earlier `at`, so use // them rather than HOOKDIR over the old ones. Inside each file: // // game line the arcade sample, at the record's own sampler rate (one // semitone down, mk-game-hooks.json `rate`), starting at // (recOnset - catStart) x SCALE. SCALE is the song's 1.05 stretch: // the record's lines sit on its beat grid, and ours is 5% slower. // // That is the record's position for THIS occurrence, not the take // pick-take substituted. The two differ by up to 0.8 s: pick-take // put hook-03's take under hook-01, whose shout starts 0.8 s into // its window against hook-01's 1.6 s, so the shipped first "Test // your might" lands two beats early (18.42 s, not 19.30 -- the // first um). The record's grid is the placement this restores. // // kept line "MORTAL KOMBAT!" has no arcade source (it is the 1993 TV spot) // and stays the separated take -- the overlay's own file, exactly // as shipped. Without the song data, the take's cut from // mk-hooks.audio stands in, and the build says so. // // A hook holding both ("MORDO GRANDE! Excellent!") keeps the take up to where // the game line starts, and the game line comes in at the level the record puts // it RELATIVE to the kept one, measured on the take. // // Also writes finale-finish-him.wav for mk-fatal-finish3.sh's FINISH_HOOK: // trimmed to its sound (the script lands the file's END on the fanfare) and // peaked at 0.39, the level of hook-17, so FINISH_GAIN 2.2 still means ~0.86. // // SCALE the song's stretch against the record (1.05) // PACK the arcade sample directory (SONG_REPORTS/) // GAME_PEAK peak of a game line in a file with no kept audio (0.5; the mixer // re-peaks every hook to its gain anyway) import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { SONG_DATA, SONG_REPORTS } from "./paths.mjs"; const [MAP, ...OVLS] = process.argv.slice(2); if (!MAP || !OVLS.length) { console.error("usage: build-game-hooks.mjs ..."); process.exit(2); } const SR = 48000; const SCALE = Number(process.env.SCALE ?? 1.05); const GAME_PEAK = Number(process.env.GAME_PEAK ?? 0.5); const FINALE_PEAK = 0.39; const FADE = Math.round(0.03 * SR); const map = JSON.parse(readFileSync(MAP, "utf8")); const PACK = process.env.PACK ?? path.join(SONG_REPORTS, map.pack.dir); const byId = new Map(map.hooks.map((h) => [h.id, h])); const abs = (f) => (path.isAbsolute(f) ? f : path.join(SONG_DATA, f)); const decode = (f, filter) => { const raw = execFileSync("ffmpeg", ["-v", "error", "-i", f, ...(filter ? ["-af", filter] : []), "-ac", "1", "-ar", String(SR), "-f", "s16le", "-"], { maxBuffer: 1 << 30 }); const i16 = new Int16Array(raw.buffer, raw.byteOffset, raw.length / 2); return Float32Array.from(i16, (v) => v / 32768); }; // A sampler's rate change: pitch and length together, as the record did it. const gameLine = (sample) => decode(path.join(PACK, `${sample}.mp3`), `aresample=${SR},asetrate=${SR * map.rate},aresample=${SR}`); // The same, less its leading silence, and how much that was: recOnset is where // the FILE starts, and an mp3 opens on encoder delay before the voice does. const voicedLine = (sample) => { const x = gameLine(sample), floor = peakOf(x) * 0.0032; // -50 dB let a = 0; while (a < x.length && Math.abs(x[a]) < floor) a += 1; return { g: x.slice(a), lead: a }; }; const peakOf = (x) => x.reduce((m, v) => Math.max(m, Math.abs(v)), 0); // RMS of the loud frames only: a line's level, not its level averaged with the // silence around it. const loudRms = (x, from = 0, to = x.length) => { const F = 960, fr = []; for (let s = Math.max(0, from); s + F <= Math.min(x.length, to); s += F) { let e = 0; for (let i = s; i < s + F; i += 1) e += x[i] * x[i]; fr.push(Math.sqrt(e / F)); } if (!fr.length) return 0; const top = Math.max(...fr), loud = fr.filter((v) => v >= top * 0.316); // within 10 dB return Math.sqrt(loud.reduce((a, v) => a + v * v, 0) / loud.length); }; function writeWav(file, x) { const n = x.length, b = Buffer.alloc(44 + n * 2); b.write("RIFF", 0, "latin1"); b.writeUInt32LE(36 + n * 2, 4); b.write("WAVE", 8, "latin1"); b.write("fmt ", 12, "latin1"); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22); b.writeUInt32LE(SR, 24); b.writeUInt32LE(SR * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write("data", 36, "latin1"); b.writeUInt32LE(n * 2, 40); for (let i = 0; i < n; i += 1) b.writeInt16LE(Math.round(Math.max(-1, Math.min(1, x[i])) * 32767), 44 + i * 2); writeFileSync(file, b); } // The kept audio for one overlay entry: the shipped file when the song data is // here, else the take's catalogue window cut from its padded mk-hooks.audio mp3. function keptAudio(entry, take) { const own = abs(entry.file); if (existsSync(own)) return { x: decode(own), from: entry.file }; const t = byId.get(take); const mp3 = path.join(SONG_REPORTS, "mk-hooks.audio", `${take}.mp3`); const all = decode(mp3); const a = Math.round(map.pad * SR), b = Math.round((map.pad + t.catEnd - t.catStart) * SR); return { x: all.slice(a, b), from: `mk-hooks.audio/${take}.mp3 (dry stem cut; ${entry.file} not on this machine)` }; } const fadeOut = (x, at) => { for (let i = 0; i < FADE && at - FADE + i >= 0; i += 1) x[at - FADE + i] *= 1 - i / FADE; x.fill(0, Math.max(0, at)); }; const built = new Map(); // dir/id -> manifest row, so a repeated id is built once const subsOff = {}; for (const ovlPath of OVLS) { const entries = JSON.parse(readFileSync(abs(ovlPath), "utf8")); const out = []; for (const e of entries) { const id = (e.label ?? "").match(/hook-\d+/)?.[0] ?? path.basename(e.file, ".wav"); const take = (e.label ?? "").match(/\[take (hook-\d+)\]/)?.[1] ?? id; const h = byId.get(id); if (!h) throw new Error(`${id}: not in ${MAP}`); const srcDir = path.dirname(e.file); const dir = srcDir.replace(/hooks-best/, "hooks-game"); if (dir === srcDir) throw new Error(`${e.file}: expected a hooks-best* directory`); const file = path.posix.join(dir, `${id}.wav`); const game = h.parts.filter((p) => !p.keep); const keep = h.parts.find((p) => p.keep); let note; if (!built.has(file)) { mkdirSync(abs(dir), { recursive: true }); let x = new Float32Array(0), rows = []; let keepRms = 0; if (keep) { const k = keptAudio(e, take); x = Float32Array.from(k.x); if (keep.keepUntil != null) fadeOut(x, Math.round((keep.keepUntil - h.catStart) * SR)); keepRms = loudRms(x); rows.push({ line: keep.line, kept: k.from }); } // The record's balance between the kept line and a game line after it, // measured on the separated take itself. let aiKeep = 0, aiTake = null; if (keep && game.length) { aiTake = decode(path.join(SONG_REPORTS, "mk-hooks.audio", `${id}.mp3`)); const c0 = Math.round((h.catStart - h.clipStart) * SR); aiKeep = loudRms(aiTake, c0, Math.round((keep.keepUntil - h.clipStart) * SR)); } // Where each game line lands in the file. A line can start BEFORE the // catalogue window -- hook-22's window opens 0.5 s into "Scorpion!" -- and // the file must not cut its head off, so the whole file then starts // earlier and its `at` moves back by the same amount: the sound lands // where the record puts it either way. const placed = game.map((p) => { const { g, lead } = voicedLine(p.sample); return { p, g, off: Math.round((p.recOnset - h.catStart) * SCALE * SR) + lead }; }); const shift = Math.max(0, -Math.min(0, ...placed.map((q) => q.off))); if (shift) { const y = new Float32Array(x.length + shift); y.set(x, shift); x = y; } for (const { p, g: g0, off: off0 } of placed) { let g = g0; const off = off0 + shift; let target; if (keep) { const s = Math.round((p.recOnset - h.clipStart) * SR); const ai = loudRms(aiTake, s, s + g.length); target = keepRms * (ai / aiKeep); const k = target / loudRms(g); g = g.map((v) => v * k); } else { const k = GAME_PEAK / peakOf(g); g = g.map((v) => v * k); } if (off + g.length > x.length) { const y = new Float32Array(off + g.length); y.set(x); x = y; } for (let i = 0; i < g.length; i += 1) if (off + i >= 0) x[off + i] += g[i]; rows.push({ line: p.line, sample: p.sample, at: +(off / SR).toFixed(4), by: p.by, ...(target ? { balancedTo: "the take's own ratio" } : {}) }); } writeWav(abs(file), x); built.set(file, { file, id, take, dur: +(x.length / SR).toFixed(4), earlier: +(shift / SR).toFixed(4), parts: rows }); } const row = built.get(file); if (game.length) subsOff[id] = 0; note = row.parts.map((p) => (p.sample ? `game ${p.sample}` : `kept AI take ${take}`)).join(" + "); const at = +(e.at - row.earlier).toFixed(4); out.push({ ...e, file, at, label: `${e.label} [${note}${row.earlier ? `, starts ${row.earlier}s earlier` : ""}]` }); } const outPath = abs(path.basename(ovlPath).replace(/-best(?=\.json$)|(?=\.json$)/, "-game")); writeFileSync(outPath, JSON.stringify(out, null, 1) + "\n"); console.log(`${entries.length} hooks -> ${path.relative(SONG_DATA, outPath)}`); } // The finale's "Finish him!", landed on the fanfare by mk-fatal-finish3.sh. const fin = map.hooks.flatMap((h) => h.parts).find((p) => p.line === "Finish him!" && !p.keep); let x = gameLine(fin.sample); const floor = peakOf(x) * 0.0032; // -50 dB let a = 0, b = x.length; while (a < b && Math.abs(x[a]) < floor) a += 1; while (b > a && Math.abs(x[b - 1]) < floor) b -= 1; x = x.slice(a, b); const pk = peakOf(x); x = x.map((v) => (v * FINALE_PEAK) / pk); for (const dir of new Set([...built.values()].map((r) => path.dirname(r.file)))) { writeWav(abs(path.posix.join(dir, "finale-finish-him.wav")), x); writeFileSync(abs(path.posix.join(dir, "manifest.json")), JSON.stringify({ about: "Built by build-game-hooks.mjs from mk-game-hooks.json.", rate: map.rate, scale: SCALE, pack: map.pack, hooks: [...built.values()].filter((r) => path.dirname(r.file) === dir), finale: { file: "finale-finish-him.wav", sample: fin.sample, dur: +(x.length / SR).toFixed(4), peak: FINALE_PEAK }, }, null, 1) + "\n"); console.log(`${dir}: ${[...built.values()].filter((r) => path.dirname(r.file) === dir).length} hooks + finale-finish-him.wav`); } console.log("\nMix with the game hooks' synthesised sub layers OFF -- the arcade samples have their own chest:"); console.log(` HOOK_SUBS='${JSON.stringify(subsOff)}'`); console.log(` FINISH_HOOK=${path.posix.join([...built.values()][0] ? path.dirname([...built.values()][0].file) : "mkvocals/hooks-game", "finale-finish-him.wav")}`);