#!/usr/bin/env node // Pull the DRUM tracks straight out of the source MIDI and re-emit them as a // channel-10 SMF on the ARRANGEMENT's timeline, ready for fluidsynth. // // The ums are the soundfont for the pitched voices, but the kit is not a pitched // voice -- a "d'ummm" can imply a beat, it cannot BE a hi-hat. So the drums come // from the MIDI as drums. // // node midi-drums.mjs [trackIndexes] // // Timing. The arrangement is the MIDI stretched by TEMPO_SCALE and trimmed by // TRIM (the MIDI's empty opening), then shifted by SHIFT: // // songTime = (midiTime - TRIM) * TEMPO_SCALE + SHIFT // // Verified against the plan: ms2-lead.json's first note is 5.6707s and // ms2-lead-t.json's is 0.221s, so TRIM = 5.4497; (0.221)*1.05 + 11 = 11.232 and // the rendered plan's first melody slot is 11.23. // // Rather than re-deriving a musical tempo, the output SMF uses a FIXED tick of // 0.5ms (division 1000 @ 120bpm), so every hit sits at its exact song time and // the tempo scaling cannot drift. import { readFileSync, writeFileSync } from "node:fs"; const [IN, OUT, TRACKSPEC] = process.argv.slice(2); const SCALE = Number(process.env.TEMPO_SCALE ?? 1.05); const TRIM = Number(process.env.TRIM ?? 5.4497); const SHIFT = Number(process.env.SHIFT ?? 11.0); const START = Number(process.env.START ?? 11.0); // drop hits before the song const END = Number(process.env.END ?? 102.85); // ...and after it ends const VEL_SCALE = Number(process.env.VEL_SCALE ?? 1.0); // ---- read ------------------------------------------------------------------ const buf = readFileSync(IN); 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(); u16(); const ntracks = u16(), div = u16(); const tempos = []; const tracks = []; for (let t = 0; t < ntracks; t += 1) { if (buf.toString("latin1", p, p + 4) !== "MTrk") throw new Error("bad track " + t); p += 4; // Read the length into a variable FIRST: `p + u32()` reads p before u32() // advances it, which puts the track end 4 bytes short and desyncs every // following track. const len = u32(); const end = p + len; let tick = 0, running = 0, name = ""; const on = new Map(), notes = []; 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(), 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) { p += vlq(); } else if (type === 0x90 || type === 0x80) { const note = u8(), vel = u8(); // Keep VELOCITY -- on a kit it is the difference between a backbeat and a // ghost note, and midi.mjs drops it. 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); }; // ---- select and map -------------------------------------------------------- const want = TRACKSPEC ? TRACKSPEC.split(",").map(Number) : tracks.filter((t) => /drum/i.test(t.name)).map((t) => t.index); const song = (mt) => (mt - TRIM) * SCALE + SHIFT; const hits = []; for (const ti of want) { const tr = tracks[ti]; if (!tr) throw new Error("no track " + ti); let kept = 0; for (const n of tr.notes) { const t = song(tickToSec(n.start)); if (t < START - 1e-6 || t > END) continue; const d = Math.max(0.02, (tickToSec(n.end) - tickToSec(n.start)) * SCALE); hits.push({ t, d, note: n.note, vel: Math.max(1, Math.min(127, Math.round(n.vel * VEL_SCALE))) }); kept += 1; } console.log(`track ${ti} ${JSON.stringify(tr.name)}: ${kept}/${tr.notes.length} hits in ${START}-${END}s`); } hits.sort((a, b) => a.t - b.t || a.note - b.note); if (!hits.length) throw new Error("no drum hits selected"); // A .json OUT emits the hits themselves instead of an SMF, so another renderer // can play the kit with something that is not a soundfont (um-drums.mjs plays it // with chopped ums). The timing above is the part that is easy to get wrong and // has been verified against the plan, so it is shared rather than reimplemented. if (OUT.endsWith(".json")) { writeFileSync(OUT, JSON.stringify(hits, null, 1)); const sp = [hits[0].t, hits[hits.length - 1].t]; console.log(`${hits.length} hits, ${sp[0].toFixed(2)}s -> ${sp[1].toFixed(2)}s -> ${OUT}`); process.exit(0); } // ---- write a channel-10 SMF on a fixed 0.5ms tick -------------------------- const TPQ = 1000, USPQ = 500000; // 1 tick = 0.5ms const secToTick = (s) => Math.round(s * (TPQ * 1e6) / USPQ); const events = []; for (const h of hits) { events.push({ tick: secToTick(h.t), kind: 1, note: h.note, vel: h.vel }); events.push({ tick: secToTick(h.t + h.d), kind: 0, note: h.note, vel: 0 }); } events.sort((a, b) => a.tick - b.tick || a.kind - b.kind); const bytes = []; const pushVLQ = (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)); }; // tempo meta pushVLQ(0); bytes.push(0xff, 0x51, 0x03, (USPQ >> 16) & 0xff, (USPQ >> 8) & 0xff, USPQ & 0xff); let last = 0; for (const e of events) { pushVLQ(e.tick - last); last = e.tick; bytes.push(e.kind ? 0x99 : 0x89, e.note, e.vel); // channel 9 == GM channel 10 } pushVLQ(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); writeFileSync(OUT, Buffer.concat([head, th, trk])); const span = [hits[0].t, hits[hits.length - 1].t]; console.log(`${hits.length} hits, ${span[0].toFixed(2)}s -> ${span[1].toFixed(2)}s -> ${OUT}`); const byNote = new Map(); for (const h of hits) byNote.set(h.note, (byNote.get(h.note) ?? 0) + 1); const GM = { 35: "Kick2", 36: "Kick", 37: "Stick", 38: "Snare", 39: "Clap", 40: "Snare2", 41: "LowTom2", 42: "ClosedHH", 43: "LowTom", 44: "PedalHH", 45: "MidTom2", 46: "OpenHH", 47: "MidTom", 48: "HiTom2", 49: "Crash", 50: "HiTom", 51: "Ride", 52: "China", 53: "RideBell", 54: "Tamb", 55: "Splash", 56: "Cowbell", 57: "Crash2", 59: "Ride2" }; console.log([...byNote.entries()].sort((a, b) => b[1] - a[1]) .map(([n, c]) => `${GM[n] ?? "note" + n}(${n})x${c}`).join(" "));