Search for gamepad to MIDI JavaScript and you get a wall of GitHub repositories — GamepadMidiController, mijoco, MidiHID — all of which work, none of which explain themselves. This post is the explanation. We build the whole thing from scratch: the Gamepad API poll loop, the Web MIDI output, the mapping table, the learn mode, the diffing, the panic handler. No framework, no dependencies, roughly 150 lines. And at the end, an honest account of the four places where the browser simply cannot go further.
- Two APIs: Gamepad API in, Web MIDI out. Both secure-context, both desktop-Chromium-and-Firefox.
- The port must already exist. The browser cannot create a virtual MIDI port — set one up on the OS first.
- Poll, quantise, diff, send. That order. Diffing raw floats floods the port.
- Triggers are analogue. Use
button.value, notbutton.pressed. - Panic on visibilitychange. A hidden tab stops polling and hangs every held note.
- The ceiling: no background operation, no port creation, no gyro, no process persistence.
What are you actually building?
A page that reads a game controller sixty times a second and writes MIDI messages to a port that your DAW is listening on — making the gamepad indistinguishable, from the DAW's point of view, from a hardware MIDI controller.
The data path has four hops. The controller reports over USB or Bluetooth to the operating system. The browser's Gamepad implementation normalises that into a Gamepad snapshot. Your loop reads the snapshot and decides what MIDI to emit. The Web MIDI implementation hands those bytes to the OS MIDI subsystem, which delivers them to whatever has the port open.
Nothing in that chain is exotic. What makes bridges go wrong is almost always one of three things: the virtual port does not exist, the diffing is wrong so the port saturates, or nobody handled the disconnect and tab-hide cases so notes hang. We will deal with all three.
Why do you need a virtual MIDI port first?
Because the Web MIDI API has no way to create one — it can only enumerate and use ports the operating system already publishes.
Read the W3C Web MIDI specification end to end and you will find no port-creation surface. MIDIAccess has inputs and outputs, and that is it. Publishing a new virtual port means registering with CoreMIDI, the Windows MIDI stack or the ALSA sequencer, which is driver territory and firmly outside the web sandbox.
So before any code runs, create the port:
- macOS: Audio MIDI Setup, show the MIDI Studio window, open the IAC Driver and tick "Device is online". Full walkthrough in the IAC Driver guide.
- Windows: install loopMIDI and create a named port. Alternatives in the loopMIDI alternatives roundup.
- Linux: use an ALSA virtual MIDI port. See the ALSA guide.
The general background is in how to create a virtual MIDI port. Give the port a name you will recognise in a list — "Gamepad Bridge" beats "IAC Bus 1" at 2am.
How do you open both APIs safely?
Feature-detect each one separately, request MIDI from a user gesture, and treat "no gamepad yet" as a prompt rather than an error.
// bridge/boot.js
export const state = {
access: null, // MIDIAccess
output: null, // MIDIOutput
padIndex: null, // number
live: false
};
export function supported() {
return {
midi: typeof navigator.requestMIDIAccess === 'function',
gamepad: typeof navigator.getGamepads === 'function'
};
}
export async function connectMidi() {
const caps = supported();
if (!caps.midi) throw new Error('This browser has no Web MIDI.');
// Call this from a click handler. Chrome 124+ prompts for all MIDI access,
// not just sysex, and a prompt with no context gets dismissed.
const access = await navigator.requestMIDIAccess({ sysex: false });
state.access = access;
access.addEventListener('statechange', (e) => {
if (state.output && e.port.id === state.output.id && e.port.state === 'disconnected') {
state.output = null;
state.live = false;
}
renderPortList(access);
});
renderPortList(access);
return access;
}
export function selectOutput(portId) {
state.output = state.access?.outputs.get(portId) ?? null;
localStorage.setItem('bridge.outputId', portId);
state.live = Boolean(state.output);
}
Persisting portId rather than an index matters: MIDIPort.id is specified to be stable across sessions, whereas position in the map is not. On reload, look the saved id up first and fall back to the first connected output. The visualiser uses the same approach on its input side.
What does a mapping table look like?
One row per physical control, naming the source index, the MIDI message type, the channel, the number, and the value range.
Keeping the map as plain data rather than a switch statement is what makes a learn mode, a preset file and a UI possible later. Here is a complete default for a standard-mapping pad.
// bridge/map.js
// src: 'b' = button index, 'a' = axis index
// type: 'note' | 'cc'
// ch: 0-15 (wire channel, so 0 is "channel 1" in a DAW)
export const DEFAULT_MAP = [
// Face buttons -> drum notes on channel 10 (wire channel 9)
{ src: 'b', i: 0, type: 'note', ch: 9, num: 36 }, // Cross / A -> kick
{ src: 'b', i: 1, type: 'note', ch: 9, num: 38 }, // Circle / B -> snare
{ src: 'b', i: 2, type: 'note', ch: 9, num: 42 }, // Square / X -> closed hat
{ src: 'b', i: 3, type: 'note', ch: 9, num: 46 }, // Triangle/Y -> open hat
// D-pad -> clip launch notes on channel 1
{ src: 'b', i: 12, type: 'note', ch: 0, num: 60 },
{ src: 'b', i: 13, type: 'note', ch: 0, num: 61 },
{ src: 'b', i: 14, type: 'note', ch: 0, num: 62 },
{ src: 'b', i: 15, type: 'note', ch: 0, num: 63 },
// Shoulders -> toggles
{ src: 'b', i: 4, type: 'cc', ch: 0, num: 80 },
{ src: 'b', i: 5, type: 'cc', ch: 0, num: 81 },
// Analogue triggers -> expression and modulation
{ src: 'b', i: 6, type: 'cc', ch: 0, num: 11 }, // L2 -> expression
{ src: 'b', i: 7, type: 'cc', ch: 0, num: 1 }, // R2 -> mod wheel
// Sticks -> four CCs. Vertical axes are inverted so "up" increases.
{ src: 'a', i: 0, type: 'cc', ch: 0, num: 16 },
{ src: 'a', i: 1, type: 'cc', ch: 0, num: 17, invert: true },
{ src: 'a', i: 2, type: 'cc', ch: 0, num: 18 },
{ src: 'a', i: 3, type: 'cc', ch: 0, num: 19, invert: true }
];
Two decisions worth flagging. The vertical axes carry invert: true because the Gamepad spec defines axis 1 and 3 as negative-up, positive-down; musically, pushing a stick away from you should go up. And the CC numbers for the triggers are 11 and 1 because those are standardised as expression and modulation in the MIDI control change list, so most synths will respond without any mapping at all.
The bridge core
Poll, apply deadzone, quantise, diff, and send — in that order, every frame.
// bridge/core.js
import { state } from './boot.js';
const DEADZONE = 0.08;
const held = new Set(); // "ch:num" of notes currently on
const prev = { axes: [], buttons: [] };
function deadzone(v) {
if (Math.abs(v) < DEADZONE) return 0;
const sign = v < 0 ? -1 : 1;
return sign * ((Math.abs(v) - DEADZONE) / (1 - DEADZONE));
}
const to7bit = (unit) => Math.max(0, Math.min(127, Math.round(unit * 127)));
const axisTo7bit = (v, invert) => to7bit(((invert ? -v : v) + 1) / 2);
function send(bytes) {
if (state.output) state.output.send(bytes);
}
function noteOn(ch, num, vel) {
send([0x90 | ch, num, vel]);
held.add(ch + ':' + num);
}
function noteOff(ch, num) {
send([0x80 | ch, num, 0]);
held.delete(ch + ':' + num);
}
function cc(ch, num, val) {
send([0xB0 | ch, num, val]);
}
export function panic() {
for (const key of [...held]) {
const [ch, num] = key.split(':').map(Number);
noteOff(ch, num);
}
// All Notes Off (CC 123) on every channel, as a belt-and-braces second pass.
for (let ch = 0; ch < 16; ch++) send([0xB0 | ch, 123, 0]);
}
export function startLoop(map) {
let lastTs = -1;
function frame() {
requestAnimationFrame(frame);
if (!state.live || state.padIndex === null) return;
const pad = navigator.getGamepads()[state.padIndex];
if (!pad || !pad.connected) return;
if (pad.timestamp === lastTs) return; // nothing new this frame
lastTs = pad.timestamp;
const axes = pad.axes.map(deadzone);
const buttons = pad.buttons;
for (const entry of map) {
if (entry.src === 'b') {
const btn = buttons[entry.i];
if (!btn) continue;
const now = to7bit(btn.value);
const was = prev.buttons[entry.i] ?? 0;
if (now === was) continue;
prev.buttons[entry.i] = now;
if (entry.type === 'note') {
if (was === 0 && now > 0) noteOn(entry.ch, entry.num, Math.max(1, now));
else if (was > 0 && now === 0) noteOff(entry.ch, entry.num);
} else {
cc(entry.ch, entry.num, now);
}
} else {
const now = axisTo7bit(axes[entry.i] ?? 0, entry.invert);
if (now === prev.axes[entry.i]) continue;
prev.axes[entry.i] = now;
cc(entry.ch, entry.num, now);
}
}
}
requestAnimationFrame(frame);
} A few details that are easy to get wrong and expensive to debug.
- Velocity is never zero on a Note On.
Math.max(1, now)guards against it, because a Note On with velocity 0 is a Note Off on most devices and your note would never sound. - Analogue triggers go through
btn.value. On a DualSense or Xbox pad that gives you a usable 0-to-127 sweep from one trigger pull;btn.pressedwould give you two values. - Quantise before diffing.
to7bit()runs before the comparison, so sub-step stick noise cannot register as a change. Diff the floats instead and you will emit 60 CC messages per axis per second at rest. - The
heldset is the panic handler's memory. All Notes Off alone is not enough, because some hosts ignore CC 123 — sending real Note Offs for what you actually started is the reliable path.
Velocity from a digital button is a design question, not a technical one. Face buttons have no velocity sensor, so you either send a fixed value or derive one from something else — a trigger held as a velocity modifier is the common trick, and the finger-drumming workflow goes through the options.
How do you wire up the gamepad side?
Listen for gamepadconnected to learn the index, and tell the user to press a button when nothing shows up.
// bridge/pad.js
import { state } from './boot.js';
import { panic } from './core.js';
export function watchPads(onChange) {
window.addEventListener('gamepadconnected', (e) => {
if (state.padIndex === null) state.padIndex = e.gamepad.index;
onChange(describe());
});
window.addEventListener('gamepaddisconnected', (e) => {
if (state.padIndex === e.gamepad.index) {
panic(); // never leave a note hanging
state.padIndex = null;
}
onChange(describe());
});
// Hidden tabs stop rAF, so the loop cannot see a button release.
document.addEventListener('visibilitychange', () => {
if (document.hidden) panic();
});
onChange(describe());
}
export function describe() {
const pads = Array.from(navigator.getGamepads()).filter(Boolean);
if (pads.length === 0) {
return { ready: false, hint: 'Press any button on your controller.' };
}
const pad = pads.find(p => p.index === state.padIndex) ?? pads[0];
state.padIndex = pad.index;
return {
ready: true,
hint: pad.id,
standard: pad.mapping === 'standard',
buttons: pad.buttons.length,
axes: pad.axes.length
};
}
The standard flag is the one to surface in your UI. If mapping is the empty string, the browser did not recognise the device and every index in DEFAULT_MAP is meaningless for it. That is not a failure — it is the signal to offer a learn mode.
How do you add a learn mode?
Record which index moves next, then bind it to the MIDI target the user has selected.
Learn mode is why the map is data. You take a baseline snapshot, watch for the first control to move by more than a threshold, and write that index into the map entry being learned. Thirty lines.
// bridge/learn.js
const MOVE_THRESHOLD = 0.4; // big enough to ignore drift and noise
export function learnNext(padIndex, { timeoutMs = 8000 } = {}) {
return new Promise((resolve, reject) => {
const base = navigator.getGamepads()[padIndex];
if (!base) return reject(new Error('No gamepad at that index.'));
const baseAxes = [...base.axes];
const baseButtons = base.buttons.map(b => b.value);
const deadline = performance.now() + timeoutMs;
function tick() {
const pad = navigator.getGamepads()[padIndex];
if (!pad) return reject(new Error('Gamepad went away.'));
for (let i = 0; i < pad.buttons.length; i++) {
if (Math.abs(pad.buttons[i].value - baseButtons[i]) > MOVE_THRESHOLD) {
return resolve({ src: 'b', i });
}
}
for (let i = 0; i < pad.axes.length; i++) {
if (Math.abs(pad.axes[i] - baseAxes[i]) > MOVE_THRESHOLD) {
return resolve({ src: 'a', i });
}
}
if (performance.now() > deadline) return reject(new Error('Timed out.'));
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
// Usage: const target = await learnNext(state.padIndex);
// map[editingRow] = { ...map[editingRow], ...target }; The threshold is deliberately high. A drifting stick will wander past 0.1 unprompted and steal the binding; 0.4 requires an actual deliberate movement. Baselining against the current values rather than against zero is the other half of that — it means a stick that is already off-centre does not immediately match.
How do you save and share a mapping?
Serialise the map array to JSON. It is already plain data, so this is three lines.
export function saveMap(name, map) {
const all = JSON.parse(localStorage.getItem('bridge.maps') || '{}');
all[name] = map;
localStorage.setItem('bridge.maps', JSON.stringify(all));
}
export function exportMap(name, map) {
const blob = new Blob([JSON.stringify({ name, map }, null, 2)],
{ type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name.replace(/\W+/g, '-').toLowerCase() + '.json';
a.click();
URL.revokeObjectURL(a.href);
} Browser storage is per-origin and per-profile, so a map saved in Chrome is invisible in Firefox and gone when the user clears site data. Export to a file is not a nice-to-have here; it is the only durable copy. Ready-made starting points for common hosts are in the mapping template library.
Where does the browser run out of road?
Four hard walls, none of which are bugs and none of which have workarounds.
It cannot create a MIDI port. Covered above. You are always dependent on a virtual port that something else created, which means your "no install" bridge requires an install anyway on Windows and, at minimum, a settings change on macOS.
It stops when the tab is hidden. requestAnimationFrame does not run in a background tab. Switch to your DAW to adjust something and your controller is inert until you switch back. You can mitigate with a panic handler, as above, but you cannot keep polling. A Web Worker does not rescue this either — navigator.getGamepads() is not exposed to workers.
It sees only what the Gamepad API normalises. Four axes, seventeen buttons, coarse rumble. No gyroscope, no touchpad coordinates, no light bar, no adaptive trigger resistance, no battery level. WebHID reaches further but only on Chromium desktop and only with a device-specific parser you write yourself.
It is a tab, not a service. Close the window, crash the renderer, or update the browser mid-set and the controller vanishes from every application at once. A native bridge publishes a port that exists independently of any window being open — which is the actual argument for one, more than latency. Browser versus native MIDI latency goes through the timing side in detail.
What should you use this for, then?
Prototyping mappings, teaching, demos, and anything where "works instantly from a URL" beats "works reliably at a gig".
A browser bridge is genuinely excellent at two things. It is the fastest possible way to find out whether a control scheme feels right, because the edit-reload cycle is seconds and the mapping is a JSON array you can retype. And it is the only way to hand somebody a working demo over a link, which is why the visualiser exists — it runs the receive half of exactly this code and proves the whole idea in one click.
Once a mapping survives a week of use, move it somewhere that does not depend on a tab staying focused. Export the JSON, rebuild it in a native bridge, and keep the browser version as the scratchpad it is good at being.
FAQ
Can you build a gamepad-to-MIDI bridge entirely in the browser?
Yes, on desktop Chrome, Edge, Opera or Firefox. The Gamepad API reads the controller and the Web MIDI API writes to a MIDI output port. The browser cannot create the virtual port itself, so one has to already exist on the operating system.
Why can the browser not create its own virtual MIDI port?
Because the Web MIDI API has no port-creation surface at all. It enumerates ports the operating system already publishes and lets you read from and write to them. Creating a port is a driver-level operation outside the web platform.
Which MIDI message should an analogue trigger send?
A control change, using the GamepadButton value property rather than pressed, scaled from its 0 to 1 range to 0 to 127. Treating a trigger as a boolean discards the analogue resolution that makes it expressive.
How do you stop a gamepad flooding the MIDI port?
Quantise each axis to the destination resolution before comparing it with the previous frame, and send only when the quantised value changes. Without that, stick noise produces a change on every frame of every axis.
Why do notes hang when I switch browser tabs?
Because requestAnimationFrame stops running in a hidden tab, so your loop never sees the button release and never sends the Note Off. Listen for visibilitychange and send All Notes Off, or an explicit Note Off for everything currently held.
Do I need a library for this?
No. The complete bridge in this post is about 150 lines of plain JavaScript with no dependencies. A library such as WEBMIDI.js is convenience, not necessity, and understanding the raw bytes makes debugging much faster.
Does a browser bridge work for live performance?
It works, but it is fragile in ways a native bridge is not: it stops when the tab is hidden, it dies if the tab crashes, it prompts for permission, and it is only available while a browser window is open. Prototype in the browser, perform with a native bridge.
How do I map a controller the browser does not recognise?
Check gamepad.mapping. If it is the empty string the browser did not remap the device, so the indices are raw and device-specific. Build a learn mode that records the next index the user moves, rather than hardcoding numbers.
Run it, then graduate it
Paste the five modules above into a folder, serve it from localhost, create a virtual port, and you have a working controller in under an hour. Watch the output land live at /play by pointing the bridge at a port the visualiser is listening on — that round trip is the fastest sanity check there is.
When the mapping is good enough that you want it available in every application, all the time, without a tab open: download Universal Controller MIDI. It does everything above natively on macOS, Windows and Linux, creates its own virtual port, keeps polling when you alt-tab, and reaches the gyro and touchpad the Gamepad API hides. Free covers one controller, one port and three saved presets; Pro is AUD $89 once.