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:
commit
db3e0c79b9
39 changed files with 13473 additions and 0 deletions
510
tests/test_scanner.py
Normal file
510
tests/test_scanner.py
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
"""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"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue