#!/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;i8) 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"