#!/usr/bin/env node // Silence a leading region of a hook WITHOUT changing its duration. // // node hush-head.mjs [fade] // // Why not just trim the head off: every hook is placed by its FILE START. The // overlays give an `at` per occurrence (mk-mix-hooks.mjs delays the clip by it), // and those times were set by ear against the game's own events -- the silent gap // at 17.15-18.086s, the gong, the fanfare. Cutting N seconds off the front moves // the shout N seconds earlier at every occurrence and quietly breaks all of it. // Silencing in place is the same edit with none of that: the file is the same // length, the shout stays exactly where it was judged to be, and only the // unwanted sound goes away. // // The other reason to prefer it: the catalogue's `gain` is a PEAK TARGET, so the // clip's loudest sample decides how loud the shout ends up. Silencing a head // whose peak is BELOW the shout's leaves the level untouched (verify with the // printed peaks); cutting one that is ABOVE it makes the shout louder, which is // usually the point but is a change that should be seen rather than discovered. // // FADE avoids a click: a hard cut at a non-zero sample is more obvious than // whatever is being removed, the same reason apply-hook-trims.mjs fades its cuts. import { execFileSync } from "node:child_process"; const [IN, OUT, UNTIL, FADE = "0.030"] = process.argv.slice(2); if (!IN || !OUT || !UNTIL) { console.error("usage: node hush-head.mjs [fade]"); process.exit(1); } const until = Number(UNTIL), fade = Number(FADE); const peakOf = (file, from, to) => { const b = execFileSync("ffmpeg", ["-v", "error", "-ss", String(from), "-to", String(to), "-i", file, "-ac", "1", "-ar", "48000", "-f", "s16le", "-"], { maxBuffer: 1 << 28 }); const a = new Int16Array(b.buffer, b.byteOffset, b.length / 2); let p = 0; for (let i = 0; i < a.length; i += 1) p = Math.max(p, Math.abs(a[i] / 32768)); return p; }; const dur = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", IN]).toString().trim()); const headPeak = peakOf(IN, 0, until); const restPeak = peakOf(IN, until, dur); // eval=frame because the expression is time-varying; the same idiom the finale // envelope in mk-fatal-finish3.sh uses. const VOL = `if(lt(t,${until}),0,if(lt(t,${until + fade}),(t-${until})/${fade},1))`; execFileSync("ffmpeg", ["-y", "-v", "error", "-i", IN, "-af", `volume='${VOL}':eval=frame`, OUT]); console.log(`${IN} -> ${OUT}`); console.log(` silenced 0-${until}s (peak ${headPeak.toFixed(4)}), kept ${until}-${dur.toFixed(3)}s (peak ${restPeak.toFixed(4)})`); console.log(` duration unchanged: ${dur.toFixed(3)}s — every 'at' in the overlays still lands where it was judged`); if (headPeak > restPeak) { console.log(` NOTE: the head WAS the clip's peak, so a peak-targeted gain will now play the shout louder`); }