bandsaunter/tests/test_scanner.py
The Dust Council db3e0c79b9 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>
2026-08-21 20:50:20 -07:00

510 lines
22 KiB
Python

"""End-to-end scanner tests driven by the built-in simulator."""
import json
import re
import wave
from pathlib import Path
import numpy as np
import pytest
from bandsaunter.config import ScanConfig
from bandsaunter.ranges import parse_range_list
from bandsaunter.scanner import Scanner, ScannerCallbacks
from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V
def run_scan(tmp_path, transmitters, ranges, **over):
dev = SimulatedDevice(transmitters=transmitters).open()
cfg = ScanConfig(ranges=parse_range_list(ranges), output_dir=str(tmp_path),
record_seconds=3.0, hang_seconds=1.0,
min_record_seconds=0.3, threshold_db=12,
dwell_seconds=0.05, max_cycles=2, revisit_seconds=0.2)
cfg.accept = list(over.pop("accept", cfg.accept))
for k, v in over.items():
setattr(cfg, k, v)
hits = []
s = Scanner(cfg, device=dev,
callbacks=ScannerCallbacks(on_record_end=hits.append))
s.prepare()
s.run()
return s, [h for h in hits if h.kept]
def test_finds_and_identifies_narrowband_fm(tmp_path):
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice", ctcss=100.0)]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M")
assert hits, "nothing recorded"
h = hits[0]
assert h.frequency == pytest.approx(146_520_000, abs=8_000)
assert h.category == "voice"
assert h.family == "nfm", h.classification
assert "voice" in h.classification.lower()
assert h.ctcss_hz == pytest.approx(100.0, abs=0.5)
def test_record_seconds_is_honoured_exactly(tmp_path):
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=2.0)
assert hits
for h in hits:
assert h.duration == pytest.approx(2.0, abs=0.15)
assert "record limit" in h.stop_reason
def test_hang_seconds_ends_a_finished_transmission(tmp_path):
"""A transmitter that stops must release the scanner after the hang time."""
tx = [V(146_520_000, "nfm", 0.4, 12_500, "burst",
period_seconds=6.0, on_seconds=1.0)]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M",
record_seconds=20.0, hang_seconds=0.8, max_cycles=3)
assert hits, "nothing recorded"
ended_on_silence = [h for h in hits if "quiet" in h.stop_reason]
assert ended_on_silence, [h.stop_reason for h in hits]
for h in ended_on_silence:
assert h.duration < 20.0
def test_min_record_seconds_discards_blips(tmp_path):
"""A transmission shorter than the minimum must leave nothing behind."""
tx = [V(146_520_000, "nfm", 0.5, 12_500, "blip",
period_seconds=3.0, on_seconds=0.35)]
dev = SimulatedDevice(transmitters=tx).open()
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
output_dir=str(tmp_path), record_seconds=20.0,
min_record_seconds=6.0, hang_seconds=0.5,
threshold_db=12, dwell_seconds=0.05, max_cycles=4,
revisit_seconds=0.1)
all_hits = []
s = Scanner(cfg, device=dev,
callbacks=ScannerCallbacks(on_record_end=all_hits.append))
s.prepare()
s.run()
assert all_hits, "the blip was never detected"
assert not any(h.kept for h in all_hits)
assert s.stats.discarded >= 1
# Nothing should have been left behind on disk.
assert not list(tmp_path.glob("*/*/audio.wav"))
def test_invalid_config_is_rejected_before_touching_the_radio(tmp_path):
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
output_dir=str(tmp_path),
record_seconds=5.0, min_record_seconds=9.0)
s = Scanner(cfg, device=SimulatedDevice().open())
with pytest.raises(ValueError, match="min_record_seconds"):
s.prepare()
def test_decodes_morse_from_a_cw_beacon(tmp_path):
tx = [V(144_100_000, "cw", 0.35, 500, "beacon", wpm=18,
message="VVV DE W1AW")]
# Several sweeps, because a 50 ms dwell can easily land in a key-up gap.
s, hits = run_scan(tmp_path, tx, "144.05M-144.15M",
record_seconds=10.0, hang_seconds=4.0, max_cycles=8,
revisit_seconds=0.1)
assert hits
h = hits[0]
assert h.mode == "cw", "should have chosen the CW demodulator"
assert "W1AW" in h.morse_text or "VVV" in h.morse_text, h.morse_text
assert h.morse_wpm == pytest.approx(18, rel=0.15)
def test_lockout_is_respected(tmp_path):
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
dev = SimulatedDevice(transmitters=tx).open()
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
output_dir=str(tmp_path), record_seconds=2.5,
threshold_db=12, dwell_seconds=0.05, max_cycles=2,
lockout=[146_520_000.0])
s = Scanner(cfg, device=dev)
s.prepare()
s.run()
assert s.stats.recordings == 0
def test_writes_audio_iq_and_metadata(tmp_path):
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M",
record_seconds=2.5, save_iq=True, max_cycles=1)
assert hits
h = hits[0]
with wave.open(h.audio_path) as w:
assert w.getnframes() / w.getframerate() == pytest.approx(2.5, abs=0.2)
assert w.getnchannels() == 1
iq = np.fromfile(h.iq_path, dtype=np.complex64)
assert iq.size > 0
meta = json.loads(open(h.meta_path).read())
assert meta["frequency_hz"] == pytest.approx(h.frequency)
assert meta["hit"]["classification"]
sigmf = json.loads(open(h.iq_path.replace(".cf32", ".sigmf-meta")).read())
assert sigmf["global"]["core:datatype"] == "cf32_le"
# the run log
assert (tmp_path / "scan_log.jsonl").exists()
lines = (tmp_path / "scan_log.csv").read_text().strip().split("\n")
assert len(lines) >= 2
def test_multiple_signals_in_one_step_all_get_visited(tmp_path):
tx = [V(146_500_000, "nfm", 0.40, 12_500, "a"),
V(146_800_000, "nfm", 0.30, 12_500, "b"),
V(147_100_000, "carrier", 0.25, 1_000, "c")]
s, hits = run_scan(tmp_path, tx, "146.4M-147.2M", record_seconds=2.5,
max_cycles=2, accept=["voice", "cw", "digital", "carrier"])
found = {round(h.frequency / 1e5) for h in hits}
assert len(found) >= 3, f"only found {found}"
def test_unreachable_frequencies_are_skipped_not_fatal(tmp_path):
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
# 2.5 GHz is beyond the tuner; the scan must still run the reachable part.
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M, 2500M-2600M",
record_seconds=2.5, max_cycles=1)
assert hits
# ---------------------------------------------------------------------------
# The content gate: only voice, CW and digital should reach the disk.
# ---------------------------------------------------------------------------
def test_static_is_never_recorded(tmp_path):
"""An empty band must produce no files, whatever the squelch does."""
dev = SimulatedDevice(transmitters=[], noise_amplitude=0.05).open()
cfg = ScanConfig(ranges=parse_range_list("146M-148M"),
output_dir=str(tmp_path), record_seconds=4.0,
hang_seconds=1.0, threshold_db=2.0, # deliberately wide open
dwell_seconds=0.05, max_cycles=2, revisit_seconds=0.1)
hits = []
s = Scanner(cfg, device=dev,
callbacks=ScannerCallbacks(on_record_end=hits.append))
s.prepare()
s.run()
assert not any(h.kept for h in hits), "static was recorded"
assert not list(tmp_path.glob("*/*/audio.wav"))
def test_bare_carrier_is_rejected_by_default(tmp_path):
tx = [V(146_520_000, "carrier", 0.4, 1_000, "dead carrier")]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=3.0)
assert not hits, "an unmodulated carrier is not a signal worth keeping"
assert not list(tmp_path.glob("*/*/audio.wav"))
def test_bare_carrier_kept_when_asked_for(tmp_path):
tx = [V(146_520_000, "carrier", 0.4, 1_000, "dead carrier")]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=3.0,
accept=["voice", "cw", "digital", "carrier"])
assert hits and hits[0].category == "carrier"
@pytest.mark.parametrize("mode,bw,category", [
("nfm", 12_500, "voice"),
("am", 8_000, "voice"),
("fsk4", 12_500, "digital"),
("fsk2", 12_500, "digital"),
])
def test_real_signals_are_categorised_and_kept(tmp_path, mode, bw, category):
tx = [V(146_520_000, mode, 0.4, bw, "tx", baud=4800 if mode == "fsk4" else 1200,
deviation=1800 if mode.startswith("fsk") else 2500)]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=4.0)
assert hits, f"{mode} was rejected"
assert hits[0].category == category, \
f"{mode} -> {hits[0].category}: {hits[0].content_reason}"
def test_keep_everything_disables_the_gate(tmp_path):
"""--keep-everything restores plain power-threshold behaviour."""
dev = SimulatedDevice(transmitters=[], noise_amplitude=0.05).open()
cfg = ScanConfig(ranges=parse_range_list("146M-148M"),
output_dir=str(tmp_path), record_seconds=1.0,
hang_seconds=0.5, threshold_db=1.0, min_record_seconds=0.2,
dwell_seconds=0.05, max_cycles=2, revisit_seconds=0.1,
require_signal=False, detector_bias_db=0.0)
hits = []
s = Scanner(cfg, device=dev,
callbacks=ScannerCallbacks(on_record_end=hits.append))
s.prepare()
s.run()
assert s.stats.detections > 0, "threshold was not wide open enough"
def test_filenames_are_flat_dated_and_named_for_the_modulation(tmp_path):
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=3.0)
assert hits
wavs = list(tmp_path.glob("*.wav"))
assert wavs, "no wav written at the top level"
assert not list(tmp_path.glob("*/*.wav")), "files should not be in subfolders"
name = wavs[0].name
assert re.match(r"^\d{4}\.\d{6}MHz--\d{4}-\d{2}-\d{2}_"
r"\d{2}_\d{2}_\d{2}-[a-z0-9-]+\.wav$", name), name
assert name.endswith("-nfm.wav"), name
assert name.startswith("0146.5"), name
# every artefact of one capture shares a stem
stem = wavs[0].stem
assert (tmp_path / f"{stem}.json").exists()
def test_recorded_length_matches_the_requested_record_time(tmp_path):
"""The WAV must hold as many seconds as the scanner says it captured.
A mismatch here is what makes a recording play at the wrong speed.
"""
tx = [V(146_520_000, "nfm", 0.4, 12_500, "voice")]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=3.0)
assert hits
h = hits[0]
with wave.open(h.audio_path) as w:
seconds = w.getnframes() / w.getframerate()
assert seconds == pytest.approx(3.0, abs=0.15)
assert seconds == pytest.approx(h.duration, abs=0.02)
# ---------------------------------------------------------------------------
# Two-way conversations: natural pauses must not end the capture.
# ---------------------------------------------------------------------------
def _two_way(freq=146_520_000):
"""Two stations alternating on one frequency with a 1.5 s gap between overs.
A is up for [0, 2.5), B for [4, 6.5), repeating every 8 s.
"""
return [
V(freq, "nfm", 0.40, 12_500, "station A",
period_seconds=8.0, on_seconds=2.5, phase_offset=0.0),
V(freq, "nfm", 0.35, 12_500, "station B", pitch_hz=190.0,
period_seconds=8.0, on_seconds=2.5, phase_offset=4.0),
]
def test_a_pause_between_overs_does_not_end_the_capture(tmp_path):
"""With hang longer than the gap, both overs land in one recording."""
s, hits = run_scan(tmp_path, _two_way(), "146.4M-146.6M",
record_seconds=0.0, hang_seconds=3.0,
max_record_seconds=14.0, max_cycles=1)
assert hits, "nothing recorded"
h = hits[0]
# Must span the 1.5 s gap and reach the second station's over.
assert h.duration > 6.0, \
f"capture ended after {h.duration:.1f}s, so it cut at the pause"
assert len(list(tmp_path.glob("*.wav"))) == 1, \
"the exchange was split into separate files"
def test_a_short_hang_stops_at_the_first_pause(tmp_path):
"""With hang shorter than the gap, the capture ends when the over does."""
s, hits = run_scan(tmp_path, _two_way(), "146.4M-146.6M",
record_seconds=0.0, hang_seconds=0.8,
max_record_seconds=14.0, max_cycles=1)
assert hits
h = hits[0]
assert h.duration < 5.0, f"ran {h.duration:.1f}s past a {0.8}s hang"
assert "quiet" in h.stop_reason or "no signal" in h.stop_reason
def test_an_established_conversation_is_never_abandoned_as_noise(tmp_path):
"""Once speech is heard, a quiet spell must not trigger the content abort."""
s, hits = run_scan(tmp_path, _two_way(), "146.4M-146.6M",
record_seconds=0.0, hang_seconds=3.0,
verify_max_seconds=2.0, # would fire during the gap
max_record_seconds=14.0, max_cycles=1)
assert hits, "an established conversation was thrown away"
assert hits[0].category == "voice"
def test_max_record_seconds_bounds_an_unlimited_capture(tmp_path):
"""--record 0 must still not run forever."""
tx = [V(146_520_000, "nfm", 0.4, 12_500, "continuous")]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=0.0,
hang_seconds=5.0, max_record_seconds=5.0, max_cycles=1)
assert hits
assert hits[0].duration == pytest.approx(5.0, abs=0.3)
assert "safety limit" in hits[0].stop_reason
def test_interference_during_a_gap_does_not_hold_the_capture_open(tmp_path):
"""Static breaking squelch mid-pause must still count as quiet.
Otherwise a burst of noise after the conversation ends keeps the receiver
parked on a dead channel indefinitely.
"""
tx = [V(146_520_000, "nfm", 0.40, 12_500, "voice",
period_seconds=40.0, on_seconds=3.0, phase_offset=0.0),
V(146_520_000, "carrier", 0.35, 1_000, "interference",
period_seconds=40.0, on_seconds=8.0, phase_offset=36.0)]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=0.0,
hang_seconds=2.0, max_record_seconds=20.0, max_cycles=1)
assert hits
h = hits[0]
# The carrier holds squelch open until 12 s; the capture must not wait it
# out. Some lag is unavoidable -- deciding a signal carries nothing takes
# a couple of seconds of evidence -- but it must be well short of that.
assert h.duration < 10.0, f"ran {h.duration:.1f}s on an empty carrier"
assert h.duration > 3.0, "cut the real speech short"
assert "silence, static or interference" in h.stop_reason
def test_being_cut_off_mid_transmission_is_reported(tmp_path):
"""The record limit silently truncating a live signal must be visible.
Otherwise the file just ends and there is nothing to say the hang time was
never the thing that stopped it.
"""
tx = [V(146_520_000, "nfm", 0.4, 12_500, "continuous")]
notes = []
dev = SimulatedDevice(transmitters=tx).open()
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
output_dir=str(tmp_path), record_seconds=4.0,
hang_seconds=3.0, threshold_db=12, dwell_seconds=0.05,
max_cycles=1, revisit_seconds=0.2)
hits = []
s = Scanner(cfg, device=dev,
callbacks=ScannerCallbacks(on_record_end=hits.append,
on_status=notes.append))
s.prepare()
s.run()
assert hits and "record limit" in hits[0].stop_reason
assert s.stats.truncated == 1
assert any("--record 0" in n for n in notes), notes
def test_no_warning_when_the_signal_really_ended(tmp_path):
tx = [V(146_520_000, "nfm", 0.4, 12_500, "burst",
period_seconds=12.0, on_seconds=2.0)]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=20.0,
hang_seconds=1.0, max_cycles=3, revisit_seconds=0.1)
assert s.stats.truncated == 0
def test_record_zero_follows_a_transmission_past_the_old_limit(tmp_path):
tx = [V(146_520_000, "nfm", 0.4, 12_500, "continuous")]
s, hits = run_scan(tmp_path, tx, "146.4M-146.6M", record_seconds=0.0,
hang_seconds=3.0, max_record_seconds=9.0, max_cycles=1)
assert hits
assert hits[0].duration > 8.0, "stopped early despite no record limit"
assert s.stats.truncated == 0
# ---------------------------------------------------------------------------
# One file per frequency
# ---------------------------------------------------------------------------
def _two_channels():
return [V(146_520_000, "nfm", 0.40, 12_500, "A",
period_seconds=6.0, on_seconds=2.0, phase_offset=0.0),
V(147_100_000, "nfm", 0.35, 12_500, "B", pitch_hz=190.0,
period_seconds=6.0, on_seconds=2.0, phase_offset=3.0)]
def test_combining_gives_one_file_per_frequency(tmp_path):
s, hits = run_scan(tmp_path, _two_channels(), "146.4M-147.2M",
record_seconds=3.0, hang_seconds=1.0, max_cycles=4,
revisit_seconds=0.1, combine_by_frequency=True)
assert len(hits) >= 3, "not enough transmissions to test combining"
wavs = sorted(p.name for p in tmp_path.glob("*.wav"))
assert len(wavs) == 2, wavs
assert all(name.endswith("MHz.wav") for name in wavs), wavs
# every capture went into one of them
assert all(h.combined_path for h in hits)
def test_combining_removes_the_separate_files_by_default(tmp_path):
s, hits = run_scan(tmp_path, _two_channels(), "146.4M-147.2M",
record_seconds=3.0, max_cycles=3, revisit_seconds=0.1,
combine_by_frequency=True)
assert hits
dated = [p for p in tmp_path.glob("*.wav") if "--" in p.name]
assert not dated, f"per-transmission files left behind: {dated}"
# the metadata for each capture is still there
assert len(list(tmp_path.glob("*.json"))) >= len(hits)
def test_combining_can_keep_the_separate_files_too(tmp_path):
s, hits = run_scan(tmp_path, _two_channels(), "146.4M-147.2M",
record_seconds=3.0, max_cycles=3, revisit_seconds=0.1,
combine_by_frequency=True, combine_keep_individual=True)
assert hits
dated = [p for p in tmp_path.glob("*.wav") if "--" in p.name]
assert len(dated) == len(hits)
def test_a_combined_file_grows_with_each_reception(tmp_path):
from bandsaunter.recorder import read_wav
# Continuously active, so each sweep produces another capture and the
# combined file has to grow.
tx = [V(146_520_000, "nfm", 0.4, 12_500, "A")]
sizes = []
def note(hit):
if hit.kept and hit.combined_path:
sizes.append(read_wav(Path(hit.combined_path))[0].size)
dev = SimulatedDevice(transmitters=tx).open()
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
output_dir=str(tmp_path), record_seconds=2.0,
hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05,
max_cycles=3, revisit_seconds=0.05,
combine_by_frequency=True, announce_timestamps=False)
s = Scanner(cfg, device=dev, callbacks=ScannerCallbacks(on_record_end=note))
s.prepare()
s.run()
assert len(sizes) >= 2, sizes
assert sizes == sorted(sizes) and sizes[-1] > sizes[0], sizes
def test_combining_off_keeps_the_old_layout(tmp_path):
s, hits = run_scan(tmp_path, _two_channels(), "146.4M-147.2M",
record_seconds=3.0, max_cycles=2, revisit_seconds=0.1)
assert hits
assert not [p for p in tmp_path.glob("*.wav") if p.name.endswith("MHz.wav")]
assert all(not h.combined_path for h in hits)
def test_a_complete_band_demodulates_each_segment_correctly(tmp_path):
"""One sweep of 2 m must handle its CW, SSB and FM segments each properly.
That is the whole point of a complete-band entry: the band is not one
mode, so scanning it as one would demodulate two thirds of it wrongly.
"""
tx = [V(144_050_000, "cw", 0.35, 500, "beacon", wpm=18,
message="VVV DE W1AW"),
V(144_200_000, "usb", 0.35, 3_000, "ssb"),
V(146_520_000, "nfm", 0.40, 12_500, "fm", ctcss=100.0)]
s, hits = run_scan(tmp_path, tx, "2m-complete", record_seconds=6.0,
hang_seconds=2.0, max_cycles=2, revisit_seconds=0.2)
def segment(freq):
if freq < 144.1e6:
return "cw"
return "ssb" if freq < 144.3e6 else "fm"
seen = {segment(h.frequency): (h.mode, h.category) for h in hits}
assert seen.get("cw") == ("cw", "cw"), seen
assert seen.get("ssb") == ("usb", "voice"), seen
assert seen.get("fm") == ("nfm", "voice"), seen
def test_frequencies_are_padded_so_names_sort_by_frequency(tmp_path):
"""Unpadded, a directory listing puts 1090 MHz before 146 MHz."""
from bandsaunter.recorder import build_stem, safe_freq_name
import datetime
assert safe_freq_name(146_520_000) == "0146.520000MHz"
assert safe_freq_name(1_090_000_000) == "1090.000000MHz"
assert safe_freq_name(1_800_000) == "0001.800000MHz"
when = datetime.datetime(2026, 8, 21, 19, 35, 48).timestamp()
names = [build_stem(when, hz, "nfm") for hz in
(1_090_000_000, 146_520_000, 98_295_546, 1_800_000, 14_058_000)]
# sorted as text must equal sorted by frequency
by_text = sorted(names)
by_freq = [build_stem(when, hz, "nfm") for hz in
sorted((1_090_000_000, 146_520_000, 98_295_546, 1_800_000,
14_058_000))]
assert by_text == by_freq
def test_combined_files_are_padded_too(tmp_path):
from bandsaunter.recorder import FrequencyLog
log = FrequencyLog(tmp_path, announce=False)
log.add(1_090_000_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
log.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
names = sorted(p.name for p in tmp_path.glob("*.wav"))
assert names == ["0146.520000MHz.wav", "1090.000000MHz.wav"]