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

213
tests/test_ranges.py Normal file
View file

@ -0,0 +1,213 @@
import pytest
from bandsaunter.bandplan import PRESETS, by_key, fmt_hz
from bandsaunter.ranges import (RangeError, ScanRange, build_plan,
parse_frequency, parse_range, parse_range_list)
@pytest.mark.parametrize("text,expected", [
("146.52M", 146_520_000), ("146520000", 146_520_000),
("433.92 MHz", 433_920_000), ("14074k", 14_074_000),
("1.09G", 1_090_000_000), ("162.4", 162_400_000), ("500Hz", 500),
])
def test_parse_frequency(text, expected):
assert parse_frequency(text) == pytest.approx(expected)
def test_parse_range_forms():
assert parse_range("144M-148M").start == 144e6
# the unit on the right end carries over to the left
r = parse_range("144-148M")
assert (r.start, r.stop) == (144e6, 148e6)
r = parse_range("144M-148M/25k@nfm")
assert r.step == 25_000 and r.mode == "nfm"
assert parse_range("gmrs").preset_key == "gmrs"
def test_single_frequency_gets_width():
r = parse_range("146.52M")
assert r.stop > r.start
def test_parse_range_list_unlimited():
rs = parse_range_list("144M-148M, 420-450M, gmrs, 162.4-162.55M, 88-108M")
assert len(rs) == 5
def test_bad_input_raises():
with pytest.raises(RangeError):
parse_range("banana")
def test_plan_parks_lo_clear_of_the_covered_span():
"""The DC spike must never land inside the frequencies being searched."""
steps = build_plan(parse_range_list("144.05M-144.15M"), 2_048_000,
dc_guard=8000.0)
assert len(steps) == 1
s = steps[0]
assert s.center < s.low
assert s.low - s.center == pytest.approx(8000.0)
def test_plan_covers_whole_range_contiguously():
ranges = parse_range_list("144M-148M")
steps = build_plan(ranges, 2_048_000)
assert steps[0].low == pytest.approx(144e6)
assert steps[-1].high == pytest.approx(148e6)
for a, b in zip(steps, steps[1:]):
assert a.high == pytest.approx(b.low)
def test_band_plan_is_well_formed():
keys = [p.key for p in PRESETS]
assert len(keys) == len(set(keys)), "duplicate preset keys"
for p in PRESETS:
assert p.stop > p.start, f"{p.key} has an empty span"
assert p.mode in ("nfm", "wfm", "am", "usb", "lsb", "cw", "raw",
"auto")
# "auto" defers to the segments beneath it, so it must not also
# declare a bandwidth that would be used in preference to theirs.
if p.mode == "auto":
assert p.bandwidth == 0, f"{p.key} both defers and dictates"
assert by_key(p.key) is p
def test_fmt_hz():
assert fmt_hz(146_520_000) == "146.52 MHz"
assert fmt_hz(1_090_000_000) == "1.09 GHz"
assert fmt_hz(500) == "500 Hz"
# ---------------------------------------------------------------------------
# Presets that stand for a set of others
# ---------------------------------------------------------------------------
def test_all_cw_expands_to_every_cw_segment():
from bandsaunter.bandplan import PRESETS, by_key
group = by_key("all-cw")
assert group is not None and group.is_group
members = group.expand()
# every CW preset in the plan should be in it, and nothing else
cw_keys = {p.key for p in PRESETS if p.mode == "cw" and not p.is_group}
assert {p.key for p in members} == cw_keys, "the group and the plan disagree"
assert all(p.mode == "cw" for p in members)
assert len(members) >= 10
def test_a_group_becomes_one_range_per_member():
ranges = parse_range_list("all-cw")
assert len(ranges) >= 10
assert all(r.mode == "cw" for r in ranges)
# separate ranges, not one span from the lowest to the highest
assert all(r.span < 1e6 for r in ranges), [r.span for r in ranges]
assert sum(r.span for r in ranges) < 5e6
def test_a_group_declares_the_extent_of_its_members():
"""The displayed span must not drift from what the group actually covers."""
from bandsaunter.bandplan import PRESETS
for group in [p for p in PRESETS if p.is_group]:
members = group.expand()
assert group.start == pytest.approx(min(m.start for m in members))
assert group.stop == pytest.approx(max(m.stop for m in members))
def test_group_members_all_exist():
from bandsaunter.bandplan import PRESETS, by_key
for group in [p for p in PRESETS if p.is_group]:
for key in group.members:
assert by_key(key) is not None, f"{group.key} names missing {key}"
assert len(group.expand()) == len(group.members)
def test_a_group_is_refused_where_only_one_range_fits():
with pytest.raises(RangeError, match="separate ranges"):
parse_range("all-cw")
def test_groups_do_not_label_detections():
"""A group spans a huge range; it must not be used to name a signal."""
from bandsaunter.bandplan import presets_covering
assert all(not p.is_group for p in presets_covering(14_050_000))
def test_a_group_mixes_with_ordinary_ranges():
ranges = parse_range_list("all-cw, 144M-148M, gmrs")
labels = [r.label for r in ranges]
assert "2 m CW" in labels
assert "GMRS / FRS" in labels
assert any("144 MHz-148 MHz" in x for x in labels)
def test_the_cw_segments_are_inside_their_bands():
"""A CW segment that strays outside its amateur band would be wrong."""
from bandsaunter.bandplan import by_key
bands = {"160m-cw": "160m", "80m-cw": "80m", "40m-cw": "40m",
"20m-cw": "20m", "17m-cw": "17m", "15m-cw": "15m",
"12m-cw": "12m", "10m-cw": "10m", "6m-cw": "6m", "2m-cw": "2m"}
for cw_key, band_key in bands.items():
cw, band = by_key(cw_key), by_key(band_key)
assert cw.start >= band.start, cw_key
assert cw.stop <= band.stop, cw_key
# ---------------------------------------------------------------------------
# Whole amateur bands
# ---------------------------------------------------------------------------
COMPLETE_BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m",
"12m", "10m", "6m", "2m", "1.25m", "70cm", "33cm"]
@pytest.mark.parametrize("band", COMPLETE_BANDS)
def test_every_amateur_band_has_a_complete_entry(band):
from bandsaunter.bandplan import by_key
whole = by_key(f"{band}-complete")
assert whole is not None, f"no {band}-complete"
assert whole.category == "Amateur Radio"
assert whole.mode == "auto"
# It must actually cover the band it names.
plain = by_key(band)
assert whole.start <= plain.start and whole.stop >= plain.stop
@pytest.mark.parametrize("band", COMPLETE_BANDS)
def test_a_complete_band_resolves_a_real_mode_everywhere(band):
""""auto" must never survive to the demodulator."""
ranges = parse_range_list(f"{band}-complete")
assert len(ranges) == 1
r = ranges[0]
step = r.span / 200
freq = r.start
while freq < r.stop:
mode = r.resolved_mode(freq)
assert mode in ("nfm", "wfm", "am", "usb", "lsb", "cw", "raw"), \
f"{band} at {freq}: {mode}"
assert r.resolved_bandwidth(freq) > 0, f"{band} at {freq}"
freq += step
def test_2m_complete_covers_cw_then_ssb_then_fm():
r = parse_range_list("2m-complete")[0]
assert r.resolved_mode(144_050_000) == "cw"
assert r.resolved_mode(144_200_000) == "usb"
assert r.resolved_mode(146_520_000) == "nfm"
def test_70cm_complete_is_not_hijacked_by_the_ism_band():
"""433 MHz is shared; inside an amateur sweep the amateur reading wins."""
r = parse_range_list("70cm-complete")[0]
assert r.resolved_mode(433_920_000) == "nfm"
assert r.resolved_mode(432_050_000) == "cw"
assert r.resolved_mode(432_200_000) == "usb"
def test_a_plain_range_still_gets_the_ism_reading():
assert ScanRange(433.9e6, 433.95e6, mode="auto").resolved_mode(433.92e6) \
== "raw"
def test_complete_bands_are_offered_where_they_would_be_looked_for():
from bandsaunter.bandplan import in_category
keys = {p.key for p in in_category("Amateur Radio")}
assert {f"{b}-complete" for b in COMPLETE_BANDS} <= keys