#!/usr/bin/env node // Apply the trims exported by mk-hooks.html, writing a NEW hook directory. // // node apply-hook-trims.mjs [outDir] // // The originals are never touched: separation bleed is a judgement call and a // trim that turns out too tight has to be undoable by re-exporting, not by // re-separating the stem. // // Hooks with no entry in the export are COPIED THROUGH unchanged, so the output // directory is always complete and can be pointed at directly with // HOOKDIR= in mk-mix-hooks.mjs. import { readFileSync, mkdirSync, copyFileSync, readdirSync, existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; import { SONG_DATA } from "./paths.mjs"; const DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname)); const [TRIMS, OUTDIR = "mkvocals/hooks-trim"] = process.argv.slice(2); const SRCDIR = process.env.HOOKDIR ?? "mkvocals/hooks"; if (!TRIMS) { console.error("usage: node apply-hook-trims.mjs [outDir]"); process.exit(1); } const trims = JSON.parse(readFileSync(TRIMS, "utf8")); const byFile = new Map(trims.map((t) => [t.file, t])); const src = path.join(SONG_DATA, SRCDIR), out = path.join(SONG_DATA, OUTDIR); mkdirSync(out, { recursive: true }); // A hard cut at a zero-crossing-free point clicks, and a click is more obvious // than the leak being removed. 8ms is short enough not to soften the shout. const FADE = Number(process.env.FADE ?? 0.008); // An entry may instead name a SOURCE to cut from: // {"file":"hook-22.wav","source":"stem","start":97.08,"end":98.06} // with start/end in STEM seconds. Some catalogue windows begin mid-word -- the // hook-22 "Scorpion!" window opens at 97.54 when the word starts at 97.10, so // the clip is a fragment and no amount of trimming inside it can recover the // syllable. Re-cutting from the stem is the only fix. // {"file":"hook-22.wav","source":"hook-35.wav"} // substitutes another hook's audio wholesale, keeping the placement. const STEM = path.join(SONG_DATA, "mkvocals/stem48.wav"); let cut = 0, recut = 0, copied = 0; for (const f of readdirSync(src).filter((f) => f.endsWith(".wav"))) { const t = byFile.get(f); const dst = path.join(out, f); if (!t) { copyFileSync(path.join(src, f), dst); copied += 1; continue; } if (t.source) { const from = t.source === "stem" ? STEM : path.join(src, t.source); const d = +(t.end - t.start).toFixed(4); const fd = Math.min(FADE, d / 4); execFileSync("ffmpeg", ["-v", "error", "-y", "-ss", String(t.start), "-t", String(d), "-i", from, "-af", `afade=t=in:st=0:d=${fd},afade=t=out:st=${(d - fd).toFixed(4)}:d=${fd}`, "-ar", "48000", "-ac", "1", dst]); console.log(` ${f} <- ${t.source} ${t.start}-${t.end}s (${d.toFixed(3)}s)`); recut += 1; continue; } const dur = +(t.end - t.start).toFixed(4); const fade = Math.min(FADE, dur / 4); execFileSync("ffmpeg", ["-v", "error", "-y", "-i", path.join(src, f), "-af", `atrim=${t.start}:${t.end},asetpts=PTS-STARTPTS,` + `afade=t=in:st=0:d=${fade},afade=t=out:st=${(dur - fade).toFixed(4)}:d=${fade}`, "-ar", "48000", "-ac", "1", dst]); console.log(` ${f} ${t.start.toFixed(3)}-${t.end.toFixed(3)}s (${dur.toFixed(3)}s)`); cut += 1; } // The catalogue travels with the hooks: mk-mix-hooks.mjs reads durations from // the files, but place-vocals-full.mjs reads this to place them. const catSrc = path.join(SONG_DATA, "mkvocals/hooks-verb/mk-vocals.json"); if (existsSync(catSrc)) copyFileSync(catSrc, path.join(out, "mk-vocals.json")); console.log(`\n${cut} trimmed, ${recut} re-cut from source, ${copied} copied through -> ${OUTDIR}`); console.log(`use it with: HOOKDIR=${OUTDIR} node mk-mix-hooks.mjs …`);