commit 44e778522a4ef0a75f24028ec56800316a172762
parent 1cad6c43041ff4e8aa812694aace18eee55baa75
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 11:58:28 -0400
umtool: the 20 one-off song run-log scripts deleted
backfill-build, mk-fatal-finish{,2,3}, mk-rebuild, ms2-{bg3,drums,full,
jerbg,play-rebuild,triangle}, pk2, pk3, pk-v12, pkmn-{rebuild,video},
rpg-remake-v5, rpg-short, vshort, yoshi-rebuild: each began with a
literal cd into one machine's job dir and was never runnable from the
app. lib/jobs.ts and BuildChain's note say it in the past tense. The
historical mentions in debox-bg, hush-head, pick-take and video-dir stay.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
22 files changed, 17 insertions(+), 2263 deletions(-)
diff --git a/umtool/components/BuildChain.tsx b/umtool/components/BuildChain.tsx
@@ -141,12 +141,13 @@ export default function BuildChain({ setId }: { setId: string }) {
</button>
</div>
- {/* Nothing here can be re-pointed at the whole-song .sh builds: they
- hardcode their own paths, ignore SONG_DIR, and take 20+ minutes. */}
+ {/* The whole-song .sh builds were never runnable here: they hardcoded
+ their own paths, ignored SONG_DIR, and took 20+ minutes. They were
+ deleted in release 12 (lib/jobs.ts). */}
<p className="mt-2 text-[11px] text-[var(--color-dim)]">
- The whole-song rebuild scripts are deliberately not runnable from here — they hardcode their
- paths, ignore <span className="font-mono">SONG_DIR</span>, and re-arrange
- non-deterministically. Run those from a shell.
+ The whole-song rebuild scripts were one-off shell run logs that hardcoded their paths
+ and ignored <span className="font-mono">SONG_DIR</span>; they are not in the tree, and
+ not runnable from here.
</p>
{error && <p className="mt-2 text-[11px] text-[var(--color-bad)]">{error}</p>}
diff --git a/umtool/lib/jobs.ts b/umtool/lib/jobs.ts
@@ -5,16 +5,17 @@ import { ENV_DENY, type Step } from "./trim";
// Running a chain of steps, with the guardrails that make it safe to put behind
// a button.
//
-// WHAT IS DELIBERATELY NOT HERE: the whole-song build scripts.
+// WHAT WAS DELIBERATELY NEVER HERE: the whole-song build scripts.
//
// mk-rebuild.sh, pkmn-rebuild.sh, yoshi-rebuild.sh, rpg-remake-v5.sh and the
-// ms2-*.sh family all begin with a literal `cd /home/user/.claude/jobs/...`
-// and write to a literal reports path. They ignore SONG_DIR and
-// SONG_REPORTS_DIR entirely, which means an e2e fixture cannot contain them --
-// a spec that clicked the button would render into the real deliverables tree.
-// They also run 20+ minutes, and arrange-poly is non-deterministic across a
-// changed palette, so one click can invalidate a set of judgements already made
-// by ear. The UI prints their command instead.
+// ms2-*.sh family were one-off shell run logs that hardcoded their paths: each
+// began with a literal `cd` into the job temp dir the song was mined in and
+// wrote to a literal reports path, ignoring SONG_DIR and SONG_REPORTS_DIR, so
+// no e2e fixture could contain them -- a spec that clicked a button would have
+// rendered into the real deliverables tree. They also ran 20+ minutes, and
+// arrange-poly is non-deterministic across a changed palette, so one click
+// could invalidate a set of judgements already made by ear. They are not in
+// the tree any more (release 12), and nothing here can run them.
//
// What runs here is the .mjs chain, which honours SONG_DATA through
// song/paths.mjs and finishes in seconds to minutes.
@@ -68,8 +69,8 @@ export const getJob = (id: string) => jobs.get(id) ?? null;
export const recentJobs = (n = 10) =>
[...jobs.values()].sort((a, b) => b.startedAt - a.startedAt).slice(0, n);
-/** The accidental-hour-long-job guard. None of the .sh builds fits under any
- * cap worth setting, which is the other reason they stay out.
+/** The accidental-hour-long-job guard. None of the old .sh builds fitted under
+ * any cap worth setting, which was the other reason they were never here.
*
* A step may ask for more (Step.timeoutMs). A 19-clip crossfaded report build
* runs 20 to 40 minutes and would otherwise be SIGKILLed at 15 -- but raising
diff --git a/umtool/song/backfill-build.sh b/umtool/song/backfill-build.sh
@@ -1,89 +0,0 @@
-#!/usr/bin/env bash
-# Backfill build.json for the songs whose recipe can actually be READ OFF a builder.
-#
-# WHAT IS HERE IS VERIFIED, AND THE GAPS ARE DELIBERATE.
-#
-# Only two builders write into videos/<song>/ themselves, which is what makes their
-# output attributable without guessing:
-#
-# pkmn-video.sh line 116 OUT=$V/<wide|vertical>[-short].mp4 all 4 cuts
-# ms2-jerbg.sh line 102 mux -> "$V/$NAME.mp4" wide, wide-short
-# line 104 mux -> "$V/variants/$NAME-umdrums.mp4"
-# line 84 ffmpeg -> "$V/variants/$NAME-nokit.mp4"
-#
-# The other three are NOT recorded, and must not be guessed at:
-#
-# mortal-kombat mk-fatal-finish3.sh takes OUT as a required env var and names no
-# plan at all -- it assembles audio. Which of the five files in
-# plan/ produced which cut is not in the script.
-# mario-rpg rpg-short.sh defaults PLANF=rpg-plan-short.json, and there is NO
-# such file in videos/mario-rpg/plan/ -- it holds rpg-plan-s2/s3
-# (the 2-voice vs 3-voice pair still awaiting a decision) and v4.
-# So the shipped cuts came from an invocation this script does not
-# describe.
-# yoshi yoshi-rebuild.sh defaults PLANF=yoshi-plan-v7.json, and plan/
-# holds the SHORT plans. Same problem.
-#
-# A build.json that says "probably this plan" is worse than one that says nothing:
-# the whole point of the file is that it can be trusted. The browse UI renders an
-# unrecorded file as unrecorded and offers the plan picker instead.
-#
-# Idempotent -- re-running just rewrites the same entries with a fresh `written`.
-set -euo pipefail
-cd "$(dirname "$0")"
-
-D=/home/user/reports/quartering-uh-song
-V=${VIDEO_ROOT:-$D/videos}
-export VIDEO_ROOT=$V
-
-rec () { node video-dir.mjs "$@"; }
-
-echo "--- pokemon (pkmn-video.sh: FORMAT x CUT, writes each cut directly)"
-JINGLE=pkc-jingle-merged.json
-for fmt in wide vertical; do
- for cut in long short; do
- rel=$fmt$( [ "$cut" = short ] && echo -short || echo "" ).mp4
- [ -f "$V/pokemon/$rel" ] || { echo " skip $rel (not built)"; continue; }
- if [ "$cut" = long ]; then BODY=pkmn-v12-body.json; RUN=pkmn-v12-run.json
- else BODY=pkmn-short-body.json; RUN=pkmn-short-run.json; fi
- rec --song pokemon --rel "$rel" \
- --script pkmn-video.sh \
- --env "FORMAT=$fmt" --env "CUT=$cut" \
- --plan "$BODY" --plan "$RUN" --plan "$JINGLE" \
- --note "body + run + the catch jingle; the shape is the video, see the script header"
- done
-done
-
-echo "--- metal-slug (ms2-jerbg.sh: jer's edit, handover on MISSION 1 START)"
-BG=$D/jer-metalslug-bg.mp4
-ANN=$D/MS_Mission_1_start.ogg
-for name in wide wide-short; do
- case $name in
- wide) PLAN=ms2-plan-11-v3.json ;;
- wide-short) PLAN=ms2-plan-short2.json ;;
- esac
- # The shipped cut carries the game's own kit; -umdrums swaps it for the um kit;
- # -nokit is the same mix with no drums at all. One recipe, three outputs.
- for rel in "$name.mp4" "variants/$name-nokit.mp4" "variants/$name-umdrums.mp4"; do
- [ -f "$V/metal-slug/$rel" ] || { echo " skip $rel (not built)"; continue; }
- case $rel in
- *-nokit.mp4) NOTE="the handover mix with no drum kit" ;;
- *-umdrums.mp4) NOTE="the um kit in place of the game's" ;;
- *) NOTE="shipped: the game's own kit, arrangement shifted +0.669s onto MISSION 1 START" ;;
- esac
- rec --song metal-slug --rel "$rel" \
- --script ms2-jerbg.sh \
- --env "BG=$BG" --env "ANNOUNCER=$ANN" \
- --plan "$PLAN" \
- --asset "$BG" --asset "$ANN" \
- --note "$NOTE"
- done
-done
-
-echo
-echo "NOT recorded, and deliberately so -- the builder does not say which plan:"
-echo " mortal-kombat/* mk-fatal-finish3.sh names no plan (OUT is a required env var)"
-echo " mario-rpg/* rpg-short.sh defaults to rpg-plan-short.json, absent from plan/"
-echo " yoshi/* yoshi-rebuild.sh defaults to yoshi-plan-v7.json, absent from plan/"
-echo
-node video-dir.mjs --list
diff --git a/umtool/song/mk-fatal-finish.sh b/umtool/song/mk-fatal-finish.sh
@@ -1,57 +0,0 @@
-#!/usr/bin/env bash
-# Finish the Mortal Kombat fatality build: put the GAME audio back under the
-# rendered song, then hand over to the fatality at full brightness.
-#
-# The renderer stops its audio at the last note (~144.2s) but leaves ~2.9s of
-# SILENT video after it, so the container duration is not the song's end. Cutting
-# on the container would put the freeze and the shatter inside the dimmed body.
-# The body is therefore cut at the last audible sample, measured here.
-#
-# Video: body (dimmed by the renderer) ++ background tail (full brightness).
-# Audio: the song, plus the game under it -- full through the gong, silent under
-# the song, back up as the song ends so the shatter is heard.
-set -euo pipefail
-cd "$(dirname "$0")"
-
-BODY=${BODY:-/home/user/.claude/jobs/efbe67a7/tmp/mkfatal.mp4}
-BG=${BG:-intro/mk-bg-fatal.mp4}
-OUT=${OUT:-/home/user/reports/quartering-uh-song/quartering-mk-fatality.mp4}
-
-# Where the FATALITY screen gives way to the tournament ladder. Ending here means
-# the video finishes ON the payoff instead of drifting into the next screen.
-END=${END:-153.10}
-# The game's gong is the select-screen sting at 18.086s, after a 0.9s silence --
-# NOT 17s, and an 18s gate would mute it.
-GONG_HOLD=${GONG_HOLD:-19.0} # game full until here
-GAME_OUT=${GAME_OUT:-20.8} # ...faded out by here, before the 21s cut
-FADE_UP=${FADE_UP:-1.4} # game rises over this long, landing on the join
-GAME_GAIN=${GAME_GAIN:-0.90}
-
-# Last audible sample of the body = the song's real end.
-T=$(ffmpeg -v error -i "$BODY" -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));});')
-
-FU=$(node -e "console.log(($T - $FADE_UP).toFixed(3))")
-TAILDUR=$(node -e "console.log(($END - $T).toFixed(3))")
-echo "song ends ${T}s · game rises ${FU}->${T}s · fatality tail ${T}->${END}s (${TAILDUR}s)"
-
-ffmpeg -y -v warning \
- -i "$BODY" \
- -i "$BG" \
- -ss "$T" -t "$TAILDUR" -i "$BG" \
- -filter_complex "
-[1:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='${GAME_GAIN}*(if(lt(t,${GONG_HOLD}),1.0, if(lt(t,${GAME_OUT}),1-(t-${GONG_HOLD})/(${GAME_OUT}-${GONG_HOLD}), if(lt(t,${FU}),0.0, min(1.0,(t-${FU})/${FADE_UP})))))':eval=frame[game];
-[0:a]atrim=0:${T},asetpts=PTS-STARTPTS,apad=whole_dur=${END}[song];
-[song][game]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a];
-[0:v]trim=0:${T},setpts=PTS-STARTPTS[bodyv];
-[2: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"
-
-echo "wrote $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/mk-fatal-finish2.sh b/umtool/song/mk-fatal-finish2.sh
@@ -1,64 +0,0 @@
-#!/usr/bin/env bash
-# Assemble the Mortal Kombat fatality build from an already-mixed audio track.
-#
-# Ending, as asked for:
-# song ends -> a beat of DEAD AIR -> the game returns QUIETLY (you can see the
-# fatality land, you can only just hear it) -> the game rises to full during
-# its OWN natural quiet gap (source 260.3-261.2s) -> the "dun dun dun" fanfare
-# at source 261.3s is the first sound back at FULL volume, with FATALITY on
-# screen.
-#
-# Background is mk-bg-fatal2.mp4: source 0->21.0 ++ 128.5->266.0, so beyond the
-# cut output = source - 107.5.
-set -euo pipefail
-cd "$(dirname "$0")"
-
-BODY=${BODY:-/home/user/.claude/jobs/efbe67a7/tmp/mkfatal2.mp4} # video (+ the un-hooked song, unused here)
-AUDIO=${AUDIO:?need the mixed audio wav}
-BG=${BG:-intro/mk-bg-fatal2.mp4}
-OUT=${OUT:?need an output path}
-
-DEAD=${DEAD:-2.0} # seconds of silence after the song
-LOW=${LOW:-0.25} # 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:-152.80} # start of the game's own quiet gap (source 260.30)
-FULL_BY=${FULL_BY:-153.70} # ...end of it (source 261.20); fanfare is at 153.80
-END=${END:-156.10} # FATALITY screen gives way to the ladder (source 263.60)
-GONG_HOLD=${GONG_HOLD:-19.0}
-GAME_OUT=${GAME_OUT:-20.8}
-GAME_GAIN=${GAME_GAIN:-0.90}
-
-# The song's real end = last audible sample of the MIXED audio.
-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"
-
-# volume envelope, in order: gong held full, out before the cut, silent under the
-# song, dead air, up to LOW for the fatality, up to FULL for the fanfare.
-VOL="${GAME_GAIN}*(if(lt(t,${GONG_HOLD}),1.0, if(lt(t,${GAME_OUT}),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}+(1-${LOW})*(t-${FULL_AT})/(${FULL_BY}-${FULL_AT}),1.0)))))))"
-
-ffmpeg -y -v warning \
- -i "$BODY" \
- -i "$AUDIO" \
- -i "$BG" \
- -ss "$T" -t "$TAILDUR" -i "$BG" \
- -filter_complex "
-[2:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='${VOL}':eval=frame[game];
-[1:a]apad=whole_dur=${END}[song];
-[song][game]amix=inputs=2: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"
-
-echo "wrote $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/mk-fatal-finish3.sh b/umtool/song/mk-fatal-finish3.sh
@@ -1,164 +0,0 @@
-#!/usr/bin/env bash
-# Assemble a Mortal Kombat fatality finale from an already-mixed audio track.
-#
-# 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, and the repo copy of this script would otherwise cd into the repo. See
-# pipeline.md: the data directory holds independent, sometimes stale copies, so the
-# repo is authoritative and every builder must name the data dir absolutely.
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-
-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-verb/hook-17.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=/home/user/.claude/jobs/efbe67a7/tmp
- # 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 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"
- 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-rebuild.sh b/umtool/song/mk-rebuild.sh
@@ -1,55 +0,0 @@
-#!/usr/bin/env bash
-# Mortal Kombat, rebuilt on the 3,959-clip palette.
-#
-# Renders the BODY ONLY -- no hooks. That is what makes the vocal balance
-# iterable: the video takes ~20 minutes and does not depend on the hooks at all,
-# so a new balance is a ~10-second re-mux by mk-mix-hooks.mjs instead of another
-# render. Round 1 (mk-variants/A-E) was built this way.
-#
-# The two finales need two BODIES, not just two tails: the background is spliced
-# at 21s and runs continuously from there, so where segment B starts changes what
-# is on screen DURING the song as well as after it.
-# FULL intro/mk-bg-fatal2.mp4 (segment B at source 128.5, fatality 153.6s)
-# SHORT intro/mk-bg-fatal3.mp4 (segment B at source 136.5, fatality 145.6s)
-set -euo pipefail
-# The DATA dir, not the script's own. `cd $(dirname $0)` worked only while this
-# script was being run from the data directory's own copy; run from the repo it
-# lands where the lead files and poly-plan.json do not exist. rpg-remake-v5.sh
-# carries the same note for the same reason.
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-WHICH="${1:-full}"
-
-# Voice settings recovered from the SHIPPED plan (mk-plan-fatal2.json), not
-# guessed: melody gain 1.0 / bass 0.68, scale 1.05, 488 + 390 notes to bar 70.
-# poolFactor 2.6 is measured -- 1.8 gives melody 0.93 / bass 1.43, 3.0 gives
-# 1.25 / 0.83, and 2.6 is the knee at 1.01 / 0.94.
-export TEMPO_SCALE=1.05 TIME_LIMIT=${TL:-120.88} MAX_IOI=0.70 WINDOW=700 LOCAL=0
-export VOICES='[{"name":"melody","lead":"mkm-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"mkm-bass.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":2.6,"fill":false}]'
-
-if [ "${SKIP_ARRANGE:-0}" != "1" ]; then
- node arrange-poly.mjs
- cp poly-plan.json "${RAWF:-mk-plan-v4.json}"
-
- # PREROLL 17.45 puts the first um at 19.29s -- after the game's own gong at
- # 18.086s (which is the SELECT-SCREEN sting, not an in-game gong) and after the
- # 0.9s of digital silence that follows it. NO_GONG because the background
- # supplies one; the record's would double it.
- PREROLL=17.45 NO_GONG=1 node place-vocals-full.mjs \
- "${RAWF:-mk-plan-v4.json}" mkvocals/hooks-verb/mk-vocals.json mkvocals/hooks-verb \
- "${FPLAN:-mk-plan-fatal4.json}" "${FOVER:-mk-overlays-fatal4.json}"
-fi
-[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0
-
-# BG and OUT can be overridden, because a cut of a different LENGTH needs its own
-# splice: where segment B starts is the only thing that decides how long after the
-# last note the fatality lands, so a new TL means a new background.
-if [ -n "${BG:-}" ]; then OUT="${OUT:-$T/mkfatal4-custom.mp4}"
-elif [ "$WHICH" = "short" ]; then BG=intro/mk-bg-fatal3.mp4; OUT="$T/mkfatal4-short.mp4"
-else BG=intro/mk-bg-fatal2.mp4; OUT="$T/mkfatal4.mp4"; fi
-
-# No OVERLAYS= on purpose: the hooks are mixed on afterwards.
-PLAN=${FPLAN:-mk-plan-fatal4.json} BG_VIDEO=$PWD/$BG LAYOUT=bg \
- node render-poly.mjs "$OUT"
-
-echo "MK_BODY_${WHICH}_DONE -> $OUT"
diff --git a/umtool/song/ms2-bg3.sh b/umtool/song/ms2-bg3.sh
@@ -1,31 +0,0 @@
-#!/bin/sh
-# Metal Slug, cut after the first statement. The tune states its material twice
-# (the second pass begins at bar 55 = 79.02s and differs only slightly), so this
-# keeps one full statement, lets ~6 bars of the repeat begin, and fades across
-# them -- the tune comes round again and dissolves rather than stopping.
-cd /home/user/.claude/jobs/efbe67a7/tmp/song || exit 1
-D=/home/user/reports/quartering-uh-song
-
-export MAX_IOI=0.70 WINDOW=700 LOCAL=0 TEMPO_SCALE=1.05 WOBBLE_W=0.08 TIME_LIMIT=${TL:-87.8}
-# At 198 melody notes the melody is in SURPLUS, so the bass pool is free -- the
-# poolFactor cliff that constrains the full-length build does not exist here.
-export VOICES='[{"name":"melody","lead":"ms2-lead-t.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"ms2-bass-t.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":5.0,"fill":false}]'
-node arrange-poly.mjs | grep -E "melody|bass|total"
-cp poly-plan.json ms2-short-plan.json
-
-TAIL_FADE=6.2 BG_VIDEO=$PWD/intro/ms2-bg.mp4 BG_FIT=pad LAYOUT=bg \
- node render-poly.mjs /tmp/ms2short.mp4
-
-LEAD=$(node -e "
-const {voices}=require('$PWD/poly-plan.json');
-const first=Math.min(...voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart));
-console.log(Math.max(0,first).toFixed(3));")
-echo "trimming ${LEAD}s of lead-in off the body"
-ffmpeg -nostdin -v error -y -ss "$LEAD" -i /tmp/ms2short.mp4 -c:v libx264 -preset medium -crf 21 \
- -pix_fmt yuv420p -video_track_timescale 30000 -c:a aac -b:a 192k -ar 48000 -ac 2 /tmp/ms2shortbd.mp4
-
-ffmpeg -nostdin -v error -y -i intro/ms2-intro.mp4 -i /tmp/ms2shortbd.mp4 \
- -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \
- -movflags +faststart "$D/quartering-metalslug2-short.mp4"
-echo MS2SHORT_DONE
diff --git a/umtool/song/ms2-drums.sh b/umtool/song/ms2-drums.sh
@@ -1,36 +0,0 @@
-#!/usr/bin/env sh
-# Metal Slug, gameplay build, WITH the game's own drum kit under it.
-#
-# The ums play the pitched voices. The kit is not a pitched voice -- a hi-hat is
-# noise, not a note -- so it comes straight out of the MIDI and is played as
-# drums. Nothing in the build ever used the three drum tracks before this.
-#
-# The drums are mixed onto the FINISHED video (video stream copied, audio
-# re-encoded), not re-rendered: the ums and the game bed are already balanced in
-# that file, and the kit simply goes on top. That is why this runs in seconds.
-set -e
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-D=/home/user/reports/quartering-uh-song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-
-# The arrangement is the MIDI trimmed by 5.4497s, stretched 1.05, shifted to 11s.
-# Verified against ms2-plan-11.json at BOTH ends: first melody slot 11.232s and
-# last 101.698s are both predicted exactly, so there is no drift over the 90s.
-TEMPO_SCALE=1.05 TRIM=5.4497 SHIFT=11.0 START=11.0 END=102.85 \
- node midi-drums.mjs ~/Downloads/ms2_stage1.mid "$T/ms2-drums.mid"
-
-# Tracks 1 "Drum(Bass & Snare)", 2 "Drum(Hi-hat)", 3 "Drum(Cymbal)" are already
-# GM percussion numbers (36 kick, 38 snare, 42 closed hat, 49 crash), so they
-# render as a kit on channel 10 with any GM soundfont.
-fluidsynth -ni -F "$T/ms2-drums.wav" -r 48000 -g 0.8 \
- /usr/share/soundfonts/FatBoy.sf2 "$T/ms2-drums.mid"
-
-# g=1.8 chosen by ear over 1.1 (too subtle) and 2.6 (too prominent): "clearly
-# part of the song but not overpowering". Peaks 27.7k of 32768 -- the limiter is
-# a backstop, it is not working at this level.
-ffmpeg -y -v error -i "$D/quartering-metalslug2-play.mp4" -i "$T/ms2-drums.wav" \
- -filter_complex "[1:a]volume=1.8,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 \
- "$D/quartering-metalslug2-drums.mp4"
-
-echo "MS2DRUMS_DONE -> $D/quartering-metalslug2-drums.mp4"
diff --git a/umtool/song/ms2-full.sh b/umtool/song/ms2-full.sh
@@ -1,18 +0,0 @@
-#!/bin/sh
-# Full-length Metal Slug 2, in two flavours. Run once the mine has grown the palette.
-# ./ms2-full.sh 2 -> lead + bass (730 notes)
-# ./ms2-full.sh 3 -> lead + bass + rhythm (1287 notes)
-cd /home/user/.claude/jobs/efbe67a7/tmp/song || exit 1
-D=/home/user/reports/quartering-uh-song
-N="${1:-2}"
-export MAX_IOI=0.70 WINDOW=700 LOCAL=0 TEMPO_SCALE=1.05 WOBBLE_W=0.08 TIME_LIMIT=999
-LEADV='{"name":"melody","lead":"ms2-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true}'
-BASSV='{"name":"bass","lead":"ms2-bass.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":1.05,"fill":false}'
-GTRV='{"name":"rhythm","lead":"ms2-gtr.json","gain":0.55,"role":"pip2","unique":true,"reuseGap":10,"contourMax":6,"poolFactor":1.0,"fill":false}'
-if [ "$N" = "3" ]; then export VOICES="[$LEADV,$BASSV,$GTRV]"; OUT="$D/quartering-metalslug2-full3.mp4"
-else export VOICES="[$LEADV,$BASSV]"; OUT="$D/quartering-metalslug2-full.mp4"; fi
-node arrange-poly.mjs | grep -E "melody|bass|rhythm|total"
-LAYOUT=pip node render-poly.mjs "$OUT" | tail -1
-ffmpeg -nostdin -v error -y -i "$OUT" -c:v libx264 -preset medium -crf 28 -pix_fmt yuv420p \
- -c:a aac -b:a 160k -movflags +faststart "${OUT%.mp4}-small.mp4"
-echo "MS2_FULL_${N}_DONE"
diff --git a/umtool/song/ms2-jerbg.sh b/umtool/song/ms2-jerbg.sh
@@ -1,110 +0,0 @@
-#!/usr/bin/env bash
-# Metal Slug 2 over the USER'S OWN EDIT — the background WITH music, handing over
-# on the game's own "MISSION 1 START".
-#
-# SUPERSEDES the no-music background. `ms2-play-rebuild.sh` is built around
-# TheSoldier's music-free gameplay capture, with the game's sounds blended under
-# the ums at quarter volume. That capture is RETIRED (user, 2026-08-17): every
-# Metal Slug cut now uses jer's edit, which has the game's music in it, and the
-# song takes over rather than sitting under it.
-#
-# The handover, recovered from the lost ms2-triangle.sh and re-verified here:
-# * "MISSION 1 START" is at 11.901s in that audio,
-# * the background's own sound is CUT DEAD there — not ducked, cut,
-# * the CLEAN announcer clip plays in its place, so it is not doubled with the
-# background's own copy of it or muddied by the music under it,
-# * and the ums fade in over 1.6s underneath, because in the game the music
-# starts alongside that voice.
-#
-# THE ARRANGEMENT IS SHIFTED, NOT RE-ARRANGED. Both shipped plans put their first
-# note at 11.232s; landing it on 11.901 is a flat +0.669 on every slot, which
-# keeps every clip the user has already judged. The KIT MUST MOVE WITH IT: the
-# drum map is songTime = (midiTime - 5.4497) x 1.05 + SHIFT, so SHIFT goes
-# 11.0 -> 11.669. Leaving it at 11.0 slides every drum hit 0.669s against the ums,
-# which is the same trap ms2-play-rebuild.sh documents at 0.232s.
-#
-# The background is 640x480, so BG_FIT=pad — cropping a 4:3 source to 16:9 cuts
-# the top and bottom of the gameplay. The pips then overhang the pillarbox bars,
-# which is what the triangle build looked like and is intended.
-set -uo pipefail
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song
-D=/home/user/reports/quartering-uh-song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-V=$D/videos/metal-slug
-
-BG=${BG:-$D/jer-metalslug-bg.mp4}
-ANNOUNCER=${ANNOUNCER:-$D/MS_Mission_1_start.ogg}
-START_AT=${START_AT:-11.901}
-UM_FADE=${UM_FADE:-1.6}
-ANN_GAIN=${ANN_GAIN:-1.0}
-DRUM_GAIN=${DRUM_GAIN:-1.8}
-mkdir -p "$V/variants"
-
-# $1 name $2 source plan $3 body path
-cut () {
- local NAME=$1 SRCPLAN=$2 BODY=$3
- local PLANF=ms2-plan-jer-$NAME.json
- echo "######## $NAME"
-
- local SH END DRUMSHIFT
- SH=$(node -e "
-const {voices}=require('./$SRCPLAN');
-const f=Math.min(...voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart));
-console.log(String((${START_AT}-f).toFixed(4)));")
- node -e "
-const fs=require('fs');
-const p=JSON.parse(fs.readFileSync('$SRCPLAN','utf8'));
-for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+$SH).toFixed(4);
-fs.writeFileSync('$PLANF',JSON.stringify(p,null,1));
-console.log(' shifted +$SH s -> first note '+
- Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)).toFixed(3)+'s');"
- DRUMSHIFT=$(node -e "console.log(String((11.0+$SH).toFixed(4)))")
-
- if [ "${REUSE_BODY:-1}" = "1" ] && [ -f "$BODY" ]; then
- echo " reusing $BODY"
- else
- PLAN=$PLANF TAIL_FADE=6.2 BG_VIDEO=$BG BG_FIT=pad LAYOUT=bg BG_DIM=0 \
- node "$S/render-poly.mjs" "$BODY" 2>&1 | tail -2
- fi
-
- END=$(node -e "
-const {voices}=require('./$PLANF');
-console.log((Math.max(...voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)))+0.6).toFixed(2));")
- echo " game sound full to ${START_AT}s then CUT; announcer + ums from there; ends ${END}s"
-
- # ---- the handover, onto the drum-less deliverable -------------------------
- ffmpeg -nostdin -v error -y -i "$BODY" -i "$BG" -i "$ANNOUNCER" \
- -filter_complex "
-[1:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='if(lt(t,${START_AT}),1.0,0.0)':eval=frame[game];
-[2:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${ANN_GAIN},adelay=delays=$(node -e "console.log(String(Math.round(${START_AT}*1000)))"):all=1,apad=whole_dur=${END}[ann];
-[0:a]atrim=0:${END},asetpts=PTS-STARTPTS,afade=t=in:st=${START_AT}:d=${UM_FADE}[song];
-[song][game][ann]amix=inputs=3:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \
- -map 0:v -map "[a]" -t "$END" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 \
- -movflags +faststart "$V/variants/$NAME-nokit.mp4"
-
- # ---- the kits ------------------------------------------------------------
- TEMPO_SCALE=1.05 TRIM=5.4497 SHIFT="$DRUMSHIFT" START="$DRUMSHIFT" END="$END" \
- node "$S/midi-drums.mjs" ~/Downloads/ms2_stage1.mid "$T/jer-$NAME.mid" 2>&1 | tail -1
- fluidsynth -ni -F "$T/jer-$NAME-kit.wav" -r 48000 -g 0.8 \
- /usr/share/soundfonts/FatBoy.sf2 "$T/jer-$NAME.mid" >/dev/null 2>&1
-
- TEMPO_SCALE=1.05 TRIM=5.4497 SHIFT="$DRUMSHIFT" START="$DRUMSHIFT" END="$END" \
- node "$S/midi-drums.mjs" ~/Downloads/ms2_stage1.mid "$T/jer-$NAME-hits.json" 2>&1 | tail -1
- MATCH="$T/jer-$NAME-kit.wav" PLAN_OUT="$T/jer-$NAME-drumplan.json" \
- node "$S/um-drums.mjs" "$T/jer-$NAME-hits.json" "$T/jer-$NAME-umkit.wav" 2>&1 | tail -4
-
- mux () {
- ffmpeg -nostdin -v error -y -i "$V/variants/$NAME-nokit.mp4" -i "$1" \
- -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 "$2"
- echo " -> $2 $(ffprobe -v error -show_entries format=duration -of csv=p=0 "$2")s"
- }
- mux "$T/jer-$NAME-kit.wav" "$V/$NAME.mp4"
- mux "$T/jer-$NAME-umkit.wav" "$V/variants/$NAME-umdrums.mp4"
-}
-
-cut wide ms2-plan-11-v3.json "$T/ms2jer-wide.mp4"
-cut wide-short ms2-plan-short2.json "$T/ms2jer-wide-short.mp4"
-
-echo MS2_JERBG_DONE
diff --git a/umtool/song/ms2-play-rebuild.sh b/umtool/song/ms2-play-rebuild.sh
@@ -1,113 +0,0 @@
-#!/bin/sh
-# Metal Slug 2 -- THE SHIPPED BUILD, rebuilt on the 3,959-clip palette:
-# the short cut over TheSoldier's NO-MUSIC gameplay, with the game's own sounds
-# blended in, and then the game's own drum kit on top.
-#
-# The no-music capture is the whole point of this build: it is what lets the
-# game's sounds sit under the ums without the game's MUSIC fighting them. Full
-# volume until the song starts at 11s, quarter volume under the song.
-#
-# Three things this rebuild must not lose, all of which are the build:
-# * background intro/ms2-play-bg.mp4 (the no-music capture), NOT ms2-bg.mp4
-# * the game audio bed, muxed under the song
-# * the drum kit from the MIDI, mixed on at 1.8 by ms2-drums.sh
-#
-# 11s of run-up is deliberate: the game's own "mission 1 start" voice sounds at
-# 19s in the SOURCE and the capture is cut from 8.753s, so 11s of output = 19.99s
-# of source -- a second clear of that voice. 10s was tried and is too close.
-#
-# Note the SLOT TIMES do not depend on the palette at all: they come from the
-# MIDI, TEMPO_SCALE and TIME_LIMIT. So the 11s shift and the drum map (verified
-# at both ends against the old plan) carry over exactly.
-set -e
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-D=/home/user/reports/quartering-uh-song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-
-PLANF=${PLANF:-ms2-plan-11-v3.json}
-# The UNSHIFTED arrangement, kept beside the shifted one. It is named after the
-# build rather than fixed, so a short cut cannot overwrite the record of the long.
-RAWF=${RAWF:-ms2-short-plan-v3.json}
-# The body render and the kit, likewise named after the build.
-BODY=${BODY:-$T/ms2play-v3.mp4}
-KIT=${KIT:-$T/ms2-drums-v3}
-PLAY=${PLAY:-$D/quartering-metalslug2-play-v3.mp4}
-DRUMS=${DRUMS:-$D/quartering-metalslug2-drums-v3.mp4}
-
-# At TIME_LIMIT 87.8 the melody is only ~198 notes and is in SURPLUS, so the bass
-# pool is free and poolFactor 5.0 costs nothing. The 1.3 cliff that constrains
-# the FULL-LENGTH cut does not exist at this length.
-export MAX_IOI=0.70 WINDOW=700 LOCAL=0 TEMPO_SCALE=1.05 WOBBLE_W=0.08 TIME_LIMIT=${TL:-87.8}
-export VOICES='[{"name":"melody","lead":"ms2-lead-t.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"ms2-bass-t.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":5.0,"fill":false}]'
-
-if [ "${REUSE_PLAN:-0}" != "1" ]; then
- node arrange-poly.mjs
- cp poly-plan.json "$RAWF"
- # Shift the whole arrangement by a FLAT +11.0s.
- #
- # NOT "move the first note to 11.0". The drum map is
- # songTime = (midiTime - 5.4497) x 1.05 + 11.0
- # and the -t voice files already carry that 5.4497 trim, so the shift term IS
- # 11.0 and the first melody slot lands at 11.232 (its trimmed time 0.221 x
- # 1.05). Normalising the first note to 11.000 instead would slide every drum
- # hit 0.232s against the ums -- and the kit is the one thing in the build that
- # is locked to the beat by arithmetic rather than by choice.
- node -e "
- const fs=require('fs');
- const p=JSON.parse(fs.readFileSync('$RAWF','utf8'));
- const SH=11.0;
- for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+SH).toFixed(4);
- fs.writeFileSync('$PLANF',JSON.stringify(p,null,1));
- for(const v of p.voices) console.log(' '+v.name+' first slot '+v.plan[0].slotStart.toFixed(3)+
- 's, last '+(v.plan.at(-1).slotStart+v.plan.at(-1).slotDur).toFixed(3)+'s');"
-fi
-[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0
-
-# TAIL_FADE 6.2 lets the tune come round again and dissolve rather than stopping
-# dead on the loop point. VIDEO_TAIL_FADE now follows it, so the picture goes
-# with the sound.
-if [ "${REUSE_BODY:-0}" = "1" ] && [ -f "$BODY" ]; then
- echo "reusing $BODY"
-else
- PLAN=$PLANF TAIL_FADE=6.2 BG_VIDEO=$PWD/intro/ms2-play-bg.mp4 BG_FIT=pad LAYOUT=bg \
- node render-poly.mjs "$BODY"
-fi
-
-# End of the song, measured -- not the container duration.
-END=$(node -e "
-const {voices}=require('$PWD/$PLANF');
-console.log((Math.max(...voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)))+0.6).toFixed(2));")
-# The game bed fades with the song and the picture. The song's own tail fade is
-# inside the render, and the picture now follows it -- so without this the last
-# 6.2s would be a black screen with the game still chattering under it.
-GFADE_ST=$(node -e "console.log(($END - 6.2).toFixed(2))")
-echo "muxing the game audio under the song, to ${END}s (game bed fades from ${GFADE_ST}s)"
-
-# The game's own sounds: FULL until the song starts at 11s, then a QUARTER under
-# it. normalize=0 so amix does not halve both inputs.
-ffmpeg -nostdin -v error -y -i "$BODY" -i intro/ms2-play-bg.mp4 \
- -filter_complex "
-[1:a]volume='if(lt(t,11),1.0,0.25)':eval=frame,atrim=0:${END},asetpts=PTS-STARTPTS,afade=t=out:st=${GFADE_ST}:d=6.2[game];
-[0:a]atrim=0:${END},asetpts=PTS-STARTPTS[song];
-[song][game]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \
- -map 0:v -map "[a]" -t "$END" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 \
- -movflags +faststart "$PLAY"
-echo "MS2PLAY_DONE -> $PLAY"
-
-# ---- drums ------------------------------------------------------------------
-# songTime = (midiTime - 5.4497) x 1.05 + 11.0, verified at BOTH ends against the
-# plan. The three drum tracks already use GM percussion numbers (36 kick,
-# 38 snare, 42 closed hat, 49 crash), so they play as a kit on channel 10.
-TEMPO_SCALE=1.05 TRIM=5.4497 SHIFT=11.0 START=11.0 END="$END" \
- node midi-drums.mjs ~/Downloads/ms2_stage1.mid "$KIT.mid"
-fluidsynth -ni -F "$KIT.wav" -r 48000 -g 0.8 \
- /usr/share/soundfonts/FatBoy.sf2 "$KIT.mid" >/dev/null 2>&1
-
-# 1.8 was chosen by ear over 1.1 (too subtle) and 2.6 (too prominent). Mixed onto
-# the finished video with -c:v copy, so re-auditioning a level costs seconds.
-ffmpeg -nostdin -v error -y -i "$PLAY" -i "$KIT.wav" \
- -filter_complex "[1:a]volume=1.8,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 "$DRUMS"
-
-echo "MS2DRUMS_DONE -> $DRUMS"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$DRUMS"
diff --git a/umtool/song/ms2-triangle.sh b/umtool/song/ms2-triangle.sh
@@ -1,94 +0,0 @@
-#!/bin/sh
-# Metal Slug 2, 3 voices, over the USER'S OWN EDIT as one continuous background,
-# handing over on the game's own "MISSION 1 START".
-#
-# RECOVERED 2026-08-17 from a session transcript: this file had been lost from the
-# repo, and it is the only place the announcer handover was worked out. Everything
-# that now uses that handover (ms2-jerbg.sh, which is the builder for the shipped
-# cuts) descends from it, so it is restored rather than left as a dead reference
-# in metal-slug.md.
-#
-# Two changes from the earlier bg builds.
-#
-# 1. ONE background, not intro-plus-body. `intro/ms2-intro.mp4` is jer's edit
-# 0->14.47s and `intro/ms2-bg.mp4` is the same edit from 14.4706s on, and
-# ms2-bg.mp4 carries NO AUDIO STREAM AT ALL -- which is why that build had no
-# game sound under it. Taking jer-metalslug-bg.mp4 whole keeps the footage
-# continuous AND keeps its audio, and lets the song start anywhere.
-#
-# 2. The handover is the announcer. "MISSION 1 START" sits at 11.901s in that
-# audio -- located by cross-correlating the clean announcer clip against it,
-# and corroborated by the energy dropping to rms 1115 at 11.5s before jumping
-# to 5433 at 12.0s. The background's own sound is CUT there and the CLEAN clip
-# plays instead, with the ums fading in underneath it, because in the game the
-# music starts alongside that voice clip.
-#
-# The picture is a TRIANGLE: the melody panel is only a little larger than the
-# two accompaniment panels and sits top-centre, with those two in the bottom
-# corners, so the character at the bottom centre is framed rather than covered.
-set -e
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-D=/home/user/reports/quartering-uh-song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-
-BG=${BG:-$D/jer-metalslug-bg.mp4}
-ANNOUNCER=${ANNOUNCER:-$D/MS_Mission_1_start.ogg}
-START_AT=${START_AT:-11.901} # "MISSION 1 START" in the background's audio
-UM_FADE=${UM_FADE:-1.6} # the ums swell in under the announcer
-ANN_GAIN=${ANN_GAIN:-1.0}
-PLANF=${PLANF:-ms2-plan-tri.json}
-OUT=${OUT:-$D/quartering-metalslug2-triangle.mp4}
-# PAD, not crop. The transcript's copy of this script said crop; the build that
-# actually SHIPPED is pillarboxed, and metal-slug.md gives the reason -- the
-# source is 640x480 and cropping 4:3 to 16:9 cuts the top and bottom off the
-# gameplay (the mistake documented on Mario RPG). The pips then overhang the
-# pillarbox bars, which is intended.
-BG_FIT=${BG_FIT:-pad}
-mkdir -p "$(dirname "$OUT")"
-
-export MAX_IOI=0.70 WINDOW=700 LOCAL=0 TEMPO_SCALE=1.05 WOBBLE_W=0.08 TIME_LIMIT=999
-export VOICES='[{"name":"melody","lead":"ms2-lead-t.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"ms2-bass-t.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":1.3,"fill":false},{"name":"rhythm","lead":"ms2-gtr-t.json","gain":0.55,"role":"pip2","unique":true,"reuseGap":10,"contourMax":6,"poolFactor":1.0,"fill":false}]'
-
-if [ "${REUSE_PLAN:-0}" != "1" ]; then
- node arrange-poly.mjs
- node -e "
- const fs=require('fs');
- const p=JSON.parse(fs.readFileSync('poly-plan.json','utf8'));
- const first=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart));
- const SH=$START_AT-first;
- for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+SH).toFixed(4);
- fs.writeFileSync('$PLANF',JSON.stringify(p,null,1));
- console.log('shifted '+SH.toFixed(3)+'s -> first note '+
- Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)).toFixed(3)+'s, ends '+
- Math.max(...p.voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur))).toFixed(3)+'s');"
-fi
-[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0
-
-# BG_MAIN_SCALE 0.36 -> a 460x260 melody panel, 1.20x the 384x216 accompaniment
-# panels, and BG_MAIN_TOP 108 keeps the top edge where it already was.
-# TAIL_FADE 4.5 covers the loop-back restatement; the picture now fades with it.
-if [ "${REUSE_BODY:-0}" = "1" ] && [ -f "$T/ms2tri-body.mp4" ]; then
- echo "reusing $T/ms2tri-body.mp4"
-else
- PLAN=$PLANF TAIL_FADE=4.5 BG_VIDEO=$BG BG_FIT=$BG_FIT LAYOUT=bg \
- BG_MAIN_SCALE=0.36 BG_MAIN_TOP=108 \
- node render-poly.mjs "$T/ms2tri-body.mp4"
-fi
-
-END=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$T/ms2tri-body.mp4")
-echo "background sound full to ${START_AT}s then CUT; announcer + ums from there; ends ${END}s"
-
-# [game] the edit's own sound, cut dead at the announcer
-# [ann] the CLEAN announcer clip, in its place
-# [song] the ums, swelling in underneath it
-ffmpeg -nostdin -v error -y -i "$T/ms2tri-body.mp4" -i "$BG" -i "$ANNOUNCER" \
- -filter_complex "
-[1:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='if(lt(t,${START_AT}),1.0,0.0)':eval=frame[game];
-[2:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${ANN_GAIN},adelay=delays=$(node -e "console.log(String(Math.round($START_AT*1000)))"):all=1,apad=whole_dur=${END}[ann];
-[0:a]afade=t=in:st=${START_AT}:d=${UM_FADE}[song];
-[song][game][ann]amix=inputs=3: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 -ar 48000 -ac 2 \
- -movflags +faststart "$OUT"
-
-echo "MS2TRI_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/pk-v12.sh b/umtool/song/pk-v12.sh
@@ -1,69 +0,0 @@
-#!/usr/bin/env bash
-# Pokemon v12 -- everything decided after v11, which needs a different build shape.
-#
-# v11 mixed all three voices inside one body render. Two decisions since make that
-# impossible: the run voice's audio gets its own effects (climb-fx), and its picture is
-# the X strokes rather than a fixed panel. Splitting the run out solves both at once --
-# a body rendered from melody+bass has no run panel to suppress.
-#
-# 1 run rendered alone -> its audio only, picture discarded
-# 2 climb-fx on that audio -> loudness/brightness/pan track the pitch
-# 3 body from melody+bass, and pk3.sh's spare CHOP input carries the run audio back in
-# 4 climb-panel over the result -> the X strokes
-#
-# Steps 1 and 3 are the expensive ones; everything else is a single encode.
-set -euo pipefail
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-D=/home/user/reports/quartering-uh-song
-S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song
-
-RUN_GAIN=${RUN_GAIN:-0.55}
-AMP=${AMP:-0.5} BRI=${BRI:-0.8} PAN=${PAN:-0.7}
-TILE_W=${TILE_W:-420} MIN_RUN=${MIN_RUN:-4} TRAIL=${TRAIL:-0.30}
-EXTENT=${EXTENT:-0.82} MARGIN=${MARGIN:-8}
-OUT=${OUT:-$D/quartering-pokemon-v12.mp4}
-
-# ---- 1. the run voice, alone ------------------------------------------------
-if [ ! -f "$T/v12-run.mp4" ]; then
- echo "=== run voice (516 notes) ==="
- PLAN=pkmn-v12-run.json BG_VIDEO=$PWD/intro/pkmn-late.mp4 BG_FIT=pad LAYOUT=bg OUT_END=0 \
- node render-poly.mjs "$T/v12-run.mp4"
-fi
-
-# ---- 2. its effects ---------------------------------------------------------
-# Applied to the run ALONE, which is the whole reason it is rendered separately: the
-# cues are meant to carry this line's contour, not to tilt the melody and bass with it.
-if [ ! -f "$T/v12-run-fx.wav" ]; then
- echo "=== climb-fx on the run ==="
- AMP=$AMP BRI=$BRI PAN=$PAN node "$S/climb-fx.mjs" pkmn-v12-run.json run "$T/v12-run.mp4" "$T/v12-run-fx.mp4"
- ffmpeg -nostdin -v error -y -i "$T/v12-run-fx.mp4" -vn -ar 48000 -ac 2 "$T/v12-run-fx.wav"
-fi
-
-# ---- 3. body + the whole finale ---------------------------------------------
-# CHOP is pk3.sh's spare audio input; the run rides back in through it at RUN_GAIN.
-echo "=== body (melody + bass) and final assembly ==="
-REUSE_BG=1 PLANF=pkmn-v12-body.json \
- JINGLE_VIDEO=1 JINGLE=$T/pk9/jingle-finale.mp4 \
- CHOP="$T/v12-run-fx.wav" CHOP_GAIN=$RUN_GAIN \
- OUT="$T/v12-base.mp4" \
- FILLER='1745-1782:3.0:17.5 2189-2223:3.0:25.0 3710-3740:1.0:24.0' \
- bash "$S/pk3.sh"
-
-# ---- 4. the run's picture, in two halves ------------------------------------
-# The run is on screen for the whole song, but as two different things, and the split
-# is the melody: while the melody sings, the run is an inner voice and sits in the
-# ordinary bottom-right pip; while the melody rests, it takes the frame as the X
-# strokes. One gate drives both, from opposite sides, so the line is drawn exactly
-# once at every instant -- never doubled, never absent.
-echo "=== run pip (while the melody sounds) ==="
-GATE_PLAN=pkmn-v12-body.json GATE_VOICE=melody \
- node "$S/run-pip.mjs" pkmn-v12-run.json run "$T/v12-run.mp4" "$T/v12-base.mp4" "$T/v12-pip.mp4"
-
-echo "=== climb-panel X strokes (while the melody rests) ==="
-TILE_W=$TILE_W MIN_RUN=$MIN_RUN TRAIL=$TRAIL EXTENT=$EXTENT MARGIN=$MARGIN \
- GATE_PLAN=pkmn-v12-body.json GATE_VOICE=melody \
- node "$S/climb-panel.mjs" pkmn-v12-run.json run "$T/v12-pip.mp4" "$OUT"
-
-echo "PK12_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/pk2.sh b/umtool/song/pk2.sh
@@ -1,166 +0,0 @@
-#!/bin/sh
-# Pokemon FR/LG wild battle, rebuilt on the 3,959-clip palette over real
-# gameplay -- the most out-of-date deliverable in the set (its only build was
-# made at ~1,050 clips, before the window and renderer fixes).
-#
-# The background is FCPlaythroughs' Pokemon Red longplay. Timings were derived,
-# not guessed, and all three sources were tied to ONE clock first:
-#
-# * pkmn-ref.mp4 (a combined A+V section) starts at source 620.000s exactly --
-# established by cross-correlating its audio against the complete audio
-# track, which matched to the sample.
-# * pkmn-long-v.webm (the high-quality video) starts at source 644.134s --
-# established by matching the 1100ms black run, which sits at 36.267s in the
-# reference and 12.133s here.
-# * the audio is cut from the complete track at 644.134s to match.
-#
-# That mattered: a first attempt cut the audio at the REQUESTED start rather
-# than the actual one, and the two were ~14s apart, so no audio event lined up
-# with any picture event.
-#
-# The splice is on the AUDIO, not the black frames. The encounter's black run
-# ends at 13.233s, but the game's own rapid-notes alarm keeps playing to ~13.2s
-# and the battle theme's downbeat lands at 13.50s -- which is where the ums come
-# in, so the game plays its own intro and hands over on the beat.
-#
-# FLOURISH=drop the MIDI's own rapid intro is removed; the background's
-# alarm is the intro (default)
-# FLOURISH=keep the ums play the rapid intro themselves, and the game audio
-# ducks BEFORE the alarm so it is not doubled
-set -e
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-D=/home/user/reports/quartering-uh-song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-
-BG_START=${BG_START:-5.866} # 10:50 in the source, in pkmn-long-v time
-SPLICE=${SPLICE:-13.500} # the battle theme's downbeat, same clock
-# Where the game's own ALARM starts, same clock. Read off the pitch contour: the
-# route music before it is B4/C#5/D5, and the alarm is the G5-F#5-F5-G6 figure --
-# audibly higher and quite different, which is how it is picked out. It runs
-# 2.6s, and the MIDI's own alarm is 33 notes over 2.5s, so the two agree.
-#
-# 10.966 was the ORIGINAL reading and it is 1.14s LATE -- the cut landed after the
-# game's alarm instead of on it, so v3 played the game's alarm and then the ums'.
-# The corrected onset is 9.826, measured by the high/low band-energy ratio rather
-# than eyeballed off a pitch contour. Every shipped build passes 9.826 by env; this
-# default now matches them instead of contradicting them.
-ALARM_AT=${ALARM_AT:-9.826}
-FLOURISH=${FLOURISH:-keep}
-# 0 = CUT the game's own sound where the ums take over, rather than leaving a
-# bed under them. The battle music would otherwise play its version of the same
-# tune underneath ours.
-DUCK=${DUCK:-0.0}
-PLANF=${PLANF:-pkmn-plan-v2.json}
-OUT=${OUT:-$D/quartering-pokemon-v2.mp4}
-mkdir -p "$(dirname "$OUT")"
-
-# The MIDI's own alarm is notes 0..32 of the lead (t=0 -> 2.500s, 78ms apart);
-# the tune proper starts at 2.969s. Those are the notes FLOURISH=drop removes.
-CUTAT=${CUTAT:-2.95}
-SCALE=${SCALE:-1.15}
-if [ "$FLOURISH" = "keep" ]; then CUTAT=-1; HANDOVER=$ALARM_AT; else HANDOVER=$SPLICE; fi
-
-# ---- arrange ----------------------------------------------------------------
-# poolFactor 6.0 on the accompaniment, IN LINE WITH THE OTHER TUNES. The old build ran the
-# accompaniment at ~1.0, the starved setting every other tune has since moved off
-# -- Metal Slug went 1.05 -> 1.3, Mario RPG to 6.0, Mortal Kombat to 2.6.
-if [ "${REUSE_PLAN:-0}" != "1" ]; then
- # Voices from PkmRB-Battle1.mid -- Pokemon RED/BLUE, which is what the
- # background actually is. Its track names spell the credit (sequenced by
- # Joao Buaes), like the Mortal Kombat file, so tracks are picked by INDEX:
- # 2 -> melody 144 notes, IOI p50 0.469s, and it carries the alarm
- # 3 -> bass 471 notes, IOI p50 0.156s
- # 1 -> harm 516 notes, IOI p50 0.078s -- LEFT OUT. At 78ms every note is
- # shorter than the palette's 0.14s minimum clip, so it can only
- # come out as fragments; Metal Slug's rhythm voice at 0.183s
- # already reads as percussion rather than melody.
- # CMAX is how far a melody note may be STRETCHED before the arranger folds it
- # an octave instead, and it is the only thing measured that moves the
- # over-pitching at all.
- #
- # At 14 a note may be dragged more than an octave rather than folded, and the
- # notes that need it are exactly the ones at the edges of the corpus's pitch
- # range (the corpus is median 103Hz / p95 127Hz; this melody asks for 73Hz and
- # 196Hz). Measured on the Pokemon melody, 14 -> 9:
- #
- # under 0.5 semitones 59 -> 122 2-3 semitones 51 -> 2
- # 3+ semitones 10 -> 0 down by 2+ 53 -> 2
- #
- # 9, 7 and 5 are identical, so it is a stable setting rather than a tuned one.
- # It is not free: 25 notes fold an octave instead of being dragged, so the
- # melody's contour changes at those notes. That is the trade -- an octave
- # displacement in place of an audibly dragged note.
- #
- # Raising SHIFT_W (what a semitone costs in the score) does NOT work: 12 -> 80
- # leaves the 2-3 cluster at 51/49/50/52. Nor does disabling the 115Hz
- # other-speaker gate, which moves exactly one note.
- # 9, not the 14 this defaulted to originally: 14 is the setting the measurements
- # below argue AGAINST, and every shipped build passes 9 by env.
- CMAX=${CMAX:-9}
- export TEMPO_SCALE=$SCALE MAX_IOI=0.70 WINDOW=700 LOCAL=0 WOBBLE_W=0.08
- export VOICES='[{"name":"melody","lead":"pkrb-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":'"$CMAX"',"fill":true},{"name":"bass","lead":"pkrb-bass.json","gain":0.62,"role":"pip1","unique":true,"reuseGap":12,"contourMax":5,"poolFactor":4.0,"fill":false}]'
- node arrange-poly.mjs
- cp poly-plan.json "$PLANF.raw"
-fi
-
-# Drop the flourish if asked, then shift so the first surviving note lands on
-# the handover.
-#
-# SKIP_SHIFT=1 leaves $PLANF exactly as it is.
-#
-# This block REGENERATES $PLANF from $PLANF.raw, and it runs on every pass --
-# including a REUSE_PLAN=1 render. So anything edited into the plan between the
-# arrange and the render is silently thrown away: a run that arranged, applied
-# nine swap-clip fixes, verified them with a scan, and then rendered produced a
-# video with none of the fixes in it, and the scan output looked perfectly
-# correct because it ran before the clobber. Swap first, then render with
-# SKIP_SHIFT=1.
-if [ "${SKIP_SHIFT:-0}" = "1" ]; then
- echo "SKIP_SHIFT=1: using $PLANF as it stands (already shifted)"
-else
-node -e "
-const fs=require('fs');
-const p=JSON.parse(fs.readFileSync('$PLANF.raw','utf8'));
-const CUT=$CUTAT;
-let dropped=0;
-if(CUT>0) for(const v of p.voices){const n0=v.plan.length; v.plan=v.plan.filter(n=>n.slotStart>=CUT*$SCALE-1e-6); dropped+=n0-v.plan.length;}
-const first=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart));
-const SH=$HANDOVER-$BG_START-first;
-for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+SH).toFixed(4);
-fs.writeFileSync('$PLANF',JSON.stringify(p,null,1));
-const f2=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart));
-const l2=Math.max(...p.voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)));
-console.log('flourish '+('$FLOURISH')+': dropped '+dropped+' notes; shifted '+SH.toFixed(3)+
- 's -> first note '+f2.toFixed(3)+'s, ends '+l2.toFixed(3)+'s');"
-fi
-[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0
-
-# ---- background -------------------------------------------------------------
-# 10:50 onward, as one file, so the renderer's clock and this script's agree.
-if [ ! -f "$T/pkmn-bg.mp4" ]; then
- ffmpeg -nostdin -v error -y -ss "$BG_START" -i intro/pkmn-bg-full.mkv \
- -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p -video_track_timescale 30000 \
- -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/pkmn-bg.mp4"
-fi
-
-PLAN=$PLANF BG_VIDEO=$T/pkmn-bg.mp4 BG_FIT=pad LAYOUT=bg \
- node render-poly.mjs "$T/pkmn-body.mp4"
-
-# ---- the game's own sound under it ------------------------------------------
-# Full until the handover -- the transition and the alarm ARE the intro -- then
-# down under the ums.
-END=$(node -e "
-const {voices}=require('$PWD/$PLANF');
-console.log((Math.max(...voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)))+0.7).toFixed(2));")
-H=$(node -e "console.log(($HANDOVER-$BG_START).toFixed(3))")
-echo "game audio full to ${H}s, then ${DUCK}; output ends ${END}s"
-ffmpeg -nostdin -v error -y -i "$T/pkmn-body.mp4" -i "$T/pkmn-bg.mp4" \
- -filter_complex "
-[1:a]volume='if(lt(t,${H}),1.0,${DUCK})':eval=frame,atrim=0:${END},asetpts=PTS-STARTPTS[game];
-[0:a]atrim=0:${END},asetpts=PTS-STARTPTS[song];
-[song][game]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \
- -map 0:v -map "[a]" -t "$END" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 \
- -movflags +faststart "$OUT"
-
-echo "PKMN_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/pk3.sh b/umtool/song/pk3.sh
@@ -1,375 +0,0 @@
-#!/usr/bin/env bash
-# Pokemon v9 -- the 35:10 opening and the caught-Pikachu finale.
-#
-# v8 opened at source 10:50 and simply stopped when the notes ran out. This one
-# opens in a GAP between notes of the route music at ~35:10, lets the encounter
-# alarm fire into the ums as before, and ends MK-style: right after the song's
-# last note the player throws a Poke Ball, catches a Pikachu, and an um-ified
-# "caught" jingle plays once the frame stills on "PIKACHU was caught!".
-#
-# READ specs/pokemon.md FIRST. In particular:
-# * every timing here was MEASURED, and the measurements are recorded there;
-# * `pk2.sh` clobbers its plan on every pass -- this script never regenerates
-# one, it REUSES v8's, which already lands its first note on the handover.
-#
-# ---------------------------------------------------------------------------
-# The one clock
-#
-# intro/pkmn-late.mp4 is a 302+251 MERGED fetch, and t=0 in it is source
-# 2090.036s -- established by cross-correlating its audio against the complete
-# local track at two independent windows (t=10 and t=45), which agreed to 0.000s.
-# Fetching 302 alone gives no audio to pin with, and the same requested range came
-# back a different length (83.415s vs 80.014s), so both streams must come from the
-# one file. Every SECTION time below is in that file's clock.
-#
-# ---------------------------------------------------------------------------
-# The shape
-#
-# [A] section BG_START -> SEG_A_END the entry gap, the alarm, the battle
-# [F] filler more battle footage, see FILLER
-# [C] section SEG_C_START -> SEG_C_END the ball, the catch, the jingle
-#
-# A is only ~20.7s and the song is 90.2s, so F exists purely to carry the song
-# to its last note with the throw waiting on the other side. F is TRIMMED to
-# whatever length makes the throw land GAP seconds after the final note, so the
-# ranges given in FILLER only have to be long ENOUGH, not exact.
-set -euo pipefail
-# The bulk data -- intro/, the plan files, cand2/, the rendered scratch -- lives in
-# the job temp dir, not beside this script. Same as pk2.sh.
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-
-T=/home/user/.claude/jobs/efbe67a7/tmp
-D=/home/user/reports/quartering-uh-song
-SRC=intro/pkmn-late.mp4
-URL='https://www.youtube.com/watch?v=D1SrSFZrV7A'
-SFX=$D/sfx/pkmn-catch.wav
-
-# ---- the measured section times ---------------------------------------------
-# Inside the 20.188->20.468 inter-note trough (source 2110.224->2110.504), not on
-# its leading onset, so the entry does not clip a note attack. It also puts the
-# alarm at bg 3.960 -- the SAME handover v8 shipped, which is why v8's plan can be
-# reused without reshifting a single note.
-BG_START=${BG_START:-20.324}
-ALARM_AT=${ALARM_AT:-24.284} # source 2114.320, found by pulse REGULARITY (82.4ms)
-# 41.000 is where the FIGHT menu returns after the two moves. The user's
-# constraint: the loop/filler must not contain any of the catching animation, and
-# the catch begins at 42.800 when the ITEM menu opens. Cutting at 41.000 keeps
-# every frame of the ball out of the body.
-SEG_A_END=${SEG_A_END:-41.000}
-SEG_C_START=${SEG_C_START:-41.000}
-SEG_C_END=${SEG_C_END:-54.350}
-GAP=${GAP:-0.80} # dead air between the last note and the throw
-
-# Filler: space-separated SOURCE ranges "start-end" in seconds. These are only
-# ever seen, never heard -- the game's own audio is cut from the handover on --
-# so they need no pinning, unlike the section above.
-FILLER=${FILLER:-}
-
-PLANF=${PLANF:-pkmn-plan-v8.json}
-OUT=${OUT:-$D/quartering-pokemon-v9.mp4}
-
-# ---- catch SFX event times, from the sidecar --------------------------------
-# The donor recording is a DIFFERENT catch attempt: its wiggle SPACING matches the
-# footage exactly (0.933s) but the group sits ~0.27s off and it supplies one wiggle
-# fewer than the picture shows. So the asset is NOT slid as a block -- each event is
-# cut out and delayed to its own measured picture time, and a wiggle tick is reused
-# for the third. That is what pkmn-catch.json's event offsets exist for.
-#
-# asset event picture (section) what it lands on
-A_THROW=0.036; P_THROW=44.860 # the ball leaves the trainer's hand
-A_LAND=1.0405; P_LAND=45.683 # Pikachu is absorbed
-A_SETTLE=1.6632; P_SETTLE=46.417 # the ball drops and settles
-A_W2=2.5937; P_W1=47.150 # wiggle 1
-A_W3=3.5287; P_W2=48.083 # wiggle 2
- P_W3=49.017 # wiggle 3 -- reuses the A_W3 tick
-P_JINGLE=50.344 # "PIKACHU was caught!" finishes; frame stills
-
-JINGLE=${JINGLE:-$T/pkc-jingle.mp4}
-SFX_GAIN=${SFX_GAIN:-0.85}
-JINGLE_GAIN=${JINGLE_GAIN:-1.0}
-
-n() { node -e "console.log(String((($1)).toFixed(4)))"; } # String(): node ANSI-colourises bare numbers
-ms() { node -e "console.log(String(Math.round(($1)*1000)))"; }
-
-LEN_A=$(n "$SEG_A_END - $BG_START")
-HANDOVER=$(n "$ALARM_AT - $BG_START")
-
-# The song, straight off v8's plan -- not rearranged, not reshifted.
-LAST=$(node -e "
-const {voices}=require('$PWD/$PLANF');
-console.log(String(Math.max(...voices.flatMap(v=>v.plan.map(x=>x.slotStart+x.slotDur))).toFixed(4)));")
-
-# Where segment C must begin, in bg time, for the throw to land GAP after the last note
-OFF_C=$(n "$LAST + $GAP - $P_THROW + $SEG_C_START")
-NEED=$(n "$OFF_C - $LEN_A")
-END=$(n "$SEG_C_END - $SEG_C_START + $OFF_C")
-
-echo "song: first note 3.960s, last note ends ${LAST}s"
-echo "segment A ${BG_START}->${SEG_A_END} (${LEN_A}s), handover at bg ${HANDOVER}s"
-echo "filler needed: ${NEED}s segment C starts at bg ${OFF_C}s output ends ${END}s"
-
-if [ -z "$FILLER" ]; then
- echo
- echo "FILLER is empty and ${NEED}s of it is needed."
- echo "Pass source ranges, e.g. FILLER='3600-3640 7200-7235' (seconds, start-end)."
- exit 2
-fi
-
-# WORK is the scratch directory for the spliced background and its pieces. It is
-# configurable so a cut of a different LENGTH -- which needs a different background --
-# does not overwrite the one an existing build was made from.
-WORK=${WORK:-$T/pk9}
-mkdir -p "$WORK" "$(dirname "$OUT")"
-
-# ---- normalise every background piece to ONE format --------------------------
-# concat demuxer needs identical streams. 800x720@60 is the source's own geometry,
-# so segment A and C are never resampled; only fetched filler is.
-norm() { # <in> <ss> <t> <out>
- ffmpeg -nostdin -v error -y -ss "$2" -t "$3" -i "$1" \
- -vf "scale=800:720:force_original_aspect_ratio=decrease,pad=800:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=60,format=yuv420p" \
- -c:v libx264 -preset medium -crf 18 -video_track_timescale 60000 \
- -c:a aac -b:a 192k -ar 48000 -ac 2 "$4"
-}
-
-# REUSE_BG=1 keeps an already-spliced background, REUSE_BODY=1 an already-rendered
-# body. The body is ~15 minutes of clip decoding, so re-running it to fix something
-# downstream of it is pure waste.
-if [ "${REUSE_BG:-0}" = "1" ] && [ -f "$WORK/bg.mp4" ]; then
- echo "REUSE_BG=1: keeping $WORK/bg.mp4 ($(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/bg.mp4")s)"
-else
-
-echo "--- segment A ---"
-norm "$SRC" "$BG_START" "$LEN_A" "$WORK/a.mp4"
-
-echo "--- filler ---"
-i=0; ACC=0; LIST="$WORK/concat.txt"
-: > "$LIST"
-echo "file '$WORK/a.mp4'" >> "$LIST"
-#
-# Each entry is FS-FE[:OFF[:LEN]] in SOURCE seconds.
-#
-# OFF skips into the FETCHED file and is not optional bookkeeping:
-# --download-sections does not start where it is asked (630->620, 645->644.134 are
-# the recorded cases), so the first seconds of a window are routinely the walk INTO
-# the battle rather than the battle. OFF is read off a frame strip of the fetch.
-#
-# LEN caps how much of the window is used, and it MATTERS here: most wild
-# encounters in this longplay are Pokedex-filling CATCHES, so a window run to its
-# end shows another Pokemon being caught -- which would undercut the one the whole
-# finale is built around. LEN stops each battle before the ball.
-for R in $FILLER; do
- OIFS=$IFS; IFS=:; set -- $R; IFS=$OIFS
- SPEC=$1; OFF=${2:-0}; LEN=${3:-}
- FS=${SPEC%%-*}; FE=${SPEC##*-}
- i=$((i+1))
- # A window fetched earlier by hand is reused rather than pulled again.
- RAW="$WORK/fill-${FS}.mp4"
- [ -f "$RAW" ] || RAW="$WORK/f${i}-raw.mp4"
- if [ ! -f "$RAW" ]; then
- # Fetch merged, as ever: a 302-only fetch has no audio and cuts differently.
- yt-dlp -f 302+251 --download-sections "*${FS}-${FE}" -P "$WORK" -o "f${i}-raw.%(ext)s" "$URL" >/dev/null 2>&1
- for E in webm mkv mp4; do
- [ -f "$WORK/f${i}-raw.$E" ] && [ "$WORK/f${i}-raw.$E" != "$RAW" ] && mv "$WORK/f${i}-raw.$E" "$RAW"
- done
- fi
- DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$RAW")
- HAVE=$(n "$DUR - $OFF")
- [ -n "$LEN" ] && HAVE=$(node -e "console.log(String(Math.min($HAVE,$LEN).toFixed(4)))")
- WANT=$(n "$NEED - $ACC")
- USE=$(node -e "console.log(String(Math.min($HAVE,$WANT).toFixed(4)))")
- if node -e "process.exit(($USE)>0.04?0:1)"; then
- norm "$RAW" "$OFF" "$USE" "$WORK/f${i}.mp4"
- echo "file '$WORK/f${i}.mp4'" >> "$LIST"
- ACC=$(n "$ACC + $USE")
- echo " filler $i: source ${FS}-${FE} +${OFF}s in, have ${HAVE}s, used ${USE}s (total ${ACC}/${NEED})"
- fi
-done
-if node -e "process.exit(($NEED-$ACC)>0.05?0:1)"; then
- echo "SHORT by $(n "$NEED - $ACC")s of filler -- add another range to FILLER."
- exit 3
-fi
-
-echo "--- segment C ---"
-norm "$SRC" "$SEG_C_START" "$(n "$SEG_C_END - $SEG_C_START")" "$WORK/c.mp4"
-echo "file '$WORK/c.mp4'" >> "$LIST"
-
-echo "--- splice ---"
-ffmpeg -nostdin -v error -y -f concat -safe 0 -i "$LIST" -c copy "$WORK/bg.mp4"
-ffprobe -v error -show_entries format=duration -of default=nw=1 "$WORK/bg.mp4"
-
-fi
-
-# ---- re-derive the segment C offset from what was ACTUALLY built -------------
-# OFF_C above is where segment C was PLANNED to start, and it sizes the filler.
-# It is not where segment C actually starts: every piece gets quantised to a frame
-# when it is encoded, so a.mp4 comes out 20.699s against a requested 20.676s and
-# the pieces together run ~70ms long. Placing the catch SFX on the planned figure
-# put every tick 67ms AHEAD of the wiggle it belongs to -- measured in the finished
-# file, picture wiggles at 93.367/94.300/95.233 against ticks at 93.300/94.233/95.167.
-#
-# So the events are placed on the REAL offset, taken as (whole background - segment
-# C), which needs no bookkeeping of the individual pieces. The song does not move;
-# the gap after its last note just absorbs the difference.
-OFF_C=$(node -e "
-const {execFileSync}=require('child_process');
-const d=f=>Number(execFileSync('ffprobe',['-v','error','-show_entries','format=duration','-of','csv=p=0',f]).toString().trim());
-console.log(String((d('$WORK/bg.mp4')-d('$WORK/c.mp4')).toFixed(4)));")
-END=$(n "$SEG_C_END - $SEG_C_START + $OFF_C")
-echo "segment C really starts at bg ${OFF_C}s; throw lands $(n "$P_THROW - $SEG_C_START + $OFF_C - $LAST")s after the last note; output ends ${END}s"
-
-# ---- body: the ums over the spliced background ------------------------------
-# OUT_END=0 stops the renderer TRIMMING at the last note, but it does not make the
-# picture run any longer than the song -- see the video assembly below.
-if [ "${REUSE_BODY:-0}" = "1" ] && [ -f "$WORK/body.mp4" ]; then
- echo "REUSE_BODY=1: keeping $WORK/body.mp4 ($(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/body.mp4")s)"
-else
-PLAN=$PLANF BG_VIDEO=$WORK/bg.mp4 BG_FIT=pad LAYOUT=bg OUT_END=0 \
- node render-poly.mjs "$WORK/body.mp4"
-fi
-
-# ---- the mix ----------------------------------------------------------------
-# Game audio full until the handover (the route gap and the alarm ARE the intro),
-# then CUT -- not ducked: the battle music would otherwise play its own version of
-# this very tune underneath ours, and the catch would fight our jingle.
-J_MS=$(ms "$P_JINGLE - $SEG_C_START + $OFF_C")
-echo "jingle at bg $(n "$P_JINGLE - $SEG_C_START + $OFF_C")s"
-
-# Each SFX event is a SEPARATE -ss/-t input off the same wav, rather than one input
-# split six ways: the six taps need six different source ranges AND six different
-# delays, so demuxer-level seeking is both simpler and cheaper than splitting and
-# atrimming inside the graph.
-# The handover, and why a HARD CUT is what makes the intro and the song feel like two
-# separate pieces.
-#
-# The game runs at full until HANDOVER and is then CUT to zero, because from the
-# downbeat onwards it plays its own version of our tune and would double it. But
-# between the handover and the downbeat the game is not playing the theme -- it is
-# playing the ENCOUNTER ALARM, the same figure the ums take over. Cutting it there
-# throws away the one passage where the two could overlap, and the join lands as a
-# step: the game stops dead and the ums start alone.
-#
-# GAME_BED holds the game at a low level across that window and fades it to nothing by
-# the downbeat, so the ums' alarm rises out of the game's own rather than replacing it
-# between one sample and the next. It is 0 by default -- the previous hard cut -- and
-# the fade ends AT the downbeat, so nothing of the game's battle theme is ever heard
-# under ours, which is the reason the cut existed in the first place.
-GAME_BED=${GAME_BED:-0}
-DOWNBEAT=${DOWNBEAT:-7.374}
-if [ "$(node -e "console.log(String($GAME_BED>0?1:0))")" = "1" ]; then
- GVOL="if(lt(t,${HANDOVER}),1.0, if(lt(t,${DOWNBEAT}), ${GAME_BED}*(1-(t-${HANDOVER})/(${DOWNBEAT}-${HANDOVER})), 0.0))"
- echo "game audio: full to ${HANDOVER}s, then a ${GAME_BED} bed fading to 0 by the downbeat at ${DOWNBEAT}s"
-else
- GVOL="if(lt(t,${HANDOVER}),1.0,0.0)"
-fi
-FC="[1:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume='${GVOL}':eval=frame,atrim=0:${END},asetpts=PTS-STARTPTS,apad=whole_dur=${END}[game];"
-FC="$FC[0:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad=whole_dur=${END}[song];"
-FC="$FC[2:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${JINGLE_GAIN},adelay=delays=${J_MS}:all=1,apad=whole_dur=${END}[jing];"
-
-IN_SFX=""
-k=0
-MIXIN="[song][game][jing]"
-for EV in "$A_THROW 0.492 $P_THROW" "$A_LAND 0.500 $P_LAND" "$A_SETTLE 0.238 $P_SETTLE" \
- "$A_W2 0.243 $P_W1" "$A_W3 0.244 $P_W2" "$A_W3 0.244 $P_W3"; do
- set -- $EV
- PAD=0.030
- SS=$(n "$1 - $PAD"); DU=$(n "$2 + 2*$PAD"); DLY=$(ms "$3 - $SEG_C_START + $OFF_C - $PAD")
- IDX=$((3+k))
- IN_SFX="$IN_SFX -ss $SS -t $DU -i $SFX"
- FC="$FC[${IDX}:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${SFX_GAIN},adelay=delays=${DLY}:all=1,apad=whole_dur=${END}[sfx${k}];"
- MIXIN="$MIXIN[sfx${k}]"
- echo " sfx $k: asset ${SS}+${DU}s -> bg $(n "$3 - $SEG_C_START + $OFF_C")s"
- k=$((k+1))
-done
-
-# ---- the chopped third voice -------------------------------------------------
-# PkmRB-Battle1's track 1 runs at 78ms a note, under the palette's 0.14s clip floor,
-# so every build has left it out -- and it is exactly what covers the melody's rests
-# in the game. Those rests are 20.3% of the song: six ~2.1s stretches where only the
-# quiet bass sounds, which is what "some stretches are kind of empty" meant.
-#
-# chop-voice.mjs makes it from slices of a handful of sustained ums instead of a
-# clip per note. It is AUDIO ONLY and deliberately has no video panel: there is no
-# single clip behind it to show, and it is a texture under the tune rather than a
-# voice with a face. That also means it mixes in HERE, costing one encode instead of
-# a 15-minute body re-render.
-CHOP_IN=""
-if [ -n "${CHOP:-}" ] && [ -f "$CHOP" ]; then
- CHOP_IN="-i $CHOP"
- FC="$FC[$((3+k)):a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${CHOP_GAIN:-1.0},apad=whole_dur=${END}[chop];"
- MIXIN="$MIXIN[chop]"
- k=$((k+1))
- echo " chopped third voice: $CHOP at gain ${CHOP_GAIN:-1.0}"
-fi
-
-FC="$FC${MIXIN}amix=inputs=$((3+k)):duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]"
-
-# shellcheck disable=SC2086
-# ---- video: the body, then the background carrying on past it -----------------
-# OUT_END=0 is NOT enough. render-poly stops its VIDEO where the song's audio
-# stops, whatever OUT_END says, so the body came out 93.066s against a 100.587s
-# background -- and the whole finale (the ball landing, the wiggles, the caught
-# screen) fell off the end. The container still measured 100.5s because the AUDIO
-# ran that long, which is exactly the trap mk-fatal-finish3.sh warns about: never
-# trust a container duration for where the picture actually ends.
-#
-# So the picture is assembled the way MK's is: the body up to where it really ends,
-# then the background itself from that point on. The tail is padded to 1280x720 the
-# same way BG_FIT=pad does inside the renderer, or it would jump at the join.
-TBODY=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/body.mp4")
-TBODY=$(node -e "console.log(String((Math.floor($TBODY*30)/30).toFixed(4)))") # to a 30fps frame
-echo "body ends ${TBODY}s; background carries the last $(n "$END - $TBODY")s"
-
-PADV="scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p"
-# render-poly DIMS the background (BG_DIM, default -0.10) so the um panels read
-# against it. Anything taken straight from bg.mp4 has not been through that, so the
-# picture visibly BRIGHTENS the moment the body ends -- reported as "you're dimming
-# the game during ums and it lightens during the finale". render-poly's own header
-# warns about exactly this join.
-#
-# The fix is to keep it dim the whole way rather than to ramp back: a constant grade
-# reads as deliberate, a switch reads as a mistake. The jingle segment needs nothing,
-# because render-poly composited it and so it is already dimmed.
-# 0, matching render-poly's new default: the background is not dimmed anywhere, so the
-# bg-derived tails need no dim to match it. This was -0.10 to hide a brightness step
-# that no longer exists.
-DIM=${DIM:-0}
-PADV_DIM="$PADV,eq=brightness=${DIM}"
-FC="$FC;[0:v]trim=0:${TBODY},setpts=PTS-STARTPTS[bodyv];"
-
-# The finale's um panels. Without this the jingle plays over bare game footage while
-# every other note in the video has a face on it, which reads as the ums stopping.
-#
-# $JINGLE supplies BOTH streams here -- it is rendered by render-poly over the
-# finale's own frames (cut straight out of the spliced bg.mp4, so the frames are
-# identical rather than merely similar) and its audio is the ums. One input, so the
-# SFX input indices below do not have to shift.
-JAT=$(n "$P_JINGLE - $SEG_C_START + $OFF_C")
-JDUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$JINGLE")
-JEND=$(n "$JAT + $JDUR")
-if [ "${JINGLE_VIDEO:-0}" = "1" ]; then
- echo "jingle panels on screen ${JAT}s -> ${JEND}s"
- # split first: a filter graph may consume each input pad only once, and the
- # background is needed both before the jingle and after it.
- FC="$FC[1:v]split=2[bgA][bgB];"
- FC="$FC[bgA]trim=${TBODY}:${JAT},setpts=PTS-STARTPTS,${PADV_DIM}[tail1];"
- FC="$FC[2:v]${PADV},setpts=PTS-STARTPTS[jv];"
- FC="$FC[bgB]trim=${JEND}:${END},setpts=PTS-STARTPTS,${PADV_DIM}[tail2];"
- FC="$FC[bodyv][tail1][jv][tail2]concat=n=4:v=1[v]"
-else
- FC="$FC[1:v]trim=${TBODY}:${END},setpts=PTS-STARTPTS,${PADV_DIM}[tailv];"
- FC="$FC[bodyv][tailv]concat=n=2:v=1[v]"
-fi
-
-ffmpeg -nostdin -v warning -y \
- -i "$WORK/body.mp4" \
- -i "$WORK/bg.mp4" \
- -i "$JINGLE" \
- $IN_SFX \
- $CHOP_IN \
- -filter_complex "$FC" \
- -map "[v]" -map "[a]" -t "$END" \
- -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p -video_track_timescale 30000 \
- -c:a aac -b:a 192k -ar 48000 -ac 2 -movflags +faststart "$OUT"
-
-echo "PK9_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/pkmn-rebuild.sh b/umtool/song/pkmn-rebuild.sh
@@ -1,123 +0,0 @@
-#!/bin/sh
-# Pokemon FR/LG wild battle, rebuilt on the 3,959-clip palette over real
-# gameplay -- the most out-of-date deliverable in the set (its only build was
-# made at ~1,050 clips, before the window and renderer fixes).
-#
-# The background is FCPlaythroughs' Pokemon Red longplay. Timings were derived,
-# not guessed, and all three sources were tied to ONE clock first:
-#
-# * pkmn-ref.mp4 (a combined A+V section) starts at source 620.000s exactly --
-# established by cross-correlating its audio against the complete audio
-# track, which matched to the sample.
-# * pkmn-long-v.webm (the high-quality video) starts at source 644.134s --
-# established by matching the 1100ms black run, which sits at 36.267s in the
-# reference and 12.133s here.
-# * the audio is cut from the complete track at 644.134s to match.
-#
-# That mattered: a first attempt cut the audio at the REQUESTED start rather
-# than the actual one, and the two were ~14s apart, so no audio event lined up
-# with any picture event.
-#
-# The splice is on the AUDIO, not the black frames. The encounter's black run
-# ends at 13.233s, but the game's own rapid-notes alarm keeps playing to ~13.2s
-# and the battle theme's downbeat lands at 13.50s -- which is where the ums come
-# in, so the game plays its own intro and hands over on the beat.
-#
-# FLOURISH=drop the MIDI's own rapid intro is removed; the background's
-# alarm is the intro (default)
-# FLOURISH=keep the ums play the rapid intro themselves, and the game audio
-# ducks BEFORE the alarm so it is not doubled
-set -e
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-D=/home/user/reports/quartering-uh-song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-
-BG_START=${BG_START:-5.866} # 10:50 in the source, in pkmn-long-v time
-SPLICE=${SPLICE:-13.500} # the battle theme's downbeat, same clock
-# Where the game's own ALARM starts, same clock. Read off the pitch contour: the
-# route music before it is B4/C#5/D5, and the alarm is the G5-F#5-F5-G6 figure --
-# audibly higher and quite different, which is how it is picked out. It runs
-# 2.6s, and the MIDI's own alarm is 33 notes over 2.5s, so the two agree.
-ALARM_AT=${ALARM_AT:-10.966}
-FLOURISH=${FLOURISH:-keep}
-# 0 = CUT the game's own sound where the ums take over, rather than leaving a
-# bed under them. The battle music would otherwise play its version of the same
-# tune underneath ours.
-DUCK=${DUCK:-0.0}
-PLANF=${PLANF:-pkmn-plan-v2.json}
-OUT=${OUT:-$D/quartering-pokemon-v2.mp4}
-mkdir -p "$(dirname "$OUT")"
-
-# The MIDI's own alarm is notes 0..32 of the lead (t=0 -> 2.500s, 78ms apart);
-# the tune proper starts at 2.969s. Those are the notes FLOURISH=drop removes.
-CUTAT=${CUTAT:-2.95}
-SCALE=${SCALE:-1.15}
-if [ "$FLOURISH" = "keep" ]; then CUTAT=-1; HANDOVER=$ALARM_AT; else HANDOVER=$SPLICE; fi
-
-# ---- arrange ----------------------------------------------------------------
-# poolFactor 3.0 on the bass. NOT 6.0: that was calibrated when the bass had 143
-# notes, and this MIDI's bass has 471 -- at 6.0 it claims 2,840 clips and STARVES
-# the melody (413 clips, p90 4.64). Measured knee: 3.0 gives melody 2.39 / bass 2.87.
-if [ "${REUSE_PLAN:-0}" != "1" ]; then
- # Voices from PkmRB-Battle1.mid -- Pokemon RED/BLUE, which is what the
- # background actually is. Its track names spell the credit (sequenced by
- # Joao Buaes), like the Mortal Kombat file, so tracks are picked by INDEX:
- # 2 -> melody 144 notes, IOI p50 0.469s, and it carries the alarm
- # 3 -> bass 471 notes, IOI p50 0.156s
- # 1 -> harm 516 notes, IOI p50 0.078s -- LEFT OUT. At 78ms every note is
- # shorter than the palette's 0.14s minimum clip, so it can only
- # come out as fragments; Metal Slug's rhythm voice at 0.183s
- # already reads as percussion rather than melody.
- export TEMPO_SCALE=$SCALE MAX_IOI=0.70 WINDOW=700 LOCAL=0 WOBBLE_W=0.08
- export VOICES='[{"name":"melody","lead":"pkrb-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":14,"fill":true},{"name":"bass","lead":"pkrb-bass.json","gain":0.62,"role":"pip1","unique":true,"reuseGap":12,"contourMax":5,"poolFactor":3.0,"fill":false}]'
- node arrange-poly.mjs
- cp poly-plan.json "$PLANF.raw"
-fi
-
-# Drop the flourish if asked, then shift so the first surviving note lands on
-# the handover.
-node -e "
-const fs=require('fs');
-const p=JSON.parse(fs.readFileSync('$PLANF.raw','utf8'));
-const CUT=$CUTAT;
-let dropped=0;
-if(CUT>0) for(const v of p.voices){const n0=v.plan.length; v.plan=v.plan.filter(n=>n.slotStart>=CUT*$SCALE-1e-6); dropped+=n0-v.plan.length;}
-const first=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart));
-const SH=$HANDOVER-$BG_START-first;
-for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+SH).toFixed(4);
-fs.writeFileSync('$PLANF',JSON.stringify(p,null,1));
-const f2=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart));
-const l2=Math.max(...p.voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)));
-console.log('flourish '+('$FLOURISH')+': dropped '+dropped+' notes; shifted '+SH.toFixed(3)+
- 's -> first note '+f2.toFixed(3)+'s, ends '+l2.toFixed(3)+'s');"
-[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0
-
-# ---- background -------------------------------------------------------------
-# 10:50 onward, as one file, so the renderer's clock and this script's agree.
-if [ ! -f "$T/pkmn-bg.mp4" ]; then
- ffmpeg -nostdin -v error -y -ss "$BG_START" -i intro/pkmn-bg-full.mkv \
- -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p -video_track_timescale 30000 \
- -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/pkmn-bg.mp4"
-fi
-
-PLAN=$PLANF BG_VIDEO=$T/pkmn-bg.mp4 BG_FIT=pad LAYOUT=bg \
- node render-poly.mjs "$T/pkmn-body.mp4"
-
-# ---- the game's own sound under it ------------------------------------------
-# Full until the handover -- the transition and the alarm ARE the intro -- then
-# down under the ums.
-END=$(node -e "
-const {voices}=require('$PWD/$PLANF');
-console.log((Math.max(...voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)))+0.7).toFixed(2));")
-H=$(node -e "console.log(($HANDOVER-$BG_START).toFixed(3))")
-echo "game audio full to ${H}s, then ${DUCK}; output ends ${END}s"
-ffmpeg -nostdin -v error -y -i "$T/pkmn-body.mp4" -i "$T/pkmn-bg.mp4" \
- -filter_complex "
-[1:a]volume='if(lt(t,${H}),1.0,${DUCK})':eval=frame,atrim=0:${END},asetpts=PTS-STARTPTS[game];
-[0:a]atrim=0:${END},asetpts=PTS-STARTPTS[song];
-[song][game]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \
- -map 0:v -map "[a]" -t "$END" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 \
- -movflags +faststart "$OUT"
-
-echo "PKMN_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/pkmn-video.sh b/umtool/song/pkmn-video.sh
@@ -1,122 +0,0 @@
-#!/usr/bin/env bash
-# THE Pokemon video. One script, one shape, both aspect ratios, both lengths.
-#
-# This exists because the shape kept drifting: a build would get the X strokes but not
-# the 2x2, or the 2x2 but not the alarm, or the vertical would get none of them because
-# it was composed before the treatment existed. The shape is not a set of optional
-# effects -- it is the video -- so it lives in one place and every cut runs the same
-# steps in the same order.
-#
-# ---------------------------------------------------------------------------
-# THE SHAPE
-#
-# melody the main panel (wide) / the primary box (vertical)
-# bass the first pip (wide) / secondary box 1 (vertical)
-# run the rapid third voice, which is NEVER a static panel:
-# melody OUT -> X strokes sweeping the screen
-# (vertical: inside the gameplay screen, not the whole frame)
-# melody IN -> its own container, and inside that container
-# notes <0.15s apart -> a 2x2 grid, one corner per note
-# slower notes -> one clip filling the container
-#
-# the intro alarm (bg 3.960 -> 7.374, which the ums play themselves)
-# -> one um per note snaking through whatever space is free:
-# the whole screen in wide, the two bands in vertical,
-# on a grid sized to travel the path about twice
-#
-# the catch jingle
-# -> plays in the RUN'S container with the run's own visuals, so
-# "PIKACHU was caught!" stays readable underneath
-#
-# ---------------------------------------------------------------------------
-# FORMAT=wide|vertical CUT=long|short
-#
-# Everything else is derived. Set REUSE=1 to keep an existing base and only redo the
-# visual layers, which is the cheap loop when the shape is being judged.
-set -euo pipefail
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-V=/home/user/reports/quartering-uh-song/videos/pokemon
-
-FORMAT=${FORMAT:-wide}
-CUT=${CUT:-long}
-
-if [ "$CUT" = "short" ]; then
- BODY_PLAN=pkmn-short-body.json; RUN_PLAN=pkmn-short-run.json
- WORK=$T/pkshort; FILLER='1745-1782:3.0:17.5'
- RUNFX=$T/pkshort-run-fx.wav; TAG=short
- LAST=39.16; ENDS=49.53
- SOLO=pkshort
-else
- BODY_PLAN=pkmn-v12-body.json; RUN_PLAN=pkmn-v12-run.json
- WORK=$T/pk9; FILLER='1745-1782:3.0:17.5 2189-2223:3.0:25.0 3710-3740:1.0:24.0'
- RUNFX=$T/v12-run-fx.wav; TAG=long
- LAST=90.21; ENDS=100.60
- SOLO=pkmn
-fi
-ALARM_A=3.96; ALARM_B=7.374
-JINGLE_AT=$(node -e "console.log(String(($ENDS - 4.03).toFixed(4)))") # jingle onset in output time
-
-mkdir -p "$V/plan" "$V/variants"
-PFX=$T/pkv-$TAG-$FORMAT # working prefix (B is taken: bars-snake reads B as its window end)
-
-# ---- 1. the audio: lift the run where the melody rests ----------------------
-# The run exists to carry those rests, and it was mixed at one level throughout.
-LIFT=${LIFT:-5} RAMP=0.25 GATE_PLAN=$BODY_PLAN GATE_VOICE=melody \
- node "$S/rest-lift.mjs" "$RUNFX" "$PFX-runfx.wav" | tail -2
-
-# ---- 2. the base ------------------------------------------------------------
-# GAME_BED=0.30: the game holds a bed across the alarm and fades to nothing BY the
-# downbeat, so the ums' alarm rises out of the game's own instead of replacing it
-# between one sample and the next. JINGLE_VIDEO is off -- the jingle gets the run's
-# visuals in step 4 instead of panels over the payoff text.
-if [ "${REUSE:-0}" = "1" ] && [ -f "$PFX-base.mp4" ]; then
- echo "REUSE=1: keeping $PFX-base.mp4"
-elif [ "$FORMAT" = "wide" ]; then
- WORK=$WORK REUSE_BG=1 REUSE_BODY=1 GAME_BED=0.30 PLANF=$BODY_PLAN \
- JINGLE=$T/pk9/jingle-finale.mp4 CHOP="$PFX-runfx.wav" CHOP_GAIN=0.55 \
- OUT="$PFX-base.mp4" FILLER="$FILLER" bash "$S/pk3.sh" | tail -2
-else
- # the vertical composes from the WIDE cut: it supplies the audio and the gimmick
- # picture, and the voice boxes come from the solo renders.
- WIDE=$V/$( [ "$CUT" = short ] && echo wide-short || echo wide ).mp4
- [ -f "$WIDE" ] || { echo "need $WIDE first -- build FORMAT=wide CUT=$CUT"; exit 2; }
- COMPOSED=$WIDE GAMEPLAY=$WORK/bg.mp4 \
- PRIMARY=$T/solo-$SOLO-melody.mp4 SEC1=$T/solo-$SOLO-bass.mp4 SEC2= CENTRE_LONE=0 \
- OFFSET=0 GIMMICKS="0-$ALARM_B,$LAST-$ENDS" \
- node "$S/shorts-compose.mjs" "$PFX-base.mp4" | tail -1
-fi
-
-# ---- 3/4/5. the shape -------------------------------------------------------
-if [ "$FORMAT" = "wide" ]; then
- BOXARG=384:216:870:478 ; FW=1280 ; FH=720
- STROKE_BOX="" ; STROKE_TILE=420
- SNAKE_BANDS="0:720" ; SNAKE_W=1280
-else
- BOXARG=480:270:552:1537 ; FW=1080 ; FH=1920
- STROKE_BOX=1080:972:0:541 ; STROKE_TILE=300
- SNAKE_BANDS="0:541,1513:1920" ; SNAKE_W=1080
-fi
-
-echo "--- the run, in its container"
-MODE=auto BOX=$BOXARG W=$FW H=$FH GATE_PLAN=$BODY_PLAN GATE_VOICE=melody \
- node "$S/run-line.mjs" "$RUN_PLAN" run "$PFX-base.mp4" "$PFX-run.mp4" 2>&1 | grep -vE "deprecated|Last message" | tail -2
-
-echo "--- the jingle, same container, same visuals"
-MODE=auto BOX=$BOXARG W=$FW H=$FH OFFSET=-$JINGLE_AT \
- node "$S/run-line.mjs" pkc-jingle-merged.json jingle "$PFX-run.mp4" "$PFX-jing.mp4" 2>&1 | grep -vE "deprecated|Last message" | tail -2
-
-echo "--- the X strokes, where the melody is out"
-BOX=$STROKE_BOX W=$FW H=$FH TILE_W=$STROKE_TILE MIN_RUN=4 TRAIL=0.30 EXTENT=0.82 MARGIN=8 \
- GATE_PLAN=$BODY_PLAN GATE_VOICE=melody \
- node "$S/climb-panel.mjs" "$RUN_PLAN" run "$PFX-jing.mp4" "$PFX-str.mp4" 2>&1 | grep -vE "deprecated|Last message" | tail -2
-
-echo "--- the intro alarm, snaking, about two loops"
-OUT=$V/$( [ "$FORMAT" = wide ] && echo wide || echo vertical )$( [ "$CUT" = short ] && echo -short || echo "" ).mp4
-A=$ALARM_A B=$ALARM_B W=$SNAKE_W PASSES=2 BANDS="$SNAKE_BANDS" \
- node "$S/bars-snake.mjs" "$BODY_PLAN" melody "$PFX-str.mp4" "$OUT" 2>&1 | grep -vE "deprecated|Last message" | tail -3
-
-cp "$BODY_PLAN" "$RUN_PLAN" "$V/plan/" 2>/dev/null || true
-echo "PKMN_${FORMAT}_${CUT}_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/rpg-remake-v5.sh b/umtool/song/rpg-remake-v5.sh
@@ -1,277 +0,0 @@
-#!/usr/bin/env bash
-# Super Mario RPG, rebuilt on the 3,959-clip palette, with the two changes asked
-# for after v4:
-#
-# DRUM_GAIN the snare roll and the drum hit are lifted harder. They are very
-# quiet in the source (peaks ~500-1600 against the transition's
-# ~6000), and at 1.7 the hit still sits under the ums that follow.
-# A BUMP, not a doubling -- 2.1 and 2.4 are the values to compare.
-#
-# UM_DELAY hold the ums back by a fraction of a second. The body is cut at
-# -ss LEAD (1.9556s, the second slot) so the first um lands at
-# final t=0; cutting at LEAD-UM_DELAY instead gives UM_DELAY
-# seconds of background before the first um WITH THE TUNE ITSELF
-# UNTOUCHED. The ending is pinned by BG_END, which is not moved, so
-# the background's wipe to black still lands on the last note --
-# the video simply becomes UM_DELAY longer at the front.
-#
-# Everything else is v4's, including the two values that took several passes to
-# get right: INTRO_END 10.400 (keeps the drum hit AND its decay; 10.100 cut one
-# frame in front of it and kept the snare's tail instead) and BG_FIT=pad (the
-# background is 960x720, and cropping it to 16:9 reads as the picture ZOOMING IN
-# the moment the song starts).
-set -euo pipefail
-# The DATA dir, not the script's own -- arrange-poly writes poly-plan.json into
-# SONG_DATA, and this script copies it from the working directory. Running the repo
-# copy with `cd $(dirname $0)` landed in the repo, where that file does not exist, and
-# the rebuild died on `cp: cannot stat 'poly-plan.json'` AFTER arranging successfully.
-# yoshi-rebuild.sh and pk3.sh both use the absolute path; this one was the exception.
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-D=/home/user/reports/quartering-uh-song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-
-INTRO_END=${INTRO_END:-10.400}
-DRUM_FROM=${DRUM_FROM:-8.60}
-DRUM_GAIN=${DRUM_GAIN:-2.1}
-# UM_DELAY=auto DERIVES the spacing instead of guessing it.
-#
-# The arrangement's opening note is a lone bass B1 at 1.4666s standing in for the
-# drum hit the theme starts on, and the real downbeat is at 1.9556s -- so the
-# composer's own spacing between the kick and the first melody note is 0.4890s.
-# The intro carries the game's REAL drum hit, whose low-band onset is at 10.115s
-# (measured: the band is silent at 10.110 and rises 1214 -> 2183 -> 3599 over the
-# next three 5ms frames, peaking 4337 at 10.150). Lining the real hit up with the
-# note that stood in for it puts the first um at 10.115 + 0.489 = 10.604s, and
-# the body starts at INTRO_END, so the delay is 10.604 - INTRO_END = 0.204s.
-KICK_AT=${KICK_AT:-10.115}
-UM_DELAY=${UM_DELAY:-auto}
-# UM_FADE fades the first um IN, rather than giving it more room.
-#
-# The body is cut at EXACTLY the slot start with no padding, so the first um's
-# attack lands on the body's very first sample -- a hard splice out of the
-# intro's decay straight onto a note onset. That can read as jarring rather than
-# as early, which is a different complaint with a different fix, so it is a
-# separate knob: UM_DELAY buys space, UM_FADE softens the entry. The body's audio
-# is the ums ALONE (the background supplies picture only), so this touches
-# nothing else.
-UM_FADE=${UM_FADE:-0}
-XFADE=${XFADE:-0}
-# INTRO_GAIN lifts the WHOLE intro, in dB, to meet the body.
-#
-# The seam was described as "stark", and it is a LEVEL STEP rather than a
-# transition problem. Measured on rpg-A-cut with ebur128: the intro runs
-# -22.0 LUFS into the seam and the body arrives at -17.0 LUFS. Five LU is a
-# plainly audible jump, and no crossfade can fix it -- a fade only smears the
-# step over its duration. rpg-D-xfade030 is actually WORSE by this measure: the
-# fade pulls the intro's last two seconds to -30.2 LUFS, so the song arrives
-# against a 13 LU contrast instead of 5.
-#
-# Headroom for it: true peak into the seam is -8.1 dBFS, so +5 dB lands at
-# -3.1 dBFS with the lifted drum hit still intact.
-INTRO_GAIN=${INTRO_GAIN:-0}
-# Separate the um kick from the game's kick IN TIME.
-#
-# At offset 0 the two land on the same instant in the same low band, and the
-# louder simply masks the other -- which is why a variant built to have two
-# kicks was heard as having one. Around 0.1s reads as two distinct hits; around
-# 0.03s reads as a flam (one thickened hit). Note the intro is hard-cut at
-# INTRO_END, so a large offset leaves the um kick very little room.
-KICK_UM_OFFSET=${KICK_UM_OFFSET:-0}
-BG_END=${BG_END:-65.583}
-# The body render, named after the build so a short cannot clobber the long.
-BODY=${BODY:-$T/r5-body.mp4}
-PLANF=${PLANF:-rpg-plan-v4.json}
-OUT=${OUT:-$D/quartering-mario-rpg-v5.mp4}
-FADE_ST=$(node -e "console.log(($INTRO_END - 0.06).toFixed(3))")
-mkdir -p "$(dirname "$OUT")"
-
-# ---- arrange ----------------------------------------------------------------
-# poolFactor 6.0 is free here and nowhere else: the melody needs only 210 of
-# ~2,800 clips, so tripling and doubling the bass pool costs it nothing
-# (p90 0.39 -> 0.41) while the bass median more than halves (0.52 -> 0.21).
-if [ "${REUSE_PLAN:-0}" != "1" ]; then
- export TEMPO_SCALE=1.10 MAX_IOI=0.70 WINDOW=700 LOCAL=0 TIME_LIMIT=${TL:-999}
- # VOICES is overridable so a variant can add the third voice (Brass B) or point at
- # held-last-note lead files, without a second copy of this script.
- export VOICES=${VOICES:-'[{"name":"melody","lead":"rpg-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"rpg-bass.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":6.0,"fill":false}]'}
- node arrange-poly.mjs
- cp poly-plan.json "$PLANF"
-fi
-[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0
-
-# ---- drop the stand-in kick note --------------------------------------------
-# v4 got rid of it by CUTTING at the second slot, which worked only because the
-# cut was exactly there. UM_DELAY moves the cut EARLIER, straight back over that
-# note -- so it has to leave the plan, or a truncated low blip returns in front
-# of the tune and the intro's real drum hit is answered by a fake one.
-LEAD=$(node -e "
-const {voices}=require('$PWD/$PLANF');
-const t=[...new Set(voices.flatMap(v=>v.plan.map(n=>n.slotStart)))].sort((a,b)=>a-b);
-console.log((t[1] ?? t[0]).toFixed(4));")
-TRIMF="${PLANF%.json}-trim.json"
-node -e "
-const fs=require('fs');
-const p=JSON.parse(fs.readFileSync('$PLANF','utf8'));
-let dropped=0;
-for(const v of p.voices){const n0=v.plan.length; v.plan=v.plan.filter(n=>n.slotStart>=$LEAD-1e-6); dropped+=n0-v.plan.length;}
-fs.writeFileSync('$TRIMF',JSON.stringify(p,null,1));
-console.log('dropped '+dropped+' note(s) before the downbeat at ${LEAD}s -> $TRIMF');"
-
-# ---- how far to hold the ums back -------------------------------------------
-if [ "$UM_DELAY" = "auto" ]; then
- UM_DELAY=$(node -e "
- const {voices}=require('$PWD/$PLANF');
- const t=[...new Set(voices.flatMap(v=>v.plan.map(n=>n.slotStart)))].sort((a,b)=>a-b);
- const gap=(t[1]??t[0])-t[0]; // the MIDI's own kick -> downbeat spacing
- console.log(Math.max(0, $KICK_AT + gap - $INTRO_END).toFixed(4));")
- echo "UM_DELAY=auto -> ${UM_DELAY}s (real kick at ${KICK_AT}s + the MIDI's own kick-to-downbeat spacing)"
-fi
-
-# ---- body -------------------------------------------------------------------
-# REUSE_BODY=1 skips the ~20-minute render when only the trim or the intro has
-# changed. The body does not depend on either -- they are concatenated, not
-# mixed -- so every UM_DELAY past the first is nearly free.
-if [ "${REUSE_BODY:-0}" = "1" ] && [ -f "$BODY" ]; then
- echo "reusing $BODY (REUSE_BODY=1)"
-else
- # OUT_END=0: do NOT trim the render to the audio's end. The ending here is the
- # background's own wipe to black at 65.583s, and the last note is at 64.53s --
- # trimming to the audio throws the ending away (7 trailing black frames where
- # v4 had 32). BG_END does the cutting instead, below.
- OUT_END=0 PLAN=$TRIMF BG_VIDEO=$PWD/${BG:-intro/smrpg-bg.mp4} BG_FIT=pad LAYOUT=bg \
- node render-poly.mjs "$BODY" | tail -3
-fi
-
-CUT=$(node -e "console.log(Math.max(0, $LEAD - $UM_DELAY).toFixed(4))")
-echo "lead-in ${LEAD}s, holding the ums back ${UM_DELAY}s -> cutting the body at ${CUT}s, ending at ${BG_END}s"
-# String(), not a bare number -- node colourises numeric console.log on a TTY,
-# so this test would compare against an ANSI-wrapped "1" and never fire.
-if [ "$(node -e "console.log(String($UM_FADE>0?1:0))")" = "1" ]; then
- # Start the fade AT the um, not at the body's first sample. UM_DELAY puts
- # that many seconds of silence in front, so a fade shorter than the delay
- # fades nothing at all -- measured: at UM_DELAY 0.204 a 0.18s fade produced a
- # byte-identical file.
- AF="-af afade=t=in:st=${UM_DELAY}:d=${UM_FADE}"
- echo " fading the first um in over ${UM_FADE}s, from ${UM_DELAY}s"
-else
- AF=""
-fi
-# shellcheck disable=SC2086
-ffmpeg -nostdin -v error -y -ss "$CUT" -to "$BG_END" -i "$BODY" $AF \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \
- -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/r5-bd.mp4"
-
-# ---- the stand-in kick, as an um, ON the real one ---------------------------
-# KICK_UM=1 plays the dropped note after all -- but placed on the BACKGROUND's
-# own kick rather than after it, so the two land together.
-#
-# It cannot simply be left in the body: the game's kick is at 10.115s and the
-# body does not start until INTRO_END (10.400s), so a note carried in the body
-# can only ever arrive 0.285s LATE. Mixing it onto the intro at the kick's own
-# time is the only way to make them coincide. Its audio comes from a one-note
-# render of the notes the trim dropped, so it is the same clip the arranger
-# chose, at the same gain.
-KICK_MIX=""
-if [ "${KICK_UM:-0}" = "1" ]; then
- KICKF="${PLANF%.json}-kick.json"
- if [ ! -f "$T/r5-kick.wav" ]; then
- # Drop the EMPTY voices and make what is left the `full` one. The stand-in
- # kick is a BASS note, so a straight filter leaves the melody with no notes
- # at all -- and the renderer looks up role:"full" to build the main panel,
- # which then has nothing to build from.
- node -e "
- const fs=require('fs');
- const p=JSON.parse(fs.readFileSync('$PLANF','utf8'));
- for(const v of p.voices) v.plan=v.plan.filter(n=>n.slotStart<$LEAD-1e-6);
- p.voices=p.voices.filter(v=>v.plan.length);
- if(!p.voices.length) throw new Error('nothing before the downbeat to extract');
- p.voices[0].role='full';
- p.voices=[p.voices[0]];
- fs.writeFileSync('$KICKF',JSON.stringify(p,null,1));
- console.log('kick-only plan: '+p.voices.map(v=>v.name+' '+v.plan.length).join(', '));"
- PLAN=$KICKF BG_VIDEO=$PWD/intro/smrpg-bg.mp4 BG_FIT=pad LAYOUT=bg \
- node render-poly.mjs "$T/r5-kickonly.mp4" >/dev/null 2>&1
- KSTART=$(node -e "
- const {voices}=require('$PWD/$KICKF');
- console.log(Math.min(...voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)).toFixed(4));")
- ffmpeg -nostdin -v error -y -ss "$KSTART" -t 0.60 -i "$T/r5-kickonly.mp4" -ac 1 -ar 48000 "$T/r5-kick.wav"
- echo " extracted the stand-in kick um from ${KSTART}s"
- fi
- KDELAY=$(node -e "console.log(String(Math.round(($KICK_AT+$KICK_UM_OFFSET)*1000)))")
- # The um kick is a 0.60s clip laid at 10.115s, but the intro is cut at
- # INTRO_END (10.400s) -- so it was being CHOPPED 285ms in, with no fade, at
- # exactly the seam. That truncation is its own contribution to the stark
- # transition. Fade it out on the same schedule as the intro instead.
- KICK_MIX="[1:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${KICK_UM_GAIN:-1.0},adelay=delays=${KDELAY}:all=1,afade=t=out:st=${FADE_ST}:d=0.06[k];[main][k]amix=inputs=2:duration=first:normalize=0[aout]"
- echo " KICK_UM=1: the um kick is laid on the real kick at ${KICK_AT}s"
-fi
-
-# ---- intro: gameplay, transition, black, silence, SNARE ROLL, DRUM HIT -------
-AFILT="volume='if(lt(t,${DRUM_FROM}),1.0,${DRUM_GAIN})':eval=frame,volume=${INTRO_GAIN}dB,afade=t=out:st=${FADE_ST}:d=0.06"
-if [ -n "$KICK_MIX" ]; then
- ffmpeg -nostdin -v error -y -t "$INTRO_END" -i intro/smrpg-intro.mp4 -i "$T/r5-kick.wav" \
- -filter_complex "[0:v]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[vout];[0:a]${AFILT}[main];${KICK_MIX}" \
- -map "[vout]" -map "[aout]" -t "$INTRO_END" \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \
- -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/r5-intro.mp4"
-else
- ffmpeg -nostdin -v error -y -t "$INTRO_END" -i intro/smrpg-intro.mp4 \
- -vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30" \
- -af "$AFILT" \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \
- -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/r5-intro.mp4"
-fi
-
-# The peak of the lifted section, BEFORE the concat -- a gain that clips the
-# drum hit is worse than one that leaves it quiet.
-node -e "
-const {execFileSync}=require('child_process');
-const b=execFileSync('ffmpeg',['-v','error','-ss','${DRUM_FROM}','-i','$T/r5-intro.mp4','-ac','1','-ar','48000','-f','s16le','-'],{maxBuffer:1<<28});
-const a=new Int16Array(b.buffer,b.byteOffset,b.length/2);
-let pk=0,s=0; for(let i=0;i<a.length;i++){const v=Math.abs(a[i]); if(v>pk)pk=v; s+=a[i]*a[i];}
-console.log(' intro after ${DRUM_FROM}s at gain ${DRUM_GAIN}: peak '+pk+'/32768 ('+(pk/32768*100).toFixed(1)+'%), rms '+Math.sqrt(s/a.length).toFixed(0)+(pk>=32700?' ** CLIPPING **':''));"
-
-# ---- assemble ---------------------------------------------------------------
-# XFADE>0 dissolves the intro into the body instead of cutting, in picture AND
-# sound, so the change from the game's own gameplay+audio to the background+ums
-# is a transition rather than a splice. It costs XFADE seconds of overlap, so the
-# ums arrive that much sooner -- which is why it is a variant and not a default.
-# XFADE_AUDIO crossfades the SOUND only and leaves the picture a hard cut.
-#
-# Judged best: "xfade30 audio but with f-level distance from the kick and static
-# visuals". The picture dissolve was never what the seam needed -- the seam was a LEVEL
-# step, which INTRO_GAIN fixes -- but a short audio crossfade still softens the arrival
-# of the first um without smearing the join visually.
-#
-# An audio crossfade SHORTENS the result by XFADE while a video concat does not, so the
-# picture has to lose the same amount or everything after the join drifts. It comes off
-# the INTRO'S TAIL, which is the static battle scene after the drum hit's decay -- the
-# one stretch where 0.3s of picture is invisible -- and it puts the cut exactly where
-# the body's sound starts fading in.
-if [ "$(node -e "console.log(String(${XFADE_AUDIO:-0}>0?1:0))")" = "1" ]; then
- ITRIM=$(node -e "console.log(($INTRO_END - $XFADE_AUDIO).toFixed(3))")
- echo " audio-only crossfade ${XFADE_AUDIO}s; picture cuts hard at ${ITRIM}s (intro tail trimmed to match)"
- ffmpeg -nostdin -v error -y -i "$T/r5-intro.mp4" -i "$T/r5-bd.mp4" \
- -filter_complex "[0:v]trim=0:${ITRIM},setpts=PTS-STARTPTS[iv];[1:v]setpts=PTS-STARTPTS[bv];\
-[iv][bv]concat=n=2:v=1[v];[0:a][1:a]acrossfade=d=${XFADE_AUDIO}:c1=tri:c2=tri[a]" \
- -map "[v]" -map "[a]" \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \
- -movflags +faststart "$OUT"
-elif [ "$(node -e "console.log(String($XFADE>0?1:0))")" = "1" ]; then
- IDUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$T/r5-intro.mp4")
- OFF=$(node -e "console.log(Math.max(0,$IDUR-$XFADE).toFixed(3))")
- echo " crossfading intro -> body over ${XFADE}s (offset ${OFF}s)"
- ffmpeg -nostdin -v error -y -i "$T/r5-intro.mp4" -i "$T/r5-bd.mp4" \
- -filter_complex "[0:v][1:v]xfade=transition=fade:duration=${XFADE}:offset=${OFF}[v];[0:a][1:a]acrossfade=d=${XFADE}:c1=tri:c2=tri[a]" \
- -map "[v]" -map "[a]" \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \
- -movflags +faststart "$OUT"
-else
- ffmpeg -nostdin -v error -y -i "$T/r5-intro.mp4" -i "$T/r5-bd.mp4" \
- -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \
- -movflags +faststart "$OUT"
-fi
-
-echo "RPG5_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/rpg-short.sh b/umtool/song/rpg-short.sh
@@ -1,152 +0,0 @@
-#!/usr/bin/env bash
-# Super Mario RPG, cut at the first repeat, ending on the transition INTO the next
-# battle.
-#
-# The tune is a literal exact double -- find-repeats scores 100% at 28.48s -- so half
-# of the v6 build is the same 28.5 seconds played twice. TL=28.48 is therefore not a
-# trim but the whole tune, once.
-#
-# That leaves the ending to be rebuilt, because v6's ending is the background's own
-# wipe at bg 64.267 and the short song is over at 31.77. The background's black runs
-# are what make the splice free:
-#
-# 0.000 - 0.850 opening black
-# 0.850 - 27.617 THE BATTLE <- the whole short song plays over this
-# 27.617 - 29.000 black, the battle ending
-# ...
-# 57.033 - 58.367 black
-# 58.367 - 64.267 overworld: Mario walks into an enemy
-# 64.267 - 65.617 THE WIPE INTO THE NEXT BATTLE, and its sting
-#
-# Cut segment A inside the first black run and start segment B inside the second, and
-# the seam is black-to-black -- invisible, rather than a jump cut between two scenes.
-# What the viewer sees is one continuous piece: the battle ends, Mario walks, he hits
-# an enemy, the screen wipes.
-#
-# The sting is bg 63.90 -> 64.78 (measured: RMS 1153 -> 2095 -> 2673 -> 2961 across
-# 63.85-64.00), followed by TRUE DIGITAL SILENCE 64.80 - 65.45 over the black. So the
-# tail ends at 65.48 -- inside that silence, before the next battle's audio starts at
-# 65.50 -- and the video simply stops in the black.
-set -euo pipefail
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-D=/home/user/reports/quartering-uh-song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song
-
-A_END=${A_END:-27.90} # inside the black run 27.617-29.000
-B_START=${B_START:-58.10} # inside the black run 57.033-58.367
-B_END=${B_END:-65.48} # inside the digital silence after the sting
-SFX_AT=${SFX_AT:-63.88} # the sting's onset in the SOURCE
-SFX_GAIN=${SFX_GAIN:-0.9}
-OUT=${OUT:-$D/quartering-mario-rpg-short.mp4}
-PLANF=${PLANF:-rpg-plan-short.json}
-BODY=${BODY:-$T/rpg-short-body.mp4}
-BASE=${BASE:-$T/rpg-short-base.mp4}
-# BLEND lays the slightest bit of the GAME'S OWN next note under our first um.
-#
-# Measured on intro/smrpg-intro.mp4: the drum kick is at 10.115, the theme's next beat
-# at 10.570 (a low hit, energy 19 -> 32 dB) and its melodic content at 10.630 (the high
-# band takes 60-65%, peak ~1.2kHz). Our first um lands at 10.604 -- between the two --
-# because UM_DELAY is derived from the MIDI's own kick-to-downbeat spacing, so the two
-# were already in agreement and nothing of the game was audible only because the intro
-# is hard-cut at INTRO_END.
-#
-# So the blend is not a retime: it is letting a little of what was already there through.
-# BLEND_GAIN is deliberately low and BLEND_FADE short -- "mostly the um with just the
-# slightest bit of the beginning of the note".
-BLEND=${BLEND:-0}
-BLEND_FROM=${BLEND_FROM:-10.400}
-BLEND_LEN=${BLEND_LEN:-0.42}
-BLEND_GAIN=${BLEND_GAIN:-0.30}
-BLEND_FADE=${BLEND_FADE:-0.26}
-
-BG=intro/smrpg-bg-short.mp4
-SPLICED=$(node -e "console.log(($A_END + $B_END - $B_START).toFixed(3))")
-STING_IN_BG=$(node -e "console.log(($A_END + $SFX_AT - $B_START).toFixed(3))")
-SFX_LEN=$(node -e "console.log(($B_END - $SFX_AT).toFixed(3))")
-echo "spliced background ${SPLICED}s; the sting lands at ${STING_IN_BG}s, ${SFX_LEN}s long"
-
-# ---- 1. the spliced background ----------------------------------------------
-# Both halves re-encoded with identical parameters so the concat demuxer can join
-# them without a re-wrap surprise. 960x720 and 60fps are the source's own; the
-# renderer pads to 1280x720 itself (BG_FIT=pad -- cropping this 4:3 capture reads
-# as the picture zooming in, see mario-rpg.md).
-if [ ! -f "$BG" ]; then
- for seg in "a:0:$A_END" "b:$B_START:$(node -e "console.log(($B_END-$B_START).toFixed(3))")"; do
- IFS=: read -r n ss dur <<EOF
-$seg
-EOF
- ffmpeg -nostdin -v error -y -ss "$ss" -i intro/smrpg-bg.mp4 -t "$dur" -an \
- -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p -video_track_timescale 60000 \
- "$T/rpgshort-$n.mp4"
- done
- printf "file '%s'\nfile '%s'\n" "$T/rpgshort-a.mp4" "$T/rpgshort-b.mp4" > "$T/rpgshort-concat.txt"
- ffmpeg -nostdin -v error -y -f concat -safe 0 -i "$T/rpgshort-concat.txt" -c copy "$BG"
- echo " built $BG ($(ffprobe -v error -show_entries format=duration -of csv=p=0 "$BG")s)"
-fi
-
-# ---- 2. the sting, and a silent spacer --------------------------------------
-# The spacer is what makes the picture reach the end of the background. render-poly
-# sizes its master buffer from max(last slot, last overlay) + 1s, and the picture
-# stops where that buffer does -- which is how the Pokemon v9 body came out 7.4s
-# short with a container duration that still read 100.5s. A silent overlay ending at
-# the background's end is the smallest thing that holds the picture open.
-#
-# The sting itself is NOT an overlay: mixed into the master it could only be
-# re-levelled by re-rendering, and levels here are judged by ear. Muxed onto the
-# finished video with -c:v copy instead, a new balance costs seconds.
-mkdir -p sfx
-[ -f sfx/rpg-transition.wav ] || ffmpeg -nostdin -v error -y -ss "$SFX_AT" -i intro/smrpg-bg.mp4 \
- -t "$SFX_LEN" -vn -ar 48000 -ac 2 sfx/rpg-transition.wav
-[ -f sfx/rpg-spacer.wav ] || ffmpeg -nostdin -v error -y -f lavfi -i anullsrc=r=48000:cl=stereo \
- -t "$SFX_LEN" sfx/rpg-spacer.wav
-node -e "
-const fs=require('fs');
-fs.writeFileSync('rpg-short-overlays.json', JSON.stringify(
- [{file:'sfx/rpg-spacer.wav', at:$STING_IN_BG, gain:0.0, label:'spacer: holds the picture open to the wipe'}], null, 1));
-console.log(' overlay spacer at ${STING_IN_BG}s');"
-
-# ---- 3. the body, over the spliced background -------------------------------
-# INTRO_GAIN=5 is the MEASURED fix for the seam, not a taste call: the intro runs
-# -22.0 LUFS into it and the body arrives at -17.0, and no crossfade can close a
-# level step. v6 (the long) was built without it, so short and long differ here.
-REUSE_PLAN=1 TL=28.48 PLANF="$PLANF" \
- BG="$BG" BG_END="$SPLICED" BODY="$BODY" \
- OVERLAYS=rpg-short-overlays.json INTRO_GAIN=${INTRO_GAIN:-5} \
- OUT="$BASE" bash "$S/rpg-remake-v5.sh"
-
-# ---- 4. the sting, onto the finished video ----------------------------------
-# Its time in the OUTPUT: the intro is concatenated in front and the body was cut at
-# CUT, so bg time t becomes 10.400 + t - CUT.
-CUT=$(node -e "
-const {voices}=require('$PWD/$PLANF');
-const t=[...new Set(voices.flatMap(v=>v.plan.map(n=>n.slotStart)))].sort((a,b)=>a-b);
-console.log(Math.max(0,(t[1]??t[0])-0.2040).toFixed(4));")
-AT=$(node -e "console.log((10.400 + $STING_IN_BG - $CUT).toFixed(3))")
-MS=$(node -e "console.log(String(Math.round((10.400 + $STING_IN_BG - $CUT)*1000)))")
-echo "muxing the sting at ${AT}s of the output (body cut at ${CUT}s)"
-
-# The blend rides in on the same mux. The intro occupies 0 -> INTRO_END of the output,
-# so a snippet cut from the intro asset at time X sits at output time X -- no offset
-# arithmetic, which is the one thing that could quietly put it in the wrong place.
-BLEND_IN=""
-BLEND_FC=""
-BLEND_MIX="[0:a][s]"
-NMIX=2
-if [ "$BLEND" = "1" ]; then
- [ -f sfx/rpg-firstnote.wav ] || ffmpeg -nostdin -v error -y -ss "$BLEND_FROM" -i intro/smrpg-intro.mp4 \
- -t "$BLEND_LEN" -vn -ar 48000 -ac 2 sfx/rpg-firstnote.wav
- BMS=$(node -e "console.log(String(Math.round($BLEND_FROM*1000)))")
- BLEND_IN="-i sfx/rpg-firstnote.wav"
- BLEND_FC="[2:a]volume=${BLEND_GAIN},aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,afade=t=out:st=0:d=${BLEND_FADE},adelay=delays=${BMS}:all=1[b];"
- BLEND_MIX="[0:a][s][b]"
- NMIX=3
- echo " blending ${BLEND_LEN}s of the game's own next note at ${BLEND_FROM}s, gain ${BLEND_GAIN}, fading over ${BLEND_FADE}s"
-fi
-# shellcheck disable=SC2086
-ffmpeg -nostdin -v error -y -i "$BASE" -i sfx/rpg-transition.wav $BLEND_IN \
- -filter_complex "[1:a]volume=${SFX_GAIN},aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,adelay=delays=${MS}:all=1[s];${BLEND_FC}${BLEND_MIX}amix=inputs=${NMIX}: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"
-
-echo "RPG_SHORT_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"
diff --git a/umtool/song/vshort.sh b/umtool/song/vshort.sh
@@ -1,52 +0,0 @@
-#!/usr/bin/env bash
-# Build one tune's vertical (1080x1920) cut: render every voice alone over black, then
-# compose the four boxes.
-#
-# VOICES="melody bass arp" from PLAN
-# VOICES="melody bass run@other.json" a voice whose plan is elsewhere (Pokemon's run
-# is rendered separately, so it has its own)
-#
-# The first voice is the PRIMARY box, the next two the secondaries. A tune with two
-# voices simply leaves the second secondary box empty -- which is what an idle box
-# looks like anyway, so nothing special is needed for it.
-set -euo pipefail
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song
-
-TUNE=${TUNE:?need TUNE}
-PLAN=${PLAN:?need PLAN}
-VOICES=${VOICES:?need VOICES}
-COMPOSED=${COMPOSED:?need COMPOSED}
-GAMEPLAY=${GAMEPLAY:?need GAMEPLAY}
-OUT=${OUT:?need OUT}
-OFFSET=${OFFSET:-0}
-GIMMICKS=${GIMMICKS:-}
-
-SOLOS=()
-for spec in $VOICES; do
- v="${spec%%@*}"
- p="${spec#*@}"; [ "$p" = "$spec" ] && p="$PLAN"
- solo="solo-$TUNE-$v.json"
- vid="$T/solo-$TUNE-$v.mp4"
- node "$S/solo-voice.mjs" "$p" "$v" "$solo"
- if [ -f "$vid" ]; then
- echo " reusing $vid"
- else
- # BG_MAIN_SCALE=1.0 fills the frame -- the default 0.70 insets the panel to
- # 896x504 and the box would carry a black border of its own. BG_DIM=0 because
- # there is no background to dim; dimming black just makes the render slower.
- PLAN="$solo" BG_VIDEO=$PWD/intro/black1280.mp4 LAYOUT=bg \
- BG_MAIN_SCALE=1.0 BG_DIM=0 OUT_END=0 \
- node render-poly.mjs "$vid" | tail -2
- fi
- SOLOS+=("$vid")
-done
-
-COMPOSED="$COMPOSED" GAMEPLAY="$GAMEPLAY" \
- PRIMARY="${SOLOS[0]}" SEC1="${SOLOS[1]:-}" SEC2="${SOLOS[2]:-}" \
- OFFSET="$OFFSET" GIMMICKS="$GIMMICKS" \
- node "$S/shorts-compose.mjs" "$OUT"
-
-echo "VSHORT_${TUNE}_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration:stream=width,height -of csv=p=0 "$OUT" | head -2
diff --git a/umtool/song/yoshi-rebuild.sh b/umtool/song/yoshi-rebuild.sh
@@ -1,81 +0,0 @@
-#!/bin/sh
-# Yoshi's Island athletic theme, rebuilt on the 3,959-clip palette.
-#
-# TIME_LIMIT 94.5 reproduces v6 exactly (melody 411 / bass 204 / arp 108). The
-# length is the lever on this tune more than any other: at full length the melody
-# degrades to p90 3.8-4.5 semitones, and raising poolFactor does NOTHING because
-# the melody is claimed LAST and takes whatever the bass and arp leave.
-#
-# The vowel is a SCORED preference (tokenPenalty), not a filter. A hard filter
-# took the melody from p90 1.59 to 4.47 at full length; scoring lets a much
-# better pitch match still buy an "uh" while "um" wins every tie. v6 landed at
-# 87% "um" that way.
-set -e
-cd /home/user/.claude/jobs/efbe67a7/tmp/song
-D=/home/user/reports/quartering-uh-song
-T=/home/user/.claude/jobs/efbe67a7/tmp
-
-TP=${TP:-6}
-PLANF=${PLANF:-yoshi-plan-v7.json}
-OUT=${OUT:-$D/quartering-yoshi-v7.mp4}
-mkdir -p "$(dirname "$OUT")"
-
-export TEMPO_SCALE=1.05 TIME_LIMIT=${TL:-94.5} MAX_IOI=0.70 WINDOW=700 LOCAL=0 WOBBLE_W=0.08
-export VOICES='[{"name":"melody","lead":"lead2.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true,"tokens":["um"],"tokenPenalty":'"$TP"',"noKeeps":true},{"name":"bass","lead":"yoshi-bass.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":2.5,"fill":false,"noKeeps":true},{"name":"arp","lead":"yoshi-harm.json","gain":0.46,"role":"pip2","unique":true,"reuseGap":12,"contourMax":5,"poolFactor":2.5,"fill":false,"noKeeps":true}]'
-
-if [ "${REUSE_PLAN:-0}" != "1" ]; then
- node arrange-poly.mjs
- cp poly-plan.json "$PLANF"
-fi
-
-# What the vowel preference actually bought, measured on the PLAN rather than
-# assumed from the setting.
-node -e "
-const {readFileSync,readdirSync}=require('fs');const path=require('path');
-const S='$PWD';const cand=new Map();
-for(const f of readdirSync(path.join(S,'cand2')).filter(f=>f.endsWith('.json')))
- for(const c of JSON.parse(readFileSync(path.join(S,'cand2',f),'utf8')).candidates){
- const k=c.video+'@'+(+c.start).toFixed(2); if(!cand.has(k))cand.set(k,c);}
-const W=JSON.parse(readFileSync(path.join(S,'words.json'),'utf8'));
-const p=JSON.parse(readFileSync('$PLANF','utf8'));
-for(const v of p.voices){const t={};
- for(const n of v.plan){const k=n.video+'@'+(+n.srcStart).toFixed(2);
- const w=((W[k]&&W[k].word)||(cand.get(k)||{}).token||'?').toLowerCase().trim(); t[w]=(t[w]||0)+1;}
- const tot=v.plan.length;
- console.log(' vowel '+v.name.padEnd(7)+' '+Object.entries(t).sort((a,b)=>b[1]-a[1])
- .map(([k,n])=>k+' '+(100*n/tot).toFixed(0)+'%').join(' '));}
-"
-[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0
-
-# TAIL_FADE is NOT set: this cut ends on the reprise, not on a loop point, so it
-# has an ending of its own. VIDEO_TAIL_FADE follows TAIL_FADE, so the picture is
-# left alone too.
-# BG_VIDEO overridable like TP/PLANF/OUT/TL above, so a treated bed -- say one
-# debox-bg.mjs has taken the game's own text-box freezes out of -- can be
-# rendered against this exact arrangement without editing the recipe.
-PLAN=$PLANF BG_VIDEO=${BG_VIDEO:-$PWD/intro/yoshi-bg.mp4} BG_FIT=pad LAYOUT=bg \
- node render-poly.mjs "$T/yoshi-${PLANF%.json}-body.mp4" | tail -3
-
-LEAD=$(node -e "
-const {voices}=require('$PWD/$PLANF');
-console.log(Math.max(0,Math.min(...voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart))).toFixed(3));")
-echo "trimming ${LEAD}s of lead-in off the body"
-ffmpeg -nostdin -v error -y -ss "$LEAD" -i "$T/yoshi-${PLANF%.json}-body.mp4" \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \
- -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/yoshi-${PLANF%.json}-bd.mp4"
-
-# Intro: yoshi-src.mp4 6.24 -> 10.05s. 6.24 is inside the FIRST FULLY BLACK
-# frame -- the source is 60fps, and detecting black by MEAN luma finds the wrong
-# frames because averaging hides a small lit region.
-ffmpeg -nostdin -v error -y -ss 6.24 -to 10.05 -i intro/yoshi-src.mp4 \
- -vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30" \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \
- -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/yoshi-${PLANF%.json}-intro.mp4"
-
-ffmpeg -nostdin -v error -y -i "$T/yoshi-${PLANF%.json}-intro.mp4" -i "$T/yoshi-${PLANF%.json}-bd.mp4" \
- -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" \
- -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \
- -movflags +faststart "$OUT"
-
-echo "YOSHI7_DONE -> $OUT"
-ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"