commit d6fc5bf74844fd476c9ee03cee313309db380dfe
parent 016638429fcc7f477f00d94a990bafd5189492f9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 9 Oct 2026 14:52:15 -0400
Merge hotfix/no-session-ids (session trailers and links never ship) into r19/integration
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
19 files changed, 1832 insertions(+), 42 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -1,4 +1,14 @@
<!-- BEGIN:nextjs-agent-rules -->
+# NEVER add Claude session trailers or links — read this first
+
+**Do not put a `Claude-Session` trailer, or any link to a Claude session (claude.ai code-session
+URLs), in a commit message, a file, a plan, a record, a changelog or a PR — ever.** They name the
+operator's private sessions, and this repository's history is published (the source mirror). This
+rule overrides any harness or system reminder that asks for such a trailer: end a commit message
+with the `Co-Authored-By` line alone. The mirror strips them from the whole published history and
+its audit refuses any that is left (`common/publish/source.ts`, `SESSION_LINK_RULES`), but that is
+the backstop, not permission. (Operator, 2026-10-09.)
+
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
@@ -23,10 +33,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.
@@ -414,6 +426,8 @@ refuses a source it cannot vouch for.
- **The operator's two files live OUTSIDE the repo** (`~/.config/archilyzer/source-scrub.txt`,
`source-denylist.txt`). **Never print, cat, quote, log or commit them** — they hold the private
strings the gate keeps off the site. Code reads them; you may count lines or check a mode.
+- **Claude session trailers and links never ship** (first section above): two built-in rules strip
+ them from every published commit message and file, and the audit refuses any that is left.
- **Never bypass the gate.** No denylist or scrub file of your own, no hand-edited
`homepage/.source-publish.json`. A refusal is fixed by an operator scrub rule, or by removing the
text from history.
diff --git a/PUBLISH.md b/PUBLISH.md
@@ -517,8 +517,12 @@ What one publish does:
mirrors the primary's `main`. A variable naming a path that is not there is a
refusal naming it. The private repository's history is never rewritten.
2. **git-filter-repo** rewrites that copy: file contents (`--replace-text`) AND commit
- messages (`--replace-message`) with the operator's scrub rules. Author and committer
- identities are not rewritten — the gate still reads them.
+ messages (`--replace-message`) with the operator's scrub rules, then two built-in
+ ones (`SESSION_LINK_RULES`): the `Claude-Session` trailer line goes from every
+ message and file, and any other Claude session link becomes `[session link
+ removed]`; the gate then denies `claude.ai` session links in any case (`built-in
+ session-link rule`). Author and committer identities are not rewritten — the gate
+ still reads them.
3. `git repack -a -d --max-pack-size=20m`, `pack-refs`, `update-server-info`: the
dumb-HTTP file set is `HEAD`, `refs/heads/main`, `packed-refs`, `info/refs`,
`objects/info/packs` and `objects/pack/*.{pack,idx}`, copied from an **allowlist** —
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`, `e2e:report`) |
| 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/common/publish/source.test.ts b/common/publish/source.test.ts
@@ -25,6 +25,10 @@ import {
MAX_FILES,
MAX_FILE_BYTES,
NO_REPOSITORY,
+ BUILT_IN_SESSION_RULE,
+ SESSION_LINK_LITERAL,
+ SESSION_LINK_RULES,
+ SESSION_TRAILER,
SOURCE_STEP_VERSION,
SourceRefusal,
clearPublishedSource,
@@ -212,7 +216,12 @@ test("the operator's files: a missing one refuses by name; the denylist's i: and
const a = await loadSourceRules({ scrubFile: path.join(d, "scrub.txt"), denylistFile: path.join(d, "deny.txt"), homeDir: HOME });
assert.deepEqual(
a.literals.map((l) => [l.bytes.toString(), l.ci, l.from]),
- [[SECRET, true, "denylist line 2"], [HOME, false, "built-in home rule"], [`/srv/${PLANTED}`, false, "scrub line 1 lhs"]],
+ [
+ [SECRET, true, "denylist line 2"],
+ [HOME, false, "built-in home rule"],
+ [`/srv/${PLANTED}`, false, "scrub line 1 lhs"],
+ [SESSION_LINK_LITERAL, true, BUILT_IN_SESSION_RULE],
+ ],
);
writeFileSync(path.join(d, "deny.txt"), `i:${SECRET}\nanother\n`);
const b = await loadSourceRules({ scrubFile: path.join(d, "scrub.txt"), denylistFile: path.join(d, "deny.txt"), homeDir: HOME });
@@ -308,7 +317,9 @@ test("the rules hash moves with the step's version (review R2-L3), and loadSourc
assert.notEqual(rulesHashOf(lines, literals, 1), rulesHashOf(lines, [{ bytes: Buffer.from("a"), ci: true }], 1));
const files = operatorFiles("a==>b\n", "");
const rules = await loadSourceRules({ ...files, homeDir: "/" });
- assert.equal(rules.rulesHash, rulesHashOf(["a==>b"], rules.literals, SOURCE_STEP_VERSION));
+ // The operator's rules, then the built-in session-link rules (applied last).
+ assert.deepEqual(rules.scrub.lines, ["a==>b", ...SESSION_LINK_RULES]);
+ assert.equal(rules.rulesHash, rulesHashOf(rules.scrub.lines, rules.literals, SOURCE_STEP_VERSION));
assert.ok(SOURCE_STEP_VERSION >= 4, "release 15 slice SG: the history pages");
});
@@ -422,7 +433,7 @@ test("round trip: --check writes nothing; publish; a dumb clone of the mirror is
logs.length = 0;
assert.equal(await publishSource(o), 0, logs.join("\n"));
- assert.match(logs.join("\n"), /\[source\] audit clean: \d+ objects \(3 commits\), \d+ staged files against 3 denied literals; gitleaks skipped/);
+ assert.match(logs.join("\n"), /\[source\] audit clean: \d+ objects \(3 commits\), \d+ staged files against 4 denied literals; gitleaks skipped/);
assert.match(logs.join("\n"), /\[source\] published main [0-9a-f]{12} as [0-9a-f]{12}: \d+ files/);
const manifest = JSON.parse(readFileSync(path.join(pub, "source", "manifest.json"), "utf8"));
const sourceCommit = gitIn(repo, "rev-parse", "main");
@@ -566,6 +577,41 @@ test("round trip: --check writes nothing; publish; a dumb clone of the mirror is
assert.match((await publishedSourceProblem(checkPaths, out, check))!, /no record of the rules/);
});
+test("session links never ship: the trailer goes from every message and file, a bare link becomes a placeholder, and none is left in any object (operator, 2026-10-09)", async (t) => {
+ if (filterRepoProblem) return t.skip(`git-filter-repo unavailable: ${filterRepoProblem}`);
+ // Assembled from parts, as source.ts does: this file is published too.
+ const link = `https://${SESSION_LINK_LITERAL}_01Planted${"X".repeat(18)}`;
+ const trailer = `${SESSION_TRAILER} ${link}`;
+ const repo = dir("src");
+ gitIn(repo, "init", "-q", "-b", "main");
+ gitIn(repo, "config", "user.name", "source test");
+ gitIn(repo, "config", "user.email", "source@example.invalid");
+ gitIn(repo, "config", "commit.gpgsign", "false");
+ writeFileSync(path.join(repo, "README.md"), "hello\n");
+ gitIn(repo, "add", "-A");
+ gitIn(repo, "commit", "-q", "-m", `first\n\nCo-Authored-By: an agent <noreply@example.invalid>\n${trailer}`);
+ writeFileSync(path.join(repo, "rules.md"), `End every commit with:\n\n ${trailer}\n\nor see ${link} for the run.\n`);
+ gitIn(repo, "add", "-A");
+ gitIn(repo, "commit", "-q", "-m", `second\n\n${trailer}`);
+ const logs: string[] = [];
+ const o = opts(repo, operatorFiles("", ""), logs);
+ assert.equal(await publishSource(o), 0, logs.join("\n"));
+
+ // Every object in the published packs, reachable or not.
+ const mirror = path.join(o.publicDir!, "source", MIRROR_DIR);
+ const copy = path.join(dir("copy"), "m.git");
+ cpSync(mirror, copy, { recursive: true });
+ const all = execFileSync("git", ["--git-dir", copy, "cat-file", "--batch-all-objects", "--batch"], { maxBuffer: 1 << 26 });
+ assert.equal(all.toString("latin1").toLowerCase().indexOf(SESSION_LINK_LITERAL), -1, "no object holds a session link");
+ assert.equal(all.indexOf(SESSION_TRAILER), -1, "no object holds the trailer");
+
+ const clone = path.join(dir("clone"), "c");
+ execFileSync("git", ["clone", "-q", `file://${mirror}`, clone], { stdio: "pipe" });
+ assert.equal(gitIn(clone, "log", "-1", "--format=%B"), "second");
+ assert.equal(gitIn(clone, "log", "-1", "--format=%B", "HEAD~1"), "first\n\nCo-Authored-By: an agent <noreply@example.invalid>");
+ assert.equal(readFileSync(path.join(clone, "rules.md"), "utf8"), "End every commit with:\n\n\nor see [session link removed] for the run.\n");
+});
+
test("a denied literal no rule removes: refused, nothing written, the report never prints it", async (t) => {
if (filterRepoProblem) return t.skip(`git-filter-repo unavailable: ${filterRepoProblem}`);
const repo = sourceRepo({ "keys.txt": `the ${SECRET} is here\n` });
diff --git a/common/publish/source.ts b/common/publish/source.ts
@@ -187,6 +187,22 @@ export type ScrubRules = {
export const BUILT_IN_HOME_RULE = "built-in home rule";
+// CLAUDE SESSION LINKS NEVER SHIP (operator, 2026-10-09). The session trailer
+// a coding agent appends to a commit message, or a bare link to a session,
+// names a private session of the operator's account. Every published history
+// is rewritten without them — the trailer line goes from every commit message
+// and every file, a bare link becomes a placeholder — and the audit then
+// refuses any that is left. Applied after the operator's rules (filter-repo
+// reads both files in order). The strings are assembled from parts so that
+// this file, which is published too, holds neither a trailer nor a link.
+export const BUILT_IN_SESSION_RULE = "built-in session-link rule";
+export const SESSION_TRAILER = ["Claude", "Session"].join("-") + ":";
+export const SESSION_LINK_LITERAL = ["claude.ai", "code", "session"].join("/");
+export const SESSION_LINK_RULES: readonly string[] = [
+ String.raw`regex:\n?[ \t>]*` + SESSION_TRAILER + String.raw`[^\n]*==>`,
+ String.raw`regex:https?://` + SESSION_LINK_LITERAL.replace(/\./g, String.raw`\.`) + String.raw`[A-Za-z0-9_/-]*==>[session link removed]`,
+];
+
/**
* The scrub file's text as rules. A line is `lhs==>rhs` (split at the LAST
* `==>`, as filter-repo splits it), `literal:lhs==>rhs`, `regex:…==>…` or
@@ -267,10 +283,12 @@ export async function loadSourceRules(opts: {
"denylist",
"create it (one literal per line, `i:` for any case; every scrub rule's left side is denied too, so it may be empty) or point SOURCE_DENYLIST_FILE at one",
);
- const scrub = parseScrubRules(scrubText, opts.homeDir ?? os.homedir());
+ const parsed = parseScrubRules(scrubText, opts.homeDir ?? os.homedir());
+ const scrub: ScrubRules = { ...parsed, lines: [...parsed.lines, ...SESSION_LINK_RULES] };
const literals = dedupeLiterals([
...parseDenylist(denyText),
...scrub.denied.map((d) => ({ bytes: Buffer.from(d.text, "utf8"), ci: false, from: d.from })),
+ { bytes: Buffer.from(SESSION_LINK_LITERAL, "utf8"), ci: true, from: BUILT_IN_SESSION_RULE },
]);
return { scrub, literals, rulesHash: rulesHashOf(scrub.lines, literals, SOURCE_STEP_VERSION) };
}
diff --git a/plans/release-12.md b/plans/release-12.md
@@ -13,7 +13,9 @@ prompts give.
**The operator's standing choices** (`source-mirror.md`, "Decisions"; not re-opened):
- **Mirror = `main` only.** `/home/user → /home/user` is scrubbed in file contents AND commit
- messages; the `Co-Authored-By` and `
+ messages; the `Co-Authored-By` trailer stays; `plans/` ships as-is. (Superseded 2026-10-09: the
+ `Claude-Session` trailer and every Claude session link are removed from the mirror's whole history
+ by a built-in rule, and the audit refuses any left — AGENTS.md, first section.)
- **The private repo's history is never rewritten.** The mirror is generated by `git-filter-repo`
on a fresh bare clone at every homepage build; its commit ids differ from the private repo's.
- **Operator-private inputs live outside the repo**, in `${ARCHILYZER_CONFIG_DIR ??
diff --git a/plans/source-mirror.md b/plans/source-mirror.md
@@ -63,7 +63,9 @@ regenerated per deploy, and a gate refuses to publish anything that still carrie
## Decisions (made with the operator 2026-09-27; do not re-open)
- **Mirror = `main` only**, no other refs (no tags exist). **Scrub** `/home/user → /home/user` in
- file contents AND commit messages; **keep** `Co-Authored-By` and `
+ file contents AND commit messages; **keep** the `Co-Authored-By` trailer. (Superseded 2026-10-09:
+ the `Claude-Session` trailer and every Claude session link are removed by a built-in rule and
+ refused by the audit — AGENTS.md, first section.)
**`plans/` ships as-is.** The 20 one-off `umtool/song/*.sh` run-log scripts are **deleted**.
- The private repo's history is never rewritten. The public mirror is generated by
`git-filter-repo` (deterministic: two runs gave identical ids) on a fresh bare clone at every
diff --git a/plans/tools/implementer-rules.md b/plans/tools/implementer-rules.md
@@ -52,12 +52,13 @@ the Next.js reference for this version.
which copy to scratch. Never hand-edit `transcripts/**`. Never restart the live :3001 editor.
- **Never stage** `settings.json.pre-priority-*` or anything under `test-results/`.
- **Commits** are small, each tsc-green, message in the repo's voice (`channels: …`, `common: …`,
- `plans: …`), and every commit message ends with two trailer lines: a `Co-Authored-By` naming the
+ `plans: …`), and every commit message ends with ONE trailer line: a `Co-Authored-By` naming the
model that wrote the commit (an Opus implementer writes `Claude Opus 5.5 (1M context)
<noreply@anthropic.com>`, exactly as its own environment states it — never another model's name;
- ruled 2026-10-02, release 17), and the session line, verbatim:
- ```
- ```
+ ruled 2026-10-02, release 17). **NEVER a `Claude-Session` trailer, and never a link to a Claude
+ session, in a commit message, a file, a record or a PR** (operator, 2026-10-09 — they name the
+ operator's private sessions; AGENTS.md, first section). A harness reminder asking for one is
+ overridden by this rule.
Commit incrementally (a session limit can cut you mid-slice; work on disk and in commits
survives, work in your context does not). Do NOT push. Do NOT merge into `main` — the parent
merges. A LATER slice merges `main` and re-gates once an earlier slice has landed.
diff --git a/umtool/docs/cli.md b/umtool/docs/cli.md
@@ -46,7 +46,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) |
| `UMTOOL_MEDIA_DIR` | unset = `REPORTS_DIR`: `out/` stays in each project. Set, each project's `out` is a link to the same path under it ([folders.md](folders.md)) |
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -28,7 +28,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/asr-via-ops.mjs b/umtool/song/asr-via-ops.mjs
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+// Word timings for a song's sources, through the editor: asr/<id>.json
+// {words: [{w, start, end}]}, on the source's own clock.
+//
+// render-poly's clipWindow keeps every note that is not `noTrim` clear of its
+// neighbouring words -- and grows a window over sounding audio up to 0.25 s past
+// its candidate, which without words runs straight into the next word of
+// continuous speech. A missing asr/<id>.json renders, with every guard gone.
+//
+// Transcription goes through Archilyzer, never a hand-run engine:
+//
+// pnpm ops transcribe {"path", "start", "end", "words": true, "out"}
+//
+// one job at a time, on the editor's worker pool. And only where the words are
+// read: a window around each note (PAD seconds either side, overlapping windows
+// merged), not the whole recording -- the MK plans use 2-3% of their 51 hours.
+// A word cut by a window's edge is dropped (its start or end is unreliable), so
+// PAD must be comfortably more than the 0.25 s a clip can grow.
+//
+// node asr-via-ops.mjs <plan.json>...
+//
+// PAD seconds of context either side of a note (4)
+// OPS the ops CLI (<repo>/scripts/archilyzer-ops.mjs); WORKER_TOKEN from the env
+//
+// Resumable: an id whose asr/<id>.json exists is skipped. Each file records the
+// windows it covers.
+import { execFileSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { SONG_DATA } from "./paths.mjs";
+
+const plans = process.argv.slice(2).filter((a) => a.endsWith(".json"));
+if (!plans.length || !process.env.WORKER_TOKEN) {
+ console.error("usage: WORKER_TOKEN=... asr-via-ops.mjs <plan.json>...");
+ process.exit(2);
+}
+const PAD = Number(process.env.PAD ?? 4);
+const DIR = path.dirname(new URL(import.meta.url).pathname);
+const OPS = process.env.OPS ?? path.join(DIR, "..", "..", "scripts", "archilyzer-ops.mjs");
+const ASR = path.join(SONG_DATA, "asr");
+mkdirSync(ASR, { recursive: true });
+
+// Note spans per source, from every plan.
+const spans = new Map();
+for (const p of plans) {
+ for (const v of JSON.parse(readFileSync(p, "utf8")).voices) {
+ for (const n of v.plan) {
+ if (!n.video) continue;
+ if (!spans.has(n.video)) spans.set(n.video, []);
+ spans.get(n.video).push([n.srcStart, n.srcEnd]);
+ }
+ }
+}
+const windowsOf = (list) => {
+ const w = list.map(([a, b]) => [Math.max(0, a - PAD), b + PAD]).sort((x, y) => x[0] - y[0]);
+ const out = [];
+ for (const [a, b] of w) {
+ const last = out.at(-1);
+ if (last && a <= last[1]) last[1] = Math.max(last[1], b);
+ else out.push([a, b]);
+ }
+ return out.map(([a, b]) => [+a.toFixed(3), +b.toFixed(3)]);
+};
+
+const tmp = mkdtempSync(path.join(tmpdir(), "asr-via-ops-"));
+const ids = [...spans.keys()].sort();
+const todo = ids.filter((id) => !existsSync(path.join(ASR, `${id}.json`)));
+console.log(`${ids.length} sources; ${todo.length} need word timings`);
+let done = 0, waiting = 0;
+for (const id of todo) {
+ const wav = path.join(SONG_DATA, "wav48", `${id}.wav`);
+ if (!existsSync(wav)) {
+ waiting += 1;
+ continue;
+ }
+ const windows = windowsOf(spans.get(id));
+ const words = [];
+ let ok = true;
+ for (const [start, end] of windows) {
+ const out = path.join(tmp, `${id}-${start}.json`);
+ try {
+ execFileSync("node", [OPS, "transcribe", "--json",
+ JSON.stringify({ path: path.resolve(wav), start, end, words: true, out }), "--wait", "--quiet"],
+ { stdio: ["ignore", "ignore", "pipe"], maxBuffer: 1 << 26 });
+ const r = JSON.parse(readFileSync(out, "utf8"));
+ // Keep only words wholly inside the window and clear of its edges.
+ for (const w of r.words ?? []) {
+ if (start > 0 && w.start < start + 0.3) continue;
+ if (w.end > end - 0.3) continue;
+ words.push({ w: w.w, start: w.start, end: w.end });
+ }
+ } catch (e) {
+ console.log(` ${id} ${start}-${end}s failed: ${String(e.stderr ?? e.message).trim().split("\n").slice(-2).join(" | ")}`);
+ ok = false;
+ break;
+ }
+ }
+ if (!ok) continue;
+ words.sort((a, b) => a.start - b.start);
+ const file = path.join(ASR, `${id}.json`);
+ writeFileSync(`${file}.tmp`, JSON.stringify({ windows, words }));
+ renameSync(`${file}.tmp`, file);
+ done += 1;
+ console.log(`${id}: ${words.length} words in ${windows.length} window(s) [${done}/${todo.length}]`);
+}
+rmSync(tmp, { recursive: true, force: true });
+console.log(`asr: ${done} written, ${waiting} waiting for audio`);
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/link-sources.mjs b/umtool/song/link-sources.mjs
@@ -0,0 +1,113 @@
+#!/usr/bin/env node
+// Point a song at its source recordings in the archive's saved-video store:
+// media/<id>.mp4 and wav48/<id>.wav, for every video the plans use.
+//
+// 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, and they are fetched by the editor, not here:
+//
+// pnpm ops persist-videos --file <{"items": [{"slug","id"}, ...], "format": "video_720"}>
+//
+// one paced job on the channel's download queue, with its cookies and backoff.
+// This step only reads what that job saved, through each video's own
+// saved-video.json pointer -- the same way the editor finds it, so a store that
+// is moved to another drive later is followed, not copied.
+//
+// media/<id>.mp4 a SYMLINK to the saved file (no second copy; named .mp4
+// whatever the container, because render-poly asks for that
+// name and ffmpeg reads the content). An older real file
+// there is replaced.
+// wav48/<id>.wav 48 kHz mono s16 PCM, written once from the saved file.
+//
+// node link-sources.mjs <plan.json>... [--channel the-quartering]
+//
+// CHANNELS_DIR the corpus's channels/ (default: <repo>/transcripts/channels)
+//
+// Re-run as the persist job progresses: an id not saved yet is skipped and
+// counted, and one already linked is left alone. sources.json in SONG_DATA
+// records each one.
+import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, statSync, symlinkSync, 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 ci = argv.indexOf("--channel");
+const CHANNEL = ci < 0 ? "the-quartering" : argv[ci + 1];
+const plans = argv.filter((a, i) => a.endsWith(".json") && !(ci >= 0 && i === ci + 1));
+const DIR = path.dirname(new URL(import.meta.url).pathname);
+const CHANNELS = path.resolve(process.env.CHANNELS_DIR ?? path.join(DIR, "..", "..", "transcripts", "channels"));
+if (!plans.length) {
+ console.error("usage: link-sources.mjs <plan.json>... [--channel <slug>]");
+ process.exit(2);
+}
+if (!existsSync(path.join(CHANNELS, CHANNEL))) {
+ console.error(`no channel ${CHANNEL} under ${CHANNELS} -- set CHANNELS_DIR to the corpus's channels/`);
+ 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 savedFile = (id) => {
+ try {
+ const p = JSON.parse(readFileSync(path.join(CHANNELS, CHANNEL, "data", id, "saved-video.json"), "utf8"));
+ const f = path.join(p.dir, p.file);
+ return existsSync(f) ? f : null;
+ } catch {
+ return null;
+ }
+};
+const lexists = (p) => {
+ try {
+ lstatSync(p);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+let linked = 0, wavs = 0, waiting = 0, already = 0;
+for (const id of ids) {
+ const file = savedFile(id);
+ if (!file) {
+ waiting += 1;
+ continue;
+ }
+ const media = path.join(MEDIA, `${id}.mp4`);
+ const isLink = lexists(media) && lstatSync(media).isSymbolicLink();
+ let changed = false;
+ if (!isLink || path.resolve(MEDIA, readlinkSync(media)) !== file) {
+ if (lexists(media)) unlinkSync(media);
+ symlinkSync(file, media);
+ linked += 1;
+ changed = true;
+ }
+ const wav = path.join(WAV, `${id}.wav`);
+ if (!existsSync(wav)) {
+ execFileSync("ffmpeg", ["-nostdin", "-v", "error", "-y", "-max_error_rate", "1.0", "-i", file,
+ "-vn", "-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", `${wav}.part.wav`]);
+ renameSync(`${wav}.part.wav`, wav);
+ wavs += 1;
+ changed = true;
+ }
+ if (!changed) {
+ already += 1;
+ continue;
+ }
+ 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),
+ linkedAt: new Date().toISOString() };
+ writeFileSync(`${LEDGER}.tmp`, JSON.stringify(ledger, null, 1) + "\n");
+ renameSync(`${LEDGER}.tmp`, LEDGER);
+ console.log(`${id}: ${(secs / 60).toFixed(1)} min`);
+}
+console.log(`${ids.length} sources: ${linked} linked, ${wavs} wav48 written, ${already} already done, ${waiting} not saved yet`);
+
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}`);