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.

Equipment & parts

  • STM32 Nucleo-L476RG with the Lab 9.1 streaming firmware as the starting point.
  • Host Mac with numpy scipy (adds scipy.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.

  1. 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);
  1. 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.
  2. 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.

  1. Stream in.wav through the harness (Lab 9.1), collect the device output, write filtered.wav.
  2. 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))
  1. 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 reference

Part C — Audible + FFT verification.

  1. Listen to in.wav vs. filtered.wav — a low-pass should audibly dull the highs.
  2. Plot np.fft.rfft magnitude of input vs. output and overlay the filter’s freqz magnitude; 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.