Moving Average Filter FIR: The Simplest ADC Post-Processor That Actually Works
The moving average is the most-deployed FIR filter in embedded control systems — one accumulator, one subtraction, zero multiply overhead on a power-of-2 window. But "simplest" does not mean "unintelligent." The box-car spectrum punches sharp nulls at exact multiples of fs/N, its group delay is fixed at (N-1)/2 samples regardless of frequency, and the -3 dB point scales predictably with window length. This post quantifies all of that for N=16 taps at fs = 44 100 Hz.
Transfer Function
An N-tap causal moving average computes:
y[n] = (1/N) · (x[n] + x[n-1] + ... + x[n-(N-1)])
Taking the z-transform:
H(z) = (1/N) · (1 + z⁻¹ + z⁻² + ... + z⁻⁽ᴺ⁻¹⁾)
For N = 16:
H(z) = (1/16) · Σ_{k=0}^{15} z⁻ᵏ
Coefficients — all taps equal 1/N:
| Tap index k | b_k |
|---|---|
| 0 – 15 | 0.0625 |
All poles sit at z = 0 (origin); all 15 zeros lie uniformly on the unit circle at z = e^{j·2πk/N} for k = 1, 2, ..., N-1.
Constant group delay (linear phase property of symmetric FIR):
τ_g = (N - 1) / 2 = 7.5 samples → 170 µs at fs = 44 100 Hz
This is the single most important property for control loops: every frequency component is delayed by exactly the same amount, so the transient waveform shape is preserved perfectly.
Frequency Response
Dark-theme Bode plot: magnitude (top) and phase (bottom) for 16-tap moving average at fs = 44 100 Hz.
Key observations for N = 16, fs = 44 100 Hz:
| Metric | Value |
|---|---|
| -3 dB cutoff | 1 222 Hz |
| First null (k = 1) | 2 756 Hz |
| Rolloff to first null | −∞ dB (hard zero) |
| Attenuation at 5 kHz | −20.1 dB |
| Group delay | 7.5 samples = 170 µs |
| Stopband ripple | −13 dB (sinc sidelobes) |
The first null frequency scales as f_null = fs / N — doubling the window length halves both the -3 dB point and the null spacing. The stopband sidelobes do not diminish with N, which is the primary reason the moving average is unsuitable as a sharp anti-aliasing filter.
Python Implementation
import numpy as np
from scipy.signal import lfilter
N = 16
FS = 44100.0
b = np.ones(N) / N # all taps = 1/N
a = np.array([1.0]) # FIR — no feedback
def filter_signal(x: np.ndarray, fs: float = FS) -> np.ndarray:
"""Apply N-tap moving-average FIR."""
return lfilter(b, a, x)
scipy.signal.lfilter handles the circular-buffer logic internally. For real-time streaming, use lfilter_zi to preserve state across buffer boundaries.
C Implementation
Direct Form with a circular buffer delivers O(1) per sample — one add, one subtract, one divide (or right-shift for power-of-2 N):
#define MA_N 16
typedef struct {
float buf[MA_N]; /* circular input buffer */
int idx; /* write pointer */
float sum; /* running accumulator */
} MAState;
float ma_process(MAState *s, float x)
{
s->sum -= s->buf[s->idx]; /* remove oldest sample */
s->buf[s->idx] = x;
s->sum += x; /* add newest sample */
s->idx = (s->idx + 1) % MA_N;
return s->sum / (float)MA_N;
}
Fixed-point note: For N = 2^k, replace /MA_N with a right arithmetic shift by k bits: return s->sum >> 4; (N=16). This eliminates the division entirely and keeps the loop at a single clock cycle on most MCUs.
MATLAB Implementation
N = 16;
fs = 44100;
b = ones(1, N) / N;
a = 1;
% Frequency response
[H, f] = freqz(b, a, 2048, fs);
plot(f, 20*log10(abs(H) + 1e-12));
xlabel('Frequency (Hz)'); ylabel('Magnitude (dB)');
title(sprintf('%d-Tap Moving Average', N));
yline(-3, 'r--', '-3 dB'); grid on;
% Pole-zero (all zeros on unit circle, all poles at origin)
zplane(b, a);
% Time-domain filtering
y = filter(b, a, noisy_signal);
MATLAB's zplane confirms 15 zeros equally spaced on the unit circle — the most even distribution possible for a linear-phase FIR.
Design Trade-offs
Rolloff vs. sidelobe level. The -13 dB maximum stopband attenuation is determined by the rectangular window, not N. A Hann-windowed FIR of the same length achieves -44 dB at the cost of a wider transition band. Use the moving average only where stopband sidelobe level is not a concern (e.g., ADC noise smoothing with a large noise floor).
Latency vs. cutoff. Since fc ≈ 0.443·fs/N, reducing the cutoff by 2× requires doubling N — which also doubles the group delay. There is no free parameter to decouple them. A single-pole IIR lowpass (EMA) achieves lower cutoff with only 2 coefficients and 0.5-sample equivalent delay, at the cost of non-linear phase.
Cascading. Two cascaded 8-tap MAs produce a Bartlett (triangular) window. The cascade reduces sidelobe level to about -26 dB but maintains the same -3 dB point and adds group delay.
Coefficient sensitivity. All coefficients are exactly equal, so there is no quantization sensitivity concern — even a 1-bit shift does not change the symmetry.
Key Takeaways
Group delay is constant:
(N-1)/2samples for all frequencies — the defining advantage over IIR for waveform-shape-critical control signals.First null is deterministic:
f_null = fs/N— choose N to place this null at a known interference frequency (e.g., N = fs/100 = 441 to null 100 Hz hum).O(1) update with circular buffer: the running-sum trick makes the per-sample cost independent of window length.
Power-of-2 N → division-free: replace
/Nwith arithmetic right-shift in firmware for zero-overhead smoothing.Not a sharp lowpass: -13 dB stopband ripple makes the moving average unsuitable for anti-aliasing; use a windowed-sinc or Butterworth for that role.
Related Posts
First-Order IIR Lowpass: The Universal Pre-Filter Every Control Loop Needs — single-pole EMA, lower latency at the cost of non-linear phase; the IIR counterpart to the moving average.
Notch Filter 2nd Order: Surgically Remove a Single Frequency Without Touching the Rest — when you need to kill one interference tone (50/60 Hz) instead of broadband smoothing.
Engineering question: If you need to simultaneously reject 100 Hz mains hum and keep group delay linear below 500 Hz, would you choose a notch followed by a moving average, or a single windowed-sinc FIR — and what window function would you pick?
