Lightweight DSP filters for Arduino and other embedded MCUs.
Includes a Direct Form II IIR filter, a FIR filter, and a DC-removal block — all using power-of-two circular buffers to replace expensive modulo operations with single-cycle bitwise AND. Coefficients are designed with the companion Python scripts and pasted directly into your sketch.
Developed for the Ray Research Lab
DanFilters/
├── src/
│ ├── DSP_Modules.h ← template IIRFilter<SIZE>, FIRFilter<TAPS>, DCRemove
│ └── DSP_Modules.cpp ← DCRemove implementation
├── examples/
│ ├── bandpass_iir/ ← cascade LP + HP to form a bandpass (the main demo)
│ ├── lowpass_iir/ ← single IIR low-pass, prints raw + filtered over Serial
│ └── fir_lowpass/ ← 16-tap FIR low-pass with linear phase
├── python/
│ ├── requirements.txt
│ ├── design_iir_filter.py ← design Butterworth / Chebyshev / Elliptic IIR
│ ├── design_fir_filter.py ← design windowed-sinc FIR
│ └── visualize_filter.py ← plot frequency response, phase, group delay, spectrogram
└── library.properties
- Copy the
DanFilters/folder into your Arduinolibraries/directory, or copysrc/DSP_Modules.handsrc/DSP_Modules.cppdirectly into your sketch folder. - Include the header:
#include <DSP_Modules.h>
- Paste coefficients generated by the Python scripts (see below).
- Instantiate a filter and call
process()once per sample.
Add this to platformio.ini:
lib_deps =
file://path/to/DanFilters#include <DSP_Modules.h>
// Coefficients from design_iir_filter.py (Butterworth low-pass, 100 Hz, 1 kHz fs)
const float b[3] = {0.00024135904904198073f, 0.00048271809808396145f, 0.00024135904904198073f};
const float a[3] = {1.0f, -1.9555782403150352f, 0.9565436765112031f};
// Template parameter must be a power of two >= (max(len(b), len(a)) - 1)
// For a 2nd-order filter (3 coefficients) use SIZE=4.
IIRFilter<4> lpf(b, 3, a, 3);
void loop() {
float raw = analogRead(A0);
float filtered = lpf.process(raw);
Serial.println(filtered);
}IIRFilter<4> lpf(b_lp, 3, a_lp, 3); // low-pass at upper edge
IIRFilter<4> hpf(b_hp, 3, a_hp, 3); // high-pass at lower edge
float y = hpf.process(lpf.process(analogRead(A0)));| Filter order | Coefficients | Minimum SIZE |
|---|---|---|
| 1st | 2 | 2 |
| 2nd | 3 | 4 |
| 3rd | 4 | 4 |
| 4th | 5 | 8 |
| 8th | 9 | 16 |
Always round up to the next power of two.
#include <DSP_Modules.h>
// 16-tap FIR (from design_fir_filter.py). TAPS must be a power of two.
// Zero-pad if your design has a non-power-of-two tap count.
const float h[16] = {
0.00338635f, 0.00978454f, 0.02194054f, 0.04027368f,
0.06273524f, 0.08612576f, 0.10567416f, 0.11754896f,
0.11754896f, 0.10567416f, 0.08612576f, 0.06273524f,
0.04027368f, 0.02194054f, 0.00978454f, 0.00338635f
};
FIRFilter<16> fir(h);
void loop() {
float y = fir.process(analogRead(A0));
Serial.println(y);
}FIR vs IIR: FIR filters always have linear phase (constant group delay), which matters for applications like ECG or audio where phase distortion is audible/visible. IIR filters achieve the same roll-off with far fewer coefficients but introduce non-linear phase.
DCRemove dc(0.995f); // R close to 1.0 = very low corner frequency
void loop() {
float y = dc.process(analogRead(A0)); // removes DC bias
}The transfer function is H(z) = (1 - z⁻¹) / (1 - R·z⁻¹).
R = 0.9→ corner ~16 Hz at 1 kHzR = 0.995→ corner ~0.8 Hz at 1 kHzR = 0.999→ corner ~0.16 Hz at 1 kHz
cd python
pip install -r requirements.txt# 2nd-order Butterworth low-pass at 100 Hz, 1 kHz sample rate
python design_iir_filter.py --type lowpass --fs 1000 --cutoff 100 --order 2
# 2nd-order Butterworth band-pass 5-100 Hz (equivalent to LP→HP cascade)
python design_iir_filter.py --type bandpass --fs 1000 --low 5 --high 100 --order 2
# 3rd-order Chebyshev Type I high-pass, 50 Hz, 1 dB passband ripple
python design_iir_filter.py --type highpass --fs 1000 --cutoff 50 --order 3 \
--family cheby1 --rp 1
# Add --plot to show the frequency and phase response
python design_iir_filter.py --type lowpass --fs 1000 --cutoff 100 --order 4 --plotOutput (paste directly into your sketch):
// ── IIR Filter Coefficients (auto-generated) ──────────────
// Type : lowpass
// Family : butter
// Order : 2
// fs : 1000 Hz
// Cutoff : 100 Hz
// ──────────────────────────────────────────────────────────
const float b[3] = {0.00024135904904198073f, 0.00048271809808396145f, 0.00024135904904198073f};
const float a[3] = {1.0f, -1.9555782403150352f, 0.9565436765112031f};
const int nB = 3;
const int nA = 3;
IIRFilter<4> filter(b, nB, a, nA);# 32-tap Hamming-windowed low-pass at 100 Hz
python design_fir_filter.py --fs 1000 --cutoff 100 --taps 32
# 64-tap band-pass 5-100 Hz
python design_fir_filter.py --fs 1000 --low 5 --high 100 --taps 64
# Kaiser window, beta=8 (high stopband attenuation)
python design_fir_filter.py --fs 1000 --cutoff 100 --taps 32 --window kaiser --beta 8 --plot# Plot IIR response (edit b/a in the script or pipe from design_iir_filter.py --plot)
python visualize_filter.py --mode iir --fs 1000
# Plot time-domain raw vs filtered from a Serial CSV log
python visualize_filter.py --mode time --fs 1000 --csv data.csv
# Spectrogram of filtered output
python visualize_filter.py --mode spectrogram --fs 1000 --csv data.csvTo capture Serial data as CSV on Linux/macOS:
# Replace /dev/tty.usbmodem* with your port
cat /dev/tty.usbmodem* > data.csvFor a 2nd-order section the update equations are:
w[n] = x[n] − a₁·w[n−1] − a₂·w[n−2] (feedback)
y[n] = b₀·w[n] + b₁·w[n−1] + b₂·w[n−2] (feedforward)
Direct Form II uses only one delay line (w) instead of two (x and y in Direct Form I), halving the state storage for the same transfer function.
The state array wHist[SIZE] acts as a circular buffer. Instead of:
writeIndex = (writeIndex + 1) % SIZE; // integer division — slow on AVRthe library uses:
writeIndex = (writeIndex + 1) & mask; // mask = SIZE - 1, single AND instructionThis is valid only when SIZE is a power of two, enforced by a static_assert at compile time.
y[n] = x[n] − x[n−1] + R · y[n−1]
A first-order IIR high-pass at near-DC frequency. It removes any constant offset (battery drift, sensor bias) before the main filter sees the signal.
MIT