You have a .mid file. You want a list of notes you can play — pitch, start time, duration — ready to hand to the Web Audio API or your synth engine. Most tutorials reach straight for @tonejs/midi, which is fine, but it hides the actual file format. If you are building anything custom (a rhythm game, a typed-onset music visualiser, a self-contained static page that ships with no npm deps), you write the parser yourself.
MIDI’s on-disk format is small and well-specified, and once you understand three things — variable-length quantities, delta-time encoding, and note-on/note-off pairing — the parser is about 80 lines of vanilla JavaScript. This post walks through each piece with runnable code.
The file: a header chunk + track chunks
A Standard MIDI File is a sequence of chunks, each prefixed by an 8-byte header:
4 bytes: chunk type ("MThd" or "MTrk")
4 bytes: length (big-endian, the byte length of the data that follows)
N bytes: data
The first chunk is always MThd with this 6-byte payload:
2 bytes: format (0 = single-track, 1 = multi-track sync, 2 = async tracks)
2 bytes: numTracks (number of MTrk chunks that follow)
2 bytes: division (usually ticks per quarter note, e.g. 480)
The division field is the bridge between ticks (MIDI’s internal clock unit, just integers) and beats. If division = 480, then 480 ticks = one quarter note, one beat = 480 ticks, and a 4-minute song at 120 BPM is 4 * 60 * 2 / beat * 480 = a few hundred thousand ticks.
function parseHeader(dv) {
// dv is a DataView of the whole file
if (dv.getUint32(0) !== 0x4d546864) throw new Error("not a MIDI file"); // "MThd"
var headerLen = dv.getUint32(4);
var format = dv.getUint16(8);
var nTracks = dv.getUint16(10);
var division = dv.getUint16(12);
return { format: format, nTracks: nTracks, ticksPerBeat: division };
}
Variable-length quantities (the weird one)
MIDI uses a variable-length integer to encode many fields. The rules:
- Each byte stores 7 bits of value (top bit is a continuation flag).
- If the top bit of a byte is
1, read the next byte too. - If the top bit is
0, this byte is the last. - Maximum 4 bytes (28 bits). LSB first — no, MSB first,(concatenate the 7-bit groups left-to-right).
This is the single most-documented source of parser bugs. The decode loop:
function readVarLen(data, off) {
// data: Uint8Array, off: index. Returns { value, nextOff }.
var v = 0;
var b = 0;
do {
b = data[off++];
v = (v << 7) | (b & 0x7f);
} while (b & 0x80);
return { value: v, nextOff: off };
}
Why this encoding? Because MIDI was designed for transmission over 31.25 kbps serial links in 1983; one byte saved is many microseconds of latency. The format survives in 2026 because it costs nothing more to parse than a fixed int.
Track events: delta time + channel/midi/meta
Inside an MTrk chunk, events are stored sequentially. Each event has:
- A delta time — the number of ticks since the previous event in this track (NOT absolute; this trips everyone up once). Encoded as a variable-length quantity.
- An event byte (or a running-status event). The high nibble is the event type:
0x8–0xEfor channel messages,0xFFfor meta events,0xF0/0xF7for system-exclusive.
You accumulate the delta times into an absolute tick per event as you walk:
function parseTrack(data, off, end, ticksPerBeat) {
if (data[off++] !== 0x4d || data[off++] !== 0x54
|| data[off++] !== 0x72 || data[off++] !== 0x6b) {
throw new Error("missing MTrk header"); // "MTrk"
}
var trackLen = (data[off] << 24) | (data[off+1] << 16)
| (data[off+2] << 8) | data[off+3];
off += 4;
var trackEnd = off + trackLen;
var absTick = 0;
var events = [];
var lastStatus = 0; // for running status
while (off < trackEnd) {
var r = readVarLen(data, off); off = r.nextOff;
absTick += r.value; // delta becomes absolute via addition
var status = data[off];
if (status < 0x80) status = lastStatus; // running status: reuse previous
else { off++; lastStatus = status; }
var type = status >> 4;
if (status === 0xFF) { // meta event
var mtype = data[off++];
var ml = readVarLen(data, off); off = ml.nextOff;
var meta = data.slice(off, off + ml.value); off += ml.value;
events.push({ tick: absTick, meta: mtype, data: meta });
} else if (type === 0x8 || type === 0x9) { // note OFF / note ON
var note = data[off++];
var vel = data[off++];
if (type === 0x9 && vel === 0) {
// Some files encode note-OFF as note-ON with zero velocity — treat as OFF
type = 0x8;
}
events.push({ tick: absTick, kind: type === 0x9 ? 'on' : 'off',
note: note, vel: vel });
} else if (type === 0xB || type === 0xA) { // CC, poly aftertouch — 1 data byte
off += 2;
} else if (type === 0xC || type === 0xD) { // program/pres change, ch pressure — 0 data bytes after status
off += 1;
} else if (type === 0xE) { // pitch bend
off += 2;
} else {
throw new Error("unknown status " + status.toString(16));
}
}
return { end: trackEnd, events: events };
}
Two gotchas the code already handles:
- Running status. Consecutive events of the same type often omit the status byte — only data bytes follow, and the parser tells by noticing the next byte is <
0x80. Cache the last status and reuse it. - Note-off disguised as note-on. A note ON with velocity 0 is a note OFF. The MIDI spec allows both encodings; production MIDIs freely mix them. Always treat
0x9? + vel 0as a note-off.
Pairing on-events with off-events
So far you have a flat list of on/off events. A playable note has a start tick, a duration in ticks, and a pitch. Pair them with a stack per pitch, since notes on the same channel can overlap:
function eventsToNotes(events) {
var pending = {}; // note pitch -> [start ticks...) (stack handles re-strike)
var notes = [];
events.forEach(function(ev) {
if (ev.kind === 'on') {
(pending[ev.note] = pending[ev.note] || []).push(ev.tick);
} else if (ev.kind === 'off') {
var stack = pending[ev.note];
if (!stack || stack.length === 0) return; // off with no on — skip
var start = stack.shift();
notes.push({ start: start, dur: ev.tick - start, note: ev.note });
}
});
notes.sort(function(a, b) { return a.start - b.start; });
return notes;
}
Pop-front on note-off (FIFO) matches the natural order when notes are played then released. If your file mixes tracks, pair within each track and then merge by start tick — on/off pairs from different tracks never belong to each other.
From ticks to seconds: the tempo track
Tick counts alone do not tell you seconds — that depends on tempo, which can change mid-song. MIDI stores tempo as a meta event (type 0x51) with a 24-bit microseconds-per-quarter-note value. Convert to BPM:
BPM = 60,000,000 / microsecondsPerQuarter
Ticks advance at BPM * ticksPerBeat / 60 per second. Walk the events list and stamp each note with an absolute time in seconds, applying whatever tempo was active at the note’s start tick:
function ticksToSeconds(events, ticksPerBeat) {
var usPerQuarter = 500000; // default 120 BPM
var secPerTick = (usPerQuarter / 1000000) / ticksPerBeat;
var curTick = 0, curSec = 0;
var out = [];
events.forEach(function(ev) {
var dt = ev.tick - curTick;
curSec += dt * secPerTick;
curTick = ev.tick;
if (ev.meta === 0x51) {
var d = ev.data;
usPerQuarter = (d[0] << 16) | (d[1] << 8) | d[2];
secPerTick = (usPerQuarter / 1000000) / ticksPerBeat;
}
});
return curSec;
}
If your app uniformly rebases tempo (e.g. play the song at 80 BPM no matter what the MIDI says), you can ignore the tempo track entirely — just take the constant secPerBeat = 60 / yourBPM and multiply note durations through it. Tempo events are common in classical MIDI files, rare in pop-song transcriptions, and you are free to override them.
What you walk away with
- A flat
notes[]array: each entry has{ start: seconds, dur: seconds, note: midiNumber }. Hand it to your synth (see part 1 of this series) or a lookahead scheduler (see part 2) and the song plays. - The whole parser is one
Uint8Arraywalk — about 80 lines of code, no dependencies, no allocations beyond the resulting arrays. - The format itself is small because it was designed for 1983 hardware with kilobytes of memory and a baud-rate serial link; once you know the three primitives (VLQ, delta time, on/off pairing), reading it is mechanical.
Common pitfalls
- Reading VLQs as fixed-width ints. A note’s delta time can be one byte (0–127) or three. Use
readVarLen()for every delta, never a flatgetUint16. - Forgetting running status. Many MIDI export tools strip redundant status bytes to save space; the parser without running-status support will misalign every subsequent event by one byte.
- Treating note-on with velocity 0 as an actual note. It is the conventional note-OFF; pair it with the matching ON, do not start a new note.
- Pairing on/off across tracks. Tracks are independent sequences; cross-track pairing produces garbage. Pair per track, merge by absolute start time at the end.
- Defaulting tempo to 120 BPM. The spec defaults
usPerQuarterto 500000 (120 BPM), but production files almost always have an explicitset_tempometa event. Walk events in tick order and apply tempo changes as you go — caching all tempos first is also acceptable; computingsecPerTickper note based on the latest tempo at/before the note’s tick is the goal.
This is the exact pipeline used to convert every MIDI file behind the piano game at ggames.mobi — track-by-track extraction, per-track on/off pairing, optional pitch-split into right and left hands, then a beats-based { n, d } encoding that keeps the song portable across the synth and the falling-notes view.