Goertzel on Hardware

Real-time tone and DTMF detection on STM32F4 and ESP32-S3

Goertzel is one of the few DSP algorithms that is easier to write yourself than to pull from a library. There is no arm_goertzel_f32 in CMSIS-DSP, and there does not need to be: the inner loop is three lines. What the algorithm gives an embedded system is exactly what it lacks elsewhere. It needs no input buffer (each sample is folded in as the ADC delivers it), it keeps two state variables per frequency, and the energy decision needs no complex arithmetic and no square root. A handful of tone detectors cost almost nothing.

For the theory, the DFT-bin derivation, and the Python prototypes, see the main Goertzel page. This page is the metal.

The two default platforms here follow ADR-005: NUCLEO-F446RE (Cortex-M4F) and ESP32-S3, single-precision float first. Both have an FPU, so the float code below runs directly; the fixed-point section at the end is for the smaller cores or when you want the headroom.


The core: one filter, processed per sample

A Goertzel detector for a single frequency is a struct holding the precomputed coefficient and the two states, plus an update called once per incoming sample.

#include <math.h>

#ifndef M_PI                       // M_PI is not ISO C; guard for strict toolchains
#define M_PI 3.14159265358979323846
#endif

typedef struct {
    float coeff;   // 2 cos(2*pi*f/fs), precomputed
    float s1;      // s[n-1]
    float s2;      // s[n-2]
    int   n;       // samples seen this block
    int   N;       // block length
} goertzel_t;

void goertzel_init(goertzel_t *g, float f, float fs, int N) {
    g->coeff = 2.0f * cosf(2.0f * (float)M_PI * f / fs);
    g->s1 = g->s2 = 0.0f;
    g->n = 0;
    g->N = N;
}

// Call once per ADC sample. Returns 1 when a full block is ready.
static inline int goertzel_push(goertzel_t *g, float x) {
    float s0 = x + g->coeff * g->s1 - g->s2;
    g->s2 = g->s1;
    g->s1 = s0;
    return (++g->n >= g->N);
}

// Real-only magnitude squared, then reset for the next block.
float goertzel_power(goertzel_t *g) {
    float power = g->s1 * g->s1 + g->s2 * g->s2 - g->coeff * g->s1 * g->s2;
    g->s1 = g->s2 = 0.0f;
    g->n = 0;
    return power;
}

The per-sample cost is one multiply and two adds (or, on a core with a fused multiply-add, one FMA and one subtract): there is nothing to optimise. Note that goertzel_power returns energy, not magnitude. Comparing power against a squared threshold avoids the sqrtf, and for a detector the square root buys nothing.


A DTMF decoder

A DTMF block runs eight detectors, four row frequencies and four column frequencies, fed from the same sample stream. At the end of a block you pick the strongest row and strongest column and look up the key.

#define FS                8000.0f
#define BLOCK_N           205     // ~25.6 ms; spaces the eight tones across distinct bins
#define ENERGY_THRESHOLD  500.0f  // min block energy to accept a tone; tune to your levels

static const float dtmf_low[4]  = { 697.0f, 770.0f,  852.0f,  941.0f };
static const float dtmf_high[4] = { 1209.0f, 1336.0f, 1477.0f, 1633.0f };
static const char  dtmf_key[4][4] = {
    { '1', '2', '3', 'A' },
    { '4', '5', '6', 'B' },
    { '7', '8', '9', 'C' },
    { '*', '0', '#', 'D' },
};

static goertzel_t g_low[4], g_high[4];

void dtmf_init(void) {
    for (int i = 0; i < 4; i++) {
        goertzel_init(&g_low[i],  dtmf_low[i],  FS, BLOCK_N);
        goertzel_init(&g_high[i], dtmf_high[i], FS, BLOCK_N);
    }
}

// Feed one ADC sample. Returns the decoded key, or 0 if the block is not
// finished or no valid tone pair is present.
char dtmf_push(float x) {
    int ready = 0;
    for (int i = 0; i < 4; i++) {
        ready  = goertzel_push(&g_low[i],  x);
        goertzel_push(&g_high[i], x);
    }
    if (!ready) return 0;

    float lp[4], hp[4];
    for (int i = 0; i < 4; i++) {
        lp[i] = goertzel_power(&g_low[i]);
        hp[i] = goertzel_power(&g_high[i]);
    }

    int lr = 0, hc = 0;
    for (int i = 1; i < 4; i++) {
        if (lp[i] > lp[lr]) lr = i;
        if (hp[i] > hp[hc]) hc = i;
    }

    // Guards: enough energy, and the two tones reasonably balanced ("twist").
    float row_e = lp[lr], col_e = hp[hc];
    if (row_e < ENERGY_THRESHOLD || col_e < ENERGY_THRESHOLD) return 0;
    if (row_e > 8.0f * col_e || col_e > 8.0f * row_e)         return 0;

    return dtmf_key[lr][hc];
}

BLOCK_N = 205 is the classic telephony choice: at 8 kHz it makes the bin spacing about 39 Hz, which lands the eight tones on distinct, well-separated bins. Because Goertzel works at any frequency, the goertzel_init above tunes each filter to the exact tone frequency rather than the nearest bin, so the small bin-to-tone mismatch costs nothing in selectivity.

The two guards matter in practice. The energy threshold rejects silence and line noise; the twist check rejects speech and music, which rarely produce two isolated, balanced tones from the DTMF grid. A production decoder usually adds a second-harmonic test as well, since a strong tone leaks a little into its harmonic and a vowel can mimic a single tone.


STM32F4: feeding it from the ADC

On the NUCLEO-F446RE the natural wiring is a timer-triggered ADC at 8 kHz with DMA into a half/full double buffer, and the DTMF detectors drained from the DMA callback so the sampling is never blocked.

#include "stm32f4xx_hal.h"

#define DMA_HALF   64
static uint16_t adc_buf[2 * DMA_HALF];   // ping-pong, 12-bit samples

static void process_half(const uint16_t *half) {
    for (int i = 0; i < DMA_HALF; i++) {
        // 12-bit unipolar ADC -> roughly [-1, 1) float
        float x = ((float)half[i] - 2048.0f) / 2048.0f;
        char key = dtmf_push(x);
        if (key) on_dtmf_key(key);       // application callback
    }
}

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *h) { process_half(&adc_buf[0]); }
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *h)     { process_half(&adc_buf[DMA_HALF]); }

The eight float Goertzel updates per sample are a few dozen cycles on the M4F’s FPU, trivially inside an 8 kHz sample budget (the NUCLEO-F446RE runs at 180 MHz, so 180e6 / 8000 = 22,500 cycles per sample). The same code compiles unchanged for the ESP32-S3; only the ADC and DMA setup differ.

Note that process_half runs in the DMA interrupt, so on_dtmf_key is called from interrupt context. Keep it short: set a flag or push the key to a queue and handle it in the main loop. Do not print, block, or allocate from inside it.


ESP32-S3: the same loop from I2S audio

On the ESP32-S3 the audio usually arrives over I2S (from an external codec or a digital mic) rather than the internal ADC. The detector loop is identical; only the source changes. Two assumptions are worth making explicit: the receive channel rx_chan is created and enabled by your I2S setup (i2s_new_channel then i2s_channel_init_std_mode then i2s_channel_enable, from the ESP-IDF i2s_std example), and the stream is mono. A stereo codec interleaves left and right (L0, R0, L1, R1, ...); feeding that straight in would double the effective sample rate and fold two channels together, shifting every Goertzel coefficient’s frequency. For stereo, configure a single slot or take every other sample.

#include "driver/i2s_std.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"

#define FRAME 256
static int16_t i2s_buf[FRAME];

extern i2s_chan_handle_t rx_chan;   // created + enabled by your I2S init (mono, 8 kHz)

void dtmf_task(void *arg) {
    dtmf_init();
    size_t n_read;
    while (1) {
        i2s_channel_read(rx_chan, i2s_buf, sizeof(i2s_buf), &n_read, portMAX_DELAY);
        int count = n_read / sizeof(int16_t);
        for (int i = 0; i < count; i++) {        // mono; for stereo use i += 2
            float x = (float)i2s_buf[i] / 32768.0f;   // Q15 sample -> float
            char key = dtmf_push(x);
            if (key) ESP_LOGI("dtmf", "key '%c'", key);
        }
    }
}

ESP-DSP ships optimised FFTs and biquads but no Goertzel, which is the point: the three-line loop above is already as fast as it needs to be, and writing it yourself avoids pulling a transform you would only throw most of away.


Fixed point: when there is no FPU

On a Cortex-M0+ or an AVR there is no hardware float, and the recurrence has to run in integer arithmetic. Goertzel in Q15 has one real hazard: its poles sit exactly on the unit circle, so unlike a normal stable filter the state has no decay to damp accumulated round-off error. Over a short block this is fine; over a long one the state can grow and the coefficient \(2\cos\omega\), which ranges over \([-2, 2]\), can push intermediate products past the Q15 range.

The trap to avoid is clamping the state to the input’s range. Near the tone the resonator deliberately builds up: the state magnitude grows to roughly \(A \cdot N\) for an input tone of amplitude \(A\) over a block of \(N\) samples (the \(A\,n/2\) envelope can build to about \(A\,N\) in phase at the low bins), so for a near-full-scale tone and \(N = 205\) the state reaches a couple of hundred, far outside the \([-1, 1)\) range of a Q15 sample. The state therefore cannot live in a Q15 int16_t; it needs integer headroom. The practical fixed-point discipline:

  • Keep the block short. A few hundred samples accumulates negligible round-off; tens of thousands does not. DTMF’s 205 is comfortably safe.
  • Scale the input down by a few bits so the input itself is small, leaving room for the \(\sim N\) growth, then compare the resulting power against a correspondingly scaled threshold. The detector only cares about relative energy, so the lost amplitude does not matter.
  • Hold the state wide. Keep \(s_1\), \(s_2\) as Q15 values in 32-bit containers so they can grow past 1.0 without saturating, and form the product in a 64-bit accumulator. The coefficient lives in Q14 (it spans \([-2, 2]\), so it cannot be Q15), so \(\text{coeff}\cdot s_1\) is \(Q14 \times Q15 = Q29\), shifted right by 14 back to Q15. This is the same wide-accumulator rule that protects any fixed-point filter, with the extra twist that here the state, not just the product, needs the headroom.
// Q15 Goertzel update. State is Q15 but held in int32_t so it can resonate
// past 1.0; the product accumulates in int64_t to avoid overflow.
// coeff_q14 = (int32_t)lroundf(2.0f * cosf(w) * 16384.0f);   // 2 cos(w) in Q14
static inline int32_t goertzel_push_q15(int32_t coeff_q14, int32_t x_q15,
                                        int32_t *s1, int32_t *s2) {
    int64_t prod = (int64_t)coeff_q14 * (int64_t)(*s1);   // Q14 * Q15 = Q29
    int32_t s0 = x_q15 + (int32_t)(prod >> 14) - *s2;     // back to Q15, wide state
    *s2 = *s1;
    *s1 = s0;
    return s0;
}

Feed it a pre-scaled, DC-centered input (for example x_q15 = ((int32_t)adc_sample - 2048) >> 6 for a unipolar 12-bit ADC: DC-centred like the float path, but pre-attenuated by 6 extra bits to leave headroom for the resonant build-up) so the resonant state stays comfortably inside int32_t, and accumulate the power in a 64-bit running sum: the power is a sum of squared states (each up to ~\(N\) times the input), which overflows int32_t quickly.

The full numerical story (why the coefficient range is the awkward part, how the marginal stability accumulates error, why second-order sections protect normal filters and why Goertzel cannot use that escape) is on the finite word-length effects page. For tone detection on a small core the short answer is: keep the block under a few thousand samples, hold the state in 32 bits and the power in 64, and the integer detector behaves.


See the main Goertzel page for the algorithm, the DFT-bin proof, and the runnable Python.