From AFSK to Goertzel: demodulating packet radio

fanf21 pts0 comments

From AFSK to Goertzel – µArt.cz

Years ago, I graduated from the Faculty of Electrical Engineering and Communication in Brno (Czech Republic), specializing in telecommunications, and since then, I’ve been working in embedded development and IoT. I’m interested in electronics, programming, 3D printing, and occasionally brewing beer.

HAM callsign: OK5VAS

Mail

GitHub

Twitter

RSS Feed

From AFSK to Goertzel

2026-05-17

HAMs, 🇬🇧

Vlastimil Slinták

Some time ago I started implementing “classic” packet radio into PacketRF. Not the fancy NPR stuff, not high-speed links, but the old and simple AX.25. The one that runs at 1200 baud over a narrow FM channel and somehow still works over surprisingly long distances.

And as soon as I decided to support 1200 baud packet radio (often called PR1200) in PacketRF, I ran into a problem that many people before me had already solved, but I had never really thought about in detail: how do you efficiently decode Bell 202 AFSK on a small embedded system without wasting half your CPU on it?

This post is a write-up of what I learned. Part refresher, part rediscovery, part “wait, why does this even work?”. I ended up making a bunch of small visualizations along the way, mostly out of curiosity, and at some point I realized this is actually a very nice way to look at DFT and Goertzel that I personally had never seen presented this way.

So this is not a textbook explanation. This is more like: I needed this for PacketRF, I looked into it, and this is what finally made it understandable for me. And I also wanted to share my fancy animated GIFs :)

What are we trying to decode?

Let’s start from the beginning. Packet radio is one of those things that “everyone knows”, but if you ask three people what PR actually is, you will probably get three slightly different answers.

Classic packet radio (on VHF/UHF) is basically a stack of very simple building blocks layered on top of each other:

AX.25 on the link layer (HDLC-like framing)

NRZI encoding of bits

AFSK modulation at the physical layer

And specifically for 1200 baud, this is Bell 202 , which uses two tones:

1200 Hz → mark (logical 1)

2200 Hz → space (logical 0)

So the whole chain looks like this:

TX: HDLC frames → NRZI → bits → AFSK (1200/2200 Hz) → audio → radio<br>RX: radio → audio → detect tones → bits → NRZI decode → HDLC frames

The part we care about in this article is just one small piece of that chain: detect tones and turn them into bits in the receiver path. So the question we will be solving in the rest of this text is:

Given a chunk of audio samples, how do we decide whether it contains 1200 Hz or 2200 Hz?

That’s it. That’s the entire problem.

The obvious solution

If you have ever seen anything about signal processing, you are probably already thinking: “just run an FFT and look at the bins”. And yes, that would work perfectly fine. Take a window of samples, run FFT, check the bins around 1200 Hz and 2200 Hz, compare energies, done. Except… we are not doing this on a desktop CPU, but on a small MCU, something in the “few hundred MHz, limited RAM, lots of other tasks and interupts happening at the same time” category. And a full FFT means:

computing a whole spectrum of frequencies we don’t care about

doing complex numbers arithmetic

moving around buffers of data

For AFSK, we only need two frequencies . Everything else is wasted work. So naturally the question becomes: can we compute just those two frequencies directly, without doing a full FFT?

Goertzel algorithm

There is a classic trick for exactly this situation called Goertzel algorithm . If you google it, you will almost immediately find something like:

s[n]=x[n]+2cos⁡(ω)s[n−1]−s[n−2]s[n] = x[n] + 2\cos(\omega)\,s[n-1] – s[n-2]

and then second expression for the final result:

X=s[N−1]−e−jωs[N−2]X = s[N-1] – e^{-j\omega}s[N-2]

At which point everything is, of course, completely obvious and we can all go home and implement it in C++.

Or… not really. Because if someone drops those equations on you without context, you don’t actually understand anything. You just copy them into code and hope for the best. At least that would have been me. I did have signal processing at university, but that was a long time ago, and Goertzel either wasn’t there or I successfully forgot it. So I ended up doing what I usually do in these situations: derive it from something I understand, and build intuition from there. And that “something” is DFT.

Let’s go back to DFT

Before Goertzel, there is the discrete Fourier transform, usually written like this:

X[k]=∑n=0N−1x[n]⋅e−j2πkn/NX[k] = \sum_{n=0}^{N-1} x[n] \cdot e^{-j 2\pi k n / N}

Yes, it looks scary, but the idea behind it is actually very simple if you ignore the notation for a moment. DFT takes your signal and asks: “How much of frequency fkf_k is present in this signal?”.

Where each frequency corresponds to a bin:

fk=kNfsf_k = \frac{k}{N} f_s

So if we are sampling at 48 kHz and we care about 2200 Hz,...

goertzel from afsk radio packet like

Related Articles