On Linux, the gamepad story is brutal in the best way. There is no SDK to learn, no entitlement to claim, no framework to negotiate with. You open /dev/input/event*, you read packed linux evdev gamepad events, you translate them into ALSA MIDI sequencer messages, and you ship. This walkthrough builds a working bridge in about 150 lines of C — the same one we use inside the Linux build of Universal Controller MIDI.
- Input:
/dev/input/eventN+struct input_event+poll(). - Identification:
ioctl(fd, EVIOCGNAME, ...)to match the gamepad by name. - MIDI out: ALSA sequencer (
snd_seq_*) — auto-discovered by Ardour, JACK, PipeWire. - Hot-plug: udev rule + inotify on
/dev/inputfor zero-config reconnects.
Why evdev, not libinput, not SDL
Three options exist on Linux for reading a gamepad: libinput (too high level — designed for keyboards/mice/touchpads in a compositor), SDL (an extra runtime dependency, multi-platform abstraction tax), or evdev direct. We pick evdev because it is the kernel's actual ABI: stable since 2.6, exposed via a single device node, documented at kernel.org's input subsystem docs. Every other Linux input API is a wrapper around evdev. Skip the wrapper.
Finding the right /dev/input/eventN
Event node numbering is unstable across reboots. Match by name, not number.
#include <fcntl.h>
#include <linux/input.h>
#include <sys/ioctl.h>
#include <string.h>
#include <glob.h>
int find_gamepad(const char *want) {
glob_t g;
glob("/dev/input/event*", 0, NULL, &g);
for (size_t i = 0; i < g.gl_pathc; i++) {
int fd = open(g.gl_pathv[i], O_RDONLY | O_NONBLOCK);
if (fd < 0) continue;
char name[256] = {0};
ioctl(fd, EVIOCGNAME(sizeof name), name);
if (strstr(name, want)) { globfree(&g); return fd; }
close(fd);
}
globfree(&g);
return -1;
}
// Usage: int fd = find_gamepad("DualSense"); The poll loop
Every input frame from the kernel arrives as a struct input_event with four fields: time, type, code, value. Three event types matter for a gamepad — EV_KEY (buttons), EV_ABS (sticks, triggers, d-pad), EV_SYN (frame boundary, ignored). Wrap the fd in poll() so the process sleeps cleanly when idle and wakes within a few microseconds of an input.
#include <poll.h>
#include <unistd.h>
void loop(int fd) {
struct pollfd pfd = { .fd = fd, .events = POLLIN };
struct input_event ev;
while (poll(&pfd, 1, -1) > 0) {
while (read(fd, &ev, sizeof ev) == sizeof ev) {
if (ev.type == EV_KEY) handle_button(ev.code, ev.value);
else if (ev.type == EV_ABS) handle_axis(ev.code, ev.value);
}
}
} evdev codes → MIDI mapping table
| evdev code | Type | DualSense input | MIDI mapping |
|---|---|---|---|
| BTN_SOUTH (304) | EV_KEY | Cross | Note 60 |
| BTN_EAST (305) | EV_KEY | Circle | Note 61 |
| BTN_NORTH (307) | EV_KEY | Triangle | Note 62 |
| BTN_WEST (308) | EV_KEY | Square | Note 63 |
| ABS_X (0) | EV_ABS | Left stick X | CC 1 |
| ABS_Y (1) | EV_ABS | Left stick Y | CC 2 |
| ABS_Z (2) | EV_ABS | L2 trigger | CC 7 |
| ABS_RZ (5) | EV_ABS | R2 trigger | CC 8 |
EV_ABS values come in the controller's native range — typically 0..255 for sticks on the DualSense's HID profile, 0..1023 for the Xbox controller. Use ioctl(fd, EVIOCGABS(code), &absinfo) to read the per-axis min, max, and flat (deadzone), then scale into 0..127 for MIDI. Always. Hard-coded ranges are the reason cross-controller code breaks.
Opening a virtual ALSA MIDI port
Your bridge is a MIDI source. ALSA sequencer makes that trivial: create a client, create a simple port with the right capabilities, send snd_seq_event_t structures. The port appears in aconnect -l, in Ardour, in qjackctl, and in any PipeWire-aware app.
#include <alsa/asoundlib.h>
snd_seq_t *seq;
int port;
void midi_init(void) {
snd_seq_open(&seq, "default", SND_SEQ_OPEN_OUTPUT, 0);
snd_seq_set_client_name(seq, "UniversalControllerMIDI");
port = snd_seq_create_simple_port(seq, "Gamepad",
SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ,
SND_SEQ_PORT_TYPE_MIDI_GENERIC | SND_SEQ_PORT_TYPE_APPLICATION);
}
void send_cc(unsigned ch, unsigned cc, unsigned val) {
snd_seq_event_t ev;
snd_seq_ev_clear(&ev);
snd_seq_ev_set_source(&ev, port);
snd_seq_ev_set_subs(&ev);
snd_seq_ev_set_direct(&ev);
snd_seq_ev_set_controller(&ev, ch, cc, val);
snd_seq_event_output_direct(seq, &ev);
}
That is the whole MIDI side. SND_SEQ_PORT_CAP_SUBS_READ is the magic flag that lets other apps subscribe; without it the port is invisible in routing UIs. The same pattern is reviewed in our virtual MIDI port explainer alongside the Mac and Windows equivalents.
uinput for round-trip haptics
Reading evdev is half the story. To send force-feedback back to the DualSense (rumble on a downbeat, adaptive trigger resistance from a CC) you need /dev/uinput — the kernel's userspace input-device creation interface. You do not write rumble through evdev; you write FF events to the same node with write() after enabling FF caps. The hidraw path is also valid for the DualSense, since adaptive triggers are vendor-specific reports. Our hidapi vs native driver post covers the tradeoff.
Hot-plug without restarting
Two options. Cheap: inotify on /dev/input and re-scan on IN_CREATE. Proper: a udev rule that triggers a script or sets a tag your daemon listens to via libudev.
# /etc/udev/rules.d/70-gamepad-midi.rules
SUBSYSTEM=="input", ATTRS{name}=="*DualSense*", \
TAG+="universal-controller-midi", MODE="0660", GROUP="audio"
Pair this with a libudev monitor on "input" filtered by the tag and the bridge picks up the controller within ~50 ms of plug-in. No sudo, no restart. This is the kind of polish that separates "demo project" from "tool people actually use" — and the kind of thing we belabour in the Ardour Linux walkthrough.
Gotchas worth knowing
- Permission — by default
/dev/input/event*is root-only. Add the user toinput(Arch) orplugdev(Debian) group, or ship a udev rule. - EV_ABS flat zone — the kernel reports a deadzone in
absinfo.flat. Respect it; otherwise sticks at rest emit drift CCs. - EV_SYN is not noise — it marks the end of a frame. If you batch updates, flush on
EV_SYN_REPORT. - Wayland vs X11 — irrelevant. evdev is the kernel, it does not care about the display server.
- SteamInput interferes — if Steam is running and "use Steam Input for non-Steam controllers" is on, the device may be claimed exclusively. Turn it off or ship instructions.
The Linux input layer is one of the most underappreciated pieces of engineering in the kernel. evdev is older than most JavaScript frameworks and rather more stable. Build on it directly, skip the abstractions, and your gamepad-to-MIDI bridge will outlive three Linux audio stacks. That is the bet we made.