#!/usr/bin/env node // Extract ONE track from midi.mjs's parsed output into an arrange-poly voice file. // // The existing voice files (pkrb-lead.json, pkc-lead.json, ...) were originally made by // ad-hoc one-liners, which is why nothing recorded how a given voice was derived. This // does it reproducibly. Tracks are picked by INDEX, never by name: several of these MIDIs // spell their credit across the track-name slots (see specs/pokemon.md). // // node midi-track.mjs [--collapse-repeats] [--min-dur=0.14] // // --collapse-repeats merges a run of consecutive same-pitch notes into one note that // spans the whole run. That is the fix for a sequencer's repeated-note doublings, where // a held note is written as 2-3 restrikes at the sequencer's grid (0.097s here) - well // under the palette's 0.14s minimum clip. Collapsing keeps the tune's rhythm; slowing the // whole jingle down to clear the floor would not. // // --min-dur only REPORTS violations (and exits 1); it never silently edits the music. import { readFileSync, writeFileSync } from "node:fs"; const [src, idxArg, outArg] = process.argv.slice(2); if (!src || idxArg === undefined) { console.error("usage: midi-track.mjs [out.json] [--collapse-repeats] [--min-dur=0.14]"); process.exit(2); } const flags = process.argv.slice(2).filter((a) => a.startsWith("--")); const collapse = flags.includes("--collapse-repeats"); const minDur = Number((flags.find((f) => f.startsWith("--min-dur=")) ?? "--min-dur=0.14").split("=")[1]); const d = JSON.parse(readFileSync(src, "utf8")); const track = d.tracks.find((t) => t.index === Number(idxArg)); if (!track) throw new Error(`no track ${idxArg} (file has ${d.ntracks})`); let notes = track.notes.map((n) => ({ t: n.t, dur: n.dur, midi: n.midi, name: n.name })); let collapsed = 0; if (collapse) { const out = []; for (const n of notes) { const prev = out[out.length - 1]; if (prev && prev.midi === n.midi) { // Extend the held note to the end of the restrike, rather than emitting a second one. prev.dur = +(n.t + n.dur - prev.t).toFixed(4); collapsed += 1; continue; } out.push({ ...n }); } notes = out; } const iois = notes.slice(1).map((n, i) => +(n.t - notes[i].t).toFixed(4)); const stats = (a) => { if (!a.length) return null; const s = [...a].sort((x, y) => x - y); const q = (p) => s[Math.max(0, Math.min(s.length - 1, Math.round(p * (s.length - 1))))]; return { min: s[0], p50: q(0.5), p90: q(0.9), max: s[s.length - 1] }; }; console.log(`track ${track.index} "${track.name}" ${track.count} notes -> ${notes.length}` + (collapse ? ` (${collapsed} repeat-restrikes collapsed)` : "")); console.log(`range ${track.lowest}-${track.highest}, length ${(notes.reduce((m, n) => Math.max(m, n.t + n.dur), 0)).toFixed(3)}s`); console.log("IOI (s):", JSON.stringify(stats(iois))); console.log("dur (s):", JSON.stringify(stats(notes.map((n) => n.dur)))); console.log(notes.map((n) => `${n.name}@${n.t.toFixed(3)}(${n.dur.toFixed(3)})`).join(" ")); const short = notes.filter((n) => n.dur < minDur); if (short.length) { console.error(`\nFAIL: ${short.length}/${notes.length} notes are under the ${minDur}s clip floor: ` + short.map((n) => `${n.name}@${n.t.toFixed(3)}=${n.dur.toFixed(3)}`).join(" ")); } if (outArg && !outArg.startsWith("--")) { writeFileSync(outArg, JSON.stringify(notes, null, 1)); console.log(`\nwrote ${outArg}`); } if (short.length) process.exit(1);