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:

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:

  1. 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.
  2. An event byte (or a running-status event). The high nibble is the event type: 0x80xE for channel messages, 0xFF for meta events, 0xF0/0xF7 for 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:

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

Common pitfalls

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.