public/lib/midi.js4.9 KB
// Dependency-free Standard MIDI File (SMF) parser.
//
// Handles format 0 and 1 files. Produces, per track, a list of notes with
// absolute start/end times in *seconds*, resolved through the file's tempo map.
class Reader {
constructor(view) {
this.view = view;
this.pos = 0;
}
u8() {
return this.view.getUint8(this.pos++);
}
u16() {
const v = this.view.getUint16(this.pos);
this.pos += 2;
return v;
}
u32() {
const v = this.view.getUint32(this.pos);
this.pos += 4;
return v;
}
str(n) {
let s = "";
for (let i = 0; i < n; i++) s += String.fromCharCode(this.u8());
return s;
}
bytes(n) {
const b = new Uint8Array(this.view.buffer, this.view.byteOffset + this.pos, n);
this.pos += n;
return b;
}
// Variable-length quantity.
varint() {
let value = 0;
for (;;) {
const b = this.u8();
value = (value << 7) | (b & 0x7f);
if (!(b & 0x80)) return value;
}
}
}
// Parse one MTrk into raw events carrying absolute tick times.
function parseTrack(reader, length) {
const end = reader.pos + length;
const events = [];
let tick = 0;
let running = 0;
while (reader.pos < end) {
tick += reader.varint();
let status = reader.u8();
if (status < 0x80) {
// Running status: reuse previous status byte, rewind one data byte.
reader.pos--;
status = running;
} else {
running = status;
}
const type = status & 0xf0;
if (status === 0xff) {
// Meta event.
const metaType = reader.u8();
const len = reader.varint();
const data = reader.bytes(len);
if (metaType === 0x51) {
events.push({ tick, kind: "tempo", usPerQuarter: (data[0] << 16) | (data[1] << 8) | data[2] });
} else if (metaType === 0x03) {
events.push({ tick, kind: "name", text: String.fromCharCode(...data) });
}
} else if (status === 0xf0 || status === 0xf7) {
// SysEx: skip.
const len = reader.varint();
reader.bytes(len);
} else if (type === 0x90) {
const note = reader.u8();
const velocity = reader.u8();
events.push({ tick, kind: velocity > 0 ? "on" : "off", note, velocity });
} else if (type === 0x80) {
const note = reader.u8();
reader.u8();
events.push({ tick, kind: "off", note });
} else if (type === 0xc0 || type === 0xd0) {
reader.u8(); // one data byte
} else {
reader.u8();
reader.u8(); // two data bytes (control change, pitch bend, etc.)
}
}
reader.pos = end;
return events;
}
// Turn an absolute tick into seconds using the (tick-sorted) tempo map.
function makeTickToSeconds(tempoMap, ticksPerQuarter) {
return (tick) => {
let seconds = 0;
let lastTick = 0;
let usPerQuarter = 500000; // default 120 BPM
for (const t of tempoMap) {
if (t.tick >= tick) break;
seconds += ((t.tick - lastTick) / ticksPerQuarter) * (usPerQuarter / 1e6);
lastTick = t.tick;
usPerQuarter = t.usPerQuarter;
}
seconds += ((tick - lastTick) / ticksPerQuarter) * (usPerQuarter / 1e6);
return seconds;
};
}
/**
* Parse a MIDI file.
* @param {ArrayBuffer} buffer
* @returns {{tracks: Array<{name:string, notes:Array<{start:number,end:number,pitch:number,velocity:number}>}>}}
*/
export function parseMidi(buffer) {
const reader = new Reader(new DataView(buffer));
if (reader.str(4) !== "MThd") throw new Error("not a MIDI file (missing MThd header)");
reader.u32(); // header length (always 6)
reader.u16(); // format
const trackCount = reader.u16();
const division = reader.u16();
if (division & 0x8000) throw new Error("SMPTE time division is not supported");
const ticksPerQuarter = division;
// First pass: collect every track's raw events, and a global tempo map.
const rawTracks = [];
const tempoMap = [];
for (let i = 0; i < trackCount; i++) {
if (reader.str(4) !== "MTrk") throw new Error(`expected MTrk chunk #${i}`);
const length = reader.u32();
const events = parseTrack(reader, length);
rawTracks.push(events);
for (const e of events) if (e.kind === "tempo") tempoMap.push(e);
}
tempoMap.sort((a, b) => a.tick - b.tick);
const tickToSeconds = makeTickToSeconds(tempoMap, ticksPerQuarter);
// Second pass: pair note-ons with note-offs into timed notes.
const tracks = rawTracks.map((events, i) => {
let name = `Track ${i + 1}`;
const notes = [];
const open = new Map(); // pitch -> start tick (last note-on)
for (const e of events) {
if (e.kind === "name" && e.text.trim()) name = e.text.trim();
else if (e.kind === "on") open.set(e.note, e.tick);
else if (e.kind === "off" && open.has(e.note)) {
const startTick = open.get(e.note);
open.delete(e.note);
notes.push({
start: tickToSeconds(startTick),
end: tickToSeconds(e.tick),
pitch: e.note,
});
}
}
return { name, notes };
});
return { tracks: tracks.filter((t) => t.notes.length > 0) };
}
