Using the Web MIDI API to control an Elektron Digitakt is one of those things that sounds like it should need a driver, an SDK and an afternoon, and in fact needs about fifteen lines of JavaScript and a USB cable. The Digitakt is a class-compliant USB MIDI device. The browser can enumerate it, write notes and control changes to it, switch its patterns and clock it. What follows is the whole path — port discovery, channel routing, finding parameter numbers without guessing, scheduled sequencing, and a gamepad front end on top.
- No driver needed. Class-compliant USB MIDI, so it shows up in
MIDIAccess.outputsdirectly. - Desktop Chrome, Edge, Opera or Firefox. Safari has no Web MIDI, so a Mac user needs a different browser.
- Channels are configured on the device. Check MIDI CONFIG first — the browser only sees numbers.
- Find CCs, do not guess them. Appendix A of the manual is authoritative; a browser CC sweeper confirms it.
- Schedule with the send() timestamp. Far steadier than
setTimeout. - Overbridge owns the USB port when it is running. Close it or go DIN.
Why drive a Digitakt from a browser at all?
Because a browser tab is the fastest sketchpad for hardware control there is — no project file, no plugin host, no install, and a reload cycle measured in seconds.
The Digitakt is a deeply sequencer-shaped instrument. Its own sequencer is excellent and you will not beat it. What a browser is good at is everything around the edges: a custom XY pad that drives two parameters at once, a generative pattern that feeds its MIDI tracks, a one-off macro that sweeps six CCs on a button press, a rehearsal tool that fires program changes on a timer. These are things that would be a weekend in a DAW and are an hour in a tab.
Elektron's own specification for the Digitakt II describes 16 tracks that can each be audio or MIDI, with four-note polyphony per step and sixteen assignable CC controls plus two assignable LFOs per MIDI track, over MIDI In, Out and Thru plus USB. That is a lot of addressable surface, and all of it is reachable from output.send().
Will the Digitakt just appear in the browser?
Yes — plug it in over USB, approve the MIDI permission, and it will be in the outputs map under its device name.
The Web MIDI specification exposes whatever the host operating system's MIDI subsystem publishes. A class-compliant USB MIDI device needs no vendor driver on macOS, Windows or Linux, so it is published automatically and the browser sees it alongside your virtual ports and interfaces.
// digitakt/connect.js
const NAME_HINTS = ['digitakt', 'elektron'];
export async function findDigitakt() {
if (!('requestMIDIAccess' in navigator)) {
throw new Error('No Web MIDI in this browser. Use desktop Chrome, Edge or Firefox.');
}
const access = await navigator.requestMIDIAccess({ sysex: false });
const match = (port) => {
const n = (port.name || '').toLowerCase();
return NAME_HINTS.some(h => n.includes(h));
};
const out = [...access.outputs.values()].find(match) ?? null;
const inp = [...access.inputs.values()].find(match) ?? null;
access.addEventListener('statechange', (e) => {
console.log(e.port.type, e.port.name, '->', e.port.state);
});
if (!out) {
const available = [...access.outputs.values()].map(p => p.name).join(', ');
throw new Error('Digitakt output not found. Ports seen: ' + (available || 'none'));
}
localStorage.setItem('digitakt.outId', out.id);
return { access, out, inp };
}
Matching on name is a convenience, not a contract — MIDIPort.name is whatever the OS decided to call the port, and it differs between platforms. Always fall back to a user-visible picker, and persist port.id rather than the name, because the spec says the id should remain stable across sessions. The general pattern is covered in the complete Web MIDI guide.
Which MIDI channel hits which track?
Whichever ones the device is set to — the browser has no way to ask, so you have to look at MIDI CONFIG on the Digitakt before writing a line of code.
Elektron boxes expose per-track MIDI channels plus an auto channel that follows whichever track is currently selected on the front panel. The auto channel is the one worth knowing about for browser work: it means a single channel in your code can drive "whatever the performer is looking at", which is exactly right for a macro surface and wrong for a sequencer.
One trap for anyone writing this code: MIDI channels on a device screen are numbered 1 to 16, but the wire protocol numbers them 0 to 15 in the low nibble of the status byte. Channel 10 on the Digitakt's screen is 0x09 in your status byte. Getting this wrong is the single most common reason "nothing happens" when the cable is fine.
// digitakt/send.js - a tiny, correct message layer.
const ch = (n) => (n - 1) & 0x0f; // screen channel (1-16) -> wire nibble
export function makeSender(output) {
const send = (bytes, when = 0) => output.send(bytes, when);
return {
noteOn: (chan, note, vel = 100, when = 0) =>
send([0x90 | ch(chan), note & 0x7f, Math.max(1, vel & 0x7f)], when),
noteOff: (chan, note, when = 0) =>
send([0x80 | ch(chan), note & 0x7f, 0], when),
cc: (chan, num, val, when = 0) =>
send([0xB0 | ch(chan), num & 0x7f, val & 0x7f], when),
programChange: (chan, program, when = 0) =>
send([0xC0 | ch(chan), program & 0x7f], when),
// Realtime bytes carry no channel.
clock: (when = 0) => send([0xF8], when),
start: (when = 0) => send([0xFA], when),
continue_:(when = 0) => send([0xFB], when),
stop: (when = 0) => send([0xFC], when),
panic: () => {
for (let c = 1; c <= 16; c++) send([0xB0 | ch(c), 123, 0]); // All Notes Off
}
};
} How do you find the right CC numbers?
Read Appendix A of the official manual for the authoritative chart, then confirm what you read by sweeping one CC at a time and watching the device screen.
This is the part where most tutorials go wrong, and badly. Community CC lists float around forums, they are compiled by hand, and they drift between operating system versions and between the original Digitakt and the Digitakt II. The only source worth building on is the manual, which Elektron publishes from its support pages. Appendix A is the MIDI implementation section and it lists every CC for both audio and MIDI tracks.
Having read it, verify it. A sweeper is twenty lines and it removes all doubt, because you watch the parameter move on the hardware.
// digitakt/sweep.js
// Walks a CC from 0 to 127 and back, slowly enough to read the screen.
export function sweepCc(sender, { chan, cc, ms = 2500 }) {
const start = performance.now();
const steps = 128;
const dt = ms / (steps * 2);
for (let i = 0; i < steps; i++) {
sender.cc(chan, cc, i, start + i * dt);
}
for (let i = 0; i < steps; i++) {
sender.cc(chan, cc, 127 - i, start + (steps + i) * dt);
}
}
// Walk a whole range of CC numbers, one at a time, logging as you go.
export async function findParameters(sender, { chan, from = 0, to = 127 }) {
for (let cc = from; cc <= to; cc++) {
console.log('Sweeping CC', cc, 'on channel', chan);
sweepCc(sender, { chan, cc, ms: 1200 });
await new Promise(r => setTimeout(r, 1500));
}
}
Note that the sweep schedules all 256 messages in one synchronous burst using the send() timestamp, rather than running a timer. That is the pattern the spec's send method is designed for: the timestamp is on the same clock as performance.now(), past values mean "now", and messages with the same timestamp are delivered in call order.
The other discovery route is to go the opposite direction. The Digitakt II's MIDI tracks expose sixteen assignable CC controls per track, and Elektron's own description notes MIDI can quickly learn from your devices — so you can assign a CC on the hardware to a parameter you care about and address that number from the browser instead of hunting for a fixed one.
How do you sequence it from the browser?
With a lookahead scheduler that emits a short window of MIDI ahead of real time, exactly as you would with Web Audio.
Do not sequence with setTimeout or setInterval directly. Timers are clamped, jittered by main-thread work, and throttled in background tabs. The technique documented for Web Audio scheduling works identically for MIDI: a cheap, imprecise timer wakes up often, and each time it does, it schedules precisely-timestamped events for the next hundred milliseconds.
// digitakt/sequencer.js
export function createSequencer(sender, { chan = 1, bpm = 120 } = {}) {
const LOOKAHEAD_MS = 120; // how far ahead we schedule
const TICK_MS = 25; // how often we wake up
let running = false;
let stepMs = (60000 / bpm) / 4; // 16th notes
let nextTime = 0;
let step = 0;
let pattern = new Array(16).fill(null); // null or { note, vel, gate }
function schedule() {
const horizon = performance.now() + LOOKAHEAD_MS;
while (nextTime < horizon) {
const hit = pattern[step % pattern.length];
if (hit) {
sender.noteOn(chan, hit.note, hit.vel ?? 100, nextTime);
sender.noteOff(chan, hit.note, nextTime + (hit.gate ?? stepMs * 0.5));
}
nextTime += stepMs;
step++;
}
if (running) setTimeout(schedule, TICK_MS);
}
return {
setPattern: (p) => { pattern = p; },
setBpm: (v) => { stepMs = (60000 / v) / 4; },
start() {
if (running) return;
running = true;
step = 0;
nextTime = performance.now() + 50;
schedule();
},
stop() {
running = false;
sender.panic();
}
};
}
// Four-on-the-floor on a MIDI track, assuming C1 triggers the sound you want.
// seq.setPattern([{note:36},null,null,null, {note:36},null,null,null,
// {note:36},null,null,null, {note:36},null,null,null]);
The gate value matters more on hardware than in software. Elektron voices respond to note length, and a note that is too short can get swallowed; a note that is too long holds a voice you wanted for the next step. Half a step is a sane default and worth exposing as a control.
Can you switch patterns from the browser?
Yes — a Program Change message, two bytes, provided the device is set to receive them.
Program Change is 0xC0 plus the channel in the low nibble, followed by a single program number from 0 to 127. It is one of the few MIDI messages with only one data byte, which catches people who assume every message is three bytes long. Whether the Digitakt acts on it depends on its program change receive setting in the MIDI configuration — check that before concluding your bytes are wrong.
// Fire a pattern change on the next bar boundary rather than immediately,
// so the switch lands musically.
export function queuePatternChange(sender, { chan, program, seqNextBarAt }) {
sender.programChange(chan, program, seqNextBarAt);
}
// Chain a set of patterns, one per bar, from a browser UI.
export function chainPatterns(sender, { chan, programs, barMs, startAt }) {
programs.forEach((p, i) => {
sender.programChange(chan, p, startAt + i * barMs);
});
} Program numbers on a device screen are usually 1-based while the wire is 0-based, same trap as channels. If your pattern changes are consistently one off, that is why.
Can the browser be the clock?
It can send clock, start, continue and stop, and scheduled timestamps make it far steadier than a timer — but it is still a browser tab on a general-purpose computer.
MIDI clock is a stream of single 0xF8 bytes at 24 per quarter note, with 0xFA to start, 0xFB to continue and 0xFC to stop. Scheduling them with the send() timestamp means the browser's MIDI thread handles delivery rather than your main thread, which removes most of the jitter you would otherwise introduce.
// digitakt/clock.js - scheduled MIDI clock master.
export function createClock(sender, { bpm = 120 } = {}) {
const PPQN = 24; // MIDI clock ticks per quarter note
const LOOKAHEAD_MS = 150;
const TICK_MS = 30;
let tickMs = 60000 / (bpm * PPQN);
let nextTick = 0;
let running = false;
function pump() {
const horizon = performance.now() + LOOKAHEAD_MS;
while (nextTick < horizon) {
sender.clock(nextTick);
nextTick += tickMs;
}
if (running) setTimeout(pump, TICK_MS);
}
return {
setBpm(v) { tickMs = 60000 / (v * PPQN); },
start() {
running = true;
nextTick = performance.now() + 60;
sender.start(nextTick);
pump();
},
stop() {
running = false;
sender.stop();
}
};
} Honest caveat, stated mechanically rather than with invented numbers: the browser schedules messages onto the platform MIDI queue, and that queue is serviced by the operating system. Nothing about a tab guarantees it gets CPU when it wants it, and a garbage collection pause or a heavy render can delay the scheduling pass even though it cannot delay already-queued messages. Widen the lookahead and the risk shrinks; it does not disappear. For a recorded take, most people let the hardware or the DAW be the clock master and treat the browser as a controller. The clock-source trade-offs are laid out in MIDI clock sync from gamepad buttons.
How do you put a gamepad on the front of it?
Poll the Gamepad API in requestAnimationFrame and route changes straight into the sender you already built.
This is where the browser earns its place. A Digitakt plus a DualSense plus a tab is a performance rig you can assemble in a hotel room. Triggers become continuous parameter sweeps, face buttons become track mutes or trigs, and the sticks become an XY pad over two CCs.
// digitakt/pad.js
import { makeSender } from './send.js';
const CHAN = 1; // set to the Digitakt track channel you want
const CC_X = 16; // confirm against Appendix A before trusting these
const CC_Y = 17;
const CC_TRIGGER = 11; // CC 11 is Expression in the MIDI 1.0 CC list
export function drivePad(output, padIndex = 0) {
const sender = makeSender(output);
const prev = { x: -1, y: -1, t: -1, buttons: [] };
const to7 = (unit) => Math.max(0, Math.min(127, Math.round(unit * 127)));
const axis = (v) => to7((v + 1) / 2);
const dead = (v) => (Math.abs(v) < 0.08 ? 0 : v);
function frame() {
requestAnimationFrame(frame);
const pad = navigator.getGamepads()[padIndex];
if (!pad || !pad.connected) return;
const x = axis(dead(pad.axes[0]));
const y = axis(-dead(pad.axes[1])); // invert: forward = up
const t = to7(pad.buttons[7]?.value ?? 0); // right analogue trigger
if (x !== prev.x) { sender.cc(CHAN, CC_X, x); prev.x = x; }
if (y !== prev.y) { sender.cc(CHAN, CC_Y, y); prev.y = y; }
if (t !== prev.t) { sender.cc(CHAN, CC_TRIGGER, t); prev.t = t; }
for (let i = 0; i < 4; i++) {
const down = pad.buttons[i]?.pressed ?? false;
if (down !== prev.buttons[i]) {
prev.buttons[i] = down;
const note = 36 + i;
if (down) sender.noteOn(CHAN, note, 110);
else sender.noteOff(CHAN, note);
}
}
}
requestAnimationFrame(frame);
} Quantising to seven bits before the comparison is what stops a resting stick saturating the MIDI port with meaningless traffic — the full reasoning is in the Gamepad API polling guide, and the generalised version of this bridge is in the browser bridge build guide. For a mapping built around the Digitakt's actual performance workflow rather than a generic one, see Digitakt gamepad performance mode.
What goes wrong, and why
Four failure modes account for nearly everything.
- The port is missing entirely. Overbridge takes the USB connection for its own protocol while it is running. Quit it, or connect over DIN through a separate USB MIDI interface. Other causes are in the port troubleshooting checklist.
- Everything sends but nothing responds. Channel off-by-one. The screen says 1-16; the wire wants 0-15.
- The wrong parameter moves. You used a CC number from a forum post for a different operating system version or a different Digitakt generation. Sweep and confirm.
- Notes hang. The tab was hidden mid-note, so
requestAnimationFramestopped and the release never fired. Bind a panic tovisibilitychange.
document.addEventListener('visibilitychange', () => {
if (document.hidden) sender.panic();
});
window.addEventListener('pagehide', () => sender.panic()); Where does the browser stop being the right tool?
The moment you need it to keep working while you are looking at something else.
A browser tab stops polling the controller when it is hidden, which means alt-tabbing to a DAW kills your gamepad control mid-phrase. It also cannot create a virtual MIDI port, so routing the same gamepad to the Digitakt and to Ableton at once means an extra layer. And it is gone the moment the window closes.
For sketching a Digitakt control scheme, none of that matters and the browser wins on speed. For a set you will actually play, run the controller natively and keep the browser for the generative and experimental layer. Browser versus native MIDI covers the full comparison, and driving hardware synths over USB host covers the computer-free version of the same rig.
FAQ
Can the Web MIDI API control an Elektron Digitakt?
Yes. The Digitakt is a class-compliant USB MIDI device, so it appears in the MIDIAccess outputs map like any other MIDI port and accepts notes, control change, program change and clock from the browser.
Which browsers can do this?
Desktop Chrome, Edge, Opera and Firefox. Safari has no native Web MIDI implementation on macOS or iOS, so a Mac user in Safari cannot drive the Digitakt from a web page without a third-party extension.
Which MIDI channel addresses which Digitakt track?
Whatever the device is configured to use. The channel assignments live in MIDI CONFIG on the Digitakt itself, including the auto channel that follows the currently selected track. The browser only sees channel numbers, so check the device before writing any code.
Where is the official Digitakt CC chart?
Appendix A of the Elektron user manual, available from the Elektron support and downloads pages. Community-compiled CC lists exist but are not authoritative and differ between operating system versions and between the original Digitakt and Digitakt II.
Can I switch Digitakt patterns from a browser?
Yes, with a Program Change message, provided program change receive is enabled in the device MIDI configuration. A Program Change is two bytes: 0xC0 plus the channel, then the program number.
Can the browser be the master clock for a Digitakt?
It can send MIDI clock, start, continue and stop bytes, and scheduling them with the send timestamp is far steadier than using setTimeout. It is still a general-purpose computer running a browser tab, so for a recorded take most people make the hardware or the DAW the clock master.
Why does the Digitakt disappear from the browser when Overbridge is running?
Because Overbridge takes the USB connection for its own protocol. Close the Overbridge application, or connect over DIN cables through a separate USB MIDI interface, and the port reappears in the browser.
Can I send sysex to a Digitakt from a browser?
Only with the stronger sysex permission, requested by passing sysex true to requestMIDIAccess. Without that grant the send call throws InvalidAccessError and incoming sysex is filtered out of the input stream silently.
Start with fifteen lines
Plug the Digitakt in, open the Web MIDI visualiser and pick it as an input to confirm the browser can see it at all. Then paste the connect and send modules above into a file, serve it from localhost, and sweep a CC while watching the screen. From there everything else is just more of the same bytes.
When the mapping is worth keeping, move it off the tab. Universal Controller MIDI turns a DualSense, Xbox, Switch Pro or 8BitDo pad into a native MIDI controller on macOS, Windows and Linux — no browser, no permission prompt, no dying when you alt-tab to the sampler. Free tier: one controller, one virtual port, three saved presets. Pro: AUD $89 one-time.