HexSDR: A Web SDR Receiver with Over 120 Decoders, Built from Scratch
Live: https://sdr.gut.red
HexSDR is my own project: a software defined radio receiver which lives in the browser. You connect an RTL-SDR dongle (or other supported device) to a server, and then everything is done on the web page: waterfall, tuning, decoding planes, ships, APRS, radiosondes, digital voice and many more. I started it in April 2026 and now it has almost 2000 commits, around 490 Python files and 310 TypeScript files.
My idea was simple. I did not want one more set of separate radio tools glued together with scripts. I wanted one system where every signal type follows the same rules, so I can understand it from top to bottom and still add new things quickly.
Stack
- Backend: Python 3.11, FastAPI, NumPy, SciPy, Numba
- Frontend: React 19, TypeScript, Vite, Tailwind 4, Zustand
- Map: MapLibre GL 5 (3D globe, terrain, buildings)
- Waterfall: WebGL (regl)
- Messaging and storage: MQTT, SQLite
- Docker, GitLab CI/CD
1. Every decoder follows one contract
At startup the backend registers 125 decoders. Each of them is a plugin with the same interface: it takes a chunk of IQ samples and gives back metadata in one common format. Each decoder also declares its preferred_bandwidth, and the DSP service uses it to configure the slice automatically when the decoder is attached. I never set this by hand.
Every decoder has also its own window in the UI, made for its output. ADS-B needs a table and a map, a weak-signal mode needs something totally different. Adding a new decoder touches a fixed list of places in backend and frontend (I wrote it down as a rule, 16 items), so nothing is forgotten. The admin panel does not keep a hardcoded list of decoders. It reads what is really registered, so a new plugin appears there by itself.
2. The waterfall is drawn by the GPU
The spectrum waterfall is rendered in WebGL, with resolution up to 128k bins. Zoom works at the cursor: the frequency under the mouse stays in place, and the rest moves around it. It sounds like a small thing, but when you tune a narrow signal it is the difference between comfortable and annoying work.
3. Many receivers from one dongle
One device can feed many “slices”. Each slice is a separate receiver inside the hardware bandwidth, with its own demodulator, and decoders attach to slices. On top of this there is a scheduler which hops between frequencies and devices alone, also in parallel from bookmarks. I checked it with several RDS stations decoded at the same time from one passband.
4. Heavy DSP, but Python stays fast enough
Python is not the first choice for signal processing, so I made rules. Hot loops are isolated in Numba functions (@njit). For high-bandwidth modes like ADS-B or DAB there is a fast path, which skips filters and audio chain that are not needed. If Numba is still not enough because of the GIL, the heavy decoder goes to a separate process.
There is one more problem which I did not expect: weak signals. When float IQ is converted to 8 or 16 bit for an external tool (for example rtl_433 or redsea), a weak signal can use only few bits and just disappear. So every bridge like this has its own adaptive normalization, a kind of internal AGC, which keeps the signal in at least half of the available range.
5. Access control, not an afterthought
There are four roles: admin, moderator, user and anonymous. Permissions go down to single decoders and demodulators, so I can for example block LSB and USB for normal users. Login can go through OpenID Connect with PKCE. Banning a user kills the session immediately, and every admin action is written to an audit log. Anonymous users can still watch the public view, because I wanted it open, but only to read.
The honest part
Not all 125 decoders were tested on air, and I do not pretend they were. I keep a status table with four states: confirmed, operational, untested and broken. The status can change only when I confirm it with a real signal. The code can be ready, and the label still says “untested”, which is the truth.
When the system starts, you see a boot screen like an old BIOS, with hardware detection and a sonar pulse logo. Even the version number on it is calculated from the git history. I know it is not necessary. I just like it.
👉 Check it live: https://sdr.gut.red
Tech stack in details
On the frontend I use React 19 with TypeScript and Vite, styled with Tailwind 4. State lives in Zustand stores, one per area (for example separate stores for ADS-B, AIS and APRS), and the WebSocket events go straight to them. The waterfall is drawn with regl on WebGL, and the map is MapLibre GL 5 with terrain, 3D buildings and a globe projection. The interface is dense on purpose, closer to a terminal than to a typical web app.
The backend is Python with FastAPI. DSP is NumPy and SciPy, with Numba for the loops which must be fast. Decoders are plugins registered at startup, and some of them use external tools through bridges when this is the better way. Telemetry goes through MQTT, and users, bookmarks and track history are kept in SQLite. Configuration is mirrored: every tactical parameter in the config file has a matching environment variable.
The whole system runs in Docker. Every push goes through a GitLab pipeline with secret scanning, static security checks, linters, unit tests, image build, smoke tests and push to a registry. After that the production is updated automatically by a webhook. Documentation and the wiki are synchronized in the same pipeline.