MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web MIDI API: Music and Hardware Control

Control MIDI devices from the browser: note input, device enumeration, and music apps.

Web MIDIMusicHardwareFrontend

By MinhVo

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.

MIDI controller setup with laptop

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.

Music production workspace

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;
  }
}

Music production setup with synthesizers

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

  1. Handle device hot-plugging gracefully: MIDI devices can be connected and disconnected at any time. Always listen for statechange events on the MIDIAccess object and update your application state accordingly. Don't assume a device will remain connected throughout a session.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. Use System Exclusive messages sparingly: SysEx messages require the sysex: true option 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

PitfallImpactSolution
Not handling device disconnectionApp crashesListen for statechange events
Ignoring MIDI timestampsTiming driftUse event timestamps for scheduling
Requesting SysEx unnecessarilyExtra permission promptsOnly request sysex: true when needed
Not debouncing controller inputUI lagDebounce at 16ms intervals
Hardcoding device namesPortability issuesUse dynamic device discovery
Missing error handling for send()Silent failuresWrap send() in try/catch
Not providing fallback inputBroken experience for non-MIDI usersAdd virtual keyboard or mouse input

Comparison with Alternatives

FeatureWeb MIDI APIWeb Serial APINative MIDI Libraries
ProtocolMIDIRaw serialMIDI
Browser SupportChrome, Edge, OperaChrome, EdgeN/A (native)
Device DiscoveryAutomaticManualAutomatic
SysEx SupportYes (with permission)YesYes
LatencyLowMediumVery Low
Ease of UseHighMediumMedium
Cross-PlatformYesYesPlatform-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 as performance.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 Uint8Array buffers 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:

  1. MIDI messages are simple: Status byte + data bytes, with clear bit-level encoding for message types and channels
  2. Device management is event-driven: Listen for statechange events to handle hot-plugging
  3. Timestamps are critical: Use the built-in timestamps for precise audio scheduling
  4. Provide fallbacks: Not all browsers support Web MIDI—always offer alternative input methods
  5. 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.