Every stick smoothing filter is a trade between two things you want simultaneously: a stick signal that is quiet when you are holding still, and a stick signal that moves instantly when you move. A fixed-coefficient filter makes you choose one number that serves both cases, and no such number exists — smooth enough to kill the resting wobble is always sluggish enough to feel laggy on a fast stab.
That is the central problem of this post, and there is a well-established solution to it that comes out of the human-computer interaction literature rather than from audio. We will build up to it from the simplest filters, because understanding why they fail is what makes the adaptive approach obviously correct rather than merely fashionable.
- Every averaging filter adds lag. That is not an implementation flaw, it is what averaging across time means.
- EMA is one line and one coefficient. Great starting point, bad final answer.
- Median removes isolated spikes that averaging filters smear instead of removing.
- Adaptive filters such as the One Euro filter vary their cutoff with signal speed, so you stop having to compromise.
- Filter before deadzone, in normalised float, using measured elapsed time rather than sample counts.
Why does a stick signal need filtering at all?
Because the value you receive is a measurement, and measurements have noise. Three sources contribute. The sensor itself produces a small varying output. The controller's analog-to-digital conversion adds quantisation and conversion noise, concentrated in the low bits. And on a worn potentiometer stick, intermittent wiper contact adds genuine electrical spikes on top of the baseline wander. The sensing differences are covered in Hall-effect versus potentiometer sticks.
A deadzone handles this at rest by forcing small values to zero. What it cannot handle is noise while you are moving, because the deadzone is not active there. Hold the stick steady at a quarter deflection and the noise is riding on top of a non-zero value, producing a continuous dither in your MIDI output — audible as a filter that shimmers when it should be holding, and visible as an automation lane full of micro-wobble. That is the gap a filter fills.
Some of this is characterised for you already. On Linux the absinfo structure attached to each absolute axis carries a fuzz field — the driver's own estimate of the noise amplitude on that axis — alongside minimum, maximum and flat, as described in the kernel input event codes documentation. Reading it costs nothing and gives you a sensible starting point for filter strength before you measure anything yourself. Apple's GCPhysicalInputProfile hands you normalised floats with no such metadata, and the W3C Gamepad API specifies none either, so on those platforms measurement is the only route.
Note what a filter cannot fix before you reach for one. If your resting value is stable but your slow sweeps sound stepped, that is 7-bit quantisation happening downstream of the filter, and more smoothing will only add lag to a problem it cannot touch. The fix there is higher-resolution output, covered in analog trigger resolution and 14-bit CC.
How does an exponential moving average behave?
It blends each new sample with the previous output in a fixed proportion. One coefficient, one stored value, trivially cheap:
// Exponential moving average
// alpha near 1.0 = barely any smoothing, fast response
// alpha near 0.0 = heavy smoothing, slow response
typedef struct { double y; int primed; } ema_t;
double ema_update(ema_t *f, double x, double alpha) {
if (!f->primed) { f->y = x; f->primed = 1; return x; }
f->y = alpha * x + (1.0 - alpha) * f->y;
return f->y;
} Its behaviour is easy to reason about. Each output is a weighted sum of all past inputs with exponentially decaying weights, so it responds to a step change by approaching the new value asymptotically rather than jumping to it. The time it takes to get most of the way there is the filter's time constant, and that time constant is the lag you feel.
The trap is that alpha alone does not define the filter's behaviour in real time — the sample rate does too. The same alpha at a 1 ms update interval and at an 8 ms interval produce completely different time constants, because the filter advances once per sample regardless of how long the sample took. Since report arrival is not perfectly periodic, a fixed-alpha EMA has a time constant that varies with scheduling, which means its lag varies, which means it is adding jitter. The fix is to express the filter in terms of a cutoff frequency and derive alpha from measured elapsed time:
// Time-aware EMA: alpha derived from cutoff and actual elapsed time
double alpha_from_cutoff(double cutoff_hz, double dt_seconds) {
double tau = 1.0 / (2.0 * M_PI * cutoff_hz);
return 1.0 / (1.0 + tau / dt_seconds);
}
// Usage: measure dt between reports, never assume it
double dt = (now_ns - last_ns) * 1e-9;
double alpha = alpha_from_cutoff(2.0, dt); // 2 Hz cutoff
double out = ema_update(&f, raw, alpha); This is worth doing even if you never move past EMA. A time-aware filter behaves consistently whether your reports arrive every millisecond or occasionally every eight, which matters for the reasons set out in jitter versus latency.
When should I use a median filter instead?
When your noise is spiky rather than fuzzy. An averaging filter handles a single wild sample badly: it mixes a fraction of the spike into the output and then takes several samples to decay it away, so one bad reading becomes a visible bump spread over time. A median filter simply discards it, because a single outlier in a window of odd size cannot be the median.
// 5-sample median - rejects isolated outliers outright
double median5(double *w) {
double s[5];
memcpy(s, w, sizeof s);
for (int i = 1; i < 5; i++) { // insertion sort, n=5
double v = s[i]; int j = i - 1;
while (j >= 0 && s[j] > v) { s[j+1] = s[j]; j--; }
s[j+1] = v;
}
return s[2];
} The cost is a delay of roughly half the window length, and a characteristic staircase quality on slow ramps, because the median of a slowly changing window holds at one value and then steps. For that reason the usual arrangement is a short median as a prefilter — just long enough to kill spikes — feeding a smoothing filter that handles the baseline wander. Three or five samples is almost always enough; longer windows cost lag without removing anything a smoothing stage would not handle better.
Spiky noise specifically is a symptom worth noting rather than just filtering. Intermittent wiper contact on a worn potentiometer stick produces exactly this signature, and a filter is treating the symptom. Stick drift compensation in software covers what else can be done about it.
Why do fixed-coefficient filters force a bad compromise?
Because the two cases you care about want opposite settings, and a fixed filter cannot tell them apart.
| Situation | What you want | What a fixed filter gives you |
|---|---|---|
| Holding a slow sweep | Heavy smoothing — the signal is barely changing and all visible movement is noise | Whatever you chose; too light and you get wobble |
| Fast stab to full deflection | No smoothing — every sample is real signal and lag is the enemy | Whatever you chose; too heavy and the stab feels mushy |
| Everything in between | Something in between | The one number you picked |
Tune for the slow sweep and your fast gestures feel rubbery. Tune for the fast gesture and your held sweeps shimmer. Tune for the middle and you get both problems in milder form. People usually end up doing the last one and then describing the controller as "a bit vague", which is a filter problem being attributed to hardware.
The observation that breaks the deadlock is that the two cases are distinguishable from the signal itself. When the signal is changing slowly, the change you are seeing is dominated by noise, so smooth hard. When it is changing quickly, the change is dominated by genuine movement, so do not smooth at all. You do not need to be told which case you are in — you can estimate it.
How does the One Euro filter work?
The One Euro filter, published by Géry Casiez, Nicolas Roussel and Daniel Vogel, is a low-pass filter whose cutoff frequency is a function of the estimated speed of the signal. The reference implementation and explanation live at the authors' project page, and the full paper is available at HAL. It was designed for interactive systems — pointing, tracking, gestural input — which is precisely the class of signal a thumbstick produces.
The structure has three parts. A derivative estimate of the signal, itself lightly smoothed. A cutoff frequency computed as a minimum cutoff plus a slope term multiplied by that speed estimate. And a time-aware low-pass filter using the resulting cutoff. The whole thing has two tuning parameters with clear meanings:
typedef struct { double x_hat, dx_hat; int primed; } oe_t;
static double lp(double x, double x_prev, double cutoff, double dt) {
double tau = 1.0 / (2.0 * M_PI * cutoff);
double alpha = 1.0 / (1.0 + tau / dt);
return alpha * x + (1.0 - alpha) * x_prev;
}
// min_cutoff : cutoff when the signal is still. Lower = quieter at rest.
// beta : how aggressively cutoff rises with speed. Higher = less lag
// on fast movement.
// d_cutoff : cutoff for the derivative estimate. 1.0 Hz is the usual default.
double one_euro(oe_t *f, double x, double dt,
double min_cutoff, double beta, double d_cutoff) {
if (!f->primed) {
f->x_hat = x; f->dx_hat = 0.0; f->primed = 1;
return x;
}
double dx = (x - f->x_hat) / dt;
f->dx_hat = lp(dx, f->dx_hat, d_cutoff, dt);
double cutoff = min_cutoff + beta * fabs(f->dx_hat);
f->x_hat = lp(x, f->x_hat, cutoff, dt);
return f->x_hat;
}
The tuning procedure the authors recommend is refreshingly concrete. Set beta to zero and lower min_cutoff until holding still produces an acceptably quiet output — you are tuning the resting case in isolation. Then raise beta until fast movement no longer feels laggy, which does not disturb the resting case because the speed estimate is near zero there. Two parameters, two independent symptoms, no interaction between them.
That independence is the real win. With a fixed filter, every adjustment makes one case better and the other worse, so tuning is a search for the least bad compromise. With an adaptive filter you tune each case separately and keep both.
Where in the chain should the filter go?
Between normalisation and deadzone, operating on floating-point values, using measured elapsed time.
// 1. Normalise using the declared HID logical range
double t = (double)(raw - logical_min) / (double)(logical_max - logical_min);
double v = (t * 2.0) - 1.0; // -1 .. +1
// 2. Filter the real signal, with real dt
double dt = (now_ns - last_ns) * 1e-9;
v = one_euro(&filt, v, dt, min_cutoff, beta, 1.0);
// 3. Deadzone and rescale on the now-quieter signal
v = radial_deadzone_and_rescale(v, partner_axis, dz_inner, dz_outer);
// 4. Curve
v = curve(v, 1.5);
// 5. Quantise ONCE
uint8_t cc = (uint8_t)lround(((v + 1.0) * 0.5) * 127.0); Putting the filter before the deadzone has a second benefit beyond ordering correctness: a quieter signal needs a smaller deadzone. Since deadzone is usable range you are throwing away, filtering well literally buys you back control, which is the argument made in deadzone and response curve mathematics.
Resist the temptation to filter the MIDI value instead of the raw value. Filtering after quantisation operates on a signal that already lost its low bits, so it smooths the staircase rather than the noise — and because the output must still be an integer, you end up either re-quantising a smoothed staircase or emitting fractional values that get truncated anyway.
What about filtering gyro and touchpad?
Gyroscope output has a different noise character — bias instability that integrates into drift over time, on top of per-sample noise — so a smoothing filter alone is not the whole answer. A high-pass or bias-estimation stage is usually needed alongside it, and a recentre binding is worth having regardless so the performer can zero the reference at will. Joy-Con motion control as MIDI covers that in practice.
Touchpad coordinates behave differently again: they are quiet while a finger is down and then produce a discontinuity at lift-off and touch-down. A filter will happily smooth across that discontinuity, sliding the value from the old position to the new one over several samples — which sounds like a glide you did not ask for. Gate the filter on the contact state and reset it on touch-down. The DualSense touchpad XY guide and Steam Deck trackpads as XY pads cover the surfaces themselves, and the touchpad mapping docs cover configuration.
How do I know my filter is set correctly?
Record two gestures back to back into the same automation lane and look at the result.
- A slow sweep, centre to full over several seconds. The line should be smooth with no visible micro-wobble.
- A fast stab, centre to full as quickly as you can, then back. The line should reach full scale at the moment you hit the stop, with no visible rounding of the corner.
If the slow sweep is clean but the stab is rounded, your filter is too heavy at speed — raise beta. If the stab is crisp but the sweep wobbles, your resting cutoff is too high — lower min_cutoff. If you cannot get both with a fixed filter, that is the compromise this post exists to explain, and it is the signal to move to an adaptive one.
One last sanity check: if the lane shows a staircase rather than a wobble, you are looking at 7-bit quantisation and no filter setting will change it. Switch the mapping to a 14-bit pair and look again.
FAQ
Why does my stick produce noisy MIDI even when I am not moving it?
Because analog sensing and the controller's analog-to-digital conversion both introduce noise, so the reported value wanders within a small band at rest unless a deadzone or filter suppresses it.
What is the simplest useful smoothing filter?
An exponential moving average, which blends each new sample with the previous output using a single coefficient and requires only one stored value.
Does smoothing add latency?
Any filter that averages across time adds some lag by construction, which is why adaptive filters exist to apply heavy smoothing only when the signal is slow enough for the lag not to matter.
What is the One Euro filter?
It is an adaptive low-pass filter published by Casiez, Roussel and Vogel that varies its cutoff frequency with the estimated speed of the signal, smoothing heavily when slow and lightly when fast.
Should I filter before or after the deadzone?
Before, because the filter should see the raw noise it is there to suppress, and because a cleaner filtered signal lets you use a smaller deadzone and keep more usable range.
Can a filter fix stepped-sounding sweeps?
Not if the stepping comes from 7-bit MIDI quantisation, because that happens after the filter; in that case you need higher-resolution output rather than more smoothing.
Put it into practice
Record a slow sweep and a fast stab into one automation lane and look at both. That single picture tells you whether your filter is too heavy, too light, or the wrong type entirely — and it takes about thirty seconds. Download Universal Controller MIDI, set an adaptive filter on your main sweep control, and tune the resting cutoff and the speed response separately rather than hunting for one compromise value.