Voracious - VOR Signal Decoder
A Rust library and CLI tool for decoding VHF Omnidirectional Range (VOR) navigation signals used in aviation.
Features
- Decode VOR signals from I/Q sample files or live SDR devices
- Extract VOR radial (bearing) using the VORtrack phase-tracking algorithm
- Decode the Morse station identifier (e.g.
KLO,ARL) - Signal quality metrics per window (SNR, clipping, lock quality, radial variance)
- JSON output for easy integration with
jqor other tools
VOR Signal Overview
VOR stations transmit on 108–117.95 MHz. The signal encodes the receiver's bearing to the station using two 30 Hz tones:
- A reference signal: FM-modulated on a 9960 Hz subcarrier, phase is fixed
- A variable signal: AM-modulated directly on the carrier, phase rotates with the antenna pattern
The bearing (radial) is the phase difference between the two 30 Hz components. Additionally, a Morse ident (three letters, ~1020 Hz tone) is transmitted every ~30 seconds.
Installation
Without RTL-SDR or Airspy support:
Usage
Decode from a gqrx recording
gqrx filenames encode center frequency and sample rate. voracious infers them automatically:
Decode from any I/Q file
Decode from a live SDR
# RTL-SDR (center frequency defaults to VOR frequency)
# Airspy device 0
# SoapySDR with embedded tuning
Parameters
| Flag | Default | Description |
|---|---|---|
--vor-freq |
required | VOR station frequency in MHz |
--center-freq |
auto | SDR center frequency in MHz (inferred from gqrx filenames) |
--sample-rate |
1800000 | Sample rate in Hz |
--format |
cf32 | I/Q format: cu8, cs8, cs16, cf32 |
--window |
3.0 | Radial calculation window in seconds |
--morse-window |
15.0 | Audio buffer size for Morse decoding in seconds |
--debug-morse |
off | Include Morse decode attempt details in JSON output |
Feature flags (all enabled by default):
rtlsdr— RTL-SDR device supportairspy— Airspy HF+/Mini supportsoapy— SoapySDR support (requires systemlibsoapysdr)
Output
One JSON line per radial window (default every 3 seconds):
timestamp is a Unix timestamp in seconds (UTC, floating-point):
- For file inputs:
t0 + elapsed, wheret0comes from the gqrx filename, file creation time, or modification time (in that order). - For live SDR: wall-clock time at output.
Pretty-print with human-readable timestamps:
|
Signal Quality Metrics
signal_quality contains raw per-window indicators. These are internal relative measures — compare across frames from the same run rather than to absolute thresholds.
| Field | Format | Meaning | Good | Bad |
|---|---|---|---|---|
clipping_ratio |
scientific string | Fraction of I/Q samples hitting the ADC ceiling | near 0 |
> 0.01 suggests gain overload |
snr_30hz_db |
float (dB) | SNR of the 30 Hz bearing tone | 10–40+ dB |
near 0 or negative |
snr_9960hz_db |
float (dB) | SNR of the 9960 Hz VOR subcarrier | 6–20+ dB |
near 0 or negative |
lock_quality |
scientific string, 0–1 |
Phase coherence between variable and reference 30 Hz tones | near 1 |
near 0 |
radial_variance |
scientific string, 0–1 |
Short-term spread of recent radial outputs | near 0 (stable) |
large (jittery) |
Library Usage
use ;
let source = new?;
for result in source
Architecture
src/
├── lib.rs — public re-exports
├── main.rs — CLI (clap, gqrx filename inference, SDR URI parsing)
├── source.rs — IqSource iterator: chunked I/Q → VorRadial
├── metrics.rs — signal quality computation (SNR, lock, variance)
└── decoders/
├── mod.rs — module coordinator and re-exports
├── vor.rs — VorDemodulator, VORtrack radial algorithm, DSP pipeline
└── morse.rs — generic Morse ident parser (reusable for NDB, ILS, DME)
DSP filters are provided by the desperado crate (desperado::dsp::filters, desperado::dsp::iir).
Signal Processing Pipeline
IQ samples (cf32, 1.8 MSps)
│
├─ Frequency shift to VOR baseband
├─ 200 kHz Butterworth lowpass
├─ AM envelope detection
├─ 20 kHz lowpass
└─ Decimate 38× → audio at ~47368 Hz
│
├─── VORtrack radial algorithm
│ Tracks 30 Hz variable (9–11 kHz BPF + envelope)
│ and reference (9.5–10.5 kHz BPF + FM demod)
│ → radial_deg
│
└─── Morse ident decoder (sliding 15-second windows)
900–1100 Hz BPF + Hilbert envelope
→ threshold → dot/dash detection → 3-letter ident
Testing
The test suite covers both unit tests (within modules) and integration tests against real VOR captures.
Unit tests
Run with:
Integration tests
Integration tests in tests/vor_decoding.rs exercise the full decoding pipeline using two real gqrx recordings, pre-demodulated to audio fixtures stored in tests/data/:
| Fixture stem | Station | Freq | Duration | Source recording |
|---|---|---|---|---|
gqrx_20250925_144051_114647000_1800000_fc |
KLO | 114.85 MHz | ~18 s | gqrx_…114647000…_fc.raw |
gqrx_20251107_182558_116000000_1800000_fc |
ARL | 116.00 MHz | ~26 s | gqrx_…116000000…_fc.raw |
Each fixture set contains three f32 binary files (all < 5 MB):
*_audio.f32— mono audio at ~47368 Hz; input to the Morse decoder and VORtrack algorithm*_var30.f32— 30 Hz variable signal extracted from the 9960 Hz subcarrier envelope*_ref30.f32— 30 Hz reference signal from FM-demodulation of the 9960 Hz subcarrier
The fixtures were generated from the raw .raw captures using the actual Rust VorDemodulator (not a Python approximation), so they match the exact filter chain used in production. To regenerate them:
The tests cover:
| Test | What it checks |
|---|---|
test_klo_vortrack_radial_in_range |
Full-signal radial in 115–125° for KLO |
test_klo_vortrack_windowed_consistent |
Three 3-second windows each agree with full-signal within 5° |
test_klo_morse_ident |
Sliding 15-second Morse windows decode to "KLO" |
test_arl_vortrack_radial_in_range |
Full-signal radial in 110–120° for ARL |
test_arl_vortrack_windowed_consistent |
Five 3-second windows each agree with full-signal within 5° |
test_arl_morse_ident |
Sliding 15-second Morse windows decode to "ARL" |
test_vortrack_returns_none_for_short_input |
<0.5 s of audio returns None gracefully |
test_calculate_radial_returns_none_for_short_input |
FFT radial method also returns None for <0.5 s |
test_morse_returns_none_for_silence |
Pure silence produces no tokens and no ident |
Why sliding windows for Morse? The VOR Morse ident is a 3-letter code transmitted for ~3 seconds out of every ~12-second cycle. Passing a 26-second buffer directly to decode_morse_ident drops the duty cycle well below the decoder's 25–55% on-ratio threshold. The IqSource iterator in production uses a 15-second sliding buffer with 50% overlap — the tests mirror this with decode_morse_sliding.
Run the integration tests with:
License
MIT License