Introduction
The Web MIDI API opens a fascinating intersection between web technology and music hardware, enabling browsers to communicate directly with MIDI instruments, controllers, and synthesizers. For decades, MIDI (Musical Instrument Digital Interface) has been the universal protocol connecting electronic musical instruments, and now web developers can tap into this ecosystem without plugins or native applications.
Since its origin in 1983 as a standard for connecting musical instruments from different manufacturers, MIDI has evolved from a simple serial communication protocol into a comprehensive system for controlling music hardware, lighting rigs, and even stage effects. The Web MIDI API, first specified in 2015 and gaining broader browser support through Chrome, Edge, and Opera (with polyfills for Firefox and Safari), brings this powerful protocol directly to the web platform.
This guide explores the Web MIDI API's capabilities in depth—from enumerating connected devices and receiving note data to sending complex control messages and building full-featured music applications. Whether you're building a virtual instrument, a DAW controller, or an interactive music installation, understanding the Web MIDI API is essential for modern music application development.
Understanding Web MIDI API: Core Concepts
The Web MIDI API provides a standardized interface for enumerating, connecting to, and communicating with MIDI devices from the browser. It operates on top of the Web MIDI specification published by the W3C and requires user permission to access hardware devices.
How MIDI Communication Works
MIDI communicates through a message-based protocol where devices exchange small packets of data representing musical events. A MIDI message consists of a status byte followed by one or two data bytes:
- Note On (0x90-0x9F): Triggers a note with channel, note number (0-127), and velocity (0-127)
- Note Off (0x80-0x8F): Stops a note with channel, note number, and release velocity
- Control Change (0xB0-0xBF): Sends controller data for knobs, faders, and switches
- Program Change (0xC0-0xCF): Changes instrument presets or patches
- Pitch Bend (0xE0-0xEF): Sends pitch wheel position as a 14-bit value
- System Exclusive (0xF0-0xF7): Manufacturer-specific data for deep device configuration
Each MIDI message operates on one of 16 channels, allowing a single connection to control multiple instruments or sound generators simultaneously. This channel-based system is fundamental to how MIDI orchestrates complex musical setups.
The Web MIDI API Architecture
The API follows the standard Web platform pattern using navigator.requestMIDIAccess() to obtain a MIDIAccess object. This object provides two collections:
MIDIInputs: Represent connected devices that send MIDI data to your application—keyboards, drum pads, wind controllers, and other instruments.
MIDIOutputs: Represent connected devices that receive MIDI data from your application—synthesizers, sound modules, DAW software, and lighting controllers.
Each input and output is represented by a MIDIPort object with properties like name, manufacturer, state, and connection. The API fires events when devices are connected or disconnected, enabling dynamic hardware management.
MIDI Message Format
A MIDI message is structured as a Uint8Array with specific byte meanings:
// Note On: channel 0, note 60 (middle C), velocity 100
const noteOn = new Uint8Array([0x90, 60, 100]);
// Note Off: channel 0, note 60, velocity 0
const noteOff = new Uint8Array([0x80, 60, 0]);
// Control Change: channel 0, controller 7 (volume), value 127
const volumeMax = new Uint8Array([0xB0, 7, 127]);The status byte (first byte) encodes both the message type and the channel. The upper nibble (4 bits) determines the message type, while the lower nibble specifies the channel number (0-15).
Permissions and Security
The Web MIDI API requires explicit user permission through the browser's permission system. When you call navigator.requestMIDIAccess(), the browser will prompt the user to allow MIDI access. This is a one-time permission that covers all MIDI devices, though individual device access may require additional prompts depending on the browser.
For security reasons, the API is only available in secure contexts (HTTPS) in production environments. Local development on localhost is exempt from this requirement.
Architecture and Design Patterns
Building robust MIDI applications requires careful architectural planning. The asynchronous nature of MIDI I/O, combined with the real-time requirements of music applications, demands specific patterns for handling device connections, message routing, and state management.
The MIDI Manager Pattern
A central MIDI manager handles device enumeration, connection lifecycle, and message routing:
class MIDIManager {
constructor() {
this.inputs = new Map();
this.outputs = new Map();
this.listeners = new Map();
this.access = null;
}
async initialize() {
this.access = await navigator.requestMIDIAccess({ sysex: false });
this.access.inputs.forEach(input => this._addInput(input));
this.access.outputs.forEach(output => this._addOutput(output));
this.access.addEventListener('statechange', (e) => {
this._handleStateChange(e.port);
});
return this;
}
_addInput(input) {
this.inputs.set(input.id, input);
input.addEventListener('midimessage', (e) => {
this._routeMessage(input.id, e.data);
});
this._emit('input-connected', { id: input.id, name: input.name });
}
_addOutput(output) {
this.outputs.set(output.id, output);
this._emit('output-connected', { id: output.id, name: output.name });
}
_handleStateChange(port) {
if (port.state === 'connected' && port.connection === 'open') {
if (port.type === 'input') this._addInput(port);
else this._addOutput(port);
} else if (port.state === 'disconnected') {
if (port.type === 'input') this.inputs.delete(port.id);
else this.outputs.delete(port.id);
this._emit('port-disconnected', { id: port.id, type: port.type });
}
}
_routeMessage(inputId, data) {
const status = data[0] & 0xF0;
const channel = data[0] & 0x0F;
const event = {
inputId,
status,
channel,
data,
timestamp: performance.now(),
};
const typeName = this._getStatusName(status);
this._emit(typeName, event);
this._emit('message', event);
}
_getStatusName(status) {
const names = {
0x80: 'noteoff',
0x90: 'noteon',
0xA0: 'aftertouch',
0xB0: 'controlchange',
0xC0: 'programchange',
0xD0: 'channelaftertouch',
0xE0: 'pitchbend',
};
return names[status] || 'unknown';
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event).add(callback);
return () => this.listeners.get(event)?.delete(callback);
}
_emit(event, data) {
this.listeners.get(event)?.forEach(cb => cb(data));
}
send(outputId, data, timestamp) {
const output = this.outputs.get(outputId);
if (output) {
output.send(data, timestamp);
}
}
sendToAll(data, timestamp) {
this.outputs.forEach(output => {
output.send(data, timestamp);
});
}
getInputList() {
return Array.from(this.inputs.values()).map(i => ({
id: i.id,
name: i.name,
manufacturer: i.manufacturer,
state: i.state,
}));
}
getOutputList() {
return Array.from(this.outputs.values()).map(o => ({
id: o.id,
name: o.name,
manufacturer: o.manufacturer,
state: o.state,
}));
}
}Message Parsing and Dispatching
Raw MIDI bytes need parsing into meaningful musical events. A parser converts bytes into structured objects:
class MIDIParser {
static parse(data) {
const status = data[0] & 0xF0;
const channel = data[0] & 0x0F;
switch (status) {
case 0x80:
return {
type: 'noteoff',
channel,
note: data[1],
velocity: data[2],
frequency: this.noteToFrequency(data[1]),
name: this.noteToName(data[1]),
};
case 0x90:
return {
type: data[2] === 0 ? 'noteoff' : 'noteon',
channel,
note: data[1],
velocity: data[2],
frequency: this.noteToFrequency(data[1]),
name: this.noteToName(data[1]),
};
case 0xB0:
return {
type: 'controlchange',
channel,
controller: data[1],
value: data[2],
controllerName: this.getControllerName(data[1]),
};
case 0xE0:
return {
type: 'pitchbend',
channel,
value: (data[2] << 7) | data[1],
normalized: ((data[2] << 7) | data[1] - 8192) / 8192,
};
default:
return { type: 'unknown', data, status, channel };
}
}
static noteToFrequency(note) {
return 440 * Math.pow(2, (note - 69) / 12);
}
static noteToName(note) {
const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(note / 12) - 1;
return `${names[note % 12]}${octave}`;
}
static getControllerName(cc) {
const controllers = {
0: 'Bank Select',
1: 'Modulation Wheel',
7: 'Channel Volume',
10: 'Pan',
11: 'Expression',
64: 'Sustain Pedal',
121: 'All Controllers Off',
123: 'All Notes Off',
};
return controllers[cc] || `CC ${cc}`;
}
}Step-by-Step Implementation
Setting Up a Basic MIDI Connection
Start with a minimal HTML/JavaScript setup:
<!DOCTYPE html>
<html>
<head>
<title>Web MIDI Demo</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.device-list { background: #f5f5f5; padding: 15px; border-radius: 8px; margin: 10px 0; }
.message-log { height: 300px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4;
padding: 15px; border-radius: 8px; font-family: monospace; font-size: 13px; }
.status { padding: 8px 12px; border-radius: 4px; margin: 5px 0; }
.connected { background: #d4edda; color: #155724; }
.disconnected { background: #f8d7da; color: #721c24; }
.note-display { display: inline-block; width: 40px; height: 40px; text-align: center;
line-height: 40px; margin: 2px; border-radius: 4px; background: #e3f2fd; }
.note-display.active { background: #2196f3; color: white; }
</style>
</head>
<body>
<h1>Web MIDI API Demo</h1>
<div id="status" class="status disconnected">Checking MIDI support...</div>
<div class="device-list">
<h3>Connected Devices</h3>
<div id="devices">No devices found</div>
</div>
<div class="device-list">
<h3>Piano Roll</h3>
<div id="piano"></div>
</div>
<h3>Message Log</h3>
<div id="log" class="message-log"></div>
<script>
let midiAccess = null;
let activeNotes = new Set();
async function initMIDI() {
const statusEl = document.getElementById('status');
if (!navigator.requestMIDIAccess) {
statusEl.textContent = 'Web MIDI API not supported in this browser';
statusEl.className = 'status disconnected';
return;
}
try {
midiAccess = await navigator.requestMIDIAccess({ sysex: false });
statusEl.textContent = 'MIDI access granted';
statusEl.className = 'status connected';
updateDeviceList();
setupListeners();
createPianoRoll();
midiAccess.addEventListener('statechange', updateDeviceList);
} catch (err) {
statusEl.textContent = `MIDI access denied: ${err.message}`;
statusEl.className = 'status disconnected';
}
}
function updateDeviceList() {
const devicesEl = document.getElementById('devices');
const devices = [];
midiAccess.inputs.forEach(input => {
devices.push(`🎹 Input: ${input.name} (${input.manufacturer || 'Unknown'})`);
});
midiAccess.outputs.forEach(output => {
devices.push(`🔊 Output: ${output.name} (${output.manufacturer || 'Unknown'})`);
});
devicesEl.innerHTML = devices.length ? devices.join('<br>') : 'No devices found';
}
function setupListeners() {
midiAccess.inputs.forEach(input => {
input.addEventListener('midimessage', handleMIDIMessage);
});
}
function handleMIDIMessage(event) {
const data = event.data;
const status = data[0] & 0xF0;
const channel = data[0] & 0x0F;
const note = data[1];
const velocity = data[2];
const logEl = document.getElementById('log');
const timestamp = new Date().toLocaleTimeString();
let message = '';
let noteName = getNoteName(note);
if (status === 0x90 && velocity > 0) {
message = `[${timestamp}] Note ON: ${noteName} (MIDI ${note}) Velocity: ${velocity}`;
activeNotes.add(note);
} else if (status === 0x80 || (status === 0x90 && velocity === 0)) {
message = `[${timestamp}] Note OFF: ${noteName} (MIDI ${note})`;
activeNotes.delete(note);
} else if (status === 0xB0) {
message = `[${timestamp}] CC: Controller ${note} Value: ${velocity}`;
} else if (status === 0xE0) {
const pitchValue = (velocity << 7) | note;
message = `[${timestamp}] Pitch Bend: ${pitchValue}`;
}
if (message) {
logEl.innerHTML += message + '<br>';
logEl.scrollTop = logEl.scrollHeight;
}
updatePianoDisplay();
}
function getNoteName(note) {
const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
return `${names[note % 12]}${Math.floor(note / 12) - 1}`;
}
function createPianoRoll() {
const piano = document.getElementById('piano');
piano.innerHTML = '';
for (let note = 48; note <= 71; note++) {
const noteEl = document.createElement('div');
noteEl.className = 'note-display';
noteEl.id = `note-${note}`;
noteEl.textContent = getNoteName(note);
noteEl.title = `MIDI Note ${note}`;
piano.appendChild(noteEl);
}
}
function updatePianoDisplay() {
document.querySelectorAll('.note-display').forEach(el => {
const note = parseInt(el.id.replace('note-', ''));
el.classList.toggle('active', activeNotes.has(note));
});
}
initMIDI();
</script>
</body>
</html>Building a MIDI-Controlled Synthesizer
Now let's create a synthesizer that responds to MIDI input using the Web Audio API:
class MIDISynthesizer {
constructor() {
this.audioContext = null;
this.oscillators = new Map();
this.gainNode = null;
this.filterNode = null;
this.reverbNode = null;
}
async initialize() {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
this.gainNode = this.audioContext.createGain();
this.gainNode.gain.value = 0.3;
this.filterNode = this.audioContext.createBiquadFilter();
this.filterNode.type = 'lowpass';
this.filterNode.frequency.value = 2000;
this.filterNode.Q.value = 5;
this.reverbNode = this.audioContext.createConvolver();
const reverbBuffer = await this.createReverbImpulse(2, 3);
this.reverbNode.buffer = reverbBuffer;
this.dryGain = this.audioContext.createGain();
this.wetGain = this.audioContext.createGain();
this.dryGain.gain.value = 0.7;
this.wetGain.gain.value = 0.3;
this.filterNode.connect(this.gainNode);
this.gainNode.connect(this.dryGain);
this.gainNode.connect(this.reverbNode);
this.reverbNode.connect(this.wetGain);
this.output = this.audioContext.destination;
this.dryGain.connect(this.output);
this.wetGain.connect(this.output);
return this;
}
async createReverbImpulse(duration, decay) {
const sampleRate = this.audioContext.sampleRate;
const length = sampleRate * duration;
const impulse = this.audioContext.createBuffer(2, length, sampleRate);
for (let channel = 0; channel < 2; channel++) {
const data = impulse.getChannelData(channel);
for (let i = 0; i < length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / length, decay);
}
}
return impulse;
}
noteOn(note, velocity = 100) {
if (this.oscillators.has(note)) {
this.noteOff(note);
}
const frequency = 440 * Math.pow(2, (note - 69) / 12);
const gain = velocity / 127;
const osc = this.audioContext.createOscillator();
const oscGain = this.audioContext.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(frequency, this.audioContext.currentTime);
const now = this.audioContext.currentTime;
oscGain.gain.setValueAtTime(0, now);
oscGain.gain.linearRampToValueAtTime(gain, now + 0.01);
oscGain.gain.linearRampToValueAtTime(gain * 0.7, now + 0.1);
oscGain.gain.setValueAtTime(gain * 0.7, now + 0.1);
osc.connect(oscGain);
oscGain.connect(this.filterNode);
osc.start(now);
this.oscillators.set(note, { osc, gain: oscGain });
}
noteOff(note) {
const voice = this.oscillators.get(note);
if (!voice) return;
const now = this.audioContext.currentTime;
voice.gain.gain.cancelScheduledValues(now);
voice.gain.gain.setValueAtTime(voice.gain.gain.value, now);
voice.gain.gain.linearRampToValueAtTime(0, now + 0.3);
voice.osc.stop(now + 0.3);
this.oscillators.delete(note);
}
setFilterFrequency(value) {
const freq = 20 + (value / 127) * 19980;
this.filterNode.frequency.setValueAtTime(freq, this.audioContext.currentTime);
}
setResonance(value) {
const q = 0.5 + (value / 127) * 29.5;
this.filterNode.Q.setValueAtTime(q, this.audioContext.currentTime);
}
setVolume(value) {
const volume = value / 127;
this.gainNode.gain.setValueAtTime(volume, this.audioContext.currentTime);
}
allNotesOff() {
this.oscillators.forEach((_, note) => {
this.noteOff(note);
});
}
}
async function setupMIDISynth() {
const synth = new MIDISynthesizer();
await synth.initialize();
const midi = await navigator.requestMIDIAccess();
midi.inputs.forEach(input => {
input.addEventListener('midimessage', (e) => {
const status = e.data[0] & 0xF0;
const note = e.data[1];
const velocity = e.data[2];
switch (status) {
case 0x90:
if (velocity > 0) synth.noteOn(note, velocity);
else synth.noteOff(note);
break;
case 0x80:
synth.noteOff(note);
break;
case 0xB0:
handleControlChange(synth, note, velocity);
break;
}
});
});
return synth;
}
function handleControlChange(synth, controller, value) {
switch (controller) {
case 7: synth.setVolume(value); break;
case 71: synth.setResonance(value); break;
case 74: synth.setFilterFrequency(value); break;
case 123: synth.allNotesOff(); break;
}
}Real-World Use Cases and Case Studies
Use Case 1: Online Music Education Platforms
Platforms like Synthesia and Playground Sessions use Web MIDI to connect student keyboards for interactive lessons. The browser receives note data from the student's keyboard, compares it against the expected notes in real-time, and provides immediate visual feedback. This eliminates the need for dedicated desktop software and works across any device with a MIDI connection.
The Web MIDI API enables these platforms to:
- Detect which notes the student plays with millisecond precision
- Provide real-time visual feedback on a virtual piano roll
- Track practice statistics and progress over time
- Support any MIDI keyboard without driver installation
Use Case 2: Browser-Based DAW Controllers
Applications like TouchOSC and Lemur have browser-based alternatives that use Web MIDI to control Digital Audio Workstations. Developers create custom control surfaces that send MIDI messages to DAWs like Ableton Live, Logic Pro, or FL Studio, allowing musicians to control their software from any web-enabled device.
These controllers typically use Control Change messages to map physical knobs and faders to software parameters, with bidirectional communication allowing the web interface to reflect changes made in the DAW.
Use Case 3: Interactive Music Installations
Art museums and public installations use Web MIDI to create interactive music experiences. Visitors connect MIDI controllers to a browser-based application that generates visuals and sounds based on their input. The portability of web technology makes deployment straightforward—any laptop with a browser becomes the installation's brain.
Use Case 4: Collaborative Music Making
Applications like BandLab and Soundtrap use Web MIDI as part of their collaborative music production platforms. Musicians connect their MIDI devices and contribute to shared projects in real-time, with MIDI data synchronized across participants through WebSocket connections. The Web MIDI API handles the local hardware interface while the collaboration layer manages the network synchronization.
Best Practices
-
Handle device hot-plugging gracefully: MIDI devices can be connected and disconnected at any time. Always listen for
statechangeevents on theMIDIAccessobject and update your application state accordingly. Don't assume a device will remain connected throughout a session. -
Use timestamps for precise timing: The Web MIDI API provides high-resolution timestamps with each message. Use these timestamps (not
Date.now()) for scheduling audio events, as they're synchronized with the audio clock. This prevents timing drift between MIDI input and audio output. -
Implement MIDI learn for user-configurable mappings: Allow users to assign MIDI controllers to application parameters by listening for the first Control Change message received after entering "learn" mode. Store these mappings in localStorage for persistence.
-
Debounce rapid controller changes: When receiving continuous controller data (like a fader being moved), debounce the updates to prevent overwhelming your UI or audio engine. A 16ms debounce (one frame at 60fps) provides smooth updates without excessive processing.
-
Provide fallback for non-MIDI browsers: Not all browsers support Web MIDI. Detect API availability with
if ('requestMIDIAccess' in navigator)and provide alternative input methods (virtual keyboard, mouse-based controls) for unsupported environments. -
Respect user privacy: MIDI device names can be unique identifiers. Be cautious about transmitting device information to servers, as it could be used for fingerprinting. Only collect device information when necessary and with user consent.
-
Use System Exclusive messages sparingly: SysEx messages require the
sysex: trueoption when requesting MIDI access and can send arbitrary data to devices. This poses security risks, so only request SysEx access when your application genuinely needs it.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Not handling device disconnection | App crashes | Listen for statechange events |
| Ignoring MIDI timestamps | Timing drift | Use event timestamps for scheduling |
| Requesting SysEx unnecessarily | Extra permission prompts | Only request sysex: true when needed |
| Not debouncing controller input | UI lag | Debounce at 16ms intervals |
| Hardcoding device names | Portability issues | Use dynamic device discovery |
| Missing error handling for send() | Silent failures | Wrap send() in try/catch |
| Not providing fallback input | Broken experience for non-MIDI users | Add virtual keyboard or mouse input |
Comparison with Alternatives
| Feature | Web MIDI API | Web Serial API | Native MIDI Libraries |
|---|---|---|---|
| Protocol | MIDI | Raw serial | MIDI |
| Browser Support | Chrome, Edge, Opera | Chrome, Edge | N/A (native) |
| Device Discovery | Automatic | Manual | Automatic |
| SysEx Support | Yes (with permission) | Yes | Yes |
| Latency | Low | Medium | Very Low |
| Ease of Use | High | Medium | Medium |
| Cross-Platform | Yes | Yes | Platform-specific |
Advanced Patterns and Techniques
MIDI Learn Implementation
class MIDILearnManager {
constructor() {
this.learning = false;
this.targetParam = null;
this.mappings = new Map();
}
startLearning(paramName) {
this.learning = true;
this.targetParam = paramName;
}
handleMIDIEvent(event) {
if (!this.learning) return false;
const status = event.data[0] & 0xF0;
if (status === 0xB0) { // Control Change
const controller = event.data[1];
this.mappings.set(this.targetParam, {
type: 'cc',
channel: event.data[0] & 0x0F,
controller,
});
this.learning = false;
this.targetParam = null;
this.saveMappings();
return true;
}
return false;
}
saveMappings() {
localStorage.setItem('midi-mappings', JSON.stringify(Array.from(this.mappings.entries())));
}
loadMappings() {
const stored = localStorage.getItem('midi-mappings');
if (stored) {
this.mappings = new Map(JSON.parse(stored));
}
}
}MIDI Clock Synchronization
class MIDIClockSync {
constructor() {
this.bpm = 120;
this.ppq = 24; // Pulses per quarter note
this.tickCount = 0;
this.lastTickTime = 0;
}
handleClockMessage(event) {
const status = event.data[0];
if (status === 0xF8) { // Timing Clock
const now = performance.now();
if (this.lastTickTime > 0) {
const delta = now - this.lastTickTime;
this.bpm = 60000 / (delta * this.ppq);
}
this.lastTickTime = now;
this.tickCount++;
if (this.tickCount % this.ppq === 0) {
// Beat boundary
this.onBeat?.(Math.floor(this.tickCount / this.ppq));
}
}
if (status === 0xFA) { // Start
this.tickCount = 0;
this.lastTickTime = 0;
}
if (status === 0xFC) { // Stop
this.tickCount = 0;
}
}
}React Hook for MIDI
import { useState, useEffect, useCallback } from 'react';
interface MIDIDevice {
id: string;
name: string;
manufacturer: string;
state: string;
}
export function useMIDI() {
const [access, setAccess] = useState<MIDIAccess | null>(null);
const [inputs, setInputs] = useState<MIDIDevice[]>([]);
const [outputs, setOutputs] = useState<MIDIDevice[]>([]);
const [lastMessage, setLastMessage] = useState<Uint8Array | null>(null);
useEffect(() => {
async function init() {
if (!navigator.requestMIDIAccess) return;
const midiAccess = await navigator.requestMIDIAccess({ sysex: false });
setAccess(midiAccess);
const updateDevices = () => {
setInputs(Array.from(midiAccess.inputs.values()).map(p => ({
id: p.id, name: p.name || '', manufacturer: p.manufacturer || '', state: p.state || '',
})));
setOutputs(Array.from(midiAccess.outputs.values()).map(p => ({
id: p.id, name: p.name || '', manufacturer: p.manufacturer || '', state: p.state || '',
})));
};
updateDevices();
midiAccess.addEventListener('statechange', updateDevices);
midiAccess.inputs.forEach(input => {
input.addEventListener('midimessage', (e) => {
setLastMessage(new Uint8Array(e.data));
});
});
}
init();
}, []);
const send = useCallback((outputId: string, data: Uint8Array) => {
const output = access?.outputs.get(outputId);
output?.send(data);
}, [access]);
return { inputs, outputs, lastMessage, send };
}Testing Strategies
// midi-manager.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock Web MIDI API
const mockInput = {
id: 'input-1',
name: 'Test Keyboard',
manufacturer: 'Test',
type: 'input',
state: 'connected',
addEventListener: vi.fn(),
};
const mockMIDIAccess = {
inputs: new Map([['input-1', mockInput]]),
outputs: new Map(),
addEventListener: vi.fn(),
};
Object.defineProperty(navigator, 'requestMIDIAccess', {
value: vi.fn().mockResolvedValue(mockMIDIAccess),
});
describe('MIDIManager', () => {
it('initializes MIDI access', async () => {
const manager = new MIDIManager();
await manager.initialize();
expect(navigator.requestMIDIAccess).toHaveBeenCalled();
expect(manager.inputs.size).toBe(1);
});
it('registers message listeners on inputs', async () => {
const manager = new MIDIManager();
await manager.initialize();
expect(mockInput.addEventListener).toHaveBeenCalledWith(
'midimessage',
expect.any(Function)
);
});
it('parses note on messages correctly', () => {
const parsed = MIDIParser.parse(new Uint8Array([0x90, 60, 100]));
expect(parsed.type).toBe('noteon');
expect(parsed.channel).toBe(0);
expect(parsed.note).toBe(60);
expect(parsed.velocity).toBe(100);
expect(parsed.name).toBe('C4');
});
});Performance Optimization
MIDI applications have strict timing requirements. Optimize for low latency:
- Use
performance.now()timestamps: The Web MIDI API timestamps are based on the same clock asperformance.now(), providing microsecond precision for scheduling. - Batch UI updates: When receiving rapid MIDI data, batch DOM updates to once per animation frame using
requestAnimationFrame. - Avoid garbage collection pauses: Reuse
Uint8Arraybuffers instead of creating new ones for each message. - Use Web Workers for heavy processing: Offload MIDI file parsing or complex audio processing to a Web Worker to keep the main thread responsive.
Conclusion
The Web MIDI API brings the power of MIDI hardware control directly to the browser, enabling a new generation of music applications that require no installation or platform-specific code. From interactive education platforms to professional DAW controllers, the API provides the foundation for building sophisticated music software that runs anywhere.
Key takeaways:
- MIDI messages are simple: Status byte + data bytes, with clear bit-level encoding for message types and channels
- Device management is event-driven: Listen for statechange events to handle hot-plugging
- Timestamps are critical: Use the built-in timestamps for precise audio scheduling
- Provide fallbacks: Not all browsers support Web MIDI—always offer alternative input methods
- Combine with Web Audio API: MIDI input + Web Audio synthesis creates complete browser-based instruments
The Web MIDI API specification continues to evolve, with proposals for MIDI 2.0 support and improved device enumeration. As browser support expands and the specification matures, web-based MIDI applications will become increasingly powerful and accessible.
For more information, explore the W3C Web MIDI API specification and the Web Audio API documentation.