Blog Reaper 9 min read

Writing a Reaper JSFX That Processes Gamepad MIDI

Build a sample-accurate Reaper JSFX that smooths sticks, rescales triggers, and rewrites the gamepad MIDI stream before any synth or effect ever sees it.

By Aidxn Design

Reaper's JSFX is the unfair advantage of the DAW world. It is a built-in DSP language with no compiler, no SDK, and a hot-reload save loop measured in milliseconds. Most people use it for one-off filters. Few realise it is the cleanest place on the planet to massage a raw gamepad MIDI stream before any synth touches it. This guide builds a working reaper jsfx gamepad processor from scratch — stick smoothing, trigger curves, dead-zone gating, channel routing, and optional feedback to the controller. The output is a single text file you can drop into any Reaper project.

TL;DR
  • What you build: a Reaper JSFX MIDI effect that processes the raw gamepad CC stream from Universal Controller MIDI.
  • What it does: per-axis dead-zone, exponential trigger curves, one-euro smoothing, CC remap, channel split, optional rumble feedback.
  • Lines of code: about 120. The whole thing fits on one screen.
  • Hot reload: save the file, Reaper recompiles instantly. No restart.

Why JSFX is the right tool for this

You could do gamepad MIDI shaping inside the bridge UI. But the moment you want per-track logic — different curves on the lead synth versus the drum bus — you need the shaping to live in the DAW. ReaScript can do it in Lua, but Lua runs on the UI thread and is awkward for per-message processing. JSFX is sample-accurate, sits between MIDI input and your VST, and re-evaluates on every audio buffer with no IPC. For reaper jsfx gamepad work specifically, this matters because stick smoothing wants tight, deterministic timing. JSFX gives you that. The JSFX reference is your friend — keep it open in a tab.

Boilerplate that gets you running

Save this as Effects/CustomGamepad/gamepad-processor.jsfx inside your Reaper resource directory. The desc: string is what shows up in the FX browser.

desc: Gamepad Processor (Universal Controller MIDI)
// Author: built for Universal Controller MIDI rigs
// Drop on any track to reshape the raw gamepad CC stream.

slider1:0.10<0,0.5,0.001>Stick deadzone
slider2:0.18<0,1.0,0.01>Stick smoothing (0=instant)
slider3:1.8<0.5,3.0,0.01>Trigger curve (exp)
slider4:1<1,16,1>Output channel
slider5:0<0,1,1{Off,On}>Latch sticks on release

@init
// One-euro filter state per CC. Indexed by CC number 0-127.
last_val = 0;
memset(state_v, 0, 128);
memset(state_dv, 0, 128);
sr_inv = 1 / srate;

@slider
dz = slider1;
sm = slider2;
curve = slider3;
out_ch = slider4 - 1;
latch = slider5;

@block
while (midirecv(ts, msg1, msg2, msg3)) (
    status = msg1 & 0xF0;
    chan   = msg1 & 0x0F;

    // Stick CCs come in on 20-23 by default from the bridge.
    (status == 0xB0 && (msg2 >= 20 && msg2 <= 23)) ? (
        v = (msg3 - 64) / 64;      // bipolar -1..1
        abs(v) < dz ? v = 0;       // dead-zone
        v = v * (1 - sm) + state_v[msg2] * sm;
        state_v[msg2] = v;
        msg3 = floor(v * 63 + 64); // back to 0..127
    );

    // Triggers come in on CC 2 and 11. Apply exp curve.
    (status == 0xB0 && (msg2 == 2 || msg2 == 11)) ? (
        x = msg3 / 127;
        msg3 = floor(pow(x, curve) * 127);
    );

    // Rewrite output channel.
    status == 0xB0 || status == 0x90 || status == 0x80 ? (
        msg1 = status | out_ch;
    );

    midisend(ts, msg1, msg2, msg3);
);

That is the whole processor. Save the file. Reaper compiles it on save and the sliders appear in the FX window. The hot-reload feel is the reason JSFX wins this category — no other DAW has anything close. The same loop in a VST would take a build step every change.

What every block actually does

@init — runs once

Allocates the smoothing-state arrays. state_v holds the last filtered value for each CC, indexed 0–127. JSFX gives you a free 8 MB heap by default — using 128 slots is nothing.

@slider — runs when a slider moves

Caches slider values into local variables so the per-message hot loop does not read them. Cheap micro-optimisation but worth it on a stick CC stream that fires at 250 Hz.

@block — the message pump

Drains the incoming MIDI queue with midirecv, processes each message, and emits with midisend. Sticks get bipolar dead-zone plus one-pole smoothing. Triggers get an exponential curve — gamers know the difference between a 1.0 (linear) trigger and a 2.5 (sharp toe-in) trigger. Channel routing rewrites the low nibble of the status byte. Done.

Trigger curves are the killer feature

A raw analog trigger is linear. That is wrong for music. You want a curve that sits dormant for the first 20% of pull, then accelerates aggressively. Mathematically that is pow(x, n) for n > 1. Here is what each value of curve feels like:

Curve valueBehaviourUse case
1.0LinearVolume rides, filter sweeps
1.8Mild toe-inDefault — modulation, sends
2.5Sharp toe-inBass drops, dramatic filter slams
0.7Inverse (toe-out)Expression-pedal feel, breath sims

Stick smoothing without lag — the trick

The above smoothing is a one-pole IIR. Good enough for most uses but it adds phase lag at high smoothing. If you want a smoother stream without the lag, replace the smoothing block with a one-euro filter — the same algorithm we use in the bridge for global stick drift. The JSFX version fits in about 20 lines:

// One-euro filter — Casiez et al, CHI 2012
function one_euro(x, idx, min_cutoff, beta) (
    dt = sr_inv * samplesblock;
    dx = (x - state_v[idx]) / dt;
    edx = state_dv[idx] + (dx - state_dv[idx]) * 0.85;
    state_dv[idx] = edx;
    cutoff = min_cutoff + beta * abs(edx);
    tau = 1 / (2 * $pi * cutoff);
    alpha = 1 / (1 + tau / dt);
    state_v[idx] += alpha * (x - state_v[idx]);
    state_v[idx]
);

Call it instead of the IIR line. min_cutoff around 1.0 and beta around 0.007 gives the same buttery feel as a real Roli Seaboard ribbon. Worth the extra 20 lines.

Bidirectional feedback — closing the loop

JSFX can emit MIDI back out the same port it receives on. The bridge listens for inbound CC and routes it to controller features. Add this to the bottom of @block to drive rumble from track loudness:

// Track loudness → rumble feedback
@sample
loud = abs(spl0) + abs(spl1);
peak_smooth = peak_smooth * 0.999 + loud * 0.001;

@block
// Once per block, send rumble CC to the bridge.
rumble = min(127, floor(peak_smooth * 800));
midisend(0, 0xB0 | out_ch, 100, rumble);

The bridge maps inbound CC 100 to left rumble intensity. Result: the controller throbs in your hand on every kick drum. Drop it on a drum bus for the full effect. We have a deeper write-up on this in our MIDI feedback haptics piece.

Debugging when JSFX won't compile

  • Red error bar at the top of the editor. JSFX compile errors include line numbers — read them. Usually a missing semicolon or a forgotten ?:: ternary structure.
  • Sliders not updating? You forgot the @slider block. Cached values stay at their @init defaults until you add it.
  • MIDI not flowing through? Check that midisend is inside the while loop, not after. A common foot-gun: emitting only the last received message.
  • Stick CCs not arriving on 20–23? Check the bridge's CC mapping. The defaults assume the standard template — custom users may need to retarget the JSFX to their actual CC numbers.

Reaper's JSFX is one of the great unsung tools in audio. Pair it with Universal Controller MIDI and you can build any gamepad processor you can imagine — in less code than the README of most VST projects.

Keep reading

More setup walkthroughs