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

288
tests/test_settings.py Normal file
View file

@ -0,0 +1,288 @@
"""The settings table, and the two front ends generated from it."""
import argparse
import pytest
from bandsaunter import settings as st
from bandsaunter.cli import build_parser
from bandsaunter.config import ScanConfig
# ---------------------------------------------------------------------------
# Parity: every setting reachable from both the command line and the menu.
# ---------------------------------------------------------------------------
def test_every_config_field_has_a_setting():
"""A field with no entry would be invisible in the in-app menu."""
missing, extra = st.coverage()
assert not missing, f"config fields with no setting: {missing}"
assert not extra, f"settings with no config field: {extra}"
def test_every_setting_has_a_command_line_flag():
"""Anything settable in the app must also be settable as an argument."""
without = [s.key for s in st.SETTINGS if not s.flags and not s.off_flags]
assert not without, f"settings with no flag: {without}"
def test_every_flag_reaches_the_parser():
parser = build_parser()
args = parser.parse_args(["scan"])
for s in st.SETTINGS:
assert hasattr(args, s.dest), f"{s.key} never reached argparse"
assert getattr(args, s.dest) is None, \
f"{s.key} defaults to a value, so it would override saved settings"
def test_flags_are_unique():
seen = {}
for s in st.SETTINGS:
for flag in s.flags + s.off_flags:
assert flag not in seen, f"{flag} used by {seen.get(flag)} and {s.key}"
seen[flag] = s.key
def test_every_setting_is_documented():
for s in st.SETTINGS:
assert s.help, s.key
assert not s.help.endswith("."), f"{s.key} help should not end in a full stop"
assert s.label, s.key
assert s.group in st.GROUPS, s.key
if s.kind in ("choice", "accept_list"):
assert s.choices, f"{s.key} is a choice with no choices"
# ---------------------------------------------------------------------------
# Values
# ---------------------------------------------------------------------------
def test_defaults_round_trip_through_parse_and_format():
"""Whatever the menu shows must be something the menu accepts back."""
cfg = ScanConfig()
for s in st.SETTINGS:
value = getattr(cfg, s.key)
shown = st.format_value(s, value)
if s.key in ("record_seconds", "max_record_seconds",
"max_runtime_seconds", "max_cycles") and not value:
continue # shown as "no limit", typed back as 0
if s.kind == "freq_list" and not value:
continue # shown as "(none)"
back = st.parse_value(s, shown)
assert back == value, f"{s.key}: {value!r} -> {shown!r} -> {back!r}"
@pytest.mark.parametrize("key,text,expected", [
("hang_seconds", "5", 5.0),
("record_seconds", "0", 0.0),
("gain", "auto", "auto"),
("gain", "28.0", 28.0),
("direct_sampling", "auto", "auto"),
("direct_sampling", "2", 2),
("detector", "PEAK", "peak"),
("save_iq", "yes", True),
("save_iq", "n", False),
("accept", "voice, cw", ["voice", "cw"]),
("accept", "voice cw digital", ["voice", "cw", "digital"]),
("lockout", "162.55M, 146.52M", [162_550_000.0, 146_520_000.0]),
("detector_bias_db", "", None),
("detector_bias_db", "6", 6.0),
("sample_rate", "2048000", 2_048_000),
("threshold_db", "12", 12.0),
])
def test_parse(key, text, expected):
assert st.parse_value(st.by_key(key), text) == expected
@pytest.mark.parametrize("key,text", [
("hang_seconds", "-1"), # below the minimum
("hang_seconds", "banana"),
("detector", "sideways"),
("accept", "voice,banana"),
("min_voice_score", "1.5"), # above the maximum
("usable_fraction", "0.01"),
("save_iq", "maybe"),
("lockout", "not-a-frequency"),
("output_dir", ""),
])
def test_bad_values_are_refused_with_a_reason(key, text):
with pytest.raises(st.SettingError) as exc:
st.parse_value(st.by_key(key), text)
assert str(exc.value), "an error with no explanation is no use"
def test_search_finds_settings_by_words_from_anywhere():
assert [s.key for s in st.search("voice score")] == ["min_voice_score"]
assert st.search("hang")[0].key == "hang_seconds"
assert st.search("--hang")[0].key == "hang_seconds"
assert st.search("")== []
assert st.search("zzzznothing") == []
def test_name_matches_rank_above_description_matches():
hits = [s.key for s in st.search("record")]
assert hits.index("record_seconds") < hits.index("audio_rate")
# ---------------------------------------------------------------------------
# Applying the command line
# ---------------------------------------------------------------------------
def test_apply_args_sets_only_what_was_given():
parser = build_parser()
args = parser.parse_args(["scan", "--hang", "6", "--record", "0"])
cfg = ScanConfig()
changed = st.apply_args(cfg, args)
assert cfg.hang_seconds == 6.0
assert cfg.record_seconds == 0.0
assert set(changed) == {"hang_seconds", "record_seconds"}
assert cfg.threshold_db == ScanConfig().threshold_db
def test_boolean_flags_work_both_ways():
parser = build_parser()
cfg = ScanConfig()
st.apply_args(cfg, parser.parse_args(["scan", "--no-audio", "--iq"]))
assert cfg.save_audio is False and cfg.save_iq is True
cfg = ScanConfig()
st.apply_args(cfg, parser.parse_args(["scan", "--keep-everything"]))
assert cfg.require_signal is False
def test_repeated_lockout_flags_accumulate():
parser = build_parser()
args = parser.parse_args(["scan", "--lockout", "162.55M",
"--lockout", "146.52M"])
cfg = ScanConfig()
st.apply_args(cfg, args)
assert cfg.lockout == [162_550_000.0, 146_520_000.0]
def test_help_text_renders_for_every_entry():
"""--help must format without an error from any entry in the table."""
top = build_parser().format_help()
assert "scan" in top and "config" in top
scan = build_parser().parse_known_args(["scan"])
assert scan is not None
# The scan sub-parser has to render too, since that is where the table goes.
sub = [a for a in build_parser()._actions
if isinstance(a, argparse._SubParsersAction)][0]
text = sub.choices["scan"].format_help()
for setting in st.SETTINGS:
for flag in setting.flags + setting.off_flags:
assert flag in text, f"{flag} missing from --help"
# ---------------------------------------------------------------------------
# The saved settings file
# ---------------------------------------------------------------------------
def test_saved_settings_round_trip(tmp_path):
from bandsaunter.config import load_default, save_default
from bandsaunter.ranges import parse_range_list
cfg = ScanConfig(ranges=parse_range_list("144M-148M, gmrs"))
cfg.hang_seconds = 6.0
cfg.record_seconds = 0.0
cfg.accept = ["voice", "cw"]
cfg.lockout = [162_550_000.0]
cfg.gain = 28.0
save_default(cfg, tmp_path)
back, path = load_default(tmp_path)
assert path == tmp_path / "config.yaml"
assert back.hang_seconds == 6.0
assert back.record_seconds == 0.0
assert back.accept == ["voice", "cw"]
assert back.lockout == [162_550_000.0]
assert back.gain == 28.0
assert [r.label for r in back.ranges] == [r.label for r in cfg.ranges]
def test_every_setting_survives_a_save_and_load(tmp_path):
"""No setting may be lost or mangled by the config file."""
from bandsaunter.config import load_default, save_default
cfg = ScanConfig()
marks = {}
for s in st.SETTINGS:
current = getattr(cfg, s.key)
if s.kind == "bool":
value = not current
elif s.kind == "choice":
value = [c for c in s.choices if c != current][0]
elif s.kind == "accept_list":
value = ["cw"]
elif s.kind == "freq_list":
value = [146_520_000.0]
elif s.kind in ("text", "path"):
value = "somewhere"
elif s.kind == "gain":
value = 22.9
elif s.kind == "direct":
value = 2
elif s.kind == "opt_float":
value = 7.0
elif s.kind == "int":
value = int(max(s.minimum or 1, 3))
else:
lo = s.minimum if s.minimum is not None else 1.0
hi = s.maximum if s.maximum is not None else lo + 5.0
value = min(hi, lo + 0.5)
setattr(cfg, s.key, value)
marks[s.key] = value
save_default(cfg, tmp_path)
back, _ = load_default(tmp_path)
for key, value in marks.items():
assert getattr(back, key) == value, key
def test_a_corrupt_config_file_does_not_crash(tmp_path):
from bandsaunter.config import load_default
(tmp_path / "config.yaml").write_text("this: [is: not: valid: yaml")
cfg, path = load_default(tmp_path)
assert cfg.hang_seconds == ScanConfig().hang_seconds
assert path is None
def test_unknown_keys_in_a_config_file_are_ignored(tmp_path):
"""An old file from a newer or older version must still load."""
from bandsaunter.config import load_default
(tmp_path / "config.yaml").write_text(
"hang_seconds: 9\nsomething_removed: 5\n")
cfg, _ = load_default(tmp_path)
assert cfg.hang_seconds == 9.0
# ---------------------------------------------------------------------------
# Layering: saved settings, then a profile, then flags
# ---------------------------------------------------------------------------
def test_flags_override_saved_settings(tmp_path, monkeypatch):
from bandsaunter import cli
from bandsaunter.config import save_default
saved = ScanConfig()
saved.hang_seconds = 6.0
saved.threshold_db = 12.0
save_default(saved, tmp_path)
monkeypatch.setattr("bandsaunter.cli.load_default",
lambda: __import__("bandsaunter.config", fromlist=["x"])
.load_default(tmp_path))
args = cli.build_parser().parse_args(["scan", "-b", "2m", "--hang", "1.5"])
cfg, source = cli._build_config(args)
assert cfg.hang_seconds == 1.5, "the flag must win"
assert cfg.threshold_db == 12.0, "the saved value must survive"
assert source is not None
def test_no_config_ignores_the_saved_file(tmp_path, monkeypatch):
from bandsaunter import cli
from bandsaunter.config import save_default
saved = ScanConfig()
saved.hang_seconds = 6.0
save_default(saved, tmp_path)
monkeypatch.setattr("bandsaunter.cli.load_default",
lambda: __import__("bandsaunter.config", fromlist=["x"])
.load_default(tmp_path))
args = cli.build_parser().parse_args(["scan", "-b", "2m", "--no-config"])
cfg, source = cli._build_config(args)
assert cfg.hang_seconds == ScanConfig().hang_seconds
assert source is None