Blog Web MIDI 12 min read

Play MIDI Online: What a Browser Can and Cannot Do

The two things people mean by playing MIDI online, a working Standard MIDI File parser in JavaScript, scheduled playback, and routing out to hardware.

By Aidxn Design

"Play MIDI online" means two completely different things, and almost every page that ranks for it only addresses one of them. One group of people has a .mid file and wants to hear it without installing anything. The other has a keyboard or a controller plugged in and wants to play it through a web page. Those are different problems with different APIs, different browser support and different failure modes. This post covers both properly, with a real Standard MIDI File parser you can use.

TL;DR
  • A MIDI file is a score, not audio. No sound is stored in it, ever.
  • Browsers have no built-in MIDI file player. You parse it yourself in JavaScript.
  • Two sound routes: synthesise in-page with Web Audio, or send out via Web MIDI to real gear.
  • Web Audio route works everywhere, including Safari and iPad. The Web MIDI route does not.
  • Timing is ticks, not seconds. You need the tempo map to convert.
  • Running status will break a naive parser. Handle it or nothing plays past bar three.

Which kind of "play MIDI" do you actually mean?

Either rendering a stored file, or responding to a live instrument — and the two share almost no code.

Playing a .mid filePlaying a live controller
InputA file on diskA MIDI device
API needed to read itFile API, plain JavaScriptWeb MIDI API
Browser support to read itEverywhere~79%, no Safari
Hard partParsing and tempo mappingPermissions and device handling
Where sound comes fromYour synth, in-page or externalSame
Timing modelScheduled from a tempo mapReal time, event-driven

If you are here for the live side, the complete Web MIDI guide is the reference, and you can see it working at /play. The rest of this post is the file side, which is the less-documented half.

Why does a MIDI file contain no sound?

Because the Standard MIDI File format stores performance data, not audio — it is sheet music for machines.

A .mid file is a list of events with timing: note 60 on at velocity 100 at tick 480, note 60 off at tick 960, control change 7 to value 90, tempo changes to 500,000 microseconds per quarter note. Nowhere in that list is a waveform. Which is why a 40 kilobyte file can represent a six-minute orchestral piece, and also why the same file sounds like a toy keyboard in one player and a convincing piano in another. The file is identical; the instrument rendering it is not.

The General MIDI specification exists to partially fix this, by standardising which program number corresponds to which instrument sound — program 0 is Acoustic Grand Piano, and so on — so a file can at least hint at the intended instrumentation. It standardises the names, not the sounds.

How do you read the file without uploading it?

Use the File API: a file input or a drop target gives you a File, and arrayBuffer() gives you the bytes, all locally.

This is worth making explicit in any tool you build, because users reasonably assume "online MIDI player" means their file goes to a server. With the File API it does not have to, and saying so prominently is both honest and a competitive advantage over the ad-laden converters that dominate this search.

// load.js
const input = document.getElementById('file');

input.addEventListener('change', async () => {
  const file = input.files?.[0];
  if (!file) return;
  const buffer = await file.arrayBuffer();     // never leaves the page
  const song = parseSmf(buffer);
  console.log('format', song.format, 'tracks', song.tracks.length,
              'division', song.division);
});

// Drag and drop works the same way.
document.body.addEventListener('drop', async (e) => {
  e.preventDefault();
  const file = e.dataTransfer?.files?.[0];
  if (file) console.log(parseSmf(await file.arrayBuffer()));
});
document.body.addEventListener('dragover', (e) => e.preventDefault());

How does a Standard MIDI File parse?

A header chunk describing the file, then one or more track chunks containing delta-timed events.

The structure is a chunked format in the IFF tradition. The header chunk has the ASCII type MThd, a 32-bit big-endian length of 6, then three 16-bit values: format, number of tracks, and division. Each track chunk has the type MTrk, a 32-bit length, and then a stream of events, each preceded by a variable-length delta time.

Three things make this harder than it looks. Delta times are variable-length quantities, seven bits per byte with the high bit as a continuation flag. Status bytes can be omitted under running status. And meta events share the 0xFF prefix with nothing else, but sysex events use 0xF0 and 0xF7 with their own length encoding.

// smf.js - a complete Standard MIDI File parser.

export function parseSmf(arrayBuffer) {
  const view = new DataView(arrayBuffer);
  let pos = 0;

  const readStr = (n) => {
    let s = '';
    for (let i = 0; i < n; i++) s += String.fromCharCode(view.getUint8(pos++));
    return s;
  };
  const readU32 = () => { const v = view.getUint32(pos); pos += 4; return v; };
  const readU16 = () => { const v = view.getUint16(pos); pos += 2; return v; };
  const readU8  = () => view.getUint8(pos++);

  // Variable-length quantity: 7 bits per byte, high bit = "more follows".
  const readVlq = () => {
    let value = 0;
    for (let i = 0; i < 4; i++) {
      const b = readU8();
      value = (value << 7) | (b & 0x7f);
      if ((b & 0x80) === 0) break;
    }
    return value;
  };

  if (readStr(4) !== 'MThd') throw new Error('Not a Standard MIDI File.');
  const headerLen = readU32();
  const format = readU16();
  const ntrks = readU16();
  const division = readU16();
  pos += headerLen - 6;                 // skip any extra header bytes

  const tracks = [];
  for (let t = 0; t < ntrks; t++) {
    if (readStr(4) !== 'MTrk') throw new Error('Expected an MTrk chunk.');
    const len = readU32();
    const end = pos + len;

    const events = [];
    let tick = 0;
    let runningStatus = 0;

    while (pos < end) {
      tick += readVlq();

      let status = readU8();
      if (status < 0x80) {
        // Running status: this byte is actually data, reuse the last status.
        pos--;
        status = runningStatus;
      } else if (status < 0xf0) {
        runningStatus = status;
      }

      if (status === 0xff) {
        const type = readU8();
        const len2 = readVlq();
        const start = pos;
        if (type === 0x51 && len2 === 3) {
          const usPerQuarter = (readU8() << 16) | (readU8() << 8) | readU8();
          events.push({ tick, kind: 'tempo', usPerQuarter });
        } else if (type === 0x2f) {
          events.push({ tick, kind: 'endOfTrack' });
        }
        pos = start + len2;
      } else if (status === 0xf0 || status === 0xf7) {
        const len2 = readVlq();
        pos += len2;                      // sysex: skipped for playback
      } else {
        const type = status & 0xf0;
        const channel = status & 0x0f;
        const d1 = readU8();
        // Program change and channel pressure carry one data byte, not two.
        const oneByte = (type === 0xc0 || type === 0xd0);
        const d2 = oneByte ? 0 : readU8();

        if (type === 0x90 && d2 > 0) {
          events.push({ tick, kind: 'noteOn', channel, note: d1, velocity: d2 });
        } else if (type === 0x80 || (type === 0x90 && d2 === 0)) {
          events.push({ tick, kind: 'noteOff', channel, note: d1 });
        } else {
          events.push({ tick, kind: 'raw', channel, status, d1, d2, oneByte });
        }
      }
    }

    pos = end;
    tracks.push(events);
  }

  return { format, division, tracks };
}

The running status branch is the one that matters. Almost every real-world MIDI file uses it, because it saves a byte on every repeated message type, and a parser without it reads a data byte as a status byte and loses the plot within a few bars. If your player "works on files I exported myself but not on files from the internet", this is why.

The one-data-byte branch is the second most common bug. Program change (0xC0) and channel pressure (0xD0) take a single data byte; every other channel message takes two. Read two for a program change and every subsequent event is shifted. The complete message table is in the MIDI Association's summary of MIDI 1.0 messages.

How do you turn ticks into seconds?

Merge all tracks into one tick-ordered list, then walk it accumulating time using the tempo in force at each point.

The header's division value, when positive, is ticks per quarter note. The tempo meta event gives microseconds per quarter note. One tick is therefore usPerQuarter / division microseconds — but only until the next tempo event, which is why this has to be a walk and not a multiplication.

// schedule.js
export function flatten(song) {
  // Merge every track into a single tick-sorted list.
  const all = [];
  song.tracks.forEach((events, trackIndex) => {
    for (const e of events) all.push({ ...e, trackIndex });
  });
  all.sort((a, b) => a.tick - b.tick);

  // Walk it, converting ticks to seconds as tempo changes.
  let usPerQuarter = 500000;              // 120 BPM default, per the spec
  let lastTick = 0;
  let seconds = 0;
  const timed = [];

  for (const e of all) {
    const deltaTicks = e.tick - lastTick;
    seconds += (deltaTicks * usPerQuarter) / song.division / 1_000_000;
    lastTick = e.tick;

    if (e.kind === 'tempo') {
      usPerQuarter = e.usPerQuarter;
      continue;
    }
    timed.push({ ...e, time: seconds });
  }

  return { events: timed, duration: seconds };
}

A negative division means SMPTE time division rather than ticks per quarter note, which is rare in music files and common in film and broadcast work. If you are handling arbitrary files, check the sign and either handle it or refuse the file clearly rather than producing nonsense timing.

How do you actually make a sound?

Either synthesise each note in the page with Web Audio, or send the events out of a MIDI port and let something else do it.

The Web Audio route works in every browser including Safari and iOS, needs no permission, and is entirely self-contained. The simplest version builds a voice per note exactly as the Web Audio gamepad instrument does, scheduled against audioContext.currentTime.

// player-webaudio.js
export function playWithWebAudio(timed, ctx) {
  const t0 = ctx.currentTime + 0.1;          // small lead-in
  const live = new Map();                    // "ch:note" -> gain node

  const hz = (n) => 440 * Math.pow(2, (n - 69) / 12);

  for (const e of timed.events) {
    const at = t0 + e.time;

    if (e.kind === 'noteOn') {
      const osc = ctx.createOscillator();
      const amp = ctx.createGain();
      osc.type = 'triangle';
      osc.frequency.setValueAtTime(hz(e.note), at);
      amp.gain.setValueAtTime(0.0001, at);
      amp.gain.exponentialRampToValueAtTime(0.18 * (e.velocity / 127), at + 0.01);
      osc.connect(amp).connect(ctx.destination);
      osc.start(at);
      live.set(e.channel + ':' + e.note, { osc, amp });
    }

    if (e.kind === 'noteOff') {
      const v = live.get(e.channel + ':' + e.note);
      if (!v) continue;
      v.amp.gain.cancelScheduledValues(at);
      v.amp.gain.setValueAtTime(v.amp.gain.value || 0.0001, at);
      v.amp.gain.exponentialRampToValueAtTime(0.0001, at + 0.12);
      v.osc.stop(at + 0.15);
      live.delete(e.channel + ':' + e.note);
    }
  }
}

That will sound like a 1980s toy, because a triangle oscillator is a toy. For anything musical you need actual instrument samples — a soundfont, or per-instrument sample sets decoded with decodeAudioData and triggered with AudioBufferSourceNode. That is where the download size of a browser MIDI player comes from, and it is why most of them either sound bad or take a while to load. There is no third option; the sound has to come from somewhere.

How do you play a MIDI file out to real hardware?

Send each event to a MIDIOutput with a scheduled timestamp, and let the synth do what synths do.

This is the route that makes a browser MIDI player genuinely useful rather than a novelty: your hardware already sounds good, so stop trying to recreate it in JavaScript. The catch is browser support — Web MIDI is desktop Chromium and desktop Firefox only, with no Safari at all, as broken down in Web MIDI browser support in 2026.

// player-webmidi.js
export async function playToHardware(timed, portName) {
  const access = await navigator.requestMIDIAccess({ sysex: false });
  const out = portName
    ? [...access.outputs.values()].find(p => p.name === portName)
    : [...access.outputs.values()][0];
  if (!out) throw new Error('No MIDI output available.');

  const t0 = performance.now() + 150;        // ms, same clock as send()

  for (const e of timed.events) {
    const at = t0 + e.time * 1000;
    if (e.kind === 'noteOn') {
      out.send([0x90 | e.channel, e.note, e.velocity], at);
    } else if (e.kind === 'noteOff') {
      out.send([0x80 | e.channel, e.note, 0], at);
    } else if (e.kind === 'raw') {
      out.send(e.oneByte ? [e.status, e.d1] : [e.status, e.d1, e.d2], at);
    }
  }

  return {
    stop() {
      out.clear();                           // drop anything still queued
      for (let ch = 0; ch < 16; ch++) out.send([0xB0 | ch, 123, 0]);
    },
    duration: timed.duration
  };
}

Two details carry their weight here. out.clear() is the method almost nobody knows exists — it drops every message that has been queued but not yet sent, which is the only way to stop a file you scheduled in one burst. And the All Notes Off sweep afterwards catches any note whose Note On already went out before you hit stop.

For a long file, scheduling every event up front is wasteful. Switch to the lookahead pattern — schedule the next few seconds, refill on a timer — exactly as described in the Web Audio scheduling article. Same technique, same reasons.

What about playing a controller through the browser?

That is the live half, and it is a midimessage listener plus whatever you want to do with the bytes.

If what you want is to see whether your keyboard, pad or controller is sending anything at all, you do not need to build anything — the visualiser does it: it requests non-sysex Web MIDI access, lists every input, lets you pick one, and renders incoming notes and control changes as a live WebGL scene. Note-on triggers a visual pulse and CC 1 drives the tempo of the animation, so you can confirm both note and continuous data in one glance.

It is also the fastest diagnostic there is when a device is misbehaving. If the port appears but nothing moves, the problem is the device or its channel. If the port does not appear, the problem is the OS or the permission, and the port troubleshooting checklist takes it from there.

What are the limits of a browser MIDI player?

Sound quality costs download size, Web MIDI is not available everywhere, and nothing survives the tab closing.

  • Sound. Convincing instruments mean megabytes of samples. There is no shortcut and no built-in browser soundfont to borrow.
  • Support. The Web Audio route works everywhere; the hardware route excludes Safari and all of iOS.
  • Polyphony. A dense orchestral file can mean hundreds of simultaneous voices, and a naive one-oscillator-per-note design will struggle.
  • Persistence. Close the tab and playback stops. Obvious, but it rules out using a browser as a playback machine in an installation.

None of those are reasons not to build one. They are reasons to be clear about what you are building: a viewer and a router, not a DAW.

FAQ

Can you play a MIDI file online in a browser?

Yes, but the browser has no built-in MIDI file player. You parse the Standard MIDI File yourself in JavaScript and then either synthesise the notes with the Web Audio API or send them out through the Web MIDI API to a synth.

Why does a MIDI file not contain any sound?

Because a Standard MIDI File is a score, not a recording. It stores note numbers, velocities, timing and control changes. The instrument that renders those into audio is entirely separate, which is why the same file sounds different on different devices.

Does playing a MIDI file need the Web MIDI API?

Only if you want the notes to leave the browser and reach a hardware or software synth. If the sound is going to be synthesised in the page, the Web Audio API is sufficient and it works in Safari and on iOS where Web MIDI does not.

Does a browser MIDI player upload my file?

A well-built one does not. The File API gives a page direct access to a chosen local file, so parsing and playback can happen entirely in the tab with no network request at all.

What is the difference between ticks and seconds in a MIDI file?

MIDI files store timing as delta ticks relative to the division value in the header, usually ticks per quarter note. Converting to seconds requires the tempo map, built from the set-tempo meta events, because tempo can change many times within a file.

What is running status in a MIDI file?

A compression technique where a status byte is omitted when it is the same as the previous event, so only the data bytes appear. A parser that does not handle it will desynchronise partway through almost every real file.

Why does the same MIDI file sound completely different in two players?

Because the sound comes from the player, not the file. One may use a General MIDI soundfont, another a synthesised approximation, another your hardware synth. The note data is identical; the instrument is not.

Can I play a MIDI file out to my hardware synth from a web page?

Yes, on desktop Chrome, Edge, Opera or Firefox. Parse the file, then send the events to a MIDIOutput with a scheduled timestamp. Safari has no Web MIDI, so that route is unavailable there.

Play something, then play it yourself

The parser above is complete and runs in any browser; the hardware route needs desktop Chrome, Edge or Firefox. Start by confirming the browser can see your gear at all with the visualiser, then feed it a file and watch the notes land.

Playing a file is the passive half. The interesting half is playing MIDI with your hands, and you almost certainly already own a controller for it. Universal Controller MIDI turns a PS5 DualSense, Xbox pad, Switch Pro controller or 8BitDo into a real MIDI device on macOS, Windows and Linux — no browser required, no permission prompt, available to every application at once. Free tier: one controller, one virtual port, three saved presets. Pro: AUD $89 one-time.

Keep reading

More setup walkthroughs