Lab 6.5 — Goertzel Tone Detector
← Course 2 syllabus · Module 6 · Prev: « Lab 6.4 · Next: Lab 6.6 »
Goal
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.
- O-DSP Ch. 9 — the DFT and efficient computation; the Goertzel filter as a recursive DFT evaluation and its pole-on-the-unit-circle interpretation.
- 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
pyserialto log the per-frame Goertzel magnitudes and the detect/no-detect decisions.
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.
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]\):
\[ s[n] = x[n] + 2\cos\omega_k \; s[n-1] - s[n-2], \]
run for \(n = 0,\dots,N-1\) (with \(s[-1]=s[-2]=0\)). This is a resonator with transfer function
\[ H(z) = \frac{1}{1 - 2\cos\omega_k\, z^{-1} + z^{-2}}, \]
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:
\[ |X[k]|^2 = s[N-1]^2 + s[N-2]^2 - 2\cos\omega_k\; s[N-1]\,s[N-2]. \]
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
\[ \underbrace{M\,N}_{\text{Goertzel}} \quad \text{vs.} \quad \underbrace{\tfrac{N}{2}\log_2 N}_{\text{FFT}}. \]
Goertzel wins when \(M < \tfrac{1}{2}\log_2 N\) — for \(N=205\) that’s about \(M \lesssim 4\) bins. DTMF needs only 8 bins (four row + four column tones), which is still a clear Goertzel win at these sizes, and Goertzel needs no bit-reversal, no twiddle table, and only two state words per bin — trivial memory. When you need the whole spectrum, the FFT wins; when you need a few known frequencies, 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(const float *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
mag2per 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_f32on 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.
| Quantity | Predicted | Measured |
|---|---|---|
| DTMF params | \(f_s=8\)k, \(N=205\), decision ≈ 25.6 ms | … |
| Bin index for 1336 Hz | \(k=\operatorname{round}(205\cdot1336/8000)=34\) | … |
| Goertzel coeff \(c=2\cos(2\pi k/N)\), \(k=34\) | ≈ 0.164 | … |
| Cost, 8 Goertzel bins | \(\approx 8N = 1640\) MACs | … |
| Cost, 256-pt FFT | \(\approx \tfrac{256}{2}\log_2 256 = 1024\) butterflies | … |
| Crossover: Goertzel wins when | \(M < \tfrac12\log_2 N\) | … |
| Detection threshold above noise floor | $= $ 6–10 dB over \(P_n\) | … |
Analysis & reconciliation
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.
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.