Initial commit: bandsaunter, an RTL-SDR signal scanner

Sweeps any set of frequency ranges, records what it finds, and works out
what kind of signal it was.

- Frequency ranges entered by hand or picked from a 135-entry US band plan,
  including whole-band and all-CW sweeps that resolve the demodulator per
  segment.
- Detection calibrated against the peak-hold detector's own noise statistics,
  so the threshold means real margin over static rather than over the floor.
- A content gate: captures are kept only if they carry voice, decodable CW,
  or an identified digital keying scheme. Speech is recognised by a pitch
  track that drifts, which static cannot imitate.
- Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR,
  NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK.
- Gapless streaming capture, with the signal path fast enough to keep up in
  real time, so recordings play back at the right speed.
- Optional one-file-per-frequency recording with spoken timestamps, and
  speech-to-text transcription.
- Menus and command line generated from one settings table, so neither can
  offer something the other cannot; settings persist in ~/.config.

367 tests, run against synthetic signals, a built-in receiver simulator, and
real hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
The Dust Council 2026-08-21 20:50:20 -07:00
commit db3e0c79b9
39 changed files with 13473 additions and 0 deletions

217
tests/test_quality.py Normal file
View file

@ -0,0 +1,217 @@
"""Tests for the content gate: what counts as a signal worth recording."""
import numpy as np
import pytest
from scipy.signal import butter, lfilter
from bandsaunter.classify import classify
from bandsaunter.demod import make_demodulator
from bandsaunter.morse import decode_morse
from bandsaunter.quality import (RAYLEIGH_CV, assess, digital_structure,
noise_likeness, voice_metrics)
from bandsaunter import dsp
from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V
from speech import synth_speech
from signals import FS, make
AUDIO_FS = 16000
def _tone(hz, seconds=5.0, fs=AUDIO_FS):
t = np.arange(int(seconds * fs)) / fs
return np.sin(2 * np.pi * hz * t)
# ---------------------------------------------------------------------------
# Voice detection
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("pitch,seed", [(110, 0), (150, 1), (210, 2)])
def test_speech_scores_high(pitch, seed):
m = voice_metrics(synth_speech(5.0, AUDIO_FS, pitch, seed), AUDIO_FS)
assert m.score > 0.6, m.describe()
assert m.pitch_hz == pytest.approx(pitch, rel=0.2)
assert m.voiced_fraction > 0.2
@pytest.mark.parametrize("name,signal", [
("white noise", np.random.default_rng(0).standard_normal(AUDIO_FS * 5)),
("steady tone", _tone(1000.0)),
("mains-style hum", sum(_tone(120.0 * k) / k for k in range(1, 6))),
("silence", np.zeros(AUDIO_FS * 3)),
])
def test_non_speech_scores_low(name, signal):
assert voice_metrics(signal, AUDIO_FS).score < 0.42, name
def test_static_scores_below_speech():
b, a = butter(4, [300 / (AUDIO_FS / 2), 3000 / (AUDIO_FS / 2)], btype="band")
static = lfilter(b, a, np.random.default_rng(1).standard_normal(AUDIO_FS * 5))
speech = synth_speech(5.0, AUDIO_FS, 120, 0)
assert voice_metrics(static, AUDIO_FS).score < \
voice_metrics(speech, AUDIO_FS).score - 0.3
def test_pitch_variation_separates_voice_from_a_buzz():
"""A talker's pitch wanders; a hum sits on one frequency."""
assert voice_metrics(synth_speech(5.0, AUDIO_FS, 120, 0),
AUDIO_FS).pitch_variation > 0.02
hum = sum(_tone(120.0 * k) / k for k in range(1, 6))
assert voice_metrics(hum, AUDIO_FS).pitch_variation < 0.01
# ---------------------------------------------------------------------------
# Noise and structure scoring
# ---------------------------------------------------------------------------
def test_noise_likeness_high_for_noise_low_for_signals():
from bandsaunter.classify import extract_features
noise = noise_likeness(extract_features(make("noise"), FS, snr_db=0))
assert noise > 0.7
for kind in ("nfm", "fsk4", "cw", "am"):
assert noise_likeness(extract_features(make(kind), FS, snr_db=28)) < noise
def test_rayleigh_constant():
"""Complex Gaussian noise has this exact envelope coefficient of variation."""
rng = np.random.default_rng(0)
x = (rng.standard_normal(200000) + 1j * rng.standard_normal(200000))
env = np.abs(x)
assert env.std() / env.mean() == pytest.approx(RAYLEIGH_CV, rel=0.02)
def test_symbol_rate_alone_is_not_digital():
"""A baud estimate with no identified keying proves nothing."""
from bandsaunter.classify import extract_features
f = extract_features(make("noise"), FS, snr_db=0)
score, bits = digital_structure(f)
assert score == 0.0 and bits == []
def test_fsk_and_ook_are_digital():
from bandsaunter.classify import extract_features
for kind in ("fsk2", "fsk4"):
score, bits = digital_structure(extract_features(make(kind), FS, snr_db=28))
assert score > 0.3, f"{kind}: {bits}"
# ---------------------------------------------------------------------------
# End-to-end verdicts through the real RF chain
# ---------------------------------------------------------------------------
def _through_radio(mode, freq_hz, demod_mode, bw=12_500, seconds=3.0, **kw):
"""Run a simulated transmitter through tune, mix, decimate and demodulate."""
sr = 2_048_000
tx = [] if mode is None else [V(146_520_000, mode, 0.4, bw, "tx", **kw)]
dev = SimulatedDevice(transmitters=tx).open()
dev.tune(146_520_000 - sr * 0.25)
mixer = dsp.Mixer(sr * 0.25, sr)
demod = make_demodulator(demod_mode, sr, bw, 16_000)
audio, iq = [], []
for _ in range(int(seconds / 0.05)):
a, i = demod.step(mixer(dev.read_samples(102400)))
audio.append(a)
iq.append(i)
audio = np.concatenate(audio)
iq = np.concatenate(iq)
cls = classify(iq, demod.if_rate, freq_hz=freq_hz, snr_db=28)
morse = None
if cls.family in ("cw", "ook", "carrier"):
cw = make_demodulator("cw", int(demod.if_rate), 800.0, 16_000)
morse = decode_morse(cw.process(iq), cw.audio_rate)
return assess(cls, audio, demod.audio_rate, morse=morse, freq_hz=freq_hz)
@pytest.mark.parametrize("mode,freq,dmode,bw,kw,want", [
("nfm", 146.52e6, "nfm", 12_500, {"ctcss": 100.0}, "voice"),
("nfm", 146.52e6, "nfm", 12_500, {}, "voice"),
("am", 121.5e6, "am", 8_000, {}, "voice"),
("usb", 14.2e6, "usb", 3_000, {}, "voice"),
("wfm", 97.5e6, "wfm", 180_000, {"deviation": 75_000}, "voice"),
("carrier", 446.0e6, "nfm", 12_500, {}, "carrier"),
("fsk4", 460.0e6, "nfm", 12_500, {"baud": 4800, "deviation": 1800}, "digital"),
("fsk2", 929.6e6, "nfm", 12_500, {"baud": 1200, "deviation": 2400}, "digital"),
("ook", 433.92e6, "nfm", 40_000, {"baud": 2000}, "digital"),
("cw", 14.05e6, "cw", 800, {"wpm": 18}, "cw"),
(None, 300.0e6, "nfm", 12_500, {}, "noise"),
])
def test_content_categories(mode, freq, dmode, bw, kw, want):
v = _through_radio(mode, freq, dmode, bw, **kw)
assert v.category == want, f"{mode} -> {v.category}: {v.reason}"
def test_accept_list_controls_what_is_kept():
v = _through_radio("carrier", 446.0e6, "nfm")
assert v.category == "carrier" and not v.accept
v2 = _through_radio("nfm", 146.52e6, "nfm")
assert v2.category == "voice" and v2.accept
def test_broadcast_leniency_does_not_apply_outside_the_fm_band():
"""A wideband hump at 250 MHz is not a broadcast station."""
v = _through_radio("wfm", 250.0e6, "wfm", 180_000, deviation=75_000)
assert v.category != "voice" or v.voice.score >= 0.45
# ---------------------------------------------------------------------------
# Regressions: each of these let static through at some point.
# ---------------------------------------------------------------------------
def test_voicing_is_necessary_not_merely_weighted():
"""Noise can satisfy dynamics, syllable rate and band -- but not pitch.
Weighting voicing alongside the other three let hiss score over the line
on their strength alone, which is how static ended up recorded as speech.
"""
rng = np.random.default_rng(7)
n = AUDIO_FS * 5
t = np.arange(n) / AUDIO_FS
b, a = butter(4, [300 / (AUDIO_FS / 2), 3000 / (AUDIO_FS / 2)], btype="band")
hiss = lfilter(b, a, rng.standard_normal(n))
# Give it speech-like dynamics and a 4 Hz syllabic envelope.
hiss *= (0.15 + 0.85 * np.abs(np.sin(2 * np.pi * 4.0 * t))) * \
(np.sin(2 * np.pi * 0.3 * t) > -0.4)
m = voice_metrics(hiss, AUDIO_FS)
assert m.dynamic_range_db > 10, "test signal should have big dynamics"
assert m.syllabic > 0.2, "test signal should have syllabic modulation"
assert m.voiced_fraction < 0.15, "but no pitch track"
assert m.score < 0.45, f"shaped hiss scored {m.score:.2f} as voice"
def test_pitch_pinned_at_the_search_limit_is_not_a_pitch():
"""A maximum on the edge of the search range is the search running out."""
rng = np.random.default_rng(3)
m = voice_metrics(rng.standard_normal(AUDIO_FS * 4), AUDIO_FS)
assert not (0 < m.pitch_hz < 75) and m.pitch_hz < 395 or m.pitch_hz == 0.0
def test_symbol_rate_must_hold_across_the_capture():
"""Real data keeps one symbol rate; an estimator on noise does not."""
from bandsaunter.classify import extract_features
for kind in ("fsk2", "fsk4", "psk2", "psk4"):
f = extract_features(make(kind), FS, snr_db=28)
assert f.baud_stability > 0.8, f"{kind}: {f.baud_stability}"
for kind in ("noise", "nfm", "carrier"):
f = extract_features(make(kind), FS, snr_db=28)
assert f.baud_stability < 0.8, f"{kind}: {f.baud_stability}"
def test_keying_needs_a_regular_grid():
"""On/off contrast alone is not keying: a fading carrier produces plenty."""
from bandsaunter.classify import extract_features
keyed = extract_features(make("cw"), FS, snr_db=28)
noise = extract_features(make("noise"), FS, snr_db=0)
assert keyed.keying_regularity > noise.keying_regularity
def test_a_fading_carrier_is_not_digital():
"""A carrier drifting across the squelch has contrast but no symbol grid."""
from bandsaunter.classify import extract_features
rng = np.random.default_rng(5)
n = 64000
t = np.arange(n) / FS
# Slow random fading, nothing quantised about it.
fade = np.interp(t, np.linspace(0, t[-1], 40), rng.random(40) ** 2)
x = (fade * np.exp(2j * np.pi * 300 * t)).astype(np.complex64)
x += 0.02 * (rng.standard_normal(n) + 1j * rng.standard_normal(n))
score, bits = digital_structure(extract_features(x, FS, snr_db=20))
assert score <= 0.3, f"fading carrier scored as digital: {bits}"