#!/usr/bin/env node // Pull ONE voice out of a plan as a plan of its own, so it can be rendered alone. // // The shorts layout gives every voice its own box, and a box cannot be cropped out of // a finished composite: a panel is only drawn while its voice SOUNDS, so during a rest // the crop catches the gameplay behind it and the box shows a second, smaller copy of // the game. Rendering the voice by itself over black is the only thing that makes an // idle box actually idle. // // The voice is given role "full" whatever it was, because BG_MAIN_SCALE only applies // to the full role -- a pip rendered as a pip would come out 384x216 in the corner of // a black frame instead of filling it. // // node solo-voice.mjs import { readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { SONG_DATA } from "./paths.mjs"; const [PLANF, VOICE, OUT] = process.argv.slice(2); if (!OUT) throw new Error("usage: solo-voice.mjs "); const abs = (f) => (path.isAbsolute(f) ? f : path.join(SONG_DATA, f)); const plan = JSON.parse(readFileSync(abs(PLANF), "utf8")); const v = plan.voices.find((x) => x.name === VOICE); if (!v) throw new Error(`no voice "${VOICE}" in ${PLANF} (have ${plan.voices.map((x) => x.name).join(", ")})`); if (!v.plan.length) throw new Error(`voice "${VOICE}" is empty`); // Gain 0 would render a silent voice, which is what a picture-only pass wants -- but // the audio is thrown away anyway and a zero gain makes the renderer's own level // reporting meaningless, so the voice keeps its gain and the mix is simply unused. plan.voices = [{ ...v, role: "full" }]; writeFileSync(abs(OUT), JSON.stringify(plan, null, 1)); const last = v.plan.at(-1); console.log(`${VOICE}: ${v.plan.length} notes, ${v.plan[0].slotStart.toFixed(2)} -> ` + `${(last.slotStart + last.slotDur).toFixed(2)}s -> ${OUT} (role full)`);