The radio community deserves a high quality, open-source digital modem.
In 2022 I set out to improve ardopc. I had been participating in Winlink Wednesdays, and I want to improve reliability with my local relay and perhaps introduce some automation and soon I discovered the timing issues which caused connections to fail on different hardware.
For a moment I thought I could be the person to fix these bugs, but as peeled back the layers I discovered a complexity far beyond my soft human mind. I couldn't articulate it at the time, but I could tell mess of domains and state when I saw one.
With AI I've started moving back through my mental checklist of projects which just seemed far to ambitious at the time. Last week I reached this project - I wanted to see how well Claude could articulate the problems and propose a solution. Claude did an excellent job. I encourage anyone looking at this project to review the (analysis/) Claude produced.
It turns out this is an ideal project for an AI agent - it's well bounded and tested. Data -> Audio -> Data. This took a couple of days only because I kept hitting my Pro's session limit. I sure it would have been less than 6 hours if I had been running the Pro Max.
This was built using Claude Opus 4.8
I've learn a lot about DSP in the process. Hopefully this freshly refactored code can help others understand the principles and even evolve the ARDOP protocol.
-Ryan
------------------ END OF HUMAN MESSAGE ------------------
A rebuilt implementation of ARDOP — the Amateur Radio Digital Open Protocol,
which carries digital data as audio over an HF radio channel. ardopb is a hard
fork of ardopcf, re-architected around a
pure, testable core with the device I/O pushed to the edges.
It is wire-compatible with the existing ARDOP ecosystem: it interoperates
over the air with ARDOP_Win/ardopc/ardopcf per the 2017 specification, and
it speaks the same TCP host protocol, so host programs (Pat, WoAD, …) work
unchanged.
make # builds ardopb + the host-client apps
./ardopb N0CALL --audio --host 8515
Looking for the application rather than the modem? ardop-station is one
window with the modem inside it — it finds your radio, sets the station up, and
does chat and file transfer over the link. See
app/ui/README.md for screenshots, and for the checklist
we would like testers to work through.
ardopcf is a working, actively maintained modem, but it descends from a
VB→C translation (ARDOP_Win → G8BPQ's ardopc → ardopcf) and the lineage
shows: the program is organised as one flat namespace of ~350 mutable globals
rather than as modules with interfaces. Two concrete consequences motivated the
rebuild:
- Hardware-dependent behaviour. Protocol timing was derived from a wall
clock while the audio device is the real clock, so sound-card quirks could
make the program refuse to start or degrade on air (the
FixTiming/-A,SlowCPUmachinery). - Untestability. The only unit-tested code was the handful of leaf modules that owned no global state. Nothing touching protocol, DSP, or I/O was separable enough to test.
The rebuild treats those as architectural problems and fixes them by
construction. It began as a written analysis of the inherited code
(analysis/) — separating the normative protocol (what must be
preserved bit-for-bit to stay interoperable) from implementation accident —
and proceeded module by module, each new piece proven equivalent to the original
before the original was retired.
A sans-I/O design: the modem and protocol are a pure library that opens no device, reads no clock of its own, binds no socket, and never blocks. All state lives in caller-owned structs. Dependencies point one way.
apps/ ardop-cat ardop-chat TCP host-protocol clients
| (host protocol over TCP) app/ the station app's spine
shell/ runtime . driver loop . host iface | -- embeds the modem
| . miniaudio / null backends / instead of dialling it
core/ codec -> modem -> link pure: no I/O, no clock,
(frames/RS/CRC) (mod/demod) (ARQ/FEC) no allocation, no globals
- One clock: the sample counter. Time enters the core as an elapsed audio
sample count. This removes the wall-clock/sample-count conflation — the entire
FixTiming/drift class of bugs is gone rather than detected. - No blocking transmit. TX is "produce N samples on demand", so transmission completion is observed (the modulator drains) rather than predicted (a nominal duration), which also removes the TX-inside-TX reentrancy the old tree had.
- Mechanically enforced.
make check-pureproves the core has no mutable globals and no allocation;make check-standaloneproves it links with onlylibm; everything builds at-Wall -Wextra -Werror -Wconversionand more.
See analysis/06-target-architecture.md
and core/README.md for the full rationale and the rules the
core is held to.
| Path | What |
|---|---|
core/ |
The pure modem + protocol library: codec (frame table, Reed–Solomon, CRC, callsign/grid coding), modem (modulator, demodulator, sync, busy detector, FFT), link (the ARQ/FEC state machine). No I/O, no globals. |
shell/ |
The impure program around the core: the sans-I/O runtime, the single-clocked driver loop, the TCP host interface, the portable socket/system layer (net, sys), settings, and the platform layer — miniaudio, keying, device enumeration. See shell/README.md. |
apps/ |
Host-client CLI tools — see apps/README.md. |
app/ |
The station application's embedding spine: the modem embedded rather than talked to, with backpressure and a TNC-ownership rule. analysis/14; see app/README.md. |
app/ui/ |
ardop-station — the windowed application: devices, station setup, chat, file transfer, session history, guests and a TNC console, with the modem compiled in. Screenshots and a tester's checklist in app/ui/README.md. |
test/ |
In-process tests (test/core) and the frozen golden-vector corpus (test/golden). |
tools/ |
loopback.sh — a virtual-audio-cable harness for running two stations against each other with no radio; package-windows.sh and package-linux.sh — assemble the release downloads. |
third_party/ |
Vendored dependencies, pinned by version and checksum. Currently miniaudio. |
analysis/ |
The written architecture review and rebuild design — how we got here. |
docs/refs/ |
The ARDOP specification and host-interface reference PDFs. |
Requirements (Debian/Ubuntu):
sudo apt install build-essential # ardopb + apps
sudo apt install libcmocka-dev # to run the tests
make builds ardopb and the apps. The modem is a long-running process that
owns the sound card and exposes the TCP host interface:
ardopb MYCALL [--listen] [--host PORT] [--telemetry [PORT]]
[--null [SECONDS] | --audio [CAPTURE PLAYBACK]]
[--audio-backend NAME] [--ptt SPEC] [--list-devices]
Every program prints its build identifier and exits:
$ ardopb --version
ardopb 56a7de4-dirty (2026-08-04)
ardop-cat --version and ardop-chat --version do the same. The station
application shows it under Help > About, and writes it as the first line of
the panel log, so a log attached to a fault report carries it.
The build. git describe --tags --always --dirty --match 'v*' at build
time. This project has issued no release tag yet, so the value is the
abbreviated commit — that is the whole of what is known, and it is enough to
find the source. When a v* tag exists the value becomes v0.4-12-g4e85005:
twelve commits after v0.4. A -dirty suffix means the source had local
changes, so the build cannot be reproduced from any commit. A build with no git
repository, such as one from a release tarball, reports unknown and relies on
the date.
--match 'v*' is deliberate. The continuous tag moves on every push to main,
and the tags 1.0.4.1.3 and 2.0.3.2.1 are inherited ardopcf releases;
neither describes a build of this software.
The host command VERSION reports the same value, as
VERSION ardopb_56a7de4-dirty. There is one identity, not two: the string a
host program records is the string that finds the source.
It used to say ardopcf_1.0.4.1.3-b — another program's name and another
program's release number. That was kept to protect host programs that might
match on the name. The protection was never tested and is not needed: Pat and
Winlink already meet ARDOP_Win, ardopc and ardopcf, which report different
names and different numbers, so a client that accepted only one string would
already fail against most of the ecosystem.
| Spec | Method |
|---|---|
none, vox |
Nothing. Always available. |
rts:DEV, dtr:DEV |
Assert a serial control line. |
civ:DEV[@ADDR] |
Icom CI-V, and every Xiegu, which emulates it. |
kenwood:DEV, yaesu:DEV |
The radio's own CAT command. |
cm108[:PATH|auto][+N] |
C-Media GPIO over raw HID (DigiRig Lite and similar). |
rigctld:HOST:PORT |
To a running rigctld. |
The right method is a property of the radio, and picking the wrong one fails silently: a Xiegu or an Icom keys by CAT command and ignores RTS entirely, while a DigiRig Mobile keys by RTS and a DigiRig Lite by CM108 GPIO. All three look identically connected and only one of them transmits.
app/ardop-spine --detect works it out for you where it can, by pairing a sound
card with the keying interface on the same USB hardware. It prints what it found
and applies nothing — confirm it keys before trusting it.
The keying paths have never been run against real hardware. If you own a
radio and can help, analysis/19 says exactly
what to try and what to send back.
CM108 on Linux needs a udev rule; the diagnostic prints it, and it is also in
shell/README.md.
$XDG_CONFIG_HOME/ardop/station.conf (or $HOME/.config/ardop/...), and
%APPDATA%\ardop\station.conf on Windows. Plain key=value, safe to edit by
hand, and unknown keys are preserved.
--audio is the sound card, on every platform: one backend (miniaudio) over
WASAPI, CoreAudio, AAudio, ALSA, PulseAudio and JACK. --list-devices prints
what is available with the id to select it by.
There is deliberately no second Linux audio path. A dedicated ALSA backend
existed and was removed: two implementations meant two versions of the
transmit-tail drain and a class of bug that reproduces on one and not the
other, and only one of them was covered by the tests. It also cost more than
it saved — miniaudio opens ALSA, PulseAudio and JACK with dlopen, so
ardopb no longer links libasound at all and no longer needs
libasound2-dev to build. Where the choice matters, --audio-backend alsa
pins it and bypasses the sound server.
--ptt takes none (VOX), rts:DEVICE, dtr:DEVICE, or
rigctld:HOST:PORT — the last keying through a running rigctld over TCP
rather than by linking hamlib.
Sound-card rates must be a whole multiple of 12000 Hz (12000, 24000, 48000,
96000). A rate like 44100 is refused with a message rather than resampled: the
sample clock is the protocol clock here, so an approximate conversion would
break the link's timing rather than merely its audio. See
shell/resample.h.
Under WSL (WSLg) the Windows audio devices appear through WSLg's PulseAudio
server and need no extra configuration — --audio finds them, and
--list-devices names them.
Fresh builds are published automatically from main — no GitHub account
needed:
| download | what it is |
|---|---|
| ardop-station-windows-x86_64.zip | the windowed application and the remote panel, for Windows |
| ardopb-windows-x86_64.zip | the command-line station program, the modem and the apps |
| ardopb-linux-x86_64.tar.gz | the same command-line set, for a Linux PC |
| ardopb-linux-aarch64.tar.gz | the same, for a 64-bit Raspberry Pi |
The command-line download is around 600 KB; the windowed one is a separate
download because Qt and ICU are two orders of magnitude larger than the modem,
and you do not need a window to run a link — ardop-spine does the same
setting-up from a command line. On Linux, build the window from source (see
app/ui/) — distro Qt is one apt line.
The Linux tarballs need glibc 2.34 or newer — Debian 12, Ubuntu 22.04 and later, Raspberry Pi OS bookworm. Nothing in them links an audio library: ALSA, PulseAudio and JACK are opened at run time if present.
ardopb.exe is a statically linked single file; ardop-station.exe ships with
its Qt DLLs beside it. Start with ardopb.exe --list-devices, then
ardopb.exe MYCALL --audio --ptt rts:COM3 --host 8515 --telemetry
ardop-station.exe --remote 127.0.0.1:8515
ardop-station.exe on its own runs the modem itself, with screens for the
devices, the station, chat, files, guests and a TNC console — see
app/ui/README.md, which has screenshots and a
validation checklist for testers. With --remote it runs no modem and only
watches one: read-only, because a telemetry stream is one-way and a display
cannot key a transmitter.
COM ports above COM9 work as written — the \\.\ prefix is applied for you.
To build it yourself, use an MSYS2 MINGW64 shell
(the package list is in .github/workflows/test.yml)
and run make. Binaries get a .exe suffix. check-pure, check-headers and
check-standalone read ELF with objdump/nm and are Linux-hosted; they
refuse to run there rather than passing vacuously, because they prove properties
of source both platforms share and are run once on the Linux CI job.
The apps are thin clients that connect to a running modem's host port:
ardop-cat --host 127.0.0.1:8515 N0DEST < file # a raw pipe over ARQ
ardop-cat --host 127.0.0.1:8515 --listen > file # the receiving end
ardop-chat --host 127.0.0.1:8515 --call N0DEST # basic two-way chat
ardop-cat is a pipe and nothing more — no filename, no length, no checksum,
netcat's guarantee. For a transfer with a name and a checksum on it, both ends
run ardop-station, which speaks a real protocol
(ASP). The two do not mix, and ardop-cat
says so rather than writing framing into your file:
| the other end is | ardop-cat |
ardop-chat |
ardop-station |
|---|---|---|---|
ardop-cat |
a pipe | — | refuses: it is not chat |
ardop-chat |
— | chat | chat, in both directions |
ardop-station |
refuses, and says which to use | chat | files, chat, resume, checksums |
Chat works across all of it because ASP degrades to plain UTF-8 with a station that does not answer its greeting, which is deliberate — most stations on the air are not running this program.
tools/loopback.sh creates a pair of virtual audio cables (PulseAudio/PipeWire
null sinks) so two ardopb instances can talk over real audio on one machine:
tools/loopback.sh check # confirm the virtual cable carries audio
tools/loopback.sh demo # run a full ARQ connect + data exchange
tools/loopback.sh pipe # transfer a file with ardop-cat, verify it
Every core module was proven equivalent to the inherited implementation before that implementation was removed. The reference code is gone now, so the ongoing regression net is:
make test-core— in-process protocol/integration tests (two stations through the real modem, the runtime, the host command surface).make golden-core/golden-shell/golden-tx— the frozen golden corpus: real recorded ARDOP audio is decoded through the core and the assembled shell and checked against a manifest, and the modulator's transmit audio is verified bit-for-bit (SHA-256) against the corpus for every data mode.make check-pure/check-headers/check-standalone— the mechanical guarantees on the core.make test-ring-tsan— the audio ring's lock-free ordering, under ThreadSanitizer. Linux-hosted for the same reasoncheck-pureis: the ring is portable C11 with no platform code, so proving the ordering once proves it for the Windows build too.
CI runs the whole sweep on Linux and, apart from the ELF- and sanitizer-based
checks, on Windows as well. golden-tx passing on both is the strongest single
statement in the pipeline: the modulator emits byte-identical on-air audio
compiled by a different toolchain on a different operating system.
See test/golden/README.md for what the corpus asserts
and how strongly.
Interoperable ARQ and FEC sessions, the full host command + data interface, and the modulator/demodulator for every 2017-spec data mode are working and covered. Linux and Windows are both built and tested on every push, with Windows binaries published automatically.
Known gaps, both of which matter to an operator:
- Memory-ARQ combining is not yet ported, so low-SNR performance is below the reference implementation's.
- On-air interop against a real
ardopcfpeer has not been validated. The golden corpus proves the waveform and the decode; it does not prove a live negotiated session with a foreign implementation.
That is why the Windows builds are published as a prerelease. macOS and Android are reachable through the same miniaudio backend but are not yet built or tested in CI.
Forked from pflarue/ardop (ardopcf),
itself descended from John Wiseman's ardopc and Rick Muething's ARDOP_Win.
MIT licensed — see LICENSE; copyright © 2014–2024 Rick Muething,
John Wiseman, Peter LaRue, and contributors to this fork.
On AI assistance. This rebuild was carried out with substantial AI assistance. That is a deliberate divergence from upstream
ardopcf, whose maintainer asks that AI-assisted contributions receive line-by-line human review before submission and does not use such tools directly. This fork is an independent line of development and is not intended as a stream of pull requests back topflarue/ardop; the normative protocol behaviour is preserved (and checked against the golden corpus) precisely so that the two can still talk to each other on the air.