#!/usr/bin/env node // Mix the shouted hooks onto an ALREADY-RENDERED song, outside render-poly. // // Why not just re-render: the video takes ~20 minutes to build (878 clips) and // does not depend on the hooks at all. Rendering once WITHOUT overlays and // mixing the hooks here turns "try another vocal balance" from a 20-minute job // into a 2-second one, so alternatives can actually be compared by ear. // // node mk-mix-hooks.mjs // // DELTA shift every hook by this many seconds. NEGATIVE = earlier. // The record's map put the first "MORTAL KOMBAT" so that the melody // changes at the END of the shout; the drop wants to land first, so // the hooks move earlier (or equivalently the song moves later). // HOOK_GAIN multiplier on each hook's peak target (render-poly used the JSON // gain as an absolute peak: 0.55 ordinary, 0.75 title). // PUNCH power-law exponent applied to the hook, 1.0 = off. Below 1 it // raises the body of the shout without raising its peak, which is // what makes it read as punchy rather than wispy. 0.65 is strong. // BOOM how much low end to add back (one-pole, ~200Hz), 0 = off. // SONG_GAIN scale the song before the hooks go on, to leave them headroom. // LIMIT final peak ceiling. import { readFileSync, writeFileSync } from "node:fs"; import { execFileSync } from "node:child_process"; import path from "node:path"; const [SONG, OVL, OUT] = process.argv.slice(2); const SR = 48000; const DELTA = Number(process.env.DELTA ?? 0); const HOOK_GAIN = Number(process.env.HOOK_GAIN ?? 1.0); const PUNCH = Number(process.env.PUNCH ?? 1.0); const BOOM = Number(process.env.BOOM ?? 0); const SONG_GAIN = Number(process.env.SONG_GAIN ?? 1.0); const LIMIT = Number(process.env.LIMIT ?? 0.97); import { SONG_DATA } from "./paths.mjs"; const DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname)); // Use the DRY hooks. `hooks-verb/` is convolution reverb, which smears the // ATTACK -- hook-01 is 3.04s dry and 4.57s wet -- and that wash is what reads as // wispy. (Most hooks were never actually reverbed: only a few files differ.) const HOOKDIR = process.env.HOOKDIR ?? ""; // Per-hook extra shift, as {"hook-01": -0.39}. Raising PREROLL moves the whole // song AND every hook later, but the opening "Test your might" is placed against // the BACKGROUND, not the song: it has to stay in the game's silent gap // (17.15-18.086s) and finish before the gong. Holding it costs one entry here // instead of a second placement pass. const HOOK_DELTAS = JSON.parse(process.env.HOOK_DELTAS ?? "{}"); // Per-hook gain multiplier, as {"hook-01": 1.5}. The catalogue's gain is a peak // target, so a clip whose PEAK is an incidental noise rather than the shout ends // up quiet however loud the shout is. const HOOK_GAINS = JSON.parse(process.env.HOOK_GAINS ?? "{}"); // Per-hook multiplier on the octave-down layers, as {"hook-01": 1.8}. Some // shouts want more bottom than others: "Test your might" is a thin high shout // and needs far more synthesised low end than "MORTAL KOMBAT", which already // has body of its own. const HOOK_SUBS = JSON.parse(process.env.HOOK_SUBS ?? "{}"); // Per-hook multiplier on the echo, as {"hook-01": 1.4}. The tail is what gives // a shout its size once the low end is right; the amount that suits a short // call-out is not the amount that suits a sustained one. const HOOK_ECHOES = JSON.parse(process.env.HOOK_ECHOES ?? "{}"); // ECHO instead of reverb: discrete repeats AFTER the shout, so the front stays // dry and punchy and the effect concentrates toward the end. const ECHO_MIX = Number(process.env.ECHO_MIX ?? 0); const ECHO_DELAY = Number(process.env.ECHO_DELAY ?? 0.155); const ECHO_FB = Number(process.env.ECHO_FB ?? 0.55); const ECHO_TAPS = Number(process.env.ECHO_TAPS ?? 4); const ECHO_RAMP = Number(process.env.ECHO_RAMP ?? 1); // 1 = wet grows across the clip // Where the FIRST repeat lands, separately from the gap between repeats. With a // pre-delay of one ECHO_DELAY the first tap starts while the shout is still // sounding and the repeats blur into it; pushing it out past the shout makes the // echo read as a distinct, disconnected slap rather than a smear. const ECHO_PREDELAY = Number(process.env.ECHO_PREDELAY ?? 0); // ...or set it as a FRACTION of the clip length, which is what actually keeps // the effect consistent: the hooks run 0.84s to 1.72s, so one fixed pre-delay // lands mid-shout on a long clip and past the end of a short one. At 1.0 the // first repeat starts exactly as the shout finishes. const ECHO_PREDELAY_REL = Number(process.env.ECHO_PREDELAY_REL ?? 0); // The um song is PEAK-normalised by the renderer, so a sparse arrangement has a // low average and is swamped by dense game audio at the same peak. Lifting its // body is what makes it sound as loud as it measures. const SONG_PUNCH = Number(process.env.SONG_PUNCH ?? 1.0); // PUNCH raises the BODY but flattens the ATTACK -- it is a compressor, and a // shout with its transient compressed away reads as flat however loud or deep it // is. These two put the hit back: // ATTACK_GAIN/ATTACK_MS emphasise the clip's first few ms, tapering to 1. // SUB_DELAY_MS hold the octave-down layers back a little so the dry // transient lands FIRST and the weight arrives behind // it, instead of smearing over the crack. // DUCK the song under each shout. Raising a hook's level cannot make it stand // over the ums: it pushes the master peak up and the final limiter then scales // the WHOLE mix back down, ums included, so the ratio is preserved almost // exactly (measured 1.23x -> 1.30x for a 2.4x hook boost). Lowering the song // under the shout is the only thing that changes the ratio, and it costs no // headroom at all. const DUCK = Number(process.env.DUCK ?? 1); // 1 = off, 0.5 = song halved const DUCK_MS = Number(process.env.DUCK_MS ?? 90); // ramp in/out // Which hooks the song ducks under. Ducking under EVERY shout sags the whole // mix on hooks that were not boosted to fill the space -- measured: the window // around an unboosted shout fell to 0.78x the song either side of it. Default // is all; name the ones that need to dominate. const DUCK_HOOKS = process.env.DUCK_HOOKS ? new Set(process.env.DUCK_HOOKS.split(",")) : null; const ATTACK_GAIN = Number(process.env.ATTACK_GAIN ?? 1); const ATTACK_MS = Number(process.env.ATTACK_MS ?? 35); const SUB_DELAY_MS = Number(process.env.SUB_DELAY_MS ?? 0); // --------------------------------------------------------------------------- // Measured against the record (record-ratio.mjs, bandcmp.mjs), 2026-08-15. // --------------------------------------------------------------------------- // CARVE instead of DUCK. Ducking drops the WHOLE song under the shout, and the // song's mass is 48% at 400-800Hz -- the ums' own formant -- while the shout // lives at 1600-3200Hz, where the song has 1.5%. So a broadband duck throws away // low end that was never in the shout's way to clear a band that was already // empty. Carving takes the song down only across CARVE_LO..CARVE_HI, which is // where the masking actually happens, so the ums keep their body and the shout // still gets its space. const CARVE = Number(process.env.CARVE ?? 1); // 1 = off const CARVE_LO = Number(process.env.CARVE_LO ?? 400); const CARVE_HI = Number(process.env.CARVE_HI ?? 2400); // The intro. The record does NOT play its intro at full level: the instrumental // runs 0.515x the body before the first "MORTAL KOMBAT", and it BUILDS across // the four "Test your might"s -- 0.36, 0.37, 0.64, 0.70 -- then steps to 1.0. // Ours has been flat at 1.0 from the first note. Breakpoints are [second, gain], // interpolated, holding 1.0 past the last one. const INTRO_STEPS = JSON.parse(process.env.INTRO_STEPS ?? "[]"); // Saturation on the shout. The record's shout moment carries 4.6% of its energy // at 3200-6400Hz against our 0.7%, and OUR SONG has 0.6% there -- the band is // empty. Harmonics put the shout somewhere nothing else is, which reads as size // without touching level or peak. tanh, normalised so the peak is unchanged. const DRIVE = Number(process.env.DRIVE ?? 0); const HOOK_DRIVES = JSON.parse(process.env.HOOK_DRIVES ?? "{}"); // Per-hook high-pass, in Hz. tym-B carries 20.2% of its energy below 200Hz; the // record's shout has 4.9%. That weight is not "boom", it is mud sitting on the // kick and on the ums' own fundamentals -- it was synthesised an octave too low. const HOOK_LOCUTS = JSON.parse(process.env.HOOK_LOCUTS ?? "{}"); // ...and where that weight SHOULD go: the record's shout moment is 22.9% across // 200-800Hz against our 11.0%. Per-hook lift of that band, as {"hook-01": 1.6}. const HOOK_MIDS = JSON.parse(process.env.HOOK_MIDS ?? "{}"); const MID_LO = Number(process.env.MID_LO ?? 200); const MID_HI = Number(process.env.MID_HI ?? 800); // One-pole building blocks. Crude next to a biquad, but these are broad tonal // moves, not surgery, and staying in plain JS keeps the whole mix one pass. const onepoleLP = (x, f) => { const a = Math.exp(-2 * Math.PI * f / SR), out = new Float32Array(x.length); let lp = 0; for (let i = 0; i < x.length; i += 1) { lp = a * lp + (1 - a) * x[i]; out[i] = lp; } return out; }; const bandOf = (x, lo, hi) => { const low = onepoleLP(x, hi), sub = onepoleLP(x, lo), out = new Float32Array(x.length); for (let i = 0; i < x.length; i += 1) out[i] = low[i] - sub[i]; return out; }; // Decode through ffmpeg rather than parsing WAV: the inputs are a mix of the // renderer's own mono wavs and mp4 audio, and ffmpeg normalises all of it. 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); const x = new Float32Array(i16.length); for (let i = 0; i < i16.length; i += 1) x[i] = i16[i] / 32768; return x; }; // The separated vocal stem has NO ENERGY BELOW 400Hz -- measured at 0.0% of its // total in each of the 50-100, 100-200 and 200-400 bands, against 13.1% for the // record. UVR high-passed the voice away from the bass, so the chest of the // shout simply is not there and no amount of low shelf can lift it: the band is // silent. Put it back by synthesising an octave-down copy from the voice itself // (asetrate halves the pitch and doubles the length; atempo restores the // length), low-passed so only the new bottom is added. const SUB_MIX = Number(process.env.SUB_MIX ?? 0); const SUB_LP = Number(process.env.SUB_LP ?? 380); // The stem is also short of 3200-6400 (6.9% vs the record's 10.8%) -- that band // is the crack of the consonants, the "punch" as distinct from the "boom". const PRESENCE = Number(process.env.PRESENCE ?? 0); // A SECOND octave down reaches 100-200Hz, which one octave cannot: the stem's // lowest real content is ~400Hz, so halving it lands at 200-400 and no lower. const SUB2_MIX = Number(process.env.SUB2_MIX ?? 0); const SUB2_LP = Number(process.env.SUB2_LP ?? 190); // ADAPT_SUB=1 sets both cutoffs from each clip's OWN dominant frequency instead // of the fixed SUB_LP/SUB2_LP, which only ever suited one clip. const ADAPT_SUB = process.env.ADAPT_SUB === "1"; const subLog = []; const subFilter = `asetrate=${SR / 2},aresample=${SR},atempo=2.0,lowpass=f=${SUB_LP},lowpass=f=${SUB_LP}`; const sub2Filter = `asetrate=${SR / 4},aresample=${SR},atempo=2.0,atempo=2.0,lowpass=f=${SUB2_LP},lowpass=f=${SUB2_LP}`; const dryFilter = PRESENCE ? `treble=g=${PRESENCE}:f=3800:width_type=h:width=2400` : null; const song = decode(SONG); const hooks = JSON.parse(readFileSync(path.join(SONG_DATA, OVL), "utf8")); const hookPath = (f) => (HOOKDIR ? path.join(SONG_DATA, HOOKDIR, path.basename(f)) : path.join(SONG_DATA, f)); let end = song.length; const loaded = []; function fft(re, im) { const n = re.length; for (let i = 1, j = 0; i < n; i += 1) { let bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j ^= bit; if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; } } for (let len = 2; len <= n; len <<= 1) { const ang = -2 * Math.PI / len, wr = Math.cos(ang), wi = Math.sin(ang); for (let i = 0; i < n; i += len) { let cr = 1, ci = 0; for (let k = 0; k < len / 2; k += 1) { const ur = re[i + k], ui = im[i + k]; const vr = re[i + k + len / 2] * cr - im[i + k + len / 2] * ci; const vi = re[i + k + len / 2] * ci + im[i + k + len / 2] * cr; re[i + k] = ur + vr; im[i + k] = ui + vi; re[i + k + len / 2] = ur - vr; im[i + k + len / 2] = ui - vi; const ncr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = ncr; } } } } // The frequency below which half the clip's energy lies. Zero-crossing rate was // tried first and overestimates badly -- it read "MORTAL KOMBAT" at 2225Hz when // a band analysis puts 56% of its energy at 800-1600Hz -- because sibilance // rides on top and inflates the crossing count. const medianFreq = (x) => { const N = 4096; if (x.length < N) return 900; const acc = new Float64Array(N / 2); let frames = 0; for (let s = 0; s + N <= x.length; s += N / 2) { const re = new Float64Array(N), im = new Float64Array(N); for (let i = 0; i < N; i += 1) re[i] = x[s + i] * (0.5 - 0.5 * Math.cos(2 * Math.PI * i / (N - 1))); fft(re, im); for (let k = 0; k < N / 2; k += 1) acc[k] += re[k] * re[k] + im[k] * im[k]; frames += 1; } if (!frames) return 900; let total = 0; for (let k = 0; k < acc.length; k += 1) total += acc[k]; let run = 0; for (let k = 0; k < acc.length; k += 1) { run += acc[k]; if (run >= total / 2) return Math.max(250, Math.min(3000, k * SR / N)); } return 900; }; for (const h of hooks) { const hid = (h.label ?? "").match(/hook-\d+/)?.[0] ?? path.basename(h.file, ".wav"); const subMul = HOOK_SUBS[hid] ?? 1; const x = decode(hookPath(h.file), dryFilter); // Cutoffs FOLLOW THE VOICE. Fixed ones were tuned on "MORTAL KOMBAT" (energy // at 400-1600Hz); "Test your might" sits an octave higher (66% at 1600-3200), // so a 440Hz lowpass deleted almost all of its octave-down layer and the clip // stayed thin no matter how far SUB_MIX was pushed. const f0 = ADAPT_SUB ? medianFreq(x) : 0; const lp1 = ADAPT_SUB ? Math.round(f0 * 0.60) : SUB_LP; const lp2 = ADAPT_SUB ? Math.round(f0 * 0.30) : SUB2_LP; const sf = `asetrate=${SR / 2},aresample=${SR},atempo=2.0,lowpass=f=${lp1},lowpass=f=${lp1}`; const s2f = `asetrate=${SR / 4},aresample=${SR},atempo=2.0,atempo=2.0,lowpass=f=${lp2},lowpass=f=${lp2}`; if (ADAPT_SUB) subLog.push(`${(h.label ?? h.file).slice(0, 22)} f0~${Math.round(f0)}Hz lp ${lp1}/${lp2}`); const sd = Math.round(SUB_DELAY_MS * SR / 1000); if (SUB_MIX > 0) { const sub = decode(hookPath(h.file), ADAPT_SUB ? sf : subFilter); for (let i = 0; i + sd < x.length && i < sub.length; i += 1) x[i + sd] += SUB_MIX * subMul * sub[i]; } if (SUB2_MIX > 0) { const sub2 = decode(hookPath(h.file), ADAPT_SUB ? s2f : sub2Filter); for (let i = 0; i + sd < x.length && i < sub2.length; i += 1) x[i + sd] += SUB2_MIX * subMul * sub2[i]; } const at = h.at + DELTA + (HOOK_DELTAS[hid] ?? 0); loaded.push({ ...h, id: hid, x, at }); end = Math.max(end, Math.round(at * SR) + x.length); } const master = new Float32Array(end + SR); // Lift the song's body before anything is mixed on top, then restore its peak. let songPk = 0; for (let i = 0; i < song.length; i += 1) songPk = Math.max(songPk, Math.abs(song[i])); let songRms = 0; for (let i = 0; i < song.length; i += 1) { let v = song[i]; if (SONG_PUNCH !== 1.0) v = Math.sign(v) * Math.pow(Math.abs(v) / songPk, SONG_PUNCH) * songPk; master[i] = v * SONG_GAIN; songRms += master[i] * master[i]; } songRms = Math.sqrt(songRms / song.length); // The record's intro build, applied to the ums. if (INTRO_STEPS.length) { const at = (t) => { if (t <= INTRO_STEPS[0][0]) return INTRO_STEPS[0][1]; for (let k = 1; k < INTRO_STEPS.length; k += 1) { const [t0, g0] = INTRO_STEPS[k - 1], [t1, g1] = INTRO_STEPS[k]; if (t <= t1) return g0 + (g1 - g0) * ((t - t0) / (t1 - t0)); } return 1; }; const until = Math.round(INTRO_STEPS[INTRO_STEPS.length - 1][0] * SR); for (let i = 0; i < Math.min(master.length, until); i += 1) master[i] *= at(i / SR); console.log(` intro build: ${INTRO_STEPS.map(([t, g]) => `${t}s x${g}`).join(" -> ")} -> x1 (the record does 0.36 -> 0.70 -> 1)`); } // Duck the SONG under every shout, before the shouts are added. if (DUCK < 1) { const ramp = Math.max(1, Math.round(DUCK_MS * SR / 1000)); const env = new Float32Array(master.length).fill(1); for (const h of loaded) { if (DUCK_HOOKS && !DUCK_HOOKS.has(h.id)) continue; const s = Math.round(h.at * SR), e = s + h.x.length; for (let i = Math.max(0, s - ramp); i < Math.min(env.length, e + ramp); i += 1) { let g = DUCK; if (i < s) g = 1 + (DUCK - 1) * ((i - (s - ramp)) / ramp); // ramp down else if (i >= e) g = DUCK + (1 - DUCK) * ((i - e) / ramp); // ramp back up if (g < env[i]) env[i] = g; // overlapping hooks } } let ducked = 0; for (let i = 0; i < master.length; i += 1) { if (env[i] < 1) ducked += 1; master[i] *= env[i]; } console.log(` ducking the song to ${DUCK} under the shouts (${(ducked / SR).toFixed(1)}s affected)`); } // CARVE: the same envelope shape, but it only takes down CARVE_LO..CARVE_HI. // Subtracting a scaled band-pass of the song IS a time-varying band cut, and it // costs one filter pass over the song instead of a filter per sample. if (CARVE < 1) { const ramp = Math.max(1, Math.round(DUCK_MS * SR / 1000)); const env = new Float32Array(master.length).fill(1); for (const h of loaded) { if (DUCK_HOOKS && !DUCK_HOOKS.has(h.id)) continue; const s = Math.round(h.at * SR), e = s + h.x.length; for (let i = Math.max(0, s - ramp); i < Math.min(env.length, e + ramp); i += 1) { let g = CARVE; if (i < s) g = 1 + (CARVE - 1) * ((i - (s - ramp)) / ramp); else if (i >= e) g = CARVE + (1 - CARVE) * ((i - e) / ramp); if (g < env[i]) env[i] = g; } } const band = bandOf(master, CARVE_LO, CARVE_HI); let n = 0; for (let i = 0; i < master.length; i += 1) { if (env[i] < 1) { master[i] -= (1 - env[i]) * band[i]; n += 1; } } console.log(` carving ${CARVE_LO}-${CARVE_HI}Hz to ${CARVE} under the shouts (${(n / SR).toFixed(1)}s affected)` + ` -- the song's 400-800Hz mass is what masks a shout, not its low end`); } for (const h of loaded) { let x = Float32Array.from(h.x); // peak-normalise first so PUNCH and BOOM act on a known scale let pk = 0; for (let i = 0; i < x.length; i += 1) pk = Math.max(pk, Math.abs(x[i])); if (pk < 1e-5) continue; for (let i = 0; i < x.length; i += 1) x[i] /= pk; // Tonal moves come FIRST, before the compressor and the transient shaper, so // those two act on the shout we actually want rather than on one we are about // to reshape. const locut = HOOK_LOCUTS[h.id] ?? 0; if (locut > 0) { const lp = onepoleLP(x, locut); for (let i = 0; i < x.length; i += 1) x[i] -= lp[i]; } const mid = HOOK_MIDS[h.id] ?? 1; if (mid !== 1) { const band = bandOf(x, MID_LO, MID_HI); for (let i = 0; i < x.length; i += 1) x[i] += (mid - 1) * band[i]; } const drive = DRIVE * (HOOK_DRIVES[h.id] ?? 1); if (drive > 0) { // tanh, renormalised: the peak comes back to where it was, so everything // this adds is harmonic content rather than level. const k = Math.tanh(drive); for (let i = 0; i < x.length; i += 1) x[i] = Math.tanh(drive * x[i]) / k; } if (PUNCH !== 1.0) { for (let i = 0; i < x.length; i += 1) x[i] = Math.sign(x[i]) * Math.pow(Math.abs(x[i]), PUNCH); } if (ATTACK_GAIN !== 1) { // Applied AFTER the compressor, so it restores what PUNCH took off the // transient rather than being squashed along with it. const n = Math.min(x.length, Math.round(ATTACK_MS * SR / 1000)); for (let i = 0; i < n; i += 1) { const f = 1 - i / n; // full at the onset, gone by ATTACK_MS x[i] *= 1 + (ATTACK_GAIN - 1) * f * f; } } if (BOOM > 0) { // one-pole low-pass ~200Hz, added back under the dry shout const a = Math.exp(-2 * Math.PI * 200 / SR); let lp = 0; for (let i = 0; i < x.length; i += 1) { lp = a * lp + (1 - a) * x[i]; x[i] += BOOM * lp; } } const echoMix = ECHO_MIX * (HOOK_ECHOES[h.id] ?? 1); if (echoMix > 0) { // Discrete repeats, each one ECHO_DELAY later and ECHO_FB quieter. The taps // only ever ADD to what came before, so the attack is untouched and the // effect accumulates -- which is the "concentrated toward the end" part. const dry = Float32Array.from(x); const pre = ECHO_PREDELAY_REL ? ECHO_PREDELAY_REL * (dry.length / SR) : (ECHO_PREDELAY || ECHO_DELAY); const wet = new Float32Array(x.length + Math.ceil((pre + ECHO_DELAY * ECHO_TAPS) * SR)); for (let k = 1; k <= ECHO_TAPS; k += 1) { const d = Math.round((pre + (k - 1) * ECHO_DELAY) * SR); const g = Math.pow(ECHO_FB, k); for (let i = 0; i < dry.length; i += 1) wet[i + d] += dry[i] * g; } const grown = new Float32Array(wet.length); for (let i = 0; i < grown.length; i += 1) { // wet envelope rises across the clip so the tail is wetter than the front const f = ECHO_RAMP ? Math.min(1, i / Math.max(1, dry.length)) : 1; grown[i] = (i < dry.length ? dry[i] : 0) + echoMix * f * wet[i]; } x = grown; } // re-peak, then to the target let pk2 = 0; for (let i = 0; i < x.length; i += 1) pk2 = Math.max(pk2, Math.abs(x[i])); const want = (h.gain ?? 0.72) * HOOK_GAIN * (HOOK_GAINS[h.id] ?? 1); const g = want / pk2; const off = Math.round(h.at * SR); for (let i = 0; i < x.length; i += 1) { const j = off + i; if (j >= 0 && j < master.length) master[j] += x[i] * g; } } // soft ceiling: only touch what exceeds it, so the body of the mix is untouched let peak = 0; for (let i = 0; i < master.length; i += 1) peak = Math.max(peak, Math.abs(master[i])); let scaled = 1; if (peak > LIMIT) { scaled = LIMIT / peak; for (let i = 0; i < master.length; i += 1) master[i] *= scaled; } // trim to the last audible sample let last = 0; for (let i = 0; i < master.length; i += 1) if (Math.abs(master[i]) > 2e-4) last = i; const n = last + 1; const 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, master[i])) * 32767), 44 + i * 2); writeFileSync(OUT, b); console.log(`${loaded.length} hooks DELTA ${DELTA >= 0 ? "+" : ""}${DELTA}s HOOK_GAIN ${HOOK_GAIN} PUNCH ${PUNCH}` + ` ECHO ${ECHO_MIX}${ECHO_MIX ? `@${ECHO_DELAY}s x${ECHO_TAPS}` : ""} SONG_PUNCH ${SONG_PUNCH} dir ${HOOKDIR || "(as in json)"}`); if (subLog.length) console.log(" adaptive sub cutoffs:\n " + subLog.join("\n ")); console.log(` song rms ${(songRms * 32768).toFixed(0)} peak ${peak.toFixed(3)}` + `${scaled < 1 ? ` -> ${LIMIT} (x${scaled.toFixed(3)})` : ""} ends ${(n / SR).toFixed(2)}s -> ${OUT}`);