#!/usr/bin/env node // A STAND-IN for a rendered um-song body: the plan's notes, at the plan's own // times and pitches, played by a General MIDI voice patch through fluidsynth. // // The real body needs the um clips (wav48/, media/), 39 GB that a machine may // not have. Hook work does not need the ums -- it needs something with the // song's rhythm, harmony and density under the shouts, so that placement and // balance can be judged in context. This is that, from a plan file alone. It is // NOT a preview of how the song sounds, and anything levelled against it (a // fit-hooks.mjs HOOK_GAINS) is levelled against a choir, not the ums: refit on // the real render. // // node standin-body.mjs // // PROGRAM GM program, 0-based (53 = Voice Oohs) // SF2 soundfont (/usr/share/soundfonts/FatBoy.sf2, as the drums use) import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; const [PLAN, OUT] = process.argv.slice(2); if (!OUT) throw new Error("usage: standin-body.mjs "); const PROGRAM = Number(process.env.PROGRAM ?? 53); const SF2 = process.env.SF2 ?? "/usr/share/soundfonts/FatBoy.sf2"; const plan = JSON.parse(readFileSync(PLAN, "utf8")); const TPQ = 1000, USPQ = 500000; // 1 tick = 0.5 ms, as midi-drums.mjs const tick = (s) => Math.round(s * (TPQ * 1e6) / USPQ); const events = []; plan.voices.forEach((v, ch) => { const vel = Math.max(1, Math.min(127, Math.round(100 * (v.gain ?? 1)))); events.push({ tick: 0, bytes: [0xc0 | ch, PROGRAM] }); for (const n of v.plan) { const note = Math.round(n.targetMidi); events.push({ tick: tick(n.slotStart), on: 1, bytes: [0x90 | ch, note, vel] }); events.push({ tick: tick(n.slotStart + (n.noteDur ?? n.slotDur)), on: 0, bytes: [0x80 | ch, note, 0] }); } }); events.sort((a, b) => a.tick - b.tick || (a.on ?? -1) - (b.on ?? -1)); const bytes = []; const vlq = (v) => { const s = []; do { s.push(v & 0x7f); v >>>= 7; } while (v); for (let i = s.length - 1; i >= 0; i -= 1) bytes.push(s[i] | (i ? 0x80 : 0)); }; vlq(0); bytes.push(0xff, 0x51, 0x03, (USPQ >> 16) & 0xff, (USPQ >> 8) & 0xff, USPQ & 0xff); let last = 0; for (const e of events) { vlq(e.tick - last); last = e.tick; bytes.push(...e.bytes); } vlq(0); bytes.push(0xff, 0x2f, 0x00); const trk = Buffer.from(bytes); const head = Buffer.alloc(14); head.write("MThd", 0, "latin1"); head.writeUInt32BE(6, 4); head.writeUInt16BE(0, 8); head.writeUInt16BE(1, 10); head.writeUInt16BE(TPQ, 12); const th = Buffer.alloc(8); th.write("MTrk", 0, "latin1"); th.writeUInt32BE(trk.length, 4); const mid = OUT.replace(/\.wav$/, "") + ".mid"; writeFileSync(mid, Buffer.concat([head, th, trk])); // fluidsynth writes stereo; the mixer decodes to mono through ffmpeg anyway. execFileSync("fluidsynth", ["-ni", "-F", OUT, "-r", "48000", "-g", "0.8", SF2, mid], { stdio: "ignore" }); const notes = plan.voices.reduce((a, v) => a + v.plan.length, 0); const t0 = Math.min(...plan.voices.map((v) => v.plan[0]?.slotStart ?? Infinity)); console.log(`${notes} notes in ${plan.voices.length} voices from ${t0.toFixed(2)}s, program ${PROGRAM} -> ${OUT}`);