Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 5502a6ced5a076c1325d0bbd0081e11ee455f916
parent bc5b8f9713050b4eb9f5974e2852655e31e21d5c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 22:54:34 -0400

umtool song: MK hooks from the original arcade samples; song data under ~/reports

- align-game-hooks.py measures where the record plays each MK1 announcer sample
  (one semitone down, per-occurrence onsets) into mk-game-hooks.json;
  build-game-hooks.mjs writes a drop-in hook set + overlays from it.
- standin-body.mjs: a plan's notes on a GM voice, for auditioning hooks when
  the um clips are not on the machine.
- fetch-sources.mjs: re-derive a song's media/ + wav48/ by asking the editor
  for each whole recording, one at a time.
- mk-finish.sh: the MK finale, restored from mk-fatal-finish3.sh and resolved
  through SONG_DATA instead of a job directory.
- SONG_DATA defaults to ~/reports/quartering-uh-song/data: bulk song data lives
  beside its deliverables, never in a job or scratch directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
MAGENTS.md | 6++++--
MWORKTREES.md | 15+++++++--------
Mumtool/docs/cli.md | 2+-
Mumtool/e2e/fixtures/make-fixture.mjs | 2+-
Mumtool/e2e/fixtures/song-capabilities.mjs | 2+-
Aumtool/song/align-game-hooks.py | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/song/build-game-hooks.mjs | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/song/fetch-sources.mjs | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/song/mk-finish.sh | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/song/mk-game-hooks.json | 745+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/song/paths.mjs | 34+++++++++++++++++-----------------
Aumtool/song/standin-body.mjs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 1653 insertions(+), 30 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -23,10 +23,12 @@ global lock for ~24 minutes of somebody else's tests and never runs the spec you package's own suite is run through its own filter — umtool's is: ```sh -SONG_DIR=~/reports/quartering-uh-song/data pnpm --filter umtool run e2e clip-bench.spec.ts +pnpm --filter umtool run e2e clip-bench.spec.ts ``` -`SONG_DIR` is read by `umtool/song/paths.mjs` when `e2e/fixtures/make-fixture.mjs` builds +The song data is `umtool/song/paths.mjs`'s `SONG_DATA`: `SONG_DIR` if set, else +`~/reports/quartering-uh-song/data` — bulk data lives beside the deliverables, never in a +job or scratch directory. It is read when `e2e/fixtures/make-fixture.mjs` builds the fixture. The suite never *runs* against the real song dir — it reads it to derive an empty-state copy and to symlink the heavy audio. umtool's suite is queued like every other. diff --git a/WORKTREES.md b/WORKTREES.md @@ -96,14 +96,13 @@ meant. Every other package's suite goes through its own filter: | editor | `pnpm e2e [spec…]` | | export | `pnpm --filter export run e2e` (also `e2e:hub`, `e2e:2origin`) | | homepage | `pnpm --filter homepage run e2e` | -| umtool | `SONG_DIR=~/reports/quartering-uh-song/data pnpm --filter umtool run e2e [spec…]` | - -umtool needs `SONG_DIR` because `e2e/fixtures/make-fixture.mjs` builds its fixture *from* -the song project's bulk data (`umtool/song/paths.mjs` resolves it, and its default is the -job-temp path that data was mined into, which no longer exists). The suite itself never -runs against that directory — the fixture is an empty-state copy with the heavy audio -symlinked in — but the fixture build reads it, so a run without `SONG_DIR` fails before the -test server starts. +| umtool | `pnpm --filter umtool run e2e [spec…]` | + +umtool's `e2e/fixtures/make-fixture.mjs` builds its fixture *from* the song project's bulk +data — `umtool/song/paths.mjs`'s `SONG_DATA`, which is `SONG_DIR` if set and +`~/reports/quartering-uh-song/data` otherwise. The suite itself never runs against that +directory — the fixture is an empty-state copy with the heavy audio symlinked in — and a +machine without the data builds an empty fixture whose song-data specs skip. The lock lives at `<git-common-dir>/e2e-queue.lock`, which resolves to the same file from every worktree. The kernel releases it when the holding process's fd closes, so a `kill -9` diff --git a/umtool/docs/cli.md b/umtool/docs/cli.md @@ -42,7 +42,7 @@ projects answering to one name is reported, never resolved by picking one. | Variable | Default | |---|---| -| `SONG_DIR` | `~/.local/share/archilyzer/song`, through its realpath — a symlink there is the supported way to keep the data where it is | +| `SONG_DIR` | `<SONG_REPORTS_DIR>/data` (`~/reports/quartering-uh-song/data`), through its realpath — a symlink there is the supported way to keep the data on another disk | | `SONG_REPORTS_DIR` | `~/reports/quartering-uh-song` | | `REPORTS_DIR` | `~/reports` (the parent of `SONG_REPORTS_DIR` when that is set) | | `CHANNELS_DIR` | `$TRANSCRIPTS_DIR/channels`, else the checkout's `transcripts/channels` (found by walking up from the cwd to `pnpm-workspace.yaml`) | diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs @@ -27,7 +27,7 @@ const CODE = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", const dest = path.resolve(process.argv[2] ?? path.join(process.cwd(), ".e2e-song")); // A readdir that answers "nothing" instead of throwing. SONG_DATA's default -// (`~/.local/share/archilyzer/song`) does not exist on most machines — see the +// (`~/reports/quartering-uh-song/data`) does not exist on most machines — see the // capabilities block below. const listDir = (p) => { try { diff --git a/umtool/e2e/fixtures/song-capabilities.mjs b/umtool/e2e/fixtures/song-capabilities.mjs @@ -6,7 +6,7 @@ import path from "node:path"; // // The 39 GB (`wav48/`, `asr/`, `media/`) is re-derivable from the archive and // deliberately not in the repo. `SONG_DIR`'s default -// (`~/.local/share/archilyzer/song`, song/paths.mjs) is empty or absent on +// (`~/reports/quartering-uh-song/data`, song/paths.mjs) is empty or absent on // most machines, so // `make-fixture.mjs` builds an empty fixture and every spec that judges a clip // used to fail — LOUDLY, as a red suite, over a machine that never had the data diff --git a/umtool/song/align-game-hooks.py b/umtool/song/align-game-hooks.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Find where each ORIGINAL-GAME sample sits inside the record's vocal hooks. + +The Mortal Kombat hooks were separated out of the record by UVR, and they sound +it: the stem has no energy below ~400 Hz, so "Test your might" arrives thin and +the whole SUB_MIX / SUB2_MIX / PRESENCE apparatus in mk-mix-hooks.mjs exists to +fake the chest back in. But the record did not invent those lines -- every one +except "MORTAL KOMBAT!" is a sample of the 1992 arcade announcer, and the arcade +samples are clean and full-bodied. So the hooks can be REBUILT from the game's +own audio, placed exactly where the record plays them. + +This measures the two things that takes: + + rate the record plays every arcade sample ONE SEMITONE DOWN. Measured by + raw-waveform correlation over rates 0.930-0.960 at 0.001 steps: the + best-correlated takes all peak at 0.944 (r 0.63-0.71), i.e. + 2^(-1/12) = 0.94387. One global rate, not one per line -- a 1993 + sampler played the whole bank at one pitch. + + onset where each line starts, in RECORD seconds, per occurrence. Band + energy envelopes (300-3500 Hz, 2 ms hop) find it to a frame; then + raw-waveform correlation within +-60 ms pins it to a sample -- but + only where that correlation is real (r >= 0.3). "Finish him!", + "Excellent!" and "Fight!" correlate at 0.08-0.23 raw (the record + treats them more heavily), so they keep the envelope answer. + +A hook holding two lines ("Excellent! Kano!") searches for the second only +after the first is mostly over: unconstrained, hook-18's "Kano" matched 0.29 s +into its own "Excellent", which is not a place a 0.7 s name can start. + +Input is what exists on a machine without the song data: the hooks UI's +catalogue (mk-hooks.data.js) and its padded stem cuts (mk-hooks.audio/*.mp3, +each opening `pad` seconds before the catalogue window). The game pack is the +MKWarehouse MK1 set; see GAME_LINES. + + align-game-hooks.py <reports-dir> <game-pack-dir> <out.json> + +Writes the tracked mapping build-game-hooks.mjs consumes. Nothing else. +""" +import json +import os +import re +import subprocess +import sys + +import numpy as np + +SR = 48000 +HOP = 96 # 2 ms +SEMITONE_DOWN = 2 ** (-1 / 12) + +# Line text (as the catalogue spells it) -> arcade sample. The catalogue's +# spellings are the separator-era transcription ("Senya!", "MORDO GRANDE!"). +GAME_LINES = { + "Test your might.": "mk1-00381", + "Finish him!": "mk1-00378", + "Excellent!": "mk1-00377", + "Fight!": "mk1-00368", + "Kano!": "mk1-00409", + "Liu Kang!": "mk1-00410", + "Raiden!": "mk1-00411", + "Johnny Cage!": "mk1-00412", + "Scorpion!": "mk1-00413", + "Sub Zero!": "mk1-00414", + "Senya!": "mk1-00415", +} +# Lines with no arcade source. "MORTAL KOMBAT!" is from the 1993 home-release TV +# spot, not the game; "I'm gonna kill you!" is not in the MK1 announcer bank. +KEEP_LINES = {"MORDO GRANDE!", "I'm gonna kill you!"} +RAW_MIN = 0.3 + + +def decode(path): + raw = subprocess.run( + ["ffmpeg", "-v", "error", "-i", path, "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"], + check=True, capture_output=True).stdout + return np.frombuffer(raw, dtype=np.int16).astype(np.float64) / 32768 + + +def resample(x, rate): + """Play x at `rate` (a sampler's pitch-and-speed change together).""" + n = int(len(x) / rate) + return np.interp(np.arange(n) * rate, np.arange(len(x)), x) + + +def bandpass(x, lo=150, hi=3800): + X = np.fft.rfft(x) + f = np.fft.rfftfreq(len(x), 1 / SR) + X[(f < lo) | (f > hi)] = 0 + return np.fft.irfft(X, len(x)) + + +def envelope(x, lo=300, hi=3500): + N = 1024 + win = np.hanning(N) + k0, k1 = int(lo / (SR / N)), int(hi / (SR / N)) + out = [] + for off in range(0, max(1, len(x) - N), HOP): + s = x[off:off + N] + if len(s) < N: + s = np.pad(s, (0, N - len(s))) + out.append(np.log((np.abs(np.fft.rfft(s * win))[k0:k1] ** 2).sum() + 1e-9)) + return np.array(out) + + +def env_search(hay, needle, lo_s, hi_s): + """Best needle START (seconds) in [lo_s, hi_s], by normalised envelope correlation.""" + L = len(needle) + nn = (needle - needle.mean()) / (needle.std() + 1e-9) + padded = np.pad(hay, (L, L), constant_values=hay.min()) + best, best_i, scores = -9.0, 0, [] + for i in range(len(padded) - L + 1): + t = (i - L) * HOP / SR + if t < lo_s or t > hi_s: + continue + s = padded[i:i + L] + sd = s.std() + v = -9.0 if sd < 1e-6 else float(np.dot((s - s.mean()) / sd, nn) / L) + scores.append(v) + if v > best: + best, best_i = v, i + sc = np.array([v for v in scores if v > -9]) + z = (best - sc.mean()) / (sc.std() + 1e-9) if len(sc) > 1 else 0.0 + return (best_i - L) * HOP / SR, best, z + + +def raw_search(hay, needle, around_s, span_s=0.06): + """Sample-accurate start near around_s, with the normalised correlation there.""" + n, m = len(needle), len(hay) + 1 # m candidate starts, 0..len(hay) + h = np.pad(hay, (0, n)) + N = 1 << int(np.ceil(np.log2(len(h) + n))) + c = np.fft.irfft(np.fft.rfft(h, N) * np.conj(np.fft.rfft(needle, N)), N)[:m] + cs = np.concatenate([[0], np.cumsum(h * h)]) + e = np.sqrt(np.maximum(cs[n:n + m] - cs[:m], 0) + 1e-12) + c = c / (e * np.sqrt((needle * needle).sum()) + 1e-12) + lo = max(0, int((around_s - span_s) * SR)) + hi = min(len(c), int((around_s + span_s) * SR)) + if hi <= lo: + return around_s, 0.0 + i = lo + int(np.argmax(c[lo:hi])) + return i / SR, float(c[i]) + + +def measure_rate(pairs): + """Median best rate over the takes that correlate for real.""" + found = [] + for hay, needle, around in pairs: + best = (-1.0, 1.0) + for r in np.arange(0.930, 0.9601, 0.001): + _, v = raw_search(hay, resample(needle, r), around, 0.05) + if v > best[0]: + best = (v, float(r)) + if best[0] >= RAW_MIN: + found.append(best) + rates = sorted(r for _, r in found) + return (rates[len(rates) // 2] if rates else None), found + + +def main(): + reports, pack, out = sys.argv[1:4] + js = open(os.path.join(reports, "mk-hooks.data.js")).read() + cat = json.loads(js[js.index("{"):js.rindex("}") + 1]) + pad = cat.get("pad", 1) + clips = cat["clips"] + game = {s: bandpass(decode(os.path.join(pack, s + ".mp3"))) for s in set(GAME_LINES.values())} + game_len = {s: len(x) / SR for s, x in game.items()} + + hooks = {} + for c in clips: + hooks[c["id"]] = bandpass(decode(os.path.join(reports, "mk-hooks.audio", c["id"] + ".mp3"))) + + def parts_of(text): + return [p.strip() for p in re.split(r"(?<=[!.])\s+", text) if p.strip()] + + # Rate first, on the single-line name hooks (the cleanest correlations). + probe = [] + for c in clips: + ps = parts_of(c["text"]) + if len(ps) == 1 and ps[0] in GAME_LINES and ps[0] not in ("Finish him!", "Excellent!", "Fight!"): + s = GAME_LINES[ps[0]] + he = envelope(hooks[c["id"]]) + at, _, _ = env_search(he, envelope(resample(game[s], SEMITONE_DOWN)), 0, len(hooks[c["id"]]) / SR) + probe.append((hooks[c["id"]], game[s], at)) + measured, found = measure_rate(probe) + print(f"rate: median {measured:.3f} over {len(found)} takes with raw r >= {RAW_MIN}" + f" (one semitone down = {SEMITONE_DOWN:.5f})", flush=True) + rate = SEMITONE_DOWN + + env_cache = {s: envelope(resample(x, rate)) for s, x in game.items()} + rs_cache = {s: resample(x, rate) for s, x in game.items()} + + rows = [] + for c in clips: + hay = hooks[c["id"]] + he = envelope(hay) + t0 = c["t0"] + parts, after = [], 0.0 + for text in parts_of(c["text"]): + if text in KEEP_LINES or text not in GAME_LINES: + parts.append({"line": text, "keep": True}) + continue + s = GAME_LINES[text] + at_env, r_env, z = env_search(he, env_cache[s], after, len(hay) / SR) + at_raw, r_raw = raw_search(hay, rs_cache[s], at_env) + use_raw = r_raw >= RAW_MIN + at = at_raw if use_raw else at_env + parts.append({ + "line": text, + "sample": s, + "recOnset": round(t0 + at, 4), + "by": "raw" if use_raw else "envelope", + "rRaw": round(r_raw, 3), + "rEnv": round(r_env, 3), + "z": round(z, 1), + }) + # The next line cannot begin until this one is most of the way out. + after = at + 0.8 * game_len[s] / rate + # A kept line that shares a hook with a game line ends where the game line + # starts -- the kept audio is cut there. + for i, p in enumerate(parts): + if p.get("keep"): + nxt = next((q for q in parts[i + 1:] if not q.get("keep")), None) + if nxt: + p["keepUntil"] = round(nxt["recOnset"] - 0.03, 4) + rows.append({ + "id": c["id"], + "text": c["text"], + "catStart": c["cat"][0], + "catEnd": c["cat"][1], + "clipStart": t0, + "parts": parts, + }) + print(c["id"], json.dumps(parts), flush=True) + + doc = { + "about": "Original-game replacements for the Mortal Kombat vocal hooks. Written by " + "align-game-hooks.py; consumed by build-game-hooks.mjs.", + "pack": { + "name": "MKWarehouse Mortal Kombat (1992 arcade) sounds", + "url": "https://www.mortalkombatwarehouse.com/mk1/sounds/mkw_mk1_sounds.zip", + "dir": "mk1-samples/zip/announcer", + }, + "rate": rate, + "rateMeasured": measured, + "rateTakes": len(found), + "lines": GAME_LINES, + "kept": sorted(KEEP_LINES), + "catalogue": "mk-hooks.data.js", + "pad": pad, + "hooks": rows, + } + with open(out, "w") as f: + json.dump(doc, f, indent=1) + f.write("\n") + print(f"wrote {out}") + + +if __name__ == "__main__": + main() diff --git a/umtool/song/build-game-hooks.mjs b/umtool/song/build-game-hooks.mjs @@ -0,0 +1,249 @@ +#!/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 <mk-game-hooks.json> <overlays.json>... +// +// 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/<pack.dir>) +// 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 <mk-game-hooks.json> <overlays.json>..."); + 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")}`); diff --git a/umtool/song/fetch-sources.mjs b/umtool/song/fetch-sources.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// Re-derive a song's SOURCE media -- media/<id>.mp4 and wav48/<id>.wav -- by +// asking the editor for each whole recording, one at a time. +// +// render-poly reads both by absolute source seconds: wav48/<id>.wav whole into +// memory (a missing one aborts the render) and media/<id>.mp4 per clip (a +// missing or short one silently drops or slides that voice's layer). So these +// are FULL recordings, not windows -- a window's t=0 is its `from`, and the plan +// would have to be rewritten to use it. +// +// Every fetch goes through the editor (POST /api/media/fetch-window, full: +// true): cookie policy, per-platform sleeps, the 429 cooldown, and the bytes +// land in the archive's saved-video store with a note of who asked and why. The +// song's copy is a HARDLINK to that file, not a second copy: the two trees share +// a filesystem, the store is the durable one, and each name survives the other +// being deleted. The link is named <id>.mp4 whatever the container is, because +// render-poly asks for that name; ffmpeg reads the content, not the extension. +// +// Slow and steady, never a burst: one job in flight, PAUSE seconds between +// them, a 409 cooldown waited out rather than retried. Three failures in a row +// stops the run -- that pattern is a bot check, and it wants a person. +// +// WORKER_TOKEN=... node fetch-sources.mjs <plan.json>... [--channel the-quartering] +// +// EDITOR_URL the editor (http://localhost:3001) +// PAUSE seconds between fetches (20) +// LIMIT stop after this many fetches (all) +// +// Resumable: an id whose media/ and wav48/ both exist is skipped, and +// sources.json (in SONG_DATA) records each one as it lands. +import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { SONG_DATA } from "./paths.mjs"; + +const argv = process.argv.slice(2); +const flag = (name, dflt) => { + const i = argv.indexOf(name); + return i < 0 ? dflt : argv[i + 1]; +}; +const CHANNEL = flag("--channel", "the-quartering"); +const plans = argv.filter((a, i) => a.endsWith(".json") && argv[i - 1] !== "--channel"); +const TOKEN = process.env.WORKER_TOKEN; +const EDITOR = (process.env.EDITOR_URL ?? "http://localhost:3001").replace(/\/$/, ""); +const PAUSE = Number(process.env.PAUSE ?? 20); +const LIMIT = Number(process.env.LIMIT ?? Infinity); +if (!plans.length || !TOKEN) { + console.error("usage: WORKER_TOKEN=... fetch-sources.mjs <plan.json>... [--channel <slug>]"); + process.exit(2); +} + +const ids = [...new Set(plans.flatMap((p) => + JSON.parse(readFileSync(p, "utf8")).voices.flatMap((v) => v.plan.map((n) => n.video)).filter(Boolean)))].sort(); +const MEDIA = path.join(SONG_DATA, "media"), WAV = path.join(SONG_DATA, "wav48"); +mkdirSync(MEDIA, { recursive: true }); +mkdirSync(WAV, { recursive: true }); +const LEDGER = path.join(SONG_DATA, "sources.json"); +const ledger = existsSync(LEDGER) ? JSON.parse(readFileSync(LEDGER, "utf8")) : {}; +const save = () => { + writeFileSync(`${LEDGER}.tmp`, JSON.stringify(ledger, null, 1) + "\n"); + renameSync(`${LEDGER}.tmp`, LEDGER); +}; +const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000)); +const log = (msg) => console.log(`${new Date().toISOString().slice(11, 19)} ${msg}`); +const headers = { authorization: `Bearer ${TOKEN}`, "content-type": "application/json" }; + +async function fetchFull(id) { + for (;;) { + const res = await fetch(`${EDITOR}/api/media/fetch-window`, { + method: "POST", + headers, + body: JSON.stringify({ + channelSlug: CHANNEL, + videoId: id, + full: true, + requestedBy: "umtool song/fetch-sources.mjs", + manifest: plans.map((p) => path.basename(p)).join(","), + reason: "re-derive um-song source media (wav48/media) lost with a job temp dir", + }), + }); + const j = await res.json().catch(() => ({})); + if (res.status === 409 && j.cooldownMs) { + log(`${id}: ${j.platform} cooldown, waiting ${Math.ceil(j.cooldownMs / 1000)}s`); + await sleep(j.cooldownMs / 1000 + 5); + continue; + } + if (res.status === 507) throw Object.assign(new Error(`editor refused for disk space: ${j.error}`), { fatal: true }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${j.error ?? ""}`); + if (j.cached) return j.file; + for (;;) { + await sleep(5); + const p = await fetch(`${EDITOR}/api/media/fetch-window/${j.jobId}`, { headers }); + const s = await p.json().catch(() => ({})); + if (!p.ok) throw new Error(`poll HTTP ${p.status}: ${s.error ?? ""}`); + if (s.status === "done") { + if (!s.file) throw new Error(`job ${j.jobId} done but no saved file`); + return s.file; + } + if (s.status === "failed" || s.status === "cancelled") { + throw new Error(`job ${j.jobId} ${s.status}: ${(s.error ?? "").split("\n").slice(-3).join(" | ")}`); + } + } + } +} + +const todo = ids.filter((id) => !(existsSync(path.join(MEDIA, `${id}.mp4`)) && existsSync(path.join(WAV, `${id}.wav`)))); +log(`${ids.length} sources in ${plans.length} plan(s); ${ids.length - todo.length} already here, ${todo.length} to fetch from ${CHANNEL}`); +let fails = 0, done = 0; +for (const id of todo) { + if (done >= LIMIT) break; + try { + const t0 = Date.now(); + const file = await fetchFull(id); + const media = path.join(MEDIA, `${id}.mp4`); + if (existsSync(media)) unlinkSync(media); + linkSync(file, media); + const wav = path.join(WAV, `${id}.wav`); + execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", "-max_error_rate", "1.0", "-i", media, + "-vn", "-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", `${wav}.part.wav`]); + renameSync(`${wav}.part.wav`, wav); + const secs = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", + "-of", "csv=p=0", wav]).toString().trim()); + ledger[id] = { channel: CHANNEL, saved: file, bytes: statSync(file).size, seconds: +secs.toFixed(2), + fetchedAt: new Date().toISOString() }; + save(); + done += 1; + fails = 0; + log(`${id}: ${(statSync(file).size / 1e9).toFixed(2)} GB, ${(secs / 60).toFixed(1)} min, ` + + `${Math.round((Date.now() - t0) / 1000)}s [${done}/${todo.length}]`); + } catch (e) { + log(`${id}: FAILED ${e.message}`); + if (e.fatal || ++fails >= 3) { + log(e.fatal ? "stopping" : "three failures in a row -- stopping (bot check?)"); + process.exit(1); + } + } + await sleep(PAUSE); +} +log(`done: ${done} fetched`); diff --git a/umtool/song/mk-finish.sh b/umtool/song/mk-finish.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# Assemble a Mortal Kombat fatality finale from an already-mixed audio track. +# +# Restored from the deleted mk-fatal-finish3.sh (292f7a96^) with its job-dir cd +# replaced by SONG_DATA and FINISH_HOOK defaulting to the original-game +# "Finish him!" (build-game-hooks.mjs). Everything else is as shipped. +# +# Supersedes mk-fatal-finish2.sh, whose DEFAULTS DID NOT DESCRIBE THE SHIPPED +# BUILD: several rounds of feedback ("start the finale earlier", "fade the game +# in earlier", "fatality a touch quieter") were applied as one-off env overrides +# and never written back, so rebuilding from that script silently changed the +# gong and the ending as well as the vocals. +# +# The defaults below are MEASURED off the shipped quartering-mk-fatality-full.mp4 +# against the raw background, region by region: +# +# region shipped/raw set by +# attract music 0.410 GAME_GAIN +# the gong (18.09s) 0.410 GONG_HOLD (still full) +# after gong (18.6s) 0.266 fading, GONG_HOLD -> GAME_OUT +# silence (18.95s) 0.000 GAME_OUT +# dead air (145.0s) 0.048 DEAD +# fatality plateau 0.242 LOW +# ramp (149.5s) 1.015 FULL_BY +# fanfare / end 1.00 (full) +# +# New here: FINISH_HOOK lays "Finish him!" over the tail so the finale is led +# into rather than opened cold on the game's own "dun dun dun" fanfare, and the +# game's drum kit is mixed on at the end instead of in a separate script. +set -euo pipefail +# The DATA dir, not the script's own -- intro/, mkvocals/ and the plan files live +# there. Resolved through song/paths.mjs (SONG_DIR, else +# ~/reports/quartering-uh-song/data), never a literal: the first copy of this +# script cd'd into a job's temp directory, and died with it. +HERE="$(cd "$(dirname "$0")" && pwd)" +SONG_DATA="$(cd "$HERE" && node -e 'import("./paths.mjs").then((m) => console.log(m.SONG_DATA))')" +cd "$SONG_DATA" + +BODY=${BODY:?need the rendered body video} +AUDIO=${AUDIO:?need the mixed audio wav} +BG=${BG:-intro/mk-bg-fatal2.mp4} +OUT=${OUT:?need an output path} + +DEAD=${DEAD:-1.06} # seconds of silence after the song +# 0.17, not the 0.24 the shipped file MEASURES against the raw background. +# +# Match the envelope in the regions that are NOT at the limiter ceiling. The +# fatality and the fanfare read ~1.0 of raw in every version regardless, because +# they ARE at the ceiling — so they cannot be used to set anything. The attract +# music and the fatality plateau are the two windows that can, and both came out +# 1.41x hot at the "measured" values, which is the 0.709 whole-envelope factor +# recorded in mortal-kombat.md. 0.24 x 0.709 = 0.17, 0.41 x 0.709 = 0.29. +LOW=${LOW:-0.17} # how loud the game is during the fatality itself +LOW_IN=${LOW_IN:-0.8} # how long it takes to reach LOW +FULL_AT=${FULL_AT:-148.60} # start of the ramp back to full... +FULL_BY=${FULL_BY:-149.50} # ...measured as where the shipped file reaches full +END=${END:-156.10} # FATALITY gives way to the ladder screen +GONG_HOLD=${GONG_HOLD:-18.5} +GAME_OUT=${GAME_OUT:-19.0} +# ATTRACT is the level of the attract music and the gong ONLY. It is not a gain +# on the whole envelope: the shipped file measures 0.410 against the raw +# background at the attract music but 1.009 at the fanfare, and one multiplier +# cannot produce both. Applying it globally (as -finish2.sh's GAME_GAIN does) +# plays the FATALITY -- the payoff the whole cut is built around -- at 41%. +ATTRACT=${ATTRACT:-0.29} +FULL_GAIN=${FULL_GAIN:-1.0} + +# "Finish him!" into the fanfare. The game's fanfare is at 153.80s, so the shout +# is placed to LAND on it rather than to sit anywhere in particular. +FINISH_HOOK=${FINISH_HOOK:-mkvocals/hooks-game/finale-finish-him.wav} +FANFARE_AT=${FANFARE_AT:-153.80} +# FINISH_GAIN is a PLAIN MULTIPLIER, not a peak target -- and hook-17 peaks at only +# 0.39 of full scale, so the old 0.85 ATTENUATED it to ~0.33 and dropped it under a game +# running at ~0.9. Measured on the shipped build: the shout is genuinely there +# (cross-correlates r=0.27 at exactly its placed time, 152.560s) and simply inaudible. +# 2.2 puts its peak at ~0.86, level with everything else in the finale. +FINISH_GAIN=${FINISH_GAIN:-2.2} +# ...and the game gets out of its way. A shout at the same level as a full-scale fanfare +# is still a shout nobody can pick out, so the game ducks to FIN_DUCK across the hook, +# ramped either side so the drop is not itself an event. +FIN_DUCK=${FIN_DUCK:-0.32} +FIN_DUCK_IN=${FIN_DUCK_IN:-0.15} +FIN_DUCK_OUT=${FIN_DUCK_OUT:-0.25} +FINISH_LEAD=${FINISH_LEAD:-0.05} # how far its END sits before the fanfare + +DRUMS=${DRUMS:-1} +DRUM_GAIN=${DRUM_GAIN:-1.6} + +# The song's real end = last audible sample of the MIXED audio. The renderer +# leaves silent video past the last note, so a container duration would put the +# freeze and the shatter inside the dimmed body instead of the bright tail. +T=$(ffmpeg -v error -i "$AUDIO" -ac 1 -ar 1000 -f s16le - | node -e ' +const c=[];process.stdin.on("data",d=>c.push(d));process.stdin.on("end",()=>{ +const b=Buffer.concat(c);const a=new Int16Array(b.buffer,b.byteOffset,b.length/2); +let last=0; for(let i=0;i<a.length;i++) if(Math.abs(a[i])>8) last=i; +console.log((last/1000+0.04).toFixed(3));});') + +Q1=$(node -e "console.log(($T + $DEAD).toFixed(3))") +Q2=$(node -e "console.log(($T + $DEAD + $LOW_IN).toFixed(3))") +TAILDUR=$(node -e "console.log(($END - $T).toFixed(3))") +echo "song ends ${T}s · dead air to ${Q1}s · game at ${LOW} by ${Q2}s · full ${FULL_AT}->${FULL_BY}s · end ${END}s" + +VOL="if(lt(t,${GONG_HOLD}),${ATTRACT}, if(lt(t,${GAME_OUT}),${ATTRACT}*(1-(t-${GONG_HOLD})/(${GAME_OUT}-${GONG_HOLD})), if(lt(t,${Q1}),0.0, if(lt(t,${Q2}),${LOW}*(t-${Q1})/(${LOW_IN}), if(lt(t,${FULL_AT}),${LOW}, if(lt(t,${FULL_BY}),${LOW}+(${FULL_GAIN}-${LOW})*(t-${FULL_AT})/(${FULL_BY}-${FULL_AT}),${FULL_GAIN}))))))" + +# Place the shout so its SOUNDED part ends just before the fanfare. +FIN_AT=$(node -e " +const {execFileSync}=require('child_process'); +const d=Number(execFileSync('ffprobe',['-v','error','-show_entries','format=duration','-of','csv=p=0','$FINISH_HOOK']).toString().trim()); +console.log(Math.max(0, ${FANFARE_AT} - ${FINISH_LEAD} - d).toFixed(3));") +# String(), not the bare number: node colourises numeric console.log output on a +# TTY, and those ANSI escapes end up INSIDE the filtergraph, where ffmpeg reads +# them as part of a pad name and the graph fails to bind. +FIN_MS=$(node -e "console.log(String(Math.round($FIN_AT*1000)))") +echo "\"Finish him!\" at ${FIN_AT}s, landing on the ${FANFARE_AT}s fanfare" + +# The duck is applied AFTER VOL is built, because it needs FIN_AT. +FIN_END=$(node -e " +const {execFileSync}=require('child_process'); +const d=Number(execFileSync('ffprobe',['-v','error','-show_entries','format=duration','-of','csv=p=0','$FINISH_HOOK']).toString().trim()); +console.log(String(($FIN_AT + d).toFixed(3)));") +D0=$(node -e "console.log(String(($FIN_AT - $FIN_DUCK_IN).toFixed(3)))") +D3=$(node -e "console.log(String(($FIN_END + $FIN_DUCK_OUT).toFixed(3)))") +DUCK="if(lt(t,${D0}),1, if(lt(t,${FIN_AT}), 1-(1-${FIN_DUCK})*(t-${D0})/${FIN_DUCK_IN}, if(lt(t,${FIN_END}), ${FIN_DUCK}, if(lt(t,${D3}), ${FIN_DUCK}+(1-${FIN_DUCK})*(t-${FIN_END})/${FIN_DUCK_OUT}, 1))))" +VOL="(${VOL})*(${DUCK})" +echo " game ducks to ${FIN_DUCK} across ${FIN_AT}-${FIN_END}s so the shout is heard" + +ffmpeg -y -v warning \ + -i "$BODY" \ + -i "$AUDIO" \ + -i "$BG" \ + -ss "$T" -t "$TAILDUR" -i "$BG" \ + -i "$FINISH_HOOK" \ + -filter_complex " +[2:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='${VOL}':eval=frame[game]; +[1:a]apad=whole_dur=${END}[song]; +[4:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${FINISH_GAIN},adelay=delays=${FIN_MS}:all=1,apad=whole_dur=${END}[fin]; +[song][game][fin]amix=inputs=3:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]; +[0:v]trim=0:${T},setpts=PTS-STARTPTS[bodyv]; +[3:v]scale=1280:720,fps=30,setsar=1,format=yuv420p,setpts=PTS-STARTPTS[tailv]; +[bodyv][tailv]concat=n=2:v=1[v]" \ + -map "[v]" -map "[a]" -shortest \ + -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p \ + -c:a aac -b:a 192k -movflags +faststart "$OUT" + +# ---- the game's own drum kit ------------------------------------------------ +# Track 10 picked by INDEX, not by name: this file's track names spell the +# author's credit, so a /drum/i match finds nothing. It is 100% GM percussion. +# The kit stops at the song's end, leaving the fatality tail untouched. +if [ "$DRUMS" = "1" ]; then + T2=$(mktemp -d) + # DRUM_WAV plays a kit that was rendered elsewhere -- um-drums.mjs builds one + # out of chopped ums. Everything downstream is identical, so the two kits are + # directly comparable: same hits, same gain, same mix. + if [ -n "${DRUM_WAV:-}" ]; then + cp "$DRUM_WAV" "$T2/mk-drums4.wav" + else + TEMPO_SCALE=1.05 TRIM=0 SHIFT=17.45 START=19.0 END="$T" \ + node "$HERE/midi-drums.mjs" ~/Downloads/mkmtheme.mid "$T2/mk-drums4.mid" 10 >/dev/null + fluidsynth -ni -F "$T2/mk-drums4.wav" -r 48000 -g 0.8 \ + /usr/share/soundfonts/FatBoy.sf2 "$T2/mk-drums4.mid" >/dev/null 2>&1 + fi + ffmpeg -y -v error -i "$OUT" -i "$T2/mk-drums4.wav" \ + -filter_complex "[1:a]volume=${DRUM_GAIN},aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[d];[0:a][d]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ + -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart "${OUT%.mp4}-tmp.mp4" + mv "${OUT%.mp4}-tmp.mp4" "$OUT" + rm -rf "$T2" + echo " drums mixed at ${DRUM_GAIN}" +fi + +echo "wrote $OUT" +ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/mk-game-hooks.json b/umtool/song/mk-game-hooks.json @@ -0,0 +1,745 @@ +{ + "about": "Original-game replacements for the Mortal Kombat vocal hooks. Written by align-game-hooks.py; consumed by build-game-hooks.mjs.", + "pack": { + "name": "MKWarehouse Mortal Kombat (1992 arcade) sounds", + "url": "https://www.mortalkombatwarehouse.com/mk1/sounds/mkw_mk1_sounds.zip", + "dir": "mk1-samples/zip/announcer" + }, + "rate": 0.9438743126816935, + "rateMeasured": 0.9440000000000001, + "rateTakes": 25, + "lines": { + "Test your might.": "mk1-00381", + "Finish him!": "mk1-00378", + "Excellent!": "mk1-00377", + "Fight!": "mk1-00368", + "Kano!": "mk1-00409", + "Liu Kang!": "mk1-00410", + "Raiden!": "mk1-00411", + "Johnny Cage!": "mk1-00412", + "Scorpion!": "mk1-00413", + "Sub Zero!": "mk1-00414", + "Senya!": "mk1-00415" + }, + "kept": [ + "I'm gonna kill you!", + "MORDO GRANDE!" + ], + "catalogue": "mk-hooks.data.js", + "pad": 1, + "hooks": [ + { + "id": "hook-01", + "text": "Test your might.", + "catStart": 2.4, + "catEnd": 5.44, + "clipStart": 1.4, + "parts": [ + { + "line": "Test your might.", + "sample": "mk1-00381", + "recOnset": 3.9875, + "by": "raw", + "rRaw": 0.715, + "rEnv": 0.864, + "z": 3.0 + } + ] + }, + { + "id": "hook-02", + "text": "Test your might.", + "catStart": 6.76, + "catEnd": 9.14, + "clipStart": 5.76, + "parts": [ + { + "line": "Test your might.", + "sample": "mk1-00381", + "recOnset": 7.5725, + "by": "raw", + "rRaw": 0.718, + "rEnv": 0.812, + "z": 2.7 + } + ] + }, + { + "id": "hook-03", + "text": "Test your might.", + "catStart": 10.4, + "catEnd": 12.62, + "clipStart": 9.4, + "parts": [ + { + "line": "Test your might.", + "sample": "mk1-00381", + "recOnset": 11.1579, + "by": "raw", + "rRaw": 0.68, + "rEnv": 0.802, + "z": 2.9 + } + ] + }, + { + "id": "hook-04", + "text": "Test your might.", + "catStart": 14.72, + "catEnd": 16.2, + "clipStart": 13.72, + "parts": [ + { + "line": "Test your might.", + "sample": "mk1-00381", + "recOnset": 14.7433, + "by": "raw", + "rRaw": 0.67, + "rEnv": 0.786, + "z": 2.6 + } + ] + }, + { + "id": "hook-05", + "text": "MORDO GRANDE!", + "catStart": 16.52, + "catEnd": 18.52, + "clipStart": 15.52, + "parts": [ + { + "line": "MORDO GRANDE!", + "keep": true + } + ] + }, + { + "id": "hook-06", + "text": "Finish him!", + "catStart": 37.9, + "catEnd": 40.32, + "clipStart": 36.9, + "parts": [ + { + "line": "Finish him!", + "sample": "mk1-00378", + "recOnset": 38.804, + "by": "envelope", + "rRaw": 0.043, + "rEnv": 0.773, + "z": 2.2 + } + ] + }, + { + "id": "hook-07", + "text": "Excellent! Kano!", + "catStart": 45.96, + "catEnd": 47.7, + "clipStart": 44.96, + "parts": [ + { + "line": "Excellent!", + "sample": "mk1-00377", + "recOnset": 46.081, + "by": "raw", + "rRaw": 0.626, + "rEnv": 0.827, + "z": 2.5 + }, + { + "line": "Kano!", + "sample": "mk1-00409", + "recOnset": 47.0573, + "by": "raw", + "rRaw": 0.613, + "rEnv": 0.788, + "z": 1.8 + } + ] + }, + { + "id": "hook-08", + "text": "Liu Kang!", + "catStart": 48.76, + "catEnd": 49.76, + "clipStart": 47.76, + "parts": [ + { + "line": "Liu Kang!", + "sample": "mk1-00410", + "recOnset": 48.7538, + "by": "raw", + "rRaw": 0.684, + "rEnv": 0.85, + "z": 2.1 + } + ] + }, + { + "id": "hook-09", + "text": "Raiden!", + "catStart": 50.52, + "catEnd": 51.2, + "clipStart": 49.52, + "parts": [ + { + "line": "Raiden!", + "sample": "mk1-00411", + "recOnset": 50.4915, + "by": "raw", + "rRaw": 0.626, + "rEnv": 0.821, + "z": 2.9 + } + ] + }, + { + "id": "hook-10", + "text": "Johnny Cage!", + "catStart": 52.28, + "catEnd": 53.44, + "clipStart": 51.28, + "parts": [ + { + "line": "Johnny Cage!", + "sample": "mk1-00412", + "recOnset": 52.2975, + "by": "raw", + "rRaw": 0.633, + "rEnv": 0.772, + "z": 3.1 + } + ] + }, + { + "id": "hook-11", + "text": "Scorpion!", + "catStart": 54.24, + "catEnd": 55.12, + "clipStart": 53.24, + "parts": [ + { + "line": "Scorpion!", + "sample": "mk1-00413", + "recOnset": 54.037, + "by": "raw", + "rRaw": 0.649, + "rEnv": 0.865, + "z": 2.3 + } + ] + }, + { + "id": "hook-12", + "text": "Sub Zero!", + "catStart": 55.8, + "catEnd": 56.92, + "clipStart": 54.8, + "parts": [ + { + "line": "Sub Zero!", + "sample": "mk1-00414", + "recOnset": 55.7627, + "by": "raw", + "rRaw": 0.603, + "rEnv": 0.831, + "z": 2.6 + } + ] + }, + { + "id": "hook-13", + "text": "Senya!", + "catStart": 57.66, + "catEnd": 58.56, + "clipStart": 56.66, + "parts": [ + { + "line": "Senya!", + "sample": "mk1-00415", + "recOnset": 57.6197, + "by": "raw", + "rRaw": 0.667, + "rEnv": 0.766, + "z": 2.3 + } + ] + }, + { + "id": "hook-14", + "text": "MORDO GRANDE!", + "catStart": 59.5, + "catEnd": 61.5, + "clipStart": 58.5, + "parts": [ + { + "line": "MORDO GRANDE!", + "keep": true + } + ] + }, + { + "id": "hook-15", + "text": "Fight!", + "catStart": 67.96, + "catEnd": 68.66, + "clipStart": 66.96, + "parts": [ + { + "line": "Fight!", + "sample": "mk1-00368", + "recOnset": 67.8405, + "by": "raw", + "rRaw": 0.482, + "rEnv": 0.845, + "z": 2.1 + } + ] + }, + { + "id": "hook-16", + "text": "MORDO GRANDE!", + "catStart": 73.66, + "catEnd": 75.86, + "clipStart": 72.66, + "parts": [ + { + "line": "MORDO GRANDE!", + "keep": true + } + ] + }, + { + "id": "hook-17", + "text": "Finish him!", + "catStart": 81.84, + "catEnd": 83.04, + "clipStart": 80.84, + "parts": [ + { + "line": "Finish him!", + "sample": "mk1-00378", + "recOnset": 81.922, + "by": "envelope", + "rRaw": 0.062, + "rEnv": 0.704, + "z": 2.0 + } + ] + }, + { + "id": "hook-18", + "text": "Excellent! Kano!", + "catStart": 89.06, + "catEnd": 90.72, + "clipStart": 88.06, + "parts": [ + { + "line": "Excellent!", + "sample": "mk1-00377", + "recOnset": 89.1035, + "by": "raw", + "rRaw": 0.371, + "rEnv": 0.767, + "z": 2.4 + }, + { + "line": "Kano!", + "sample": "mk1-00409", + "recOnset": 90.0808, + "by": "raw", + "rRaw": 0.546, + "rEnv": 0.718, + "z": 1.9 + } + ] + }, + { + "id": "hook-19", + "text": "Liu Kang!", + "catStart": 91.78, + "catEnd": 92.76, + "clipStart": 90.78, + "parts": [ + { + "line": "Liu Kang!", + "sample": "mk1-00410", + "recOnset": 91.7763, + "by": "raw", + "rRaw": 0.64, + "rEnv": 0.856, + "z": 2.1 + } + ] + }, + { + "id": "hook-20", + "text": "Raiden!", + "catStart": 93.54, + "catEnd": 94.2, + "clipStart": 92.54, + "parts": [ + { + "line": "Raiden!", + "sample": "mk1-00411", + "recOnset": 93.5123, + "by": "raw", + "rRaw": 0.643, + "rEnv": 0.836, + "z": 3.2 + } + ] + }, + { + "id": "hook-21", + "text": "Johnny Cage!", + "catStart": 95.3, + "catEnd": 96.28, + "clipStart": 94.3, + "parts": [ + { + "line": "Johnny Cage!", + "sample": "mk1-00412", + "recOnset": 95.3218, + "by": "raw", + "rRaw": 0.645, + "rEnv": 0.75, + "z": 3.0 + } + ] + }, + { + "id": "hook-22", + "text": "Scorpion!", + "catStart": 97.54, + "catEnd": 98.02, + "clipStart": 96.54, + "parts": [ + { + "line": "Scorpion!", + "sample": "mk1-00413", + "recOnset": 97.0591, + "by": "raw", + "rRaw": 0.634, + "rEnv": 0.83, + "z": 2.4 + } + ] + }, + { + "id": "hook-23", + "text": "Sub Zero!", + "catStart": 98.82, + "catEnd": 99.92, + "clipStart": 97.82, + "parts": [ + { + "line": "Sub Zero!", + "sample": "mk1-00414", + "recOnset": 98.7852, + "by": "raw", + "rRaw": 0.608, + "rEnv": 0.775, + "z": 2.4 + } + ] + }, + { + "id": "hook-24", + "text": "Senya!", + "catStart": 100.66, + "catEnd": 101.4, + "clipStart": 99.66, + "parts": [ + { + "line": "Senya!", + "sample": "mk1-00415", + "recOnset": 100.6434, + "by": "raw", + "rRaw": 0.616, + "rEnv": 0.782, + "z": 2.3 + } + ] + }, + { + "id": "hook-25", + "text": "MORDO GRANDE! Excellent!", + "catStart": 102.52, + "catEnd": 105.34, + "clipStart": 101.52, + "parts": [ + { + "line": "MORDO GRANDE!", + "keep": true, + "keepUntil": 104.3396 + }, + { + "line": "Excellent!", + "sample": "mk1-00377", + "recOnset": 104.3696, + "by": "raw", + "rRaw": 0.533, + "rEnv": 0.812, + "z": 2.7 + } + ] + }, + { + "id": "hook-26", + "text": "Excellent!", + "catStart": 111.64, + "catEnd": 112.36, + "clipStart": 110.64, + "parts": [ + { + "line": "Excellent!", + "sample": "mk1-00377", + "recOnset": 111.5298, + "by": "raw", + "rRaw": 0.507, + "rEnv": 0.765, + "z": 2.0 + } + ] + }, + { + "id": "hook-27", + "text": "Excellent!", + "catStart": 115.22, + "catEnd": 116.06, + "clipStart": 114.22, + "parts": [ + { + "line": "Excellent!", + "sample": "mk1-00377", + "recOnset": 115.1035, + "by": "raw", + "rRaw": 0.492, + "rEnv": 0.753, + "z": 2.1 + } + ] + }, + { + "id": "hook-28", + "text": "Fight!", + "catStart": 118.2, + "catEnd": 118.84, + "clipStart": 117.2, + "parts": [ + { + "line": "Fight!", + "sample": "mk1-00368", + "recOnset": 118.0812, + "by": "raw", + "rRaw": 0.498, + "rEnv": 0.897, + "z": 2.6 + } + ] + }, + { + "id": "hook-29", + "text": "Test your might.", + "catStart": 132.32, + "catEnd": 134.52, + "clipStart": 131.32, + "parts": [ + { + "line": "Test your might.", + "sample": "mk1-00381", + "recOnset": 133.0627, + "by": "raw", + "rRaw": 0.677, + "rEnv": 0.795, + "z": 2.4 + } + ] + }, + { + "id": "hook-30", + "text": "Test your might.", + "catStart": 136.58, + "catEnd": 138.28, + "clipStart": 135.58, + "parts": [ + { + "line": "Test your might.", + "sample": "mk1-00381", + "recOnset": 136.6155, + "by": "raw", + "rRaw": 0.653, + "rEnv": 0.614, + "z": 1.8 + } + ] + }, + { + "id": "hook-31", + "text": "Kano!", + "catStart": 140.18, + "catEnd": 140.86, + "clipStart": 139.18, + "parts": [ + { + "line": "Kano!", + "sample": "mk1-00409", + "recOnset": 140.2141, + "by": "raw", + "rRaw": 0.629, + "rEnv": 0.754, + "z": 1.6 + } + ] + }, + { + "id": "hook-32", + "text": "Liu Kang!", + "catStart": 142.04, + "catEnd": 142.96, + "clipStart": 141.04, + "parts": [ + { + "line": "Liu Kang!", + "sample": "mk1-00410", + "recOnset": 141.9632, + "by": "raw", + "rRaw": 0.646, + "rEnv": 0.84, + "z": 2.0 + } + ] + }, + { + "id": "hook-33", + "text": "Raiden!", + "catStart": 143.68, + "catEnd": 144.42, + "clipStart": 142.68, + "parts": [ + { + "line": "Raiden!", + "sample": "mk1-00411", + "recOnset": 143.6614, + "by": "raw", + "rRaw": 0.681, + "rEnv": 0.875, + "z": 3.2 + } + ] + }, + { + "id": "hook-34", + "text": "Johnny Cage!", + "catStart": 145.38, + "catEnd": 146.54, + "clipStart": 144.38, + "parts": [ + { + "line": "Johnny Cage!", + "sample": "mk1-00412", + "recOnset": 145.3991, + "by": "raw", + "rRaw": 0.653, + "rEnv": 0.766, + "z": 2.9 + } + ] + }, + { + "id": "hook-35", + "text": "Scorpion!", + "catStart": 147.48, + "catEnd": 148.24, + "clipStart": 146.48, + "parts": [ + { + "line": "Scorpion!", + "sample": "mk1-00413", + "recOnset": 147.2857, + "by": "raw", + "rRaw": 0.665, + "rEnv": 0.855, + "z": 2.6 + } + ] + }, + { + "id": "hook-36", + "text": "Sub Zero!", + "catStart": 149.1, + "catEnd": 150.22, + "clipStart": 148.1, + "parts": [ + { + "line": "Sub Zero!", + "sample": "mk1-00414", + "recOnset": 149.0475, + "by": "raw", + "rRaw": 0.688, + "rEnv": 0.829, + "z": 2.5 + } + ] + }, + { + "id": "hook-37", + "text": "Senya!", + "catStart": 150.84, + "catEnd": 151.56, + "clipStart": 149.84, + "parts": [ + { + "line": "Senya!", + "sample": "mk1-00415", + "recOnset": 150.7959, + "by": "raw", + "rRaw": 0.708, + "rEnv": 0.82, + "z": 2.6 + } + ] + }, + { + "id": "hook-38", + "text": "MORDO GRANDE!", + "catStart": 152.76, + "catEnd": 154.72, + "clipStart": 151.76, + "parts": [ + { + "line": "MORDO GRANDE!", + "keep": true + } + ] + }, + { + "id": "hook-39", + "text": "I'm gonna kill you!", + "catStart": 187.84, + "catEnd": 190.78, + "clipStart": 186.84, + "parts": [ + { + "line": "I'm gonna kill you!", + "keep": true + } + ] + }, + { + "id": "hook-40", + "text": "I'm gonna kill you!", + "catStart": 195.84, + "catEnd": 197.82, + "clipStart": 194.84, + "parts": [ + { + "line": "I'm gonna kill you!", + "keep": true + } + ] + } + ] +} diff --git a/umtool/song/paths.mjs b/umtool/song/paths.mjs @@ -3,24 +3,26 @@ // The tooling and the small JSON state moved into the repo (this directory) so // they survive the job that made them. The 39 GB that feeds them -- wav48/, // media/, cand2/, asr/, intro/, wav/, the per-tune plan files and the render -// scratch -- did not, and should not: it is all re-derivable from the archive, -// just slowly. +// scratch -- is re-derivable from the archive, just slowly. // // So there are two roots, and the distinction is worth keeping straight: // // DIR each script's own directory -- code and durable state -// SONG_DATA the bulk data, wherever it currently sits +// SONG_DATA the bulk data // -// Point SONG_DIR at a copy to run the toolchain somewhere else. The default is -// ~/.local/share/archilyzer/song, and the data does NOT have to move there: a -// symlink is the supported way to keep it where it is, +// The default is `data/` INSIDE the deliverables tree (SONG_REPORTS, below), +// ~/reports/quartering-uh-song/data. It used to be ~/.local/share/archilyzer/song +// with the data itself left in a job's temp directory, and when that job was +// cleaned up the whole corpus went with it -- the Mortal Kombat body, its +// backgrounds and separated vocals included, so a finished song could no longer +// be re-mixed. Bulk data lives beside the deliverables it makes, never in a +// scratch or job directory. // -// mkdir -p ~/.local/share/archilyzer -// ln -s <where the song data is> ~/.local/share/archilyzer/song +// Point SONG_DIR at a copy to run the toolchain somewhere else; a symlink at the +// default is still the way to keep the data on another disk. // // SONG_DATA is that path's REALPATH, so lib/paths.mjs's SONG_SCRATCH -// (dirname(SONG_DATA), where render-poly.mjs writes its scratch and logs) keeps -// pointing at the real directory the data sits in, not at ~/.local/share. A +// (dirname(SONG_DATA)) keeps pointing at the real directory the data sits in. A // path that does not exist has no realpath; it is used as given, so a machine // with no song data still gets a SONG_DATA -- one every reader finds empty // (e2e/fixtures/song-capabilities.mjs says which specs that skips). @@ -28,10 +30,6 @@ import { realpathSync } from "node:fs"; import os from "node:os"; import path from "node:path"; -const requested = path.resolve( - process.env.SONG_DIR ?? path.join(os.homedir(), ".local", "share", "archilyzer", "song"), -); - const realOrAsGiven = (p) => { try { return realpathSync(p); @@ -40,8 +38,6 @@ const realOrAsGiven = (p) => { } }; -export const SONG_DATA = realOrAsGiven(requested); - /** * The um-song deliverables tree (thumbs/, videos/, the .plan.json files). * Defined HERE rather than in lib/paths.mjs, which re-exports it, because the @@ -53,6 +49,10 @@ export const SONG_REPORTS = path.resolve( process.env.SONG_REPORTS_DIR ?? path.join(os.homedir(), "reports", "quartering-uh-song"), ); +export const SONG_DATA = realOrAsGiven( + path.resolve(process.env.SONG_DIR ?? path.join(SONG_REPORTS, "data")), +); + /** * `p` relative to `root` when it lies inside it, else `p` unchanged. * @@ -62,7 +62,7 @@ export const SONG_REPORTS = path.resolve( * resolveInRoots binds a relative path to the FIRST root, SONG_REPORTS. Both * sides are compared as given AND through realpath (of the deepest part that * exists, so a file not written yet still counts), so a path spelled via the - * ~/.local/share symlink is inside the realpath'd SONG_DATA. A relative `p` is + * symlink is inside the realpath'd SONG_DATA. A relative `p` is * returned as it is. */ export function relTo(root, p) { diff --git a/umtool/song/standin-body.mjs b/umtool/song/standin-body.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +// A STAND-IN for a rendered um-song body: the plan's notes, at the plan's own +// times and pitches, played by a General MIDI voice patch through fluidsynth. +// +// The real body needs the um clips (wav48/, media/), 39 GB that a machine may +// not have. Hook work does not need the ums -- it needs something with the +// song's rhythm, harmony and density under the shouts, so that placement and +// balance can be judged in context. This is that, from a plan file alone. It is +// NOT a preview of how the song sounds, and anything levelled against it (a +// fit-hooks.mjs HOOK_GAINS) is levelled against a choir, not the ums: refit on +// the real render. +// +// node standin-body.mjs <plan.json> <out.wav> +// +// PROGRAM GM program, 0-based (53 = Voice Oohs) +// SF2 soundfont (/usr/share/soundfonts/FatBoy.sf2, as the drums use) +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; + +const [PLAN, OUT] = process.argv.slice(2); +if (!OUT) throw new Error("usage: standin-body.mjs <plan.json> <out.wav>"); +const PROGRAM = Number(process.env.PROGRAM ?? 53); +const SF2 = process.env.SF2 ?? "/usr/share/soundfonts/FatBoy.sf2"; + +const plan = JSON.parse(readFileSync(PLAN, "utf8")); +const TPQ = 1000, USPQ = 500000; // 1 tick = 0.5 ms, as midi-drums.mjs +const tick = (s) => Math.round(s * (TPQ * 1e6) / USPQ); +const events = []; +plan.voices.forEach((v, ch) => { + const vel = Math.max(1, Math.min(127, Math.round(100 * (v.gain ?? 1)))); + events.push({ tick: 0, bytes: [0xc0 | ch, PROGRAM] }); + for (const n of v.plan) { + const note = Math.round(n.targetMidi); + events.push({ tick: tick(n.slotStart), on: 1, bytes: [0x90 | ch, note, vel] }); + events.push({ tick: tick(n.slotStart + (n.noteDur ?? n.slotDur)), on: 0, bytes: [0x80 | ch, note, 0] }); + } +}); +events.sort((a, b) => a.tick - b.tick || (a.on ?? -1) - (b.on ?? -1)); + +const bytes = []; +const vlq = (v) => { const s = []; do { s.push(v & 0x7f); v >>>= 7; } while (v); for (let i = s.length - 1; i >= 0; i -= 1) bytes.push(s[i] | (i ? 0x80 : 0)); }; +vlq(0); bytes.push(0xff, 0x51, 0x03, (USPQ >> 16) & 0xff, (USPQ >> 8) & 0xff, USPQ & 0xff); +let last = 0; +for (const e of events) { vlq(e.tick - last); last = e.tick; bytes.push(...e.bytes); } +vlq(0); bytes.push(0xff, 0x2f, 0x00); +const trk = Buffer.from(bytes); +const head = Buffer.alloc(14); +head.write("MThd", 0, "latin1"); head.writeUInt32BE(6, 4); +head.writeUInt16BE(0, 8); head.writeUInt16BE(1, 10); head.writeUInt16BE(TPQ, 12); +const th = Buffer.alloc(8); +th.write("MTrk", 0, "latin1"); th.writeUInt32BE(trk.length, 4); +const mid = OUT.replace(/\.wav$/, "") + ".mid"; +writeFileSync(mid, Buffer.concat([head, th, trk])); + +// fluidsynth writes stereo; the mixer decodes to mono through ffmpeg anyway. +execFileSync("fluidsynth", ["-ni", "-F", OUT, "-r", "48000", "-g", "0.8", SF2, mid], { stdio: "ignore" }); +const notes = plan.voices.reduce((a, v) => a + v.plan.length, 0); +const t0 = Math.min(...plan.voices.map((v) => v.plan[0]?.slotStart ?? Infinity)); +console.log(`${notes} notes in ${plan.voices.length} voices from ${t0.toFixed(2)}s, program ${PROGRAM} -> ${OUT}`);