Tonejs gamepad rigs are what you build when you want the live-coding ethos but with a proper instrument under your thumbs. Tone.js is the Web Audio framework that doesn't make you want to throw your laptop — real PolySynths, real Transport, real scheduling. Bolt a gamepad to it via Universal Controller MIDI and you have a programmable performance rig that lives in a single HTML file. Code the patches by day, perform with your thumbs by night.
- What you do: wire WebMIDI into a Tone.js page, point CCs at
Tone.Param.value, point buttons atTone.Transport. - What you need: Tone.js (CDN or npm), Universal Controller MIDI, a gamepad, a browser.
- Time: 20 minutes to a sequenced patch with macro control.
- Cost: $89 bridge. Tone.js is MIT.
Why Tone.js earns the spot
Tone.js sits at exactly the right altitude — high enough that you don't manually wire AudioNodes, low enough that you still understand what's running under the hood. Tone.PolySynth handles voice stealing. Tone.Transport handles tempo, swing, scheduling. Tone.Pattern handles sequencing. Each of those exposes parameters as Tone.Param objects that you can .set(), .rampTo(), or just assign .value on. That's the bind surface for the gamepad.
Project skeleton
One HTML file. The user has to click Start once — browsers won't fire up an AudioContext without a gesture.
<!doctype html>
<html>
<head><script src="https://unpkg.com/tone"></script></head>
<body>
<button id="start">Start</button>
<script type="module" src="./rig.js"></script>
</body>
</html>
// rig.js
const CC = {}; // latest value per CC number (0..127)
const NOTES = {}; // which notes are currently held
async function initMIDI() {
const midi = await navigator.requestMIDIAccess();
for (const input of midi.inputs.values()) {
input.onmidimessage = ({ data: [s, d1, d2] }) => {
const type = s & 0xf0;
if (type === 0xb0) CC[d1] = d2;
else if (type === 0x90 && d2 > 0) NOTES[d1] = d2;
else if (type === 0x80 || (type === 0x90 && d2 === 0)) delete NOTES[d1];
};
}
}
document.getElementById('start').addEventListener('click', async () => {
await Tone.start();
await initMIDI();
buildRig();
});
Wiring the synth
Build a small chain: PolySynth → Filter → Reverb → Destination. Every node exposes Tone.Params that the gamepad will modulate.
let synth, filter, reverb;
function buildRig() {
reverb = new Tone.Reverb({ decay: 4, wet: 0.3 }).toDestination();
filter = new Tone.Filter(800, 'lowpass').connect(reverb);
synth = new Tone.PolySynth(Tone.Synth, {
oscillator: { type: 'sawtooth' },
envelope: { attack: 0.02, decay: 0.4, sustain: 0.3, release: 0.8 },
}).connect(filter);
// 60Hz read loop — pull latest CC, push to Tone params with smoothing.
setInterval(updateParams, 16);
// Schedule a simple sequence on Transport.
new Tone.Loop((time) => {
const pattern = ['C3', 'E3', 'G3', 'B3'];
synth.triggerAttackRelease(pattern[step % 4], '8n', time, 0.7);
step++;
}, '8n').start(0);
}
let step = 0;
function updateParams() {
// CC 0 (L-stick X) → filter cutoff, log-scaled 80..8000Hz
if (CC[0] != null) {
const norm = CC[0] / 127;
filter.frequency.rampTo(80 * Math.pow(100, norm), 0.05);
}
// CC 1 (L-stick Y) → filter Q 0.5..20
if (CC[1] != null) filter.Q.rampTo(0.5 + (CC[1] / 127) * 19.5, 0.05);
// CC 7 (R2) → reverb wet 0..1
if (CC[7] != null) reverb.wet.rampTo(CC[7] / 127, 0.1);
// CC 6 (L2) → BPM 60..180
if (CC[6] != null) Tone.Transport.bpm.rampTo(60 + (CC[6] / 127) * 120, 0.2);
}
rampTo is the magic call. It's a smooth ramp on the underlying AudioParam — no zipper noise, no stair-step. Without it, dragging a stick across the room sounds like a robot stepping on bubble wrap.
Buttons drive Transport and patches
Notes from the gamepad land in the NOTES object. Wire face buttons to Transport control and patch switching. The bridge fires Note On for press and Note Off for release.
// Listen for transport buttons. Face buttons are notes 36..39 by default.
function wireButtons() {
const onNoteOn = (note) => {
if (note === 36) Tone.Transport.start(); // ✕
if (note === 37) Tone.Transport.stop(); // ◯
if (note === 38) synth.set({ oscillator: { type: 'square' } }); // □
if (note === 39) synth.set({ oscillator: { type: 'sawtooth' } }); // △
};
// Replace the simple NOTES map with a callback.
const old = midiInput.onmidimessage;
midiInput.onmidimessage = (e) => {
old(e);
const [s, d1, d2] = e.data;
if ((s & 0xf0) === 0x90 && d2 > 0) onNoteOn(d1);
};
}
The full macro map
| Gamepad input | MIDI | Tone.js target |
|---|---|---|
| Left stick X | CC 0 | filter.frequency (log) |
| Left stick Y | CC 1 | filter.Q |
| Right stick X | CC 2 | synth.envelope.attack |
| Right stick Y | CC 3 | synth.envelope.release |
| L2 | CC 6 | Tone.Transport.bpm |
| R2 | CC 7 | reverb.wet |
| Touchpad X / Y | CC 16 / 17 | XY pad — delay time / feedback |
| Cross / Circle | Note 36 / 37 | Transport start / stop |
| Square / Triangle | Note 38 / 39 | Waveform switch |
| D-pad up / down | Note 40 / 41 | Pattern shift +12 / -12 |
Sync to MIDI clock from a DAW
The bridge can pass MIDI Beat Clock (0xF8) through to the browser. Tone.Transport doesn't accept external clock natively, but you can roll your own PLL in twenty lines: count incoming clock ticks, average the interval, set Tone.Transport.bpm. Or skip it and let the gamepad's L2 trigger be the tempo source — for a solo set that's plenty. The gamepad tap-tempo guide covers the gesture side.
Where Tone.js shines (and where it doesn't)
Tone.js is the right tool when you need scheduled patterns, voice management, and a real Transport. It loses to Faust for hand-tuned DSP, and to Hydra for visuals. Pair it with Hydra in a second tab — one gamepad, two listeners, audio in Tone, visuals in Hydra. The CC stream is broadcast to every MIDI input the browser sees, so both react to the same physical gesture. That's the live-coding sweet spot: one input device, two creative surfaces, zero glue code beyond what you already wrote.
For the price of a controller you already own and a $89 bridge, you have a programmable Web Audio rig that fits in a browser tab. Universal Controller MIDI is the bit in the middle.