#!/usr/bin/env node // Minimal SMF reader: returns per-track note events in seconds, honouring tempo map. import { readFileSync } from "node:fs"; const buf = readFileSync(process.argv[2]); let p = 0; const u32 = () => { const v = buf.readUInt32BE(p); p += 4; return v; }; const u16 = () => { const v = buf.readUInt16BE(p); p += 2; return v; }; const u8 = () => buf[p++]; const vlq = () => { let v = 0, b; do { b = u8(); v = (v << 7) | (b & 0x7f); } while (b & 0x80); return v; }; if (buf.toString("latin1", 0, 4) !== "MThd") throw new Error("not a MIDI file"); p = 4; u32(); const format = u16(), ntracks = u16(), div = u16(); const tracks = []; const tempos = []; // {tick, usPerQuarter} for (let t = 0; t < ntracks; t += 1) { if (buf.toString("latin1", p, p + 4) !== "MTrk") throw new Error("bad track " + t); p += 4; const len = u32(); const end = p + len; let tick = 0, running = 0; const on = new Map(); // key -> {tick, vel} const notes = []; let name = ""; while (p < end) { tick += vlq(); let st = buf[p]; if (st & 0x80) { p += 1; running = st; } else st = running; const type = st & 0xf0; if (st === 0xff) { const meta = u8(); const ml = vlq(); if (meta === 0x51) tempos.push({ tick, us: (buf[p] << 16) | (buf[p + 1] << 8) | buf[p + 2] }); if (meta === 0x03) name = buf.toString("latin1", p, p + ml); p += ml; } else if (st === 0xf0 || st === 0xf7) { const sl = vlq(); p += sl; } else if (type === 0x90 || type === 0x80) { const note = u8(), vel = u8(); if (type === 0x90 && vel > 0) on.set(note, { tick, vel }); else { const s = on.get(note); if (s) { notes.push({ note, start: s.tick, end: tick, vel: s.vel }); on.delete(note); } } } else if (type === 0xa0 || type === 0xb0 || type === 0xe0) p += 2; else if (type === 0xc0 || type === 0xd0) p += 1; else p += 1; } p = end; tracks.push({ index: t, name, notes }); } tempos.sort((a, b) => a.tick - b.tick); if (!tempos.length) tempos.push({ tick: 0, us: 500000 }); const tickToSec = (tk) => { let sec = 0, last = 0, us = tempos[0].us; for (const tp of tempos) { if (tp.tick >= tk) break; sec += ((tp.tick - last) / div) * (us / 1e6); last = tp.tick; us = tp.us; } return sec + ((tk - last) / div) * (us / 1e6); }; const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; const nn = (m) => `${NAMES[m % 12]}${Math.floor(m / 12) - 1}`; const out = { format, ntracks, division: div, tempoBPM: tempos.map((t) => ({ tick: t.tick, bpm: +(6e7 / t.us).toFixed(2) })), tracks: tracks.map((t) => ({ index: t.index, name: t.name, count: t.notes.length, lowest: t.notes.length ? nn(Math.min(...t.notes.map((n) => n.note))) : null, highest: t.notes.length ? nn(Math.max(...t.notes.map((n) => n.note))) : null, notes: t.notes .sort((a, b) => a.start - b.start || a.note - b.note) .map((n) => ({ midi: n.note, name: nn(n.note), t: +tickToSec(n.start).toFixed(4), dur: +(tickToSec(n.end) - tickToSec(n.start)).toFixed(4), beat: +(n.start / div).toFixed(3), beats: +((n.end - n.start) / div).toFixed(3), })), })), }; console.log(JSON.stringify(out, null, 1));