voracious 0.4.1

VOR signal decoder for aviation navigation
Documentation

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 jq or 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

cargo build --release -p voracious

Without RTL-SDR or Airspy support:

cargo build --release -p voracious --no-default-features

Usage

Decode from a gqrx recording

gqrx filenames encode center frequency and sample rate. voracious infers them automatically:

voracious gqrx_20251107_182558_116000000_1800000_fc.raw --vor-freq 116

Decode from any I/Q file

voracious samples.cf32 \
    --vor-freq 114.85 \
    --center-freq 114.647 \
    --sample-rate 1800000 \
    --format cf32 \
    --window 3.0

Decode from a live SDR

# RTL-SDR (center frequency defaults to VOR frequency)
voracious rtlsdr:// --vor-freq 114.85

# Airspy device 0
voracious airspy:// --vor-freq 116 --sample-rate 2500000

# SoapySDR with embedded tuning
voracious "soapy://driver=rtlsdr?freq=114.85M&rate=1.8M" --vor-freq 114.85

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 support
  • airspy — Airspy HF+/Mini support
  • soapy — SoapySDR support (requires system libsoapysdr)

Output

One JSON line per radial window (default every 3 seconds):

{
  "timestamp": 1762539961.05835,
  "radial_deg": 115.31,
  "frequency_mhz": 116.0,
  "signal_quality": {
    "clipping_ratio": "<1.82e-7",
    "snr_30hz_db": 36.89,
    "snr_9960hz_db": 15.7,
    "lock_quality": "9.72e-2",
    "radial_variance": "1.00e0"
  },
  "ident": "ARL"
}

timestamp is a Unix timestamp in seconds (UTC, floating-point):

  • For file inputs: t0 + elapsed, where t0 comes 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:

voracious gqrx_20251107_182558_116000000_1800000_fc.raw --vor-freq 116 \
  | jq -c '.timestamp = (.timestamp | floor | strftime("%Y-%m-%d %H:%M:%S"))'

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 voracious::{IqSource, IqFormat};

let source = IqSource::new(
    "samples.cf32",
    1_800_000,     // sample rate (Hz)
    IqFormat::Cf32,
    114.85,        // VOR frequency (MHz)
    114.647,       // SDR center frequency (MHz)
    3.0,           // radial window (seconds)
    15.0,          // Morse window (seconds)
    false,         // debug_morse
)?;

for result in source {
    match result {
        Ok(radial) => println!("{:.1}° ident={:?}", radial.radial_deg, radial.ident),
        Err(e) => eprintln!("Error: {}", e),
    }
}

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:

cargo test -p voracious

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:

cargo run --release --example extract_audio -- \
  samples/gqrx_20250925_144051_114647000_1800000_fc.raw \
  114.85 114.647 tests/data/gqrx_20250925_144051_114647000_1800000_fc

cargo run --release --example extract_audio -- \
  samples/gqrx_20251107_182558_116000000_1800000_fc.raw \
  116.0 116.0 tests/data/gqrx_20251107_182558_116000000_1800000_fc 26

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:

cargo test -p voracious --test vor_decoding

License

MIT License