What Is a Decoder in HexSDR? From Raw Radio Samples to Planes, Ships and Song Titles
When people see a software defined radio for the first time, they see a waterfall: colourful lines moving down. It looks nice, but a waterfall alone does not tell you anything. The interesting part starts when the computer understands what is inside the signal. In HexSDR this job is done by decoders, and now I have over 120 of them. In this post I explain how they work and what they are for, on examples of public signals which anyone can receive.
Stack
- Python decoders, NumPy / SciPy, Numba for fast loops
- External proven tools used through bridges, when this is the better way
- WebSocket to the browser, one React window per decoder
- MapLibre map for decoders which have positions
1. One contract for every decoder
A decoder in HexSDR is a plugin with a simple promise. It gets a chunk of IQ samples (raw radio data, complex numbers) and it returns metadata in one common, JSON-friendly format. The decoder does its own demodulation inside, so the core engine does not need to know anything about the protocol. There are also rules: a decoder is stateless between resets, and processing one block should return in less than 500 ms, so the live view does not lag.
Thanks to this, the core is the same for an aircraft decoder and for a CW decoder. The core only knows how to start a decoder, feed it and read the result.
2. Every decoder says how wide it needs to be
Different signals need different width of the radio window. So each decoder declares preferred_bandwidth, and when you attach it to a slice, the DSP service sets the width for you. Some examples from my code:
| Decoder | What it is | Preferred bandwidth |
|---|---|---|
| CW | Morse code | 3 kHz |
| APRS | amateur radio packets | 12.5 kHz |
| AIS | ship positions | 25 kHz |
| RDS | FM radio text data | 200 kHz |
| DAB | digital radio | 1.536 MHz |
| ADS-B | aircraft positions | 2 MHz |
You see how big the difference is: from 3 thousand to 2 million hertz. Without this declaration I would need to remember it for every protocol, and I would forget.
3. Native code or a proven tool: I choose what is better
I do not write everything from zero, when it makes no sense. ADS-B is decoded by my own Python code. For some protocols a very good open-source tool already exists, and then I use it through a bridge. For example, the DAB decoder starts dablin and sends the audio to the browser as a PCM stream. For APRS and FM radio data I use well-known tools in the same way. The bridge takes care of the lifecycle: start, stop, restart, and passing the output in the common format.
Weak signals have one extra trap. When the IQ data is converted to 8 or 16 bits for an external tool, a quiet signal can be lost. That is why my bridges have an adaptive normalization before the conversion.
4. What you can really do with them
Here are the examples, all of them are public broadcasts, which are meant to be received:
- Planes (ADS-B). Aircraft send their position, altitude, speed and heading on 1090 MHz, openly, for air traffic safety. The decoder turns it into a live table and into markers on the 3D map, with a flight track and altitude.
- Ships (AIS). Ships announce who they are and where they go. You get a map of vessels around you, with names and courses.
- FM radio data (RDS). Behind every FM station there is a small data stream with the station name and the current song or programme text. The decoder shows it.
- Digital radio (DAB). The decoder reads the ensemble, lists the services and plays the audio in the browser.
- Amateur radio. CW (Morse), APRS (position and messages from hams) and the weak-signal modes of the WSJT family, like FT8 and WSPR, which were designed to work even when the signal is below the noise.
- Sensors around you. Wireless weather stations and similar devices send short messages on the ISM band. With the right decoder you can read your own sensors in one place.
5. One output format, one window per decoder
Because every decoder returns the same kind of packet, the frontend can be generic where it should, and specific where it matters. Each decoder has its own window in the UI, made for its data: a table for aircraft, a text view for radio data, a station list for DAB. If the decoder has coordinates, its data goes to the map. Everything is also stored with timestamps, so you can replay the history with a timeline.
6. The honest status
I do not claim that all of them are tested on air. In my status table a decoder can be confirmed, operational, untested or broken, and I change it only when I see a real signal. ADS-B, APRS, FM radio data and DAB are confirmed. AIS and CW are operational. The amateur weak-signal modes have the code ready, but I did not validate them with the real signals yet, so they stay marked as untested.
There is also a physical limit which a lot of people forget. One cheap dongle sees about 2.4 MHz of spectrum at the time. Planes are on 1090 MHz and ships around 162 MHz, far away from each other. So with one device you cannot watch both in the same second. For this I have a scheduler, which hops between frequencies and devices alone. With two dongles it is easy: one for planes, one for ships.
A note about law: this post is only about receiving public, unencrypted broadcasts. Rules for radio monitoring are different in every country, so please check yours before you listen to anything else.
👉 Check it live: https://sdr.gut.red
Tech stack in details
The decoders are Python classes which inherit from one abstract base class. It requires three things: processing a block of IQ, resetting the state and describing the decoder. Output is a small dataclass with the decoder ID, a payload dictionary, a request ID and a timestamp, always in snake_case and always serializable to JSON. A registry finds all decoder modules at startup and checks them, so one broken module cannot take the system down.
For speed, the loops which touch every sample are Numba functions. For heavy ones, like ADS-B at 2 MHz, there is a fast path which skips the audio chain and the IF filters. If it is still too much, a decoder can live in a separate process. External tools are started and watched by a bridge, which also restarts them when they die.
On the frontend, each decoder has a plugin registry entry, a display component and defaults for its slice. Decoder data goes to Zustand stores through WebSocket events, and the stores for positional decoders feed the map and the timeline. The list of decoders in the admin panel is built from what is really registered, not from a hardcoded list.