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.

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 + matplotlib to receive and plot the streamed spectrum live.

Wiring & bench setup

Input side of the Lab 6.1 chain only — there is no analog output this time; the “output” is the spectrum streamed over the ST-LINK VCP to the host plot. The MCP4725 tone feeds A0 = PA0 directly in Parts A–B; in Part C it first passes through the Lab 4.3 clipper (scaled/biased back into 0–3.3 V). Scope CH1 watches the ADC input and doubles as the independent FFT cross-check.

flowchart LR
  SRC["MCP4725<br/>test tone<br/>(Lab 3.3 generator)"]
  CLIP["MCP6002 clipper<br/>(Lab 4.3, Part C only)"]
  MCU["NUCLEO-L476RG<br/>ADC A0 = PA0<br/>rfft on-chip"]
  HOST["Mac host<br/>live spectrum plot"]
  SC1["Siglent CH1<br/>+ FFT math"]
  SRC -- "Parts A–B: OUT → A0 (PA0)" --> MCU
  SRC -.-> CLIP
  CLIP -. "divider/bias into 0–3.3 V" .-> MCU
  MCU -- "USART2 VCP over USB" --> HOST
  SRC -.-> SC1

flowchart LR
  SRC["MCP4725<br/>test tone<br/>(Lab 3.3 generator)"]
  CLIP["MCP6002 clipper<br/>(Lab 4.3, Part C only)"]
  MCU["NUCLEO-L476RG<br/>ADC A0 = PA0<br/>rfft on-chip"]
  HOST["Mac host<br/>live spectrum plot"]
  SC1["Siglent CH1<br/>+ FFT math"]
  SRC -- "Parts A–B: OUT → A0 (PA0)" --> MCU
  SRC -.-> CLIP
  CLIP -. "divider/bias into 0–3.3 V" .-> MCU
  MCU -- "USART2 VCP over USB" --> HOST
  SRC -.-> SC1

Pin map:

From To Pin/jack
Nucleo 3V3 / GND breadboard + / − rails → MCP4725 (and clipper stage) supply 3V3, GND headers
Nucleo D15 / D14 MCP4725 SCL / SDA PB8 / PB9 (I2C1)
MCP4725 OUT Nucleo A0 (Parts A–B) or clipper input (Part C) PA0 (ADC1_IN5)
Clipper output (Part C) divider/bias network → Nucleo A0 PA0
Scope CH1 probe tip + ground clip ADC input node / − rail 10× (switch and menu)
ST-LINK USB Mac /dev/tty.usbmodem*, 115200 8-N-1

Breadboard layout is unchanged from the Lab 6.1 sketch, plus the Part C clipper stage if you use it. Keep DAC-synthesized tones digitally biased to mid-scale as in 6.1; the clipper output must be re-scaled into 0–3.3 V before PA0 (see Safety).

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.

Project & environment setup

Firmware — reuse the Module 6 project firmware/m6-dsp/ (created in Lab 6.1). Nothing new to enable; confirm the .ioc still has:

CubeMX page Setting
Software Packs ARM CMSIS-DSP present — arm_rfft_fast_f32 / arm_cmplx_mag_f32 must link (setup essentials)
ADC1 + DMA + TIM2 IN5 (PA0), circular half-word DMA, TRGO at \(f_s = 16\) kHz — as Lab 6.1
Connectivity → I2C1 / USART2 400 kHz MCP4725 bus / 115200 8-N-1 VCP

At 115200 baud a 513-bin float frame (~2 kB) takes ~180 ms, so the live display updates at ~5 frames/s — the stream, not the FFT, is the bottleneck (as Background predicts). Raise the USART2 baud in the .ioc if you want a faster display; the ST-LINK VCP handles well above 115200.

Host — the live plot runs in the course venv (Toolchain):

source venv/bin/activate   # pyserial + matplotlib + numpy (frequency axis, dB conversion)
mkdir -p labs/lab-6-3/host labs/lab-6-3/captures

One script labs/lab-6-3/host/live_spectrum.py (you write it): pyserial reads each streamed mag[] frame (binary, or CSV for bring-up), numpy builds the \(k\,f_s/N\) axis and dB conversion, matplotlib animates the live plot.

Keep this lab’s reconciliation in labs/lab-6-3/host/analysis.ipynb — the notebook convention — and export final figures next to it.

Where results go:

Artifact Path
Bench note labs/lab-6-3/notes.md
Streamed spectrum frames (tone on/off-bin runs) labs/lab-6-3/captures/spectra.log
Leakage before/after Hann (Part B plots) labs/lab-6-3/host/leakage-rect-vs-hann.png
Clipped-signal spectrum, harmonics labeled labs/lab-6-3/host/clipped-harmonics.png
Scope FFT cross-check (screenshot/CSV) labs/lab-6-3/captures/scope-fft.csv

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 halfway between two bins reads low because it’s sampled off the main-lobe peak — for a rectangular window the worst-case loss is \(20\log_{10}(2/\pi) \approx 3.9\) dB, and for the wider-lobed Hann window it is only ~1.4 dB (the wider main lobe is why Hann scallops less).

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.

  1. 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.

  2. 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, PERIODIC form (2*PI*n/N, not N-1) for unbiased DFT analysis:
       win[n] = 0.5f*(1.0f - cosf(2.0f*PI*n/1024.0f)); */
    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 the packed real-FFT */
    /* CMSIS packs DC in spec[0] and Nyquist in spec[1] (both purely real);
       bins 1..511 are interleaved (re,im) in spec[2..1023]. Unpack, or arm_cmplx_mag_f32
       would compute a bogus mag[0] = sqrt(DC^2 + Nyquist^2): */
    float32_t dc = spec[0], nyq = spec[1];
    spec[0] = 0.0f; spec[1] = 0.0f;              /* so bin 0 isn't the DC+Nyquist mix */
    arm_cmplx_mag_f32(spec, mag, 512);           /* mag[1..511] = |X[1..511]| (mag[0] now 0) */
    mag[0]   = fabsf(dc);                         /* |X[0]|   = DC term            */
    mag[512] = fabsf(nyq);                        /* |X[512]| = Nyquist term       */
  3. Convert to dB and stream mag[] over USART2/VCP each block (binary for speed, or CSV for a first bring-up). On the host, plot 20*log10(mag) vs. k * fs/N.

Part B — Validate with a known tone.

  1. 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.
  2. 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.
  3. 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.

  1. 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.
  2. 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 Lesson 36: 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.

Cross-platform ports & language variants

See the syllabus Implementation tracks for the framing; this is the FFT-specific version. The FFT is block/batched and throughput-bound — the cleanest throughput-vs-latency contrast in the module, and the one case where Jetson and Pi genuinely win.

STM32 bare-metal (C, and Rust). In C, arm_rfft_fast_f32 does one 1024-point transform in well under a millisecond, deterministically, inside the block period — its virtue is predictable single-transform latency, verified with the DWT cycle counter (setup essentials). In Rust (#![no_std]) reach for the microfft or fourier crate; both compute a real FFT without CMSIS and without an allocator.

Raspberry Pi 5 (Linux userspace, C or NumPy). FFTW or numpy.fft.rfft, NEON-accelerated, chews through single transforms far faster than the M4F — but on a preemptive scheduler the when is not guaranteed, so single-block latency has a jittery tail even though throughput is high.

Jetson Orin Nano. cuFFT batched is the natural home: thousands of FFTs per second, ideal for multi-channel or overlapping spectrograms — this is exactly the path that feeds Lab 8.1. It wins overwhelmingly on batched throughput, but pays a kernel-launch plus host↔︎device transfer cost that ruins single-block latency. Batch to amortize, or don’t bother.

Jetson Orin Nano — detailed procedure (embedded Linux)

The module’s showcase GPU port — this is the lab where the Jetson genuinely wins, and the deliverable is the pair of numbers that says by how much and on which axis. Reuse the Lab 6.1 Jetson harness conventions; board config in the Jetson setup essentials.

  1. mkdir -p labs/lab-6-3/edge; export the lab’s test blocks (the same windowed 1024-point records, e.g. the two-tone resolution case) and the numpy.fft.rfft reference spectra.
  2. CPU single-block: time numpy.fft.rfft (and, if you want the compiled rung, FFTW from the C++ template) on one 1024-point block over thousands of repetitions → p50/p99. The median will embarrass the M4F’s sub-millisecond number; the p99 tail is the scheduler’s signature.
  3. GPU single-block: same transform via cupy.fft.rfft with an explicit cupy.cuda.Stream.null.synchronize() before the stop timestamp — unsynchronized GPU timing measures nothing. First-call cost includes cuFFT plan creation: time it separately, then exclude it by warming up (plans are cached per shape). Record how badly a single small FFT loses to the CPU.
  4. GPU batched: stack B blocks into a (B × 1024) array and transform along the last axis — one launch, B transforms. Sweep B = 1, 8, 64, 512, 4096; compute FFTs/second at each; find the crossover B where the GPU passes the CPU and the plateau where copies stop mattering (optionally re-run from pinned memory / with data resident on-device to separate copy cost from compute).
  5. Confirm every path’s magnitudes match the NumPy reference bin-for-bin, then plot FFTs/s vs B (CPU flat, GPU rising to a plateau) into labs/lab-6-3/edge/ — that one figure is the throughput-vs-latency lesson, and it’s the exact regime Lab 8.1 lives in.
  6. Record single-block p50/p99 (CPU, GPU) and plateau FFTs/s in the table below next to the STM32 DWT number.

Raspberry Pi 5 differences: CPU rung only (performance governor); its NEON numpy.fft/FFTW numbers slot into the same table, and the missing GPU column is the point of running it.

Measure and compare (fill Measured on each platform):

Platform / build Single-block latency Batched throughput (FFTs/s) Predicted Measured
STM32 bare-metal, C (arm_rfft_fast_f32, DWT) predictable, \(\ll\) block period n/a (one at a time) deterministic
STM32 bare-metal, Rust (microfft) predictable n/a ≈ C
Pi 5, FFTW / numpy.fft fast median, jittery tail high p99 ≫ p50
Jetson, cuFFT batched launch-overhead-bound very high wins batched

The takeaway: single-block latency (STM32 predictable) versus batched throughput (Jetson) — the same transform, judged on opposite axes.

Same STM32: bare-metal vs RTOS

The runtime axis has a middle rung worth measuring on the MCU itself: run the analyzer under FreeRTOS and compare against the bare-metal build. This lab is the cleanest example of a loose deadline, so it’s the ideal place to show the scheduler costing you nothing, and it sets up Lab 7.2.

  • Bare-metal (above): the DMA full-transfer callback runs the windowing + arm_rfft_fast_f32 + arm_cmplx_mag_f32 on the completed block inline, in ISR context, then streams the magnitudes.
  • FreeRTOS (C): turn the DMA callback into a fast signal and move the FFT into a prioritized DSP task. HAL_ADC_ConvCpltCallback only does osSemaphoreRelease(sem) (or queues the ready-buffer index with osMessageQueuePut) and returns; a high-priority FFT task blocks on osSemaphoreAcquire(sem, osWaitForever), runs the transform and magnitude, and hands the spectrum to a separate streaming task. Setup: enable FREERTOS → CMSIS_V2 and move the HAL timebase to a spare timer (TIM17) per the setup essentials, then osSemaphoreNew / osThreadNew.
  • Rust (RTIC / Embassy): in RTIC, the DMA-complete IRQ is a hardware task that spawns a lower-priority software task carrying the buffer index; the software task runs the FFT. In Embassy, an async task awaits the DMA future and transforms on wake. Same ISR→task deferral, statically scheduled.
  • What you’ll see: the block period is \(N/f_s = 1024/16000 = 64\) ms while the FFT itself finishes in well under a millisecond, so there is enormous slack — the ~few-µs context switch is a rounding error against 64 ms. This is the textbook case of loose deadline ⇒ RTOS costs nothing, the exact opposite extreme from the tight 100 µs ISR of Lab 2.2. Measure the added per-block latency with the DWT counter and watch the deadline margin stay essentially full.
Build (same STM32) Per-block latency/jitter added Deadline margin Structural benefit Measured
Bare-metal, inline in DMA callback none (transforms in ISR) full (≪ 64 ms) monolithic ISR
FreeRTOS, DMA→semaphore→FFT task + one context switch (µs vs 64 ms block) still essentially full FFT / streaming as separate tasks
Rust RTIC, DMA hw task→sw task ≈ FreeRTOS still essentially full compile-time-checked task priorities

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.