Blog Creative 10 min read

Tone.js Live Coding — Gamepad as the Macro Keyboard

Build a Tone.js live-coding rig where a DualSense or Xbox controller drives Tone.Param values, Transport, and pattern switching over WebMIDI.

By Aidxn Design

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.

TL;DR
  • What you do: wire WebMIDI into a Tone.js page, point CCs at Tone.Param.value, point buttons at Tone.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 inputMIDITone.js target
Left stick XCC 0filter.frequency (log)
Left stick YCC 1filter.Q
Right stick XCC 2synth.envelope.attack
Right stick YCC 3synth.envelope.release
L2CC 6Tone.Transport.bpm
R2CC 7reverb.wet
Touchpad X / YCC 16 / 17XY pad — delay time / feedback
Cross / CircleNote 36 / 37Transport start / stop
Square / TriangleNote 38 / 39Waveform switch
D-pad up / downNote 40 / 41Pattern 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.

Keep reading

More setup walkthroughs