An AIS receiver in C99: complex baseband IQ in, AIVDM sentences out. No dependencies, MIT licensed.
ais_rx_t *rx = ais_rx_create(192000.0); /* radio tuned to 162.000 MHz */
ais_rx_set_callback(rx, on_message, NULL);
ais_rx_process_cs16(rx, iq, n); /* n = complex samples */Both AIS channels are decoded from one tune. Feed it samples from any radio.
Permissive AIS message decoders are easy to find. pyais is MIT, libais is Apache-2.0, and both do a fine job of turning an AIVDM sentence into fields.
The demodulator — the part that turns radio samples into those sentences — is a different story:
| project | licence |
|---|---|
| gnuais | GPL-2 |
| rtl-ais | GPL-2 |
| AIS-catcher | GPL-3 |
| libaisdemod | MIT |
So if you are building something closed, or something you would simply rather not license virally, you have had to write the demodulator yourself. That is a few weeks of DSP work with several traps that fail silently. This library is that work, given away.
cmake -B build && cmake --build build
./build/test_aisdemodC99, no external libraries, no build-time configuration. Tested on macOS and Linux with clang and gcc.
./build/aisfile recording.wav
./build/aisfile -r 250000 capture.cs16
./build/aisfile --channel A -v recording.wavThe recording must be complex baseband tuned to 162.000 MHz at 96 kHz or above. Sentences go to stdout, so this pipes straight into anything that speaks NMEA:
./build/aisfile recording.wav | nc -l 10110 # point OpenCPN at port 10110Nine functions. The whole interface is in
include/aisdemod.h.
ais_rx_t *rx = ais_rx_create(sample_rate_hz);
ais_rx_set_callback(rx, on_message, user);
ais_rx_process_cs16(rx, iq, complex_samples); /* or _cf32 */
ais_rx_destroy(rx);The callback receives a ais_message_t holding the ready-to-share !AIVDM
sentence plus decoded fields: MMSI, position, speed, course, heading, rate of
turn, name, call sign, destination, ship type. Optional fields carry explicit
has_* flags rather than sentinels, because AIS's own "not available" values
are real numbers — heading 511, speed 1023, longitude 181 degrees — and treating
them as data is how vessel lists fill with ghost ships parked at the north pole.
Only CRC-valid messages are delivered. Nothing unverified reaches your application.
ais_rx_create() assumes the standard pair: channel A at −25 kHz, B at
+25 kHz, so you tune to 162.000 MHz.
Long-range AIS needs no different call. Channels 75 and 76 (156.775 /
156.825 MHz) carry type 27 position reports intended for satellite
reception, and they are the same 50 kHz apart — so tune the radio to
156.800 MHz and ais_rx_create() decodes them unchanged. Type 27 is decoded
into the same fields, with one thing to know:
Type 27 is deliberately coarse: position to 1/10 minute (~185 m) instead of 1/10000, and speed and course as whole units. That is the standard's choice, so it is not a loss here — but check
typebefore comparing a type 27 fix against a type 1 from the same vessel.
For anything else, including running a single channel at half the CPU cost:
double off = -25000.0;
ais_rx_t *rx = ais_rx_create_ex(96000.0, &off, "A", 1);IQ --NCO--> wideband decimator --> channel filter --> FM discriminator
|
Gaussian matched filter <-----+
|
8 parallel symbol phases
|
slice -> NRZI -> HDLC -> CRC -> AIVDM
Three decisions matter more than the rest, and each was reached by measurement rather than taste:
The channel filter is designed to the ITU plan, not hand-tuned. The sister channel is 50 kHz away and is frequently the stronger of the two. Anything that lets it through, or aliases it into the passband while decimating, buries the channel you want. The spec is a 7 kHz passband, a 12.5 kHz stopband (half the 25 kHz channel spacing) and 80 dB; the realised design measures +0.001 dB ripple to 7 kHz, −82 dB at the channel edge and −111.6 dB at the sister channel. The first version of this decoder used a boxcar decimator and decoded literally nothing.
Timing is parallel symbol phases, not a tracking loop. AIS bursts are about 26 ms, roughly 256 symbols. A Gardner or early-late loop spends part of every burst acquiring, and loses outright any burst it fails to acquire in time. Eight hypotheses run at once, each with its own fractional accumulator, so non-integer samples-per-symbol tracks exactly and there is no acquisition time at all. Raising the count to 16 or 32 changes the decode rate by less than 1%.
The frequency-offset tracker is slow on purpose. At a 50 Hz corner its time constant is short against a 26 ms burst, so it tracks the data rather than the carrier offset. That cost about 4 dB and produced an irreducible packet error floor that no amount of SNR would clear. 20 Hz.
Polarity needs no handling anywhere: NRZI codes data as transitions, so inverting every symbol leaves the bits identical. Swapped I/Q costs nothing.
Packet error rate against Eb/N0 in AWGN, from ./build/test_aisdemod --ber:
| Eb/N0 | PER |
|---|---|
| 10 dB | 75% |
| 12 dB | 33% |
| 14 dB | 12% |
| 16 dB | 1.7% |
| 18 dB | 0% |
50% at roughly 11 dB, 1% at roughly 16 dB, and no error floor: 0 losses in 300 bursts at 24 dB and above.
Raw bit error rate, from ./build/test_aisdemod --raw-ber, for comparison with
demodulators published on that axis:
| Eb/N0 | BER |
|---|---|
| 6 dB | 6.2 × 10⁻² |
| 8 dB | 2.4 × 10⁻² |
| 10 dB | 1.1 × 10⁻² |
| 12 dB | 3.1 × 10⁻³ |
| 14 dB | 1.0 × 10⁻³ |
| 16 dB | 5.1 × 10⁻⁵ |
The two are consistent, which is the point of measuring both: a 5.1 × 10⁻⁵ bit error rate over a ~200-bit burst predicts about 1% packet loss, and the packet measurement independently gives 1.7% at that Eb/N0.
Against theory that is roughly 7 dB from coherent MSK and 3 dB from non-coherent orthogonal FSK. The gap is the expected penalty for discriminator detection of GMSK, and closing it needs non-coherent sequence detection (Viterbi over the GMSK trellis) rather than parameter tuning — the tuning was already measured and is not where the loss is.
There is no acquisition threshold. Demodulators built around a clock-recovery loop have one, and its position moves with the loop bandwidth: gr-satellites' FSK demodulator, for instance, locks at about 11 dB Eb/N0 at its default bandwidth and about 7 dB at a reduced one. The parallel-phase design here has no loop to lock, so performance degrades smoothly all the way down rather than falling off a cliff, and the analogous parameter — the number of phases — changes results by less than 1% between 8 and 32.
Encode-side validation of the same algorithms in their original form: 710 of 710 messages on a ground-truth scenario spanning a 48 dB level range and ±488 Hz carrier offset, and 49 vessels from an off-air recording of the Thames.
./build/test_aisdemod # 64 checks, no data files needed
./build/test_aisdemod --ber # packet error rate vs Eb/N0
./build/test_aisdemod --raw-ber # bit error rate vs Eb/N0The suite modulates AIS bursts and then decodes them, so it exercises the whole chain from IQ samples to decoded fields with nothing to ship and nothing to go stale. The modulator is written independently from ITU-R M.1371, so a shared misreading of the standard cannot cancel itself out — the only thing the two paths agree on is the specification.
It covers type 27 long-range reports (including negative latitude and longitude, which exercise the short two's-complement fields where an off-by-one width would hide), the CRC check value, bit destuffing, round trips at integer and non-integer samples-per-symbol, both channels in one stream, single-channel operation rejecting the sister channel, carrier offset across the full ±500 Hz ITU tolerance, cs16 and cf32 producing identical sentences, and — the one that matters most in the field — that pure noise manufactures no vessels.
Clean-room from ITU-R M.1371 and the publicly documented AIVDM format. No code, structure or algorithm was taken from gnuais, rtl-ais, AIS-catcher or any other GPL AIS implementation. Those were used only as black-box oracles: run a recording through one, diff the sentences.
That independence is what makes the MIT licence here real rather than asserted.
No SDR drivers, no networking, no database, no map, no configuration files. It does one thing so that it stays small enough to audit and boring enough to depend on. Roughly 1,500 lines including the header.
Slot-collision handling — two vessels transmitting in the same slot — is not implemented. No conventional decoder recovers those.
MIT. Copyright (c) 2026 Pieter Ibelings. See LICENSE.
Built as part of SpectraFlux, a native macOS SDR spectrum analyser.