# Web MIDI Permission, statechange and the Stuck Note

- Author: Abdullah Chaudary
- Category: Browser Creative
- Published: 2026-10-01T16:37:01.512Z
- Updated: 2026-10-01T16:37:01.587Z
- Reading time: 9 min
- Tags: Web MIDI API, JavaScript, Browser Permissions, Tone.js

> A Web MIDI app has three states to track: permission, device and connection. When an input disconnects, release every note it started, or the note sticks after the device returns.

**TL;DR:** A JavaScript Web MIDI app tracks three separate states: the `"midi"` permission, each port's device `state` (`connected` or `disconnected`) and its `connection` (`open`, `closed` or `pending`). Handling hot-plug through the `statechange` event is not enough on its own. When an input disconnects with a key held, the app has to release every note that port started, or the note keeps sounding, and in some handlers it also blocks that same note after the device comes back.

## What Happens When a MIDI Device Disconnects Mid-Note?

When a MIDI input disconnects mid-note, the browser fires `statechange` on the `MIDIAccess` object, the port's `state` becomes `disconnected` and its `connection` becomes `pending`. The browser never sends the note-off. Whatever the app was playing for that key keeps playing until the app releases it itself.

I built a test rig to watch that happen. It plays two notes from a virtual MIDI keyboard, releases one, and removes the device while the other is still held, then brings the device back and does it again. The device is a real ALSA sequencer port that a small C program creates and deletes, and Chromium 151 reports it through the standard Web MIDI `statechange` events. Here is the demo page's log for the first cycle, captured on 2026-10-01:

```text
[4161ms] noteOn  B04 Virtual Keyboard ch1 note 60 vel 100 (voices: 1)
[4461ms] noteOn  B04 Virtual Keyboard ch1 note 64 vel 90 (voices: 2)
[4762ms] noteOff B04 Virtual Keyboard ch1 note 60 (voices: 1)
[5067ms] statechange input "B04 Virtual Keyboard" state=disconnected connection=pending
[5067ms] released 1 stuck voice(s) from 90764E8A...2093 (device disconnected)
[7084ms] statechange input "B04 Virtual Keyboard" state=connected connection=open
```

Note 64 never got a note-off from the device. The demo released it because its `statechange` handler knows which voices belong to which port. Then I ran the same cycle through the MIDI handlers of [MusicGen, a browser synthesizer handling MIDI input and output in both directions](/projects/musicgen), copied verbatim from its `Synth.js` into a test page, with audio replaced by the same held-key bookkeeping. The handlers tracked devices cleanly: the input left the map on disconnect, came back on reconnect, and the input count in the console followed the device. The run ended with this line:

```text
held at end: [64] midiEnabled=true inputs=1
```

Note 64 was still held. On the second cycle, the reconnected device's note-on for 64 was dropped too, because `playNote` skips any key it already considers active. A handler that tracks devices correctly can still lose track of the notes those devices started.

## How Does the statechange Event Work in the Web MIDI API?

The `statechange` event fires on `MIDIAccess` whenever a port appears or an existing port changes state, and `event.port` is the `MIDIInput` or `MIDIOutput` concerned. MDN puts it this way: the event "is fired when a new MIDI port is added or when an existing port changes state." Each port carries two fields that change independently:

- **state** describes the device: `connected` or `disconnected`. The W3C spec adds that a disconnected device "should not appear in the relevant map of input and output ports," so `access.inputs` shrinks on its own.
- **connection** describes the page's use of the port: `open`, `closed` or `pending`.

`pending` is the state that matters for hot-plug. The spec defines it as a port that "has been opened (either implicitly or explicitly), but the device has subsequently been disconnected and is unavailable for use." If the device comes back, the browser tries to reopen it before telling the page, so the event "will reflect the final connection state as well as the device state."

The Chromium 151 capture followed the W3C spec's state model exactly:

1. A new input arrived as `connected` / `closed`, then fired a second event, `connected` / `open`, the moment the page assigned `onmidimessage`. Assigning the handler is what opens an input.
2. The opened input left as `disconnected` / `pending`. The output on the same device, never opened, left as `disconnected` / `closed`.
3. On reconnect, the input came back as `connected` / `open` in a single event, with the same port `id` it had before.

A reconnected Web MIDI port keeps the same `port.id`, so a voice, a mapping or the user's chosen input keyed by `port.id` can be restored when the device returns.

## How Should a JavaScript Synth Map MIDI Inputs to Voices?

A JavaScript synth should key every sounding voice by the port that started it, plus channel and note, so a disconnect can release exactly that port's notes and nothing else. Keying voices by note alone, or tracking only the set of held keys, loses the one fact the `statechange` handler needs.

This is the voice map and the hot-plug handler from the demo page (trimmed):

```js
// Voices keyed by port id + channel + note, so a disconnect
// can release exactly the notes that port started.
const voices = new Map();
const voiceKey = (portId, ch, note) => `${portId}|${ch}|${note}`;

function releaseAllFrom(portId, reason) {
  let n = 0;
  for (const [k, v] of voices) if (v.portId === portId) { voices.delete(k); n++; }
  if (n) log(`released ${n} stuck voice(s) from ${portId} (${reason})`);
}

access.addEventListener('statechange', (e) => {
  const p = e.port;
  if (p.type === 'input' && p.state === 'connected' && !p.onmidimessage) attach(p);
  if (p.type === 'input' && p.state === 'disconnected') releaseAllFrom(p.id, 'device disconnected');
  render(access); // re-read access.inputs and access.outputs, never a cached snapshot
});
```

In a real synth, `releaseAllFrom` would also call the release on each voice: with Tone.js, `triggerRelease` for that note on the `PolySynth`. The message handler needs one detail as well. A note-on with velocity 0 is a note-off, so the parser treats `0x90` with velocity 0 the same as `0x80`. MusicGen already handles that, and maps velocity onto Tone.js as `velocity / 127`.

The same test produced two smaller rules. Re-read `access.inputs` and `access.outputs` on every event instead of keeping a list from the first grant, since the maps are live and a disconnected device leaves them. And when an output disconnects, check whether it is the output in use before clearing the selection; a different device leaving says nothing about the one the user picked.

## What Is the Difference Between a Denied Permission and an Empty Device List?

A denied permission rejects the `requestMIDIAccess()` promise with `NotAllowedError`; an empty device list resolves it with a `MIDIAccess` whose `inputs` and `outputs` maps have size zero. They are different situations and need different screens: one asks the user to allow access, the other asks them to plug something in.

Chrome changed the first case in 2024. The Chrome team's announcement says "the entire Web MIDI API is now gated behind a permission prompt," rolling out from Chrome 124, and the Chrome 124 release notes state: "From Chrome 125, all access to the Web MIDI API requires a user permission." Before that, Chrome prompted only for System Exclusive (SysEx) access, so code written earlier may never have handled a rejection for plain MIDI.

The demo page ran the first two branches in headless Chromium 151; the spec and MDN's compatibility data define the other two. Four outcomes to plan for:

- **Not granted:** both permission queries returned `prompt`, then the request rejected with `NotAllowedError: Permission to use Web MIDI API was not granted.`
- **Granted, but no MIDI backend available:** the request rejected with `InvalidStateError: Platform dependent initialization failed.` `InvalidStateError` is a different failure from a refusal, and it deserves its own message.
- **Granted, no devices:** the promise resolves with a `MIDIAccess` whose maps are empty. Nothing went wrong yet; the user has nothing plugged in.
- **API missing:** `'requestMIDIAccess' in navigator` is false. That is the whole of Safari today.

`navigator.permissions.query({ name: 'midi' })` reads the permission without triggering a prompt, and accepts `sysex: true` as a separate, stronger descriptor. The spec states that `{name: "midi", sysex: true}` "is stronger than" the plain one. Only ask for SysEx when the app really sends it; Chrome's own guidance is to request it "only if your website absolutely needs this feature."

## How Do You Verify a Web MIDI App Survives Disconnect and Reconnect?

Verify it by driving a full cycle with a key held: connect, play, disconnect mid-note, reconnect, play the same note again, and assert that no voice is left at the end. One connection test proves enumeration. Only a disconnect with a note held proves the `statechange` handler.

The rig behind this article runs on Linux in three parts:

1. A C program of under 50 lines uses the ALSA sequencer API to create a port named "B04 Virtual Keyboard", send note-on and note-off events, delete the port while note 64 is held, and recreate it.
2. The official Playwright Docker image runs Chromium 151 with the host's `/dev/snd/seq` passed in, so the browser sees the virtual keyboard as a MIDI device. Playwright grants the `midi` and `midi-sysex` permissions up front, so no prompt blocks the run.
3. The page logs every `statechange`, every voice and every release, and the test reads the log and the voice map at the end.

The same rig ran the demo and MusicGen's handlers back to back, which is how the held note showed up: same virtual device, same event script, different handler. The demo ended both cycles with no voices held. On MusicGen's handlers the rig flagged `[64]`, the exact case it was built to catch.

## Which Browsers Support the Web MIDI API?

Chromium-based browsers support the Web MIDI API from Chrome 43, with a permission prompt for all MIDI access since Chrome 124 and 125. Firefox supports it from version 108, gated behind a site permission add-on. Safari does not support it on macOS or iOS, and Firefox for Android does not either.

MDN marks the API as not Baseline, "because it does not work in some of the most widely-used browsers." The details, from MDN's browser compatibility data and the vendors' own pages:

- **Chrome and Chromium-based Edge:** supported since Chrome 43, with Edge mirroring Chrome in MDN's data; permission prompt for all MIDI access rolled out from Chrome 124, required from 125.
- **Firefox:** supported since 108 in secure contexts. Per the Firefox 108 release notes, calls to `requestMIDIAccess()` "will prompt users with active MIDI devices to install a Site Permission Add-On, which is required to enable the API."
- **Safari (macOS and iOS):** not supported. WebKit's Web MIDI master bug, 107250, was opened in January 2013 and is still `NEW`.
- **Firefox for Android:** not supported.

Feature detection with `'requestMIDIAccess' in navigator` is a main path, not a fallback. In Safari on iPhone and iPad it is the only path, so the page needs a usable mode without MIDI.

## Limitations

- The device in these tests is a virtual ALSA sequencer port inside a Docker container, not a USB controller on a cable. The event sequence matched the W3C spec's state model; physical-unplug timings are outside this run.
- Every permission result comes from headless Chromium 151 under Playwright's permission overrides, not a person answering a real prompt. Under those overrides, granting only `midi` still rejected a plain `requestMIDIAccess()` call; it resolved only with `midi-sysex` granted as well. No fetched documentation explains that, so test permission flows in a headed browser too.
- Firefox and Safari were not run. Their status comes from MDN's compatibility data, the Firefox 108 release notes and the WebKit bug tracker.
- MIDI output was not exercised. The tests cover inputs, voices and port state.

A held note is the one bug in a MIDI app that a person in the room hears before any log shows it. This article opens the [Browser Creative category](/blog/category/browser-creative), alongside [the rest of the field notes from this rebuild](/blog). Which browser and operating system combinations are you testing Web MIDI on?

## References

- [W3C Web MIDI API, Working Draft 21 January 2025](https://www.w3.org/TR/webmidi/): the `"midi"` permission and SysEx descriptor, `statechange`, the `state` and `connection` enums, and the reopen rule for `pending` ports.
- [Chrome for Developers: Web MIDI permission prompt](https://developer.chrome.com/blog/web-midi-permission-prompt) and the [Chrome 124 release notes](https://developer.chrome.com/release-notes/124): the permission requirement for all Web MIDI access.
- MDN: [Navigator.requestMIDIAccess()](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/requestMIDIAccess), [MIDIAccess statechange event](https://developer.mozilla.org/en-US/docs/Web/API/MIDIAccess/statechange_event), [MIDIPort.connection](https://developer.mozilla.org/en-US/docs/Web/API/MIDIPort/connection) and the [Web MIDI API overview](https://developer.mozilla.org/en-US/docs/Web/API/Web_MIDI_API).
- [MDN browser compatibility data for requestMIDIAccess](https://raw.githubusercontent.com/mdn/browser-compat-data/main/api/Navigator.json) and the [Firefox 108 release notes](https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/108): browser support.
- [WebKit bug 107250](https://bugs.webkit.org/show_bug.cgi?id=107250): Safari's Web MIDI status.
- [MusicGen on GitHub](https://github.com/abdullahchaudary/musicgen) (`src/helpers/Synth.js`) and the demo page, C program and captures recorded for this article: the primary source for every log line above.
