/** a note from a midi file, timed in beats */ export type MidiNote = { start: number; dur: number; pitch: number; vel: number }; export type MidiPart = { name: string; channel: number; notes: MidiNote[] }; const DRUM_CHANNEL = 9; const MIN_DUR = 1 / 16; /** * read the notes out of a standard midi file, one part per track and channel. drums are left * out: they'd only come out as a pitched mess on a piano */ export function parseMidi(bytes: Uint8Array): MidiPart[] { const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const tag = (at: number) => String.fromCharCode(...bytes.subarray(at, at + 4)); if (bytes.length < 14 || tag(0) !== "MThd") throw new Error("not a MIDI file"); const division = view.getUint16(12); if (division & 0x8000) throw new Error("SMPTE-timed MIDI files aren't supported"); const parts: MidiPart[] = []; let trackIndex = 0; for (let pos = 8 + view.getUint32(4); pos + 8 <= bytes.length;) { const end = Math.min(bytes.length, pos + 8 + view.getUint32(pos + 4)); if (tag(pos) === "MTrk") parts.push(...parseTrack(bytes.subarray(pos + 8, end), division, trackIndex++)); pos = end; } return parts; } function parseTrack(data: Uint8Array, ppq: number, index: number): MidiPart[] { let pos = 0; let tick = 0; let status = 0; let name = ""; const held = new Map(); const byChannel = new Map(); const varInt = () => { let value = 0; let b; do { b = data[pos++]; value = (value << 7) | (b & 0x7f); } while (b & 0x80 && pos < data.length); return value; }; const release = (channel: number, pitch: number, at: number) => { // repeated note-ons of one pitch are paired first-in, first-out const on = held.get(channel * 128 + pitch)?.shift(); if (!on) return; const notes = byChannel.get(channel) ?? []; byChannel.set(channel, notes); notes.push({ start: on.tick / ppq, dur: Math.max(MIN_DUR, (at - on.tick) / ppq), pitch, vel: on.vel / 127, }); }; while (pos < data.length) { tick += varInt(); let event = data[pos]; if (event & 0x80) pos++; else if (status) event = status; // running status: the data byte is still unread else throw new Error("malformed MIDI track"); if (event === 0xff) { const type = data[pos++]; const length = varInt(); if (type === 0x03 && !name) name = new TextDecoder().decode(data.subarray(pos, pos + length)); if (type === 0x2f) break; pos += length; status = 0; } else if (event === 0xf0 || event === 0xf7) { const length = varInt(); pos += length; status = 0; } else { status = event; const kind = event & 0xf0; const channel = event & 0x0f; const pitch = data[pos++]; const vel = kind === 0xc0 || kind === 0xd0 ? 0 : data[pos++]; if (kind === 0x90 && vel > 0) { const key = channel * 128 + pitch; held.set(key, [...(held.get(key) ?? []), { tick, vel }]); } else if (kind === 0x80 || kind === 0x90) release(channel, pitch, tick); } } for (const [key, ons] of held) ons.forEach(() => release(Math.floor(key / 128), key % 128, tick)); const channels = [...byChannel.keys()].filter(c => c !== DRUM_CHANNEL).sort((a, b) => a - b); return channels.map(channel => ({ name: (name.trim() || `track ${index + 1}`) + (channels.length > 1 ? ` ch${channel + 1}` : ""), channel, notes: byChannel.get(channel)!.sort((a, b) => a.start - b.start || a.pitch - b.pitch), })); }