Lab 6.3 — FFT Spectrum Analyzer
← Course 2 syllabus · Module 6 · Prev: « Lab 6.2 · Next: Lab 6.4 »
Goal
Turn the STM32 into a real-time spectrum analyzer: take blocks of ADC samples, run an \(N\)-point FFT on-chip with CMSIS-DSP’s arm_rfft_fast_f32, compute the magnitude spectrum, and stream it to a host plot that updates live. Along the way you’ll internalize the two facts that make or break every spectrum measurement — bin spacing \(\Delta f = f_s/N\) (your frequency resolution) and spectral leakage (what a non-integer number of periods does to a bin), and you’ll fix leakage with a Hann window. You’ll validate the analyzer against a known DAC tone, then point it at a clipped signal and read off its harmonics — the frequency-domain view of the distortion you created back in Lab 4.3. Real-time FFT is the workhorse of DSP firmware (vibration monitors, audio analyzers, comms); this is where you learn to trust it.
Recommended reading
- Lyons Ch. 3–4 — the DFT and the FFT: bin frequencies, leakage, windowing, and scalloping loss, in the practitioner’s language. Primary reading.
- O-DSP Ch. 8–9 — the DFT as sampled DTFT, circular convolution, and the FFT algorithms (radix-2 decimation-in-time) rigorously.
- Course 1 Weeks 9–10 — the z-transform/DTFT (Week 9) and the Fourier transform and sampling (Week 10). The DFT is the DTFT sampled at \(N\) points on the unit circle; the leakage you’ll see is the DTFT of a rectangular window convolved with your tone.
Equipment & parts
- STM32 Nucleo-64 (NUCLEO-L476RG) with the Module 5 timer-triggered ADC + DMA project as the sampler.
- MCP4725 DAC (or on-chip DAC) to synthesize a known test tone into the ADC input.
- MCP6002-based clipping stage from Lab 4.3 (or a DAC-synthesized clipped waveform) as the harmonic-rich test signal.
- Siglent SDS1104X-E scope with FFT math for an independent spectral cross-check.
- Host with
pyserial+matplotlibto receive and plot the streamed spectrum live.
Safety & don’t-break-it
- 0–3.3 V on the ADC pin, always. The clipped signal from Lab 4.3 may swing to a 5 V rail depending on the op-amp supply — scale/level-shift it into 0–3.3 V before the STM32 pin, or you’ll damage the analog input. This is the single most likely way to kill the board in this lab.
- DC-bias your test signal to mid-rail. The single-supply ADC reads 0–3.3 V; an AC signal must be biased to ~1.65 V so both half-cycles are captured. An un-biased bipolar signal gets clipped at 0 V by the ADC itself and will look like harmonic distortion in your FFT — a measurement artifact, not the DUT.
- Share grounds among DAC, clipper, STM32, and scope.
- No smoke risk in the firmware, but an under-sized FFT buffer or wrong length silently produces garbage bins — verify \(N\) and the real-FFT layout before trusting a peak.
Background
The \(N\)-point DFT of a real block \(x[0..N-1]\) is
\[ X[k] = \sum_{n=0}^{N-1} x[n]\, e^{-j 2\pi k n / N}, \qquad k = 0, 1, \dots, N-1. \]
Each bin \(k\) corresponds to a physical frequency
\[ f_k = k\,\frac{f_s}{N}, \qquad \Delta f = \frac{f_s}{N}, \]
so the bin spacing — your frequency resolution — is set entirely by \(f_s/N\). For a real input, \(X[k]\) is conjugate-symmetric, so only bins \(0\) to \(N/2\) are unique (DC up to the Nyquist frequency \(f_s/2\)); the real FFT (arm_rfft_fast_f32) returns exactly those. The magnitude spectrum is
\[ |X[k]| = \sqrt{\Re\{X[k]\}^2 + \Im\{X[k]\}^2}, \]
which CMSIS-DSP computes in one call with arm_cmplx_mag_f32.
Spectral leakage. The DFT implicitly assumes the block is one period of a periodic signal. If a tone’s frequency does not fall exactly on a bin center — i.e., the block does not contain an integer number of cycles — its energy leaks into neighboring bins. Formally, sampling a length-\(N\) record is multiplying the infinite signal by a rectangular window; in the frequency domain that convolves the tone’s line with the window’s transform, the Dirichlet kernel \(\frac{\sin(N\omega/2)}{\sin(\omega/2)}\), whose sidelobes are only ~13 dB down. So an off-bin tone smears across many bins with a slow \(1/f\) sidelobe roll-off — leakage.
Windowing. Multiplying the block by a tapered window \(w[n]\) before the FFT trades main-lobe width for sidelobe height. The Hann window,
\[ w[n] = \tfrac{1}{2}\Big(1 - \cos\tfrac{2\pi n}{N-1}\Big), \]
widens the main lobe to ~2 bins but drops the nearest sidelobes to ~31 dB and rolls them off as \(1/f^3\), dramatically reducing leakage from strong off-bin components. The price is coherent gain loss — a Hann window scales the amplitude by its average, \(0.5\), so multiply recovered magnitudes by \(1/0.5 = 2\) for amplitude-correct peaks (and by a different factor for power; see Lab 6.4). Also expect scalloping loss: a tone exactly between two bins reads up to ~1.5 dB low (rectangular) or ~1.4 dB (Hann) because it’s sampled off the main-lobe peak.
Real-time budget. A radix-2 FFT costs \(\approx \tfrac{N}{2}\log_2 N\) butterflies. For \(N = 1024\) that’s ~5000 butterflies; on the M4F with CMSIS-DSP it runs in well under a millisecond, comfortably inside a block period \(N/f_s = 1024/16000 = 64\) ms — so the FFT is not your bottleneck; the plot/stream rate is. Overlapping blocks (e.g., 50%) gives smoother updates at the cost of more compute.
Procedure
Part A — Build the on-chip FFT path.
Choose \(N = 1024\), \(f_s = 16\) kHz, so \(\Delta f = 15.625\) Hz and the analyzer spans DC to 8 kHz. Collect one full block from the DMA buffer (Lab 5.3), convert ADC codes to centered float.
Initialize and run the real FFT and magnitude:
arm_rfft_fast_instance_f32 fft; arm_rfft_fast_init_f32(&fft, 1024); static float32_t win[1024], buf[1024], spec[1024], mag[513]; /* precompute Hann once: win[n] = 0.5f*(1 - cosf(2*PI*n/(1024-1))); */ for (int n = 0; n < 1024; n++) buf[n] = xblock[n] * win[n]; /* apply window */ arm_rfft_fast_f32(&fft, buf, spec, 0); /* 0 = forward; spec is packed complex */ arm_cmplx_mag_f32(spec, mag, 512); /* mag[0..511] = |X[0..511]| */ /* mag[0] and Nyquist are packed specially — see CMSIS docs */Convert to dB and stream
mag[]over USART2/VCP each block (binary for speed, or CSV for a first bring-up). On the host, plot20*log10(mag)vs.k * fs/N.
Part B — Validate with a known tone.
- Play a bin-centered tone from the DAC: pick \(f = m \cdot \Delta f\) for integer \(m\), e.g. \(m = 64 \Rightarrow 1000\) Hz exactly. With no window (rectangular), confirm the energy lands almost entirely in bin 64 with tiny leakage.
- Now play an off-bin tone, e.g. \(1007.8\) Hz (halfway between bins 64 and 65). Rectangular: watch the energy smear across many bins with ~13 dB sidelobes. Apply the Hann window and watch the smear collapse into a ~2-bin main lobe with far lower sidelobes. This is the leakage lesson, made visible.
- Cross-check any peak against the scope’s own FFT math on the same tone — the two should agree on peak frequency and relative levels.
Part C — Measure a clipped signal’s harmonics.
- Feed the clipped waveform from Lab 4.3 (biased into 0–3.3 V). A symmetrically clipped sine produces odd harmonics (3rd, 5th, 7th, …) of the fundamental. With a Hann window, read off the harmonic bins and their levels relative to the fundamental.
- Compute total harmonic distortion from the measured bins: \(\text{THD} = \sqrt{\sum_{h\ge 2} |X_h|^2} / |X_1|\). Compare a lightly-clipped vs. hard-clipped case and watch the harmonic content grow.
Deliverable & expected results
Capture: the live spectrum plot for a bin-centered tone, the off-bin tone with and without the Hann window (the leakage before/after), and the clipped-signal spectrum with the harmonics labeled. Log the measured peak bin, its frequency, and the harmonic levels.
| Quantity | Predicted | Measured |
|---|---|---|
| Bin spacing \(\Delta f = f_s/N\) (\(N=1024\), \(f_s=16\)k) | 15.625 Hz | … |
| Peak bin for a 1000 Hz tone | bin 64 | … |
| Off-bin leakage, rectangular window | nearest sidelobe ≈ −13 dB | … |
| Off-bin leakage, Hann window | nearest sidelobe ≈ −31 dB | … |
| Hann amplitude-correction factor | ×2 (coherent gain 0.5) | … |
| Scalloping loss, tone between bins (Hann) | ≈ 1.4 dB low | … |
| 3rd-harmonic level of a hard-clipped 1 kHz sine | (from THD prediction) | … |
| FFT compute time, \(N=1024\) | ≪ 64 ms block period | … |
Analysis & reconciliation
Confirm the peak lands in the predicted bin and that its frequency equals \(k\,f_s/N\) — a peak in the wrong bin usually means an \(f_s\) that isn’t what you think it is (verify the timer-triggered sample rate from Module 5). Reconcile the rectangular-vs-Hann sidelobes against the theoretical −13 dB / −31 dB numbers; the leftover discrepancy is scalloping (the off-bin tone samples the main lobe off-peak) plus ADC noise. When you window, remember to un-do the coherent gain before comparing peak amplitudes to the DAC’s known output — a factor-of-2 miss here is the classic “my Hann spectrum reads 6 dB low” error. For the clipped signal, check that only odd harmonics appear for symmetric clipping (even harmonics indicate an asymmetric clip or a DC-bias error — trace it to the biasing, not the DUT). Tie the whole picture back to Course 1 Week 10: the DFT is the Fourier transform of a windowed, sampled signal, and every artifact you measured — leakage, scalloping, the Nyquist fold — is that chain of operations made concrete.
Going further
- Add overlap (50% or 75%) between successive blocks for a smoother real-time display; note the compute cost rises proportionally.
- Try other windows (Blackman-Harris for deep sidelobes, flat-top for accurate amplitude of off-bin tones) and tabulate the main-lobe-width vs. sidelobe trade-off.
- Implement zero-padding (FFT a 1024-sample record into 4096 points) to interpolate the spectrum and see that it improves display resolution but not the true \(f_s/N\) resolution — a subtle, important distinction.
- Feed the FFT output into a simple peak-frequency estimator (parabolic interpolation of the top three bins) to beat the bin spacing on a pure tone.
- This analyzer is the front end for Lab 6.4 (averaged PSD) and Lab 6.5 (single-bin detection) — keep the code modular.