import type { Note, Song, TempoEvent, TimeSigEvent, Track } from '../types.ts'; import { newId } from '../types.ts'; import { GM_PROGRAMS } from './gm.ts'; // Standard MIDI file parser for formats 0 and 1, with running status and a // merged tempo map. Each (SMF track, channel) pair that carries notes // becomes one editable track; this splits multi-channel format 0 files // sensibly and is harmless for format 1. class Reader { pos = 0; constructor(readonly d: Uint8Array) {} u8(): number { if (this.pos >= this.d.length) throw new Error('unexpected end of MIDI data'); return this.d[this.pos++]; } u16(): number { return (this.u8() << 8) | this.u8(); } u32(): number { return (this.u16() << 16) | this.u16(); } bytes(n: number): Uint8Array { const out = this.d.subarray(this.pos, this.pos + n); if (out.length < n) throw new Error('unexpected end of MIDI data'); this.pos += n; return out; } varLen(): number { let v = 0; for (let i = 0; i < 4; i++) { const b = this.u8(); v = (v << 7) | (b & 0x7f); if ((b & 0x80) === 0) return v; } throw new Error('variable-length quantity too long'); } } interface RawNote { tick: number; duration: number; pitch: number; velocity: number; channel: number } export function parseMidi(data: Uint8Array): Song { const r = new Reader(data); const magic = String.fromCharCode(...r.bytes(4)); if (magic !== 'MThd') throw new Error('not a standard MIDI file (missing MThd)'); const headerLen = r.u32(); const format = r.u16(); const ntrks = r.u16(); const division = r.u16(); r.bytes(headerLen - 6); if (format === 2) throw new Error('MIDI format 2 is not supported'); if (division & 0x8000) throw new Error('SMPTE time division is not supported'); const ppq = division; const tempo: TempoEvent[] = []; const timeSig: TimeSigEvent[] = []; const tracks: Track[] = []; for (let ti = 0; ti < ntrks; ti++) { if (r.pos >= data.length) break; const chunk = String.fromCharCode(...r.bytes(4)); const len = r.u32(); const end = r.pos + len; if (chunk !== 'MTrk') { r.pos = end; continue; } let tick = 0; let running = 0; let trackName = ''; const notes: RawNote[] = []; const open = new Map(); const programByChannel = new Map(); const closeNote = (channel: number, pitch: number, atTick: number) => { const key = channel * 128 + pitch; const stack = open.get(key); if (!stack || stack.length === 0) return; const o = stack.pop()!; notes.push({ tick: o.tick, duration: Math.max(1, atTick - o.tick), pitch, velocity: o.velocity, channel }); }; while (r.pos < end) { tick += r.varLen(); let status = r.u8(); if (status < 0x80) { if (running === 0) throw new Error('running status with no prior status byte'); r.pos -= 1; status = running; } if (status === 0xff) { running = 0; const type = r.u8(); const mlen = r.varLen(); const body = r.bytes(mlen); if (type === 0x51 && mlen >= 3) { tempo.push({ tick, usPerQuarter: (body[0] << 16) | (body[1] << 8) | body[2] }); } else if (type === 0x58 && mlen >= 2) { timeSig.push({ tick, numerator: body[0], denominator: 1 << body[1] }); } else if (type === 0x03 && trackName === '') { trackName = new TextDecoder().decode(body).trim(); } else if (type === 0x2f) { break; } } else if (status === 0xf0 || status === 0xf7) { running = 0; const slen = r.varLen(); r.bytes(slen); } else { running = status; const kind = status >> 4; const channel = status & 0x0f; if (kind === 0x9) { const pitch = r.u8(); const vel = r.u8(); if (vel > 0) { const key = channel * 128 + pitch; if (!open.has(key)) open.set(key, []); open.get(key)!.push({ tick, velocity: vel }); } else { closeNote(channel, pitch, tick); } } else if (kind === 0x8) { const pitch = r.u8(); r.u8(); closeNote(channel, pitch, tick); } else if (kind === 0xc) { const prog = r.u8(); if (!programByChannel.has(channel)) programByChannel.set(channel, prog); } else if (kind === 0xd) { r.u8(); } else { r.u8(); r.u8(); } } } r.pos = end; // Close anything left hanging with a quarter-note duration. for (const [key, stack] of open) { const pitch = key % 128; const channel = Math.floor(key / 128); for (const o of stack) notes.push({ tick: o.tick, duration: Math.max(1, Math.round(ppq / 4)), pitch, velocity: o.velocity, channel }); } if (notes.length === 0) continue; const channels = [...new Set(notes.map((n) => n.channel))].sort((a, b) => a - b); for (const ch of channels) { const chNotes = notes .filter((n) => n.channel === ch) .sort((a, b) => a.tick - b.tick || a.pitch - b.pitch) .map((n) => ({ id: newId('n'), tick: n.tick, duration: n.duration, pitch: n.pitch, velocity: n.velocity })); const prog = programByChannel.get(ch); let name = trackName; if (channels.length > 1 && name !== '') name = `${name} ch${ch + 1}`; if (name === '') name = ch === 9 ? 'Drums' : prog !== undefined ? GM_PROGRAMS[prog] ?? `Track ${tracks.length + 1}` : `Track ${tracks.length + 1}`; tracks.push({ id: newId('t'), name, instrumentId: ch === 9 ? 'drumkit' : 'pluck', volume: 1, mute: false, solo: false, notes: chNotes, }); } } tempo.sort((a, b) => a.tick - b.tick); timeSig.sort((a, b) => a.tick - b.tick); if (tempo.length === 0) tempo.push({ tick: 0, usPerQuarter: 500000 }); if (timeSig.length === 0) timeSig.push({ tick: 0, numerator: 4, denominator: 4 }); return { ppq, tempo, timeSig, tracks }; }