Lab 9.2 — Real-Time Audio Filtering on a Streamed WAV
← Course 2 syllabus · Bonus Module 9 · Prev: « Lab 9.1 · Next: Lab 9.3 »
Goal
Turn the loopback harness from Lab 9.1 into a real DSP pipeline: the host streams a WAV, the STM32 runs the FIR/IIR filter you built in Module 6 on each block as it arrives, and the filtered PCM is streamed back and written to filtered.wav. Then comes the part that makes this a verifiable workflow — the host compares the device output sample-by-sample against scipy.signal.lfilter, the exact same filter coefficients run in double precision on the laptop. For the float path you should match the reference to within round-off; for the Q15 fixed-point path you should match it to within a quantization bound you can predict. This is precisely how production audio firmware is regression-tested: a golden reference on the host, the real implementation on the target, and a bounded error between them.
Recommended reading
- Lyons Ch. 5–6 — FIR and IIR filters: convolution, the difference equation, coefficient quantization, and fixed-point implementation effects. The core of this lab.
- O-DSP Ch. 7 — filter structures (direct form I/II, transposed, cascade biquads) and finite-wordlength effects: coefficient quantization, roundoff noise, limit cycles.
- Kuo — fixed-point (Q-format) filter implementation on a real DSP/MCU, including scaling and overflow handling.
- Course 1 Week 10 (../course1/index.qmd#week-10) — Fourier transform, the frequency response as \(H(e^{j\omega})\), and why filtering multiplies spectra.
- Your own Lab 6.1 and Lab 6.2 — reuse those coefficients and structures directly.
Equipment & parts
- STM32 Nucleo-L476RG with the Lab 9.1 streaming firmware as the starting point.
- Host Mac with
numpy scipy(addsscipy.signal) and headphones/speaker for the audible check. - A test WAV with real spectral content — music or speech, 16-bit mono, 44.1/48 kHz. A file with obvious high-frequency content (cymbals, sibilance) makes a low-pass audibly obvious.
- Optionally CMSIS-DSP for
arm_fir_f32/arm_biquad_cascade_df1_q15.
Safety & don’t-break-it
Still a data lab; the hazards are numerical, and in fixed point they bite hard:
- Q15 overflow is silent and ugly. A Q15 sample is in \([-1, 1)\). Filter gain \(>1\) at any frequency will wrap a full-scale input past \(\pm 1\) and produce a loud click or buzz. Scale the input (headroom) or use CMSIS saturating biquad routines; never let a Q15 accumulator wrap around.
- Watch the accumulator width. An FIR of length \(N\) summing Q15·Q15 products needs a wide accumulator (the M4’s 64-bit MAC or a Q1.30 intermediate) before you round back to Q15. Summing in Q15 directly overflows.
- IIR stability is a real failure mode. A biquad with poles pushed outside the unit circle by coefficient quantization will grow without bound — exactly the trap Lab 6.2 studied. Verify pole radius after quantizing coefficients.
- Preserve the transport guarantees. Don’t let the filter’s per-block compute stall the RX DMA drain — keep the double-buffer discipline from Lab 9.1 or you’ll reintroduce overruns.
Background
The filter. An FIR filter is a finite convolution of the input with the impulse response \(h[k]\):
\[y[n] = \sum_{k=0}^{N-1} h[k]\, x[n-k].\]
An IIR filter obeys a recursive difference equation (here a direct-form biquad section):
\[y[n] = b_0 x[n] + b_1 x[n-1] + b_2 x[n-2] - a_1 y[n-1] - a_2 y[n-2].\]
Both are linear time-invariant, so processing the signal in contiguous blocks is exactly equivalent to processing it whole provided you carry the filter state across block boundaries — the last \(N-1\) input samples (FIR) or the delay-line contents \(x[n-1],x[n-2],y[n-1],y[n-2]\) (IIR). This carried state is the whole subtlety of block-based streaming: get it right and blockwise output equals whole-signal output bit-for-bit; drop it and you get a click at every block edge.
The frequency-domain view. Filtering multiplies spectra, \(Y(e^{j\omega}) = H(e^{j\omega})X(e^{j\omega})\), so the audible/FFT effect is predictable: a low-pass attenuates content above its cutoff by \(|H(e^{j\omega})|\). This is the Course 1 Week 10 result applied on the bench.
The fixed-point error bound. Represent a sample in Q15: \(\hat{x} = \operatorname{round}(x\cdot 2^{15})/2^{15}\), so each quantization introduces error \(|e|\le 2^{-16}\) (half an LSB, step \(\Delta = 2^{-15}\)). Coefficients are likewise rounded to Q15. To first order, the Q15 output differs from the exact (double-precision) lfilter output by an accumulation of these roundings; for an FIR of length \(N\) a conservative worst-case bound on the per-sample deviation is
\[|y_{Q15}[n] - y_{\text{ref}}[n]| \;\lesssim\; \Big(\sum_{k}|h[k]|\Big)\,\frac{\Delta}{2} \;+\; \Delta,\]
i.e. it scales with the filter’s \(\ell_1\) gain. Expressed as SNR, the roundoff noise floor sits near the Q15 quantization SNR of \(\approx 6.02\cdot 15 + 1.76 \approx 92\text{ dB}\), degraded by the filter’s noise gain. The float path, by contrast, differs from the reference only by IEEE-754 round-off — max abs error on the order of \(10^{-6}\)–\(10^{-7}\) of full scale.
Procedure
Part A — Firmware: filter each block.
- Start from the Lab 9.1 loopback. Replace the echo with a call to your filter, carrying state across blocks. Two coefficient paths, switchable by a host command byte:
/* FLOAT path (CMSIS-DSP) — near-reference accuracy */
static arm_fir_instance_f32 fir; // init once with h[], state[]
arm_fir_f32(&fir, in_f32, out_f32, BLOCK_SAMPLES); // state persists across calls
/* Q15 path — cascade of biquads, saturating */
static arm_biquad_casd_df1_inst_q15 iir; // init with Q15 coeffs, state
arm_biquad_cascade_df1_q15(&iir, in_q15, out_q15, BLOCK_SAMPLES);- Convert int16 PCM → the filter’s format on the way in (int16→float, or int16→Q15 which is a reinterpret), and back on the way out with saturation (clip to int16 range), not wraparound.
- Keep the exact same
h[]/ biquad coefficients you designed in Lab 6.1/6.2, so the host reference uses identical coefficients.
Part B — Host: stream, receive, and reference.
- Stream
in.wavthrough the harness (Lab 9.1), collect the device output, writefiltered.wav. - Run the golden reference with the same coefficients:
from scipy.signal import lfilter
# FIR: b=h, a=[1]; IIR: b, a from your biquad (or sos + sosfilt)
y_ref = lfilter(b, a, x.astype(np.float64))- Compare device vs. reference:
err = y_dev.astype(np.float64) - y_ref
max_abs = np.max(np.abs(err)) / 32768.0 # fraction of FS
snr_db = 10*np.log10(np.sum(y_ref**2) / np.sum(err**2)) # vs referencePart C — Audible + FFT verification.
- Listen to
in.wavvs.filtered.wav— a low-pass should audibly dull the highs. - Plot
np.fft.rfftmagnitude of input vs. output and overlay the filter’sfreqzmagnitude; the output/input ratio should trace \(|H(e^{j\omega})|\).
Deliverable & expected results
filtered.wav, plus the max-abs-error and SNR-vs-reference for both the float and Q15 paths, and the input/output FFT overlay showing \(|H|\).
For a representative length-63 low-pass FIR (cutoff ~4 kHz at \(f_s=48\) kHz, \(\sum|h[k]|\approx 1\)):
| Quantity | Predicted | Measured |
|---|---|---|
| Float path max abs error (fraction FS) | \(\sim 10^{-6}\) | … |
Float path SNR vs lfilter |
\(\gtrsim 120\) dB | … |
| Q15 quantization SNR (ideal, 15-bit) | ≈ 92 dB | … |
Q15 path SNR vs lfilter |
≈ 85–92 dB (noise-gain degraded) | … |
| Q15 max abs error | ≈ few LSB (\(\sim 10^{-4}\) FS) | … |
| Output/input FFT ratio at passband / stopband | ≈ \(|H|\) (≈ 0 dB / stopband atten.) | … |
Analysis & reconciliation
The float path should track scipy.signal.lfilter to within floating round-off; if it doesn’t, you almost certainly dropped filter state across a block boundary (look for periodic error spikes every BLOCK samples — the block-edge click) or used different coefficients on the two sides. The Q15 path should sit near the predicted quantization SNR; a worse number points to accumulator overflow (missing headroom or non-saturating rounding) or coefficient quantization that moved a pole — check the pole radius as in Lab 6.2. Reconcile the measured max-abs-error against the \(\ell_1\)-gain bound above; being within the bound confirms it’s honest quantization, not a bug. The FFT overlay is the frequency-domain cross-check on the time-domain SNR: both should tell the same story, connecting back to the Course 1 Week 10 statement that filtering is spectral multiplication.
Going further
- Sweep a chirp WAV through the filter and reconstruct the measured \(|H(e^{j\omega})|\) from output/input FFT ratio — a swept-sine frequency-response measurement done entirely in files.
- Compare direct-form vs. cascade-biquad IIR at Q15 and show the cascade’s lower roundoff noise (Lyons/O-DSP predict it).
- Add a stereo path and filter L/R with independent state; confirm the reference matches per channel.
- Push the block size down and measure per-block compute headroom on the M4 — the runway you’ll need for the FFT-based effects in Lab 9.3.