Autonomous spectrum scanner for the RTL-SDR v3 with a terminal UI. It sweeps bands on its own, estimates the noise floor, flags frequencies that show "life", lets you listen and record them, and helps you place your antenna.
- Sweep engine — hops across any frequency range, computes an averaged PSD (FFT) per hop and stitches a full-range power spectrum.
- Automatic squelch — rolling noise-floor estimation (median + percentile) with persistence tracking: a channel only becomes "active" after it stays above the floor for several consecutive frames.
- Radio-first terminal UI — opens like an FM radio: full-height spectrum
carousel over a waterfall, frequency ruler beneath, a bright tuning cursor
you walk with the arrow keys; channel table, log and clips pane are opt-in
(
t), settings live in a menu (m). - Listen & record — NFM / WFM / AM demodulation in numpy, playback through
ffplay/aplay, VOX-gated WAV recording of transmissions with an optional RF-carrier squelch gate so quiet channels stop producing hiss-only clips. - Bookmarks & ignore list (
~/.config/radiotui/channels.toml) — name the frequencies you care about, silence the birdies; names show in the table and exports, ignored windows never become channels or auto-holds. - Recordings browser — session clip list in the TUI (
c), enter replays a clip through the player; every kept WAV gets a.jsonsidecar with frequency, demod, band, timestamps, RSSI and hardware context. - Channel export (
e,scan --export) — CSV for spreadsheets, JSON with context for scripts. - Autonomous scan-and-hold (
oin the TUI,--autonomousheadless) — when a channel crosses the activation gate the scanner pauses its sweep, VOX-records transmissions on that frequency and releases back to sweeping after a few seconds of silence, with per-channel cooldown. - Hardware controls — bias tee (~4.5 V for LNAs / active antennas), PPM crystal correction and optional LO-offset tuning.
- HF coverage — bands below 24 MHz automatically switch the v3 into direct-sampling Q-branch mode (500 kHz - 28.8 MHz).
- Antenna advisor — wavelength math (quarter-wave, dipole legs in cm), band-specific antenna/orientation recommendations, and a live tuner mode with a big signal bar for physically optimizing your setup.
- Simulator — no dongle plugged in? A synthetic device generates realistic carriers and bursts so every feature is demoable and testable.
uv sync --group dev # core (simulator mode)
uv sync --group dev --extra sdr # + pyrtlsdr for real hardwareYou also need the RTL-SDR userspace tools or at least librtlsdr on your
system for real hardware (sudo apt install rtl-sdr), plus ffmpeg for audio
playback (optional; falls back to aplay).
sudo apt install rtl-sdr ffmpeg # librtlsdr userspace + audio playbackThe DVB-T kernel driver (dvb_usb_rtl28xxu) claims the dongle by default.
librtlsdr detaches it automatically on each open (you'll see
Detached kernel driver on stderr), so no reboot is needed after dropping a
blacklist file; to make it permanent:
echo 'blacklist dvb_usb_rtl28xxu' | sudo tee /etc/modprobe.d/blacklist-dvb_usb_rtl28xxu.confUbuntu/Debian quirk: the distro librtlsdr2 package (2.0.1) lacks symbols
that pyrtlsdr requires at import time (rtlsdr_set_dithering, GPIO helpers,
rtlsdr_set_and_get_tuner_bandwidth), so import rtlsdr fails with
AttributeError: undefined symbol. Fix without touching system packages by
building a tiny forwarder library and putting it first on the loader path:
mkdir -p ~/.local/lib/rtlsdr-shim && cd ~/.local/lib/rtlsdr-shim
cat > shim.c <<'EOF'
/* Forwarder: adds symbols missing from Ubuntu's librtlsdr2 (2.0.1).
Real functionality comes from the DT_NEEDED system library;
dithering/GPIO stubs are functional no-ops (bias-tee still works,
the system build exports rtlsdr_set_bias_tee). */
int rtlsdr_set_dithering(void *dev, int on) { (void)dev; (void)on; return 0; }
int rtlsdr_set_gpio_output(void *dev, unsigned char g) { (void)dev; (void)g; return 0; }
int rtlsdr_set_gpio_input(void *dev, unsigned char g) { (void)dev; (void)g; return 0; }
int rtlsdr_set_gpio_bit(void *dev, unsigned char g, int v) { (void)dev; (void)g; (void)v; return 0; }
unsigned char rtlsdr_get_gpio_bit(void *dev, unsigned char g) { (void)dev; (void)g; return 0; }
int rtlsdr_set_gpio_byte(void *dev, int v) { (void)dev; (void)v; return 0; }
unsigned char rtlsdr_get_gpio_byte(void *dev) { (void)dev; return 0; }
int rtlsdr_set_gpio_status(void *dev, unsigned char *b) { (void)dev; if(b)*b=0; return 0; }
int rtlsdr_set_and_get_tuner_bandwidth(void *dev, unsigned long bw,
unsigned long *applied, int apply) {
(void)dev; (void)bw; (void)apply; if(applied)*applied=0; return 0; }
EOF
gcc -shared -fPIC -Wl,-soname,librtlsdr.so -Wl,--no-as-needed \
-o librtlsdr.so shim.c -l:librtlsdr.so.2 -Wl,-rpath,/lib/x86_64-linux-gnu
echo 'export LD_LIBRARY_PATH="$HOME/.local/lib/rtlsdr-shim${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"' >> ~/.bashrcThen verify with radiotui devices.
| Metric | Value |
|---|---|
| Device detection | Realtek RTL2838UHIDIR SN 00000001, works as non-root user |
| FM broadcast sweep (27 hops) | ~4 s/sweep (~7 hops/s) standalone, dwell 120 ms |
| Simulated sweep | ~50 hops/s (no USB latency) |
| Real noise floor | −49 dB @ gain 28 dB (simulator baseline: −62 dBFS); default threshold margin works unmodified |
| DC spike | masked automatically around every hop center (±3% of sample rate, real devices only); no phantom channels observed |
| Audio playback | aplay fallback works without ffmpeg |
| Recordings | WAV clips land in recordings/ (48 kHz mono PCM16) |
- Switching into a band below 24 MHz enables
set_direct_sampling(2)(Q-branch) automatically; switching back to VHF/UHF disables it. - Sample rate is forced to 28.8 MHz / 115 ≈ 250.43 kS/s in HF mode.
Sync reads at ~1 MS/s in direct-sampling mode overflow the USB ring buffer
(
LIBUSB_ERROR_OVERFLOW) on this host. 250 kS/s is rock solid and still far wider than any HF channel. - Sync reads are aligned to multiples of 256 samples inside
RtlSdrDevice.read_samples: the RTL2832 bulk endpoint overruns unless transfer lengths are multiples of 512 bytes. - Validated live: shortwave broadcast carriers at 9.815 / 11.765 / 11.910 MHz detected with the stock indoor dipole; AM listen works.
rtl_test -p is noisy on this host because of USB sample losses
(cumulative −70 ppm, per-window values −106…+320). FM carrier centroids gave
station-dependent results (+6…−22 ppm) because broadcast modulation skews the
centroid. Practical guidance: start with the cumulative rtl_test -p value,
then refine against a known narrowband reference (DAB or GSM downlink) if you
need sub-kHz accuracy. --offset-tune is exposed but reports "unsupported"
on Ubuntu's librtlsdr2 2.0.1 even via raw symbol calls — the automatic DC
masking makes it unnecessary anyway.
radiotui # launch the TUI (auto-detects device)
radiotui scan # headless sweep, prints live table
radiotui scan --band airband # presets: hf_broadcast, hf_ham_40m, hf_ham_80m,
# fm_broadcast, airband, vhf_ham, vhf_marine,
# pmr446, uhf_ham
radiotui scan --autonomous # sweep + auto-hold + VOX record live channels
radiotui listen 145.500e6 # tune + demodulate + play
radiotui listen 96.9e6 --bias-tee # power an LNA while listening
radiotui record 446.00625e6 # VOX-record transmissions to WAV
radiotui scan --ppm -70 # correct crystal error (see measurement below)
radiotui scan --autonomous --export findings.json # soak + machine-readable results
radiotui antenna 145.500e6 # antenna advisor report
radiotui tuner 145.5M # live signal bar to optimize the antenna
radiotui devices # list detected SDR hardwareHardware flags --bias-tee, --ppm N and --offset-tune work on every
command (scan / listen / record / tuner / tui). Bands starting below 24 MHz
switch into HF direct sampling automatically.
Stop retyping flags: ~/.config/radiotui/config.toml merges as
defaults → config file → CLI flags, so flags still win. radiotui config
shows the path; --no-config ignores it for one run.
[hardware]
ppm = -70 # crystal correction, applied on every run
bias_tee = true # keep your LNA powered
gain_db = 28.0 # default gain when --gain is not given
[scanner]
squelch_rssi_dbfs = -30 # RF gate for recordings; null = VOX level only
region = "r1" # ITU region: r1, r2, or r3
[audio]
recordings_dir = "~/radio/recordings"
min_clip_seconds = 0.7 # shorter VOX blips are discarded
deemphasis_us = 50 # FM broadcast curve: 50 or 75
[[band]] # custom presets join the built-ins:
name = "ism_433" # --band ism_433; press 0 to cycle user bands in the TUI
label = "ISM 433"
start_hz = 433_050_000
end_hz = 434_790_000
demod = "nfm"~/.config/radiotui/channels.toml names the frequencies you care about and
silences the ones you don't.
Press b in the TUI to name the selected channel, x to ignore it, and Shift-X
to remove an ignore window at the selected frequency — all three actions
write this file immediately.
[[bookmark]]
freq_hz = 145_500_000
name = "2m calling"
demod = "nfm"
[[ignore]]
freq_hz = 96_000_000
width_hz = 20_000
note = "birdie"Ignored windows never become channels, never reach exports and are never auto-held; bookmarked channels carry their name in the table, in headless scan output and in CSV/JSON exports.
The TUI opens in the radio view: spectrum carousel over the waterfall with the
frequency ruler beneath it.
←/→ walk the tuning cursor across the band, ↑/↓ coarse-step 100 kHz,
enter plays what the cursor points at.
| Key | Action |
|---|---|
← / → |
tuning cursor one column left / right |
↑ / ↓ |
cursor -/+ 100 kHz |
enter |
radio view: listen under the cursor · panels: listen to selected row |
l |
stop listening / replaying |
s |
start / stop sweeping (refused while listening) |
t |
show/hide analyst panels (channel table, log, clips) |
m |
settings menu (threshold, dwell, min SNR, squelch, volume) |
M |
mute / unmute |
r |
record selected channel |
n / p |
jump to next / previous active channel (panels) |
+ / - |
gain up / down |
< / > |
volume down / up while listening |
[ / ] |
detection threshold down / up |
{ / } |
hop dwell down / up |
, |
sort table by frequency / peak |
b |
name (bookmark) the selected channel |
x |
add the selected channel to the ignore list |
Shift-X |
remove the ignore window at the selected frequency |
c |
toggle the session clips pane (enter replays a clip) |
e |
export discovered channels to CSV |
f |
tune an arbitrary frequency or sweep a custom range |
a |
antenna advisor for selected channel |
o |
autonomous scan-and-hold on / off |
? |
help overlay generated from these bindings |
| number keys | 1-9 select built-ins; 0 cycles registered user bands |
q |
quit / close overlay |
While listening, sweeping stays paused by design: one tuner, one consumer.
src/radiotui/
├── cli.py argparse CLI, headless scan/listen/record/tuner
├── config.py band presets, settings dataclasses, ranges
├── config_file.py config.toml merge (defaults <- file <- flags)
├── channels_file.py user bookmarks + ignore list (channels.toml)
├── tuning.py frequency/demod parsing helpers
├── export.py CSV/JSON channel export
├── core/ shared models (Channel, ScanState, ...)
├── sdr/ device abstraction: real RtlSdr (+compat shim) and simulator
├── dsp/ FFT PSD, noise floor, peak detection/tracking
├── scanner/ sweeper thread, channel monitor (listen/record loop)
├── audio/ demodulators, DSP classifier, player, VOX recorder
├── antenna/ wavelength math + recommendations
└── tui/ Textual app: radio-first view, panels, widgets
Deeper material under docs/:
docs/adr/— architecture decision records (why the DSP is numpy-only, how tuner exclusivity works, where user channels live, ...).docs/research/— measurement studies with numbers: audio chain redesign, dropout forensics, hardware validation incl. the 30 min soak.docs/reasoning_logs/— chronological investigation logs, including the dead ends, for anyone re-tracing how a conclusion was reached.