Detect specific tones without paying for a full FFT. The Goertzel algorithm evaluates a single DFT bin with a tiny second-order recursive filter — one multiply and two adds per input sample — so when you only need to know “is this handful of frequencies present?” (DTMF dialing, comms signaling, a tuning reference, a beacon), it’s far cheaper than an \(N\)-point FFT. You’ll implement Goertzel on the STM32, detect DTMF-style tones synthesized by the DAC, set a detection threshold that survives the noise floor you measured in Lab 6.4, and quantify exactly when Goertzel beats the FFT and when it doesn’t. This is a staple of embedded DSP: the right algorithm for a narrow question.
Recommended reading
Lyons Ch. 13 — the Goertzel algorithm: derivation as a single-bin DFT / second-order resonator, the magnitude computation, and its cost vs. the FFT. Primary reading.
Kuo — real-time implementation of Goertzel on an MCU, block processing, and fixed-point considerations.
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 test tones and DTMF pairs (sum of two sinusoids) into the ADC input.
Siglent SDS1104X-E scope / its FFT math to confirm the injected tones.
Host with pyserial to log the per-frame Goertzel magnitudes and the detect/no-detect decisions.
Wiring & bench setup
Input side of the Lab 6.1 chain plus the timing GPIO: the MCP4725 synthesizes the tone / DTMF pair into A0 = PA0, the decoded digits stream over the ST-LINK VCP, and D7 = PA8 carries the Part D Goertzel-vs-FFT timing pulses to the Saleae. Scope CH1 (with its FFT math) confirms the injected tones.
Breadboard layout is unchanged from the Lab 6.1 sketch. Synthesize the two-tone sum digitally around mid-scale with the combined peak inside the rail (see Safety) — the headroom check happens in the DAC codes, not on the breadboard.
Safety & don’t-break-it
0–3.3 V on the ADC pin. DTMF is a sum of two tones; make sure the combined peak (which can reach the sum of the two amplitudes) stays inside the rail after mid-rail biasing — an un-headroomed sum clips and creates intermodulation products that will trip your detector falsely.
Bias to mid-rail so both tones are fully captured; a clipped input adds harmonics/IMD that leak into the Goertzel bins and corrupt detection.
Choose target bins on the DFT grid. Goertzel is exact only for frequencies at \(k\,f_s/N\); an off-grid target leaks like any DFT bin. Pick \(f_s\) and \(N\) so your target tones land on (or very near) bin centers, or accept the leakage loss in your threshold.
No component hazard; the failure mode is a mis-set threshold (false alarms or misses), which the procedure calibrates against real noise.
Project & environment setup
Firmware — reuse the Module 6 projectfirmware/m6-dsp/ (created in Lab 6.1). One real change — this lab samples at 8 kHz, not 16:
CubeMX page
Setting
Timers → TIM2
retune TRGO to \(f_s = 8\) kHz: \(80\,\text{MHz}/[(\text{PSC}+1)(\text{ARR}+1)]\), e.g. PSC = 9, ARR = 999
ADC1 + DMA
IN5 (PA0), circular half-word DMA — unchanged from Lab 6.1 (the \(N = 205\) frame is a buffer-length choice in your code, not an .ioc setting)
GPIO
PA8 (D7) output push-pull — the Part D timing toggle
Connectivity → I2C1 / USART2
400 kHz MCP4725 bus / 115200 8-N-1 VCP
Software Packs
ARM CMSIS-DSP — needed only for the Part D 256-pt arm_rfft_fast_f32 comparison (setup essentials)
Host — the sanity check and logging run in the course venv (Toolchain):
Two scripts under labs/lab-6-5/host/ (you write them): goertzel_check.py — the Part A NumPy recurrence vs. np.fft.rfft bin-power check plus the eight \(k\)/coefficient values; log_decodes.py — pyserial log of per-frame mag2 values and decoded digits, with the Part D false-alarm count over a few thousand frames.
Keep this lab’s reconciliation in labs/lab-6-5/host/analysis.ipynb — the notebook convention — and export final figures next to it.
Where results go:
Artifact
Path
Bench note (incl. the calibrated threshold table)
labs/lab-6-5/notes.md
Single-tone mag2 trace, tone on vs. off
labs/lab-6-5/host/mag2-trace.png
DTMF decode + false-alarm log
labs/lab-6-5/captures/decodes.log
Saleae capture, Goertzel vs. FFT GPIO pulses
labs/lab-6-5/captures/goertzel-vs-fft.sal
Background
The Goertzel algorithm computes one DFT bin \(X[k]\) using a second-order recursive filter with a pole on the unit circle at the target angle \(\omega_k = 2\pi k/N\). Define the intermediate state \(s[n]\) driven by the input \(x[n]\):
whose poles are exactly \(e^{\pm j\omega_k}\) — on the unit circle at the target frequency (a marginally stable filter, run for only \(N\) samples so it never diverges). After the \(N\) input samples, one final complex step yields the DFT value:
\[
X[k] = s[N-1] - e^{-j\omega_k}\, s[N-2],
\]
and the quantity you usually want, the squared magnitude, needs no complex arithmetic at all:
So the whole per-bin cost is: one coefficient \(c = 2\cos\omega_k\) precomputed, then \(N\) iterations of one multiply and two adds, plus a handful of operations at the end. The bin frequency is the same DFT grid as Lab 6.3:
\[
f_k = k\,\frac{f_s}{N}, \qquad k = \operatorname{round}\!\Big(N\,\frac{f_\text{target}}{f_s}\Big).
\]
Efficiency vs. the FFT. A full \(N\)-point FFT costs \(\approx \tfrac{N}{2}\log_2 N\) complex butterflies and gives you all\(N/2\) bins. Goertzel costs \(\approx N\) real MACs per bin. So for \(M\) target bins the comparison is roughly
Goertzel wins on MAC count when \(M < \tfrac{1}{2}\log_2 N\) — for \(N=205\) that’s about \(M \lesssim 4\) bins. DTMF needs 8 bins (four row + four column tones), which is past that crossover: eight 205-sample Goertzel bins (\(\approx 1640\) MACs) cost about the same as, or slightly more than, a single 256-point FFT (\(\approx 1024\) butterflies). So at DTMF sizes Goertzel’s real advantage is not raw arithmetic — it’s memory, simplicity, and latency: no bit-reversal, no twiddle table, only two state words per bin, no power-of-two length constraint, and a decision that accumulates sample-by-sample so it’s ready the instant the last sample arrives. When you need the whole spectrum, the FFT wins; when you need a few known frequencies with a tiny footprint and the lowest latency-to-decision, Goertzel wins.
Detection threshold. The detector fires when \(|X[k]|^2\) exceeds a threshold \(T\). Set \(T\) above the noise-floor energy in that bin (from Lab 6.4): if the per-bin noise power is \(P_n\), choose \(T = \alpha\,P_n\) with a margin \(\alpha\) (e.g. 6–10 dB) to trade off false-alarm rate against sensitivity. For DTMF, robust decoding also checks the ratio of the strongest row bin to the others (twist, and second-harmonic/relative-level tests) to reject speech and noise — a single threshold is the teaching version.
Choosing \(N\). The DTMF tones (697, 770, 852, 941 Hz rows; 1209, 1336, 1477, 1633 Hz columns) are the classic target. At \(f_s = 8\) kHz the standard choice is \(N = 205\), which places all eight tones close to bin centers while giving a fast enough decision (~25 ms). Longer \(N\) narrows each bin (better selectivity, better noise rejection) but slows the decision and demands the tone stay on-grid.
Procedure
Part A — Precompute the Goertzel coefficients (host).
Choose \(f_s = 8\) kHz, \(N = 205\). For each target frequency compute \(k = \operatorname{round}(N f/f_s)\) and \(c = 2\cos(2\pi k/N)\). Tabulate the eight DTMF coefficients (or start with a single 1 kHz tone to bring the algorithm up).
Sanity-check on the host in NumPy: run the recurrence on a synthetic tone and confirm \(|X[k]|^2\) matches np.abs(np.fft.rfft(x))[k]**2.
Part B — Single-tone Goertzel on the STM32.
In the DMA block callback, run one bin over a frame of \(N\) samples (centered floats):
float goertzel_mag2(constfloat*x,int N,float coeff){float s0, s1 =0.0f, s2 =0.0f;for(int n =0; n < N; n++){ s0 = x[n]+ coeff * s1 - s2;/* 1 mul, 2 add per sample */ s2 = s1; s1 = s0;}return s1*s1 + s2*s2 - coeff*s1*s2;/* |X[k]|^2, no complex math */}
Play a 1 kHz tone from the DAC into the ADC and stream mag2 per frame. Confirm it’s large when the tone is present and drops to the noise floor when you mute the DAC.
Part C — DTMF-style multi-tone detection.
Run all eight Goertzel bins per frame. Synthesize a DTMF digit as the DAC sum of one row and one column tone (e.g. digit “5” = 770 Hz + 1336 Hz), biased to mid-rail with headroom.
Decode: pick the largest row bin and the largest column bin; if both exceed the threshold (and pass a simple relative-level check), map the (row, column) pair to the digit. Stream the decoded digit over the VCP.
Step through several digits and confirm correct decoding; deliberately lower the tone amplitude toward the noise floor to find the sensitivity limit.
Part D — Threshold calibration and FFT comparison.
With the DAC muted (grounded/biased input), measure the per-bin noise energy \(P_n\) for each target bin (this reuses the Lab 6.4 noise floor). Set \(T = \alpha P_n\) and verify the false-alarm rate is acceptably low over a few thousand frames.
Toggle a GPIO around the 8-bin Goertzel and around a 256-point arm_rfft_fast_f32 on the same frame; measure both on the Saleae/scope and compare the cycle cost, confirming the \(M N\) vs. \(\tfrac{N}{2}\log_2 N\) prediction.
Deliverable & expected results
Capture: the single-tone mag2 trace (tone on vs. off), a DTMF decode log for a sequence of digits, the threshold set from the measured noise floor, and the GPIO timing comparison Goertzel vs. FFT. Log the decoded digits and the two timing pulse widths.
Confirm the on-chip \(|X[k]|^2\) matches the host FFT bin power for the same frame — a mismatch usually means the target frequency is off the DFT grid (leakage) or the frame length \(N\) differs between the two. Reconcile the timing: for \(N=205\) and 8 bins, Goertzel’s ~1640 MACs are comparable to (and, per-bin, far cheaper than) a same-length FFT, but note the FFT gives you all bins — so state the comparison honestly as “cost per bin you actually need.” Verify the crossover rule \(M < \tfrac{1}{2}\log_2 N\) against your measured GPIO times: if you only need 8 of ~100 bins, Goertzel should win; if you needed 50 bins, the FFT would. Set the threshold from the measured per-bin noise floor (not a guess) and reconcile the false-alarm/miss trade against the margin \(\alpha\) — this is where Lab 6.4 pays off directly. If a digit mis-decodes, check for input clipping (IMD products landing in a wrong bin) before blaming the algorithm.
Cross-platform ports & language variants
See the syllabus Implementation tracks for the framing; this is the Goertzel-specific version. Goertzel is the flagship bare-metal-determinism case: one multiply and two adds per sample, two state words per bin, no library, perfectly bounded latency. It sits firmly in the sequential / latency-bound class — the STM32’s natural home, and the case where the GPU offers essentially nothing.
STM32 bare-metal (C, and Rust). The goertzel_mag2 recurrence runs per sample inside the DMA block callback with no OS. Every sample costs the same handful of cycles, so the time-to-decision is deterministic to the cycle — measure it with the DWT cycle counter (setup essentials), not GPIO. In Rust (#![no_std], RTIC task or a bare loop) the float recurrence is identical; the interesting divergence is the Q15 version, where the resonator state grows over the \(N\) samples and can overflow — Rust makes that decision explicit (wrapping_mul / saturating_add / checked arithmetic) where C silently wraps.
Raspberry Pi 5 (Linux userspace, C or NumPy, and Rust). The same 5-line C function compiles unchanged, or vectorize it in NumPy. It runs fine, but on a preemptive scheduler the per-frame decision latency grows a jittery tail — the median is fast, the p99 is not, because the OS can deschedule you mid-frame. Live analog input on the Pi means the ADS1115 over I²C (slow, ≤ 860 SPS), so the Pi is best used replaying a captured array/WAV rather than sampling live. Rust on the Pi (rppal for I²C if you do sample live, otherwise plain Rust) closes most of the jitter gap only under a PREEMPT_RT kernel.
Jetson Orin Nano. Deliberately a non-target: 1 mul + 2 adds per sample is far below CUDA kernel-launch and host↔︎device-copy overhead, so a GPU port is slower, not faster. You could assign the \(M\) bins to \(M\) threads, but there is nothing to gain. This is the concrete lesson — the right algorithm for the constrained target belongs on the constrained target.
Jetson Orin Nano — detailed procedure (embedded Linux)
The CPU port is a real measurement — decision-latency percentiles under a scheduler — and the GPU gets the same treatment as Lab 6.2: one recorded non-run. Reuse the Lab 6.1 Jetson harness conventions; board config in the Jetson setup essentials.
mkdir -p labs/lab-6-5/edge; synthesize a long stream of DTMF frames (valid digits + silence + out-of-band tones, the Part D test set) with your host generator and save it as the replay input, with the expected digit sequence as ground truth.
Compile the same 5-line goertzel_mag2 C from the firmware (shared/ kernel) in the edge/ CMake project. Replay the stream frame-by-frame and record per-frame decision latency (timestamp at frame-in to digit-out) into p50/p99/max — first stock, then under sudo taskset -c 3 chrt -f 80, with jetson_clocks pinned throughout.
Verify 100% of the ground-truth digits decode on every run — a dropped digit under load is a finding, not noise; correlate it with the latency tail.
Run the same replay while loading the box (stress-ng --cpu 4) and watch p99 stretch; repeat under chrt and watch it recover. That pair of numbers against the STM32’s cycle-constant decision time is this port’s whole lesson.
GPU non-run: time one CuPy per-frame Goertzel (or a trivial per-bin kernel) once, record that launch+copy exceeds the entire CPU frame computation, and write the number in notes.md.
Save latency CSVs to labs/lab-6-5/edge/ and fill the table rows.
Raspberry Pi 5 differences: identical procedure (performance governor); the PREEMPT_RT row of the table is traditionally a Pi exercise if you want the kernel rung.
Measure and compare (fill Measured on each platform):
Platform / build
Per-sample cost
Decision-latency jitter (p50 → p99)
Predicted
Measured
STM32 bare-metal, C (DWT cycles)
fixed few cycles/sample
~0 (deterministic)
tight, constant
…
STM32 bare-metal, Rust
≈ same as C
~0
≈ C
…
Pi 5, Linux userspace
fast median
scheduler tail (p99 ≫ p50)
jittery
…
Pi 5, PREEMPT_RT
fast median
reduced tail
jittery, tighter
…
Jetson GPU
launch-overhead-bound
irrelevant (net loss)
slower than STM32
…
Same STM32: bare-metal vs RTOS
The runtime axis has a middle rung worth measuring on the MCU itself: run the detector under FreeRTOS and compare against the bare-metal build. Because the recurrence is per-sample but the decision is per-frame, this lab draws a clean line between what stays in the fast path and what moves to a task, and it sets up Lab 7.2.
Bare-metal (above): the goertzel_mag2 recurrence — one multiply, two adds per sample — runs inline in the DMA block callback, in ISR context; the natural home for a per-sample recurrence is the sample path, and the detect/no-detect decision falls out at the end of each frame.
FreeRTOS (C): keep the cheap per-sample accumulation where it belongs, but move the decision to a task. The HAL_ADC_Conv*CpltCallback runs (or finishes) the frame’s mag2 and then osSemaphoreRelease(sem)s (or osMessageQueuePuts the frame’s magnitudes); a decode task blocks on osSemaphoreAcquire / osMessageQueueGet, applies the threshold, the row/column pick, and the relative-level checks, and emits the digit. 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 runs the per-frame mag2 and spawns a lower-priority decode software task with the results. In Embassy, an async decode task awaits each frame’s magnitudes over a channel. Same fast-path/decision split, statically scheduled.
What you’ll see: the compute is so cheap (1 mul + 2 adds per sample) that the ~few-µs context switch is negligible against the ~25 ms frame — the RTOS overhead effectively vanishes. The value is not performance; it’s isolating the decode/decision logic in its own task (thresholds, twist checks, digit mapping), keeping the sample-rate path minimal and the policy code testable on its own. Measure the added per-frame latency with the DWT counter and confirm the deadline margin is essentially unchanged.
Build (same STM32)
Per-block latency/jitter added
Deadline margin
Structural benefit
Measured
Bare-metal, recurrence + decision in DMA callback
none (runs in ISR)
full
monolithic sample-path + decision
…
FreeRTOS, mag2 in callback → decode task
+ one context switch (negligible vs ~25 ms frame)
essentially unchanged
decode/decision isolated from the sample path
…
Rust RTIC, DMA hw task→decode sw task
≈ FreeRTOS
essentially unchanged
compile-time-checked task priorities
…
Going further
Add the standard DTMF validity checks (twist limits, second-harmonic rejection, minimum tone duration) to make the decoder robust to speech — the difference between a demo and a real receiver.
Implement Goertzel in Q15 fixed point and compare its detection sensitivity to the float version near the noise floor (ties to the fixed-point work in Lab 6.1/6.2).
Use Goertzel as a cheap continuous tuning meter or a beacon detector: run one bin continuously and threshold — no FFT, tiny footprint.
Sweep \(N\) and plot detection SNR vs. decision latency to see the selectivity/speed trade directly.
Compare against the Lab 6.3 full spectrum analyzer on the same DTMF input — same physics, two algorithms, and a concrete rule for choosing between them.