JavaScript Web MIDI API
Learn the JavaScript Web MIDI API: request access, enumerate inputs and outputs, read MIDIMessageEvent data, and send MIDI messages to hardware.
The Web MIDI API lets a web page talk directly to MIDI hardware — keyboards, drum pads, control surfaces, and synthesizers — without any plugin or native app. You can read what a musician plays in real time and send notes and control changes back out to a sound module.
This guide covers the whole flow: requesting access, enumerating input and output ports, reading messages with MIDIMessageEvent, sending Note On/Off and Control Change messages, handling devices that are plugged in or removed while the page is open, and the secure-context and permission rules the API enforces.
What MIDI actually is
MIDI (Musical Instrument Digital Interface) does not carry audio. It carries small events — "this key went down with this much force," "this knob moved to this value." The browser exposes those events as raw bytes; turning them into sound is the job of a synthesizer, whether that's external hardware or your own code (for example, the Web Audio API).
A standard channel message is three bytes:
| Byte | Name | Meaning |
|---|---|---|
| 1 | Status | Message type (high nibble) + channel 0–15 (low nibble) |
| 2 | Data 1 | Note number (0–127), or controller number |
| 3 | Data 2 | Velocity (0–127), or controller value |
Common status bytes (channel 1, where the channel nibble is 0):
0x90— Note On (velocity0is treated as Note Off)0x80— Note Off0xB0— Control Change (sustain pedal, modulation, volume, …)0xE0— Pitch Bend
Note number 60 is Middle C; velocity 0–127 describes how hard the key was struck.
Requesting access
Everything starts with navigator.requestMIDIAccess(). It returns a Promise that resolves to a MIDIAccess object holding the available ports. The browser may prompt the user for permission on first use.
async function initMIDI() {
// Feature-detect before calling — support is not universal.
if (!navigator.requestMIDIAccess) {
console.warn('Web MIDI API is not supported in this browser.');
return;
}
try {
// Pass { sysex: true } only if you genuinely need System Exclusive messages —
// it triggers a stricter, separate permission prompt.
const midiAccess = await navigator.requestMIDIAccess({ sysex: false });
console.log('MIDI access granted', midiAccess);
return midiAccess;
} catch (err) {
console.error('Could not access MIDI devices:', err);
}
}Secure context and permissions
The Web MIDI API only works in a secure context — pages served over https:// (or http://localhost during development). Calling requestMIDIAccess() on an insecure page rejects the promise.
Access is also gated by the Permissions Policy and a user permission prompt. If the user denies it (or a Permissions-Policy: midi=() header blocks the feature), the promise rejects — which is why the call is wrapped in try/catch. Requesting sysex: true asks for a higher privilege level and prompts separately, because SysEx messages can reprogram a device, so request it only when needed.
Enumerating inputs and outputs
MIDIAccess exposes two Map-like collections — inputs (devices that send data to the browser) and outputs (devices the browser can send data to). Both are MIDIInputMap / MIDIOutputMap, so you iterate them like a Map, keyed by a stable port id.
function listPorts(midiAccess) {
console.log('Inputs:');
for (const input of midiAccess.inputs.values()) {
console.log(` ${input.name} (${input.manufacturer}) — ${input.state}`);
}
console.log('Outputs:');
for (const output of midiAccess.outputs.values()) {
console.log(` ${output.name} (${output.manufacturer}) — ${output.state}`);
}
}Each port has useful metadata: id, name, manufacturer, type ("input" or "output"), state ("connected" / "disconnected"), and connection ("open", "closed", or "pending").
Reading MIDI input
Attach an onmidimessage handler to an input port. Every event is a MIDIMessageEvent whose data property is a Uint8Array of the raw bytes (see Binary arrays for how typed arrays work). This is the same callback pattern you use elsewhere with JavaScript events.
function startListening(midiAccess) {
midiAccess.inputs.forEach((input) => {
input.onmidimessage = onMIDIMessage;
});
}
function onMIDIMessage(event) {
// event.data is a Uint8Array; channel messages are usually 3 bytes.
const [status, data1, data2] = event.data;
const command = status & 0xf0; // high nibble = message type
const channel = status & 0x0f; // low nibble = channel 0–15
switch (command) {
case 0x90: // Note On
if (data2 > 0) {
console.log(`Note On — note ${data1}, velocity ${data2}, ch ${channel}`);
} else {
console.log(`Note Off — note ${data1} (velocity 0)`);
}
break;
case 0x80: // Note Off
console.log(`Note Off — note ${data1}, ch ${channel}`);
break;
case 0xb0: // Control Change
console.log(`Control Change — controller ${data1}, value ${data2}`);
break;
default:
console.log('Other message:', Array.from(event.data));
}
}Masking the status byte with & 0xf0 and & 0x0f separates the message type from the channel, so one handler works no matter which of the 16 MIDI channels a device transmits on.
Sending MIDI output
To control external hardware or software, grab an output port and call output.send(data), where data is an array (or Uint8Array) of bytes.
function sendNote(midiAccess) {
const output = midiAccess.outputs.values().next().value; // first available port
if (!output) {
console.log('No MIDI outputs available.');
return;
}
output.send([0x90, 60, 100]); // Note On: Middle C, velocity 100, channel 1
output.send([0x80, 60, 0], performance.now() + 500); // Note Off scheduled 500 ms later
}send() accepts an optional timestamp (a DOMHighResTimeStamp from performance.now()). Scheduling the Note Off in the future is more reliable than setTimeout, because the timing is handled by the MIDI subsystem rather than the JavaScript event loop. Sending 0 with no timestamp means "right now."
Avoiding stuck notes
The single most common bug is a stuck note — a Note On with no matching Note Off, leaving the sound playing forever. Always pair them: track which notes are on and send Note Off when the key is released or the page unloads.
const activeNotes = new Set();
function noteOn(output, note, velocity = 100) {
output.send([0x90, note, velocity]);
activeNotes.add(note);
}
function noteOff(output, note) {
output.send([0x80, note, 0]);
activeNotes.delete(note);
}
// Panic: silence everything (e.g. on window 'pagehide')
function allNotesOff(output) {
for (const note of activeNotes) output.send([0x80, note, 0]);
activeNotes.clear();
}Handling hot-plugging
USB MIDI devices come and go while the page is open. Listen for statechange on the MIDIAccess object so you can attach handlers to newly connected inputs and update your UI when something is unplugged.
async function setupMIDI() {
const midiAccess = await navigator.requestMIDIAccess();
function attachInputHandlers() {
midiAccess.inputs.forEach((input) => {
input.onmidimessage = onMIDIMessage;
});
}
attachInputHandlers();
midiAccess.onstatechange = (event) => {
const port = event.port;
console.log(`${port.type} "${port.name}" is now ${port.state}`);
if (port.type === 'input' && port.state === 'connected') {
attachInputHandlers(); // wire up the device that just appeared
}
};
}Browser support and best practices
- Feature-detect with
if (navigator.requestMIDIAccess)before calling — Safari only added support relatively recently, and some environments disable it. - Serve over HTTPS (or
localhost); the secure-context requirement is not optional. - Request
sysex: trueonly when needed, since it triggers a stricter prompt. - Always pair Note On / Note Off and silence all notes on
pagehide/beforeunload. - Use
send()timestamps for tight timing instead ofsetTimeout. - For browser-generated sound (rather than external hardware), combine MIDI input with the Web Audio API.
Summary
The Web MIDI API gives web apps a direct, low-latency channel to musical hardware. Request a MIDIAccess object with navigator.requestMIDIAccess(), iterate its inputs and outputs, read incoming events from MIDIMessageEvent.data, and send three-byte messages with port.send(). Mind the secure-context and permission rules, handle device hot-plugging via statechange, and always clean up notes to avoid the classic stuck-note bug. From there you can build virtual keyboards, MIDI controllers, and instruments that play right in the browser.