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

37
tests/morse_gen.py Normal file
View file

@ -0,0 +1,37 @@
"""Generate correctly-timed Morse audio for testing (1/3/7 unit spacing)."""
import numpy as np
from bandsaunter.morse import encode_morse
def morse_keying(msg: str, wpm: float, fs: float) -> np.ndarray:
"""Key-down/key-up waveform with ITU timing: element 1, letter 3, word 7."""
dot = 1.2 / wpm
segs = [(0, 8)] # lead-in silence
letters = encode_morse(msg).split(" ")
for i, tok in enumerate(letters):
if tok == "/":
segs.append((0, 7)) # word gap
continue
if i > 0 and letters[i - 1] != "/":
segs.append((0, 3)) # letter gap
for j, el in enumerate(tok):
if j > 0:
segs.append((0, 1)) # element gap
segs.append((1, 1 if el == "." else 3))
segs.append((0, 8))
return np.concatenate([np.full(max(1, int(u * dot * fs)), float(s))
for s, u in segs])
def morse_audio(msg: str, wpm: float, fs: float = 16000.0, snr_db: float = 20.0,
tone: float = 700.0, seed: int = 0) -> np.ndarray:
rng = np.random.default_rng(seed)
env = morse_keying(msg, wpm, fs)
# Soften the keying edges the way a real transmitter's shaping does.
k = max(3, int(0.006 * fs))
env = np.convolve(env, np.hanning(k), "same")
env /= max(env.max(), 1e-9)
t = np.arange(env.size) / fs
x = env * np.sin(2 * np.pi * tone * t)
n = np.sqrt(np.mean(x ** 2) / (10 ** (snr_db / 10.0)))
return x + n * rng.standard_normal(x.size)

76
tests/signals.py Normal file
View file

@ -0,0 +1,76 @@
"""Synthetic test signals shared by the test modules."""
import numpy as np
from scipy.signal import butter, hilbert, lfilter
FS = 32000.0
def _noise(x, snr_db, rng):
p = float(np.mean(np.abs(x) ** 2))
n = np.sqrt(p / (2 * 10 ** (snr_db / 10.0)))
return (x + n * (rng.standard_normal(x.size)
+ 1j * rng.standard_normal(x.size))).astype(np.complex64)
def voice(n, fs=FS, seed=0):
"""Band-limited noise with a syllabic envelope -- a good speech stand-in."""
rng = np.random.default_rng(seed)
b, a = butter(4, [300 / (fs / 2), 2700 / (fs / 2)], btype="band")
v = lfilter(b, a, rng.standard_normal(n))
v /= max(np.abs(v).max(), 1e-9)
t = np.arange(n) / fs
return v * (0.4 + 0.6 * np.abs(np.sin(2 * np.pi * 1.7 * t)))
def make(kind, n=64000, fs=FS, snr_db=30.0, seed=3):
rng = np.random.default_rng(seed)
t = np.arange(n) / fs
v = voice(n, fs, seed)
if kind == "nfm":
msg = v + 0.15 * np.sin(2 * np.pi * 100.0 * t)
x = np.exp(1j * np.cumsum(2 * np.pi * 2500 * msg / fs))
elif kind == "wfm":
x = np.exp(1j * np.cumsum(2 * np.pi * 3000 * v / fs))
elif kind == "am":
x = ((1 + 0.6 * v) * np.exp(2j * np.pi * 30 * t))
elif kind == "usb":
x = 0.5 * hilbert(v)
elif kind == "lsb":
x = 0.5 * np.conj(hilbert(v))
elif kind == "carrier":
x = np.exp(2j * np.pi * 137 * t)
elif kind == "cw":
dot = 0.08
pat = [1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0]
key = np.zeros(n)
i = 0
while i < n:
for b in pat:
m = int(dot * fs)
if i + m > n:
break
key[i:i + m] = b
i += m
env = np.convolve(key, np.hanning(int(0.005 * fs)), "same")
env /= max(env.max(), 1e-9)
x = env * np.exp(2j * np.pi * 300 * t)
elif kind.startswith("fsk"):
levels = int(kind[3])
baud, dev = (1200.0, 2400.0) if levels == 2 else (4800.0, 1800.0)
sp = int(fs / baud)
sym = rng.integers(0, levels, n // sp + 1)
lv = (sym - (levels - 1) / 2) / max(1, (levels - 1) / 2)
f = np.resize(np.repeat(lv, sp) * dev, n)
x = np.exp(1j * np.cumsum(2 * np.pi * f / fs))
elif kind.startswith("psk"):
m = int(kind[3])
sp = int(fs / 4800.0)
sym = rng.integers(0, m, n // sp + 1)
x = np.exp(1j * np.resize(np.repeat(2 * np.pi * sym / m, sp), n))
elif kind == "noise":
return (0.01 * (rng.standard_normal(n)
+ 1j * rng.standard_normal(n))).astype(np.complex64)
else:
raise ValueError(kind)
return _noise(np.asarray(x, dtype=np.complex128), snr_db, rng)

52
tests/speech.py Normal file
View file

@ -0,0 +1,52 @@
"""Synthetic speech for testing the voice detector.
A glottal pulse train with jitter, shaped by three formant resonators and
gated into syllables with pauses between phrases -- the structure a speech
detector is supposed to key on, without needing a recorded voice sample.
"""
import numpy as np
from scipy import signal as sps
def synth_speech(seconds=4.0, fs=16000.0, pitch=120.0, seed=0,
syllable_rate=4.0, pause_fraction=0.25):
rng = np.random.default_rng(seed)
n = int(seconds * fs)
t = np.arange(n) / fs
# Glottal excitation: pulse train with natural pitch drift and jitter.
f0 = pitch * (1.0 + 0.06 * np.sin(2 * np.pi * 0.7 * t)
+ 0.02 * rng.standard_normal(n).cumsum() / np.sqrt(n))
phase = np.cumsum(2 * np.pi * f0 / fs)
exc = np.zeros(n)
pulses = np.flatnonzero(np.diff(np.floor(phase / (2 * np.pi))) > 0)
exc[pulses] = 1.0
exc -= exc.mean()
# Three formants, swept so the spectrum moves the way articulation does.
out = np.zeros(n)
for base, bw, amp in ((650.0, 90.0, 1.0), (1200.0, 110.0, 0.55),
(2600.0, 160.0, 0.30)):
sweep = base * (1.0 + 0.22 * np.sin(2 * np.pi * 1.9 * t
+ rng.uniform(0, 6.283)))
# Piecewise-constant formant, cheap but spectrally honest.
seg = int(fs * 0.04)
y = np.zeros(n)
for s in range(0, n - seg, seg):
fc = float(np.mean(sweep[s:s + seg]))
b, a = sps.iirpeak(min(fc, fs / 2 * 0.95) / (fs / 2),
max(2.0, fc / bw))
y[s:s + seg] = sps.lfilter(b, a, exc[s:s + seg])
out += amp * y
# Voiced/unvoiced: add fricative noise on some syllables.
out += 0.05 * rng.standard_normal(n) * (np.abs(np.sin(2 * np.pi * 2.3 * t)) > 0.8)
# Syllabic gating plus phrase pauses.
syl = 0.5 + 0.5 * np.sin(2 * np.pi * syllable_rate * t)
phrase = (np.sin(2 * np.pi * 0.35 * t) > (-1 + 2 * pause_fraction)).astype(float)
phrase = sps.lfilter(np.ones(int(0.02 * fs)) / int(0.02 * fs), [1.0], phrase)
out *= syl * phrase
out /= max(np.abs(out).max(), 1e-9)
return out * 0.7

321
tests/test_announce.py Normal file
View file

@ -0,0 +1,321 @@
"""Spoken announcements and the files they are appended to."""
from datetime import datetime
import numpy as np
import pytest
from bandsaunter import announce
from bandsaunter.recorder import FrequencyLog, append_wav, read_wav
from bandsaunter.quality import voice_metrics
# ---------------------------------------------------------------------------
# Words
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("n,words", [
(0, "zero"), (7, "seven"), (10, "ten"), (12, "twelve"), (19, "nineteen"),
(20, "twenty"), (21, "twenty one"), (45, "forty five"), (59, "fifty nine"),
(100, "one hundred"), (146, "one hundred forty six"),
(2026, "two thousand twenty six"),
])
def test_number_words(n, words):
assert " ".join(announce.number_words(n)) == words
def test_timestamp_phrase_reads_naturally():
phrase = announce.timestamp_phrase(datetime(2026, 8, 21, 14, 38, 5))
assert "august" in phrase
assert "twenty one" in phrase
assert "twenty twenty six" in phrase # years are said in pairs
assert "fourteen thirty eight" in phrase
assert "oh five" in phrase # 05 seconds, not "five"
def test_midnight_and_single_digits():
phrase = announce.timestamp_phrase(datetime(2026, 1, 3, 0, 5, 0))
assert "january three" in phrase
assert "oh oh" in phrase # hour 00 and second 00
def test_frequency_is_spoken_digit_by_digit():
phrase = announce.timestamp_phrase(datetime(2026, 1, 1, 0, 0, 0),
frequency=146_520_000.0)
assert "one hundred forty six point one four six" not in phrase
assert "point five two zero" in phrase
assert "megahertz" in phrase
def test_every_word_in_the_vocabulary_can_be_spoken():
"""A word with no pronunciation would be silently dropped."""
for word in announce.WORDS:
for phone in announce.WORDS[word].split():
assert phone in announce.PHONEMES, f"{word} uses unknown {phone}"
def test_all_the_words_a_timestamp_needs_are_covered():
for month in range(1, 13):
for day in (1, 9, 15, 21, 28): # 28 is safe in every month
for hour in (0, 5, 12, 23):
phrase = announce.timestamp_phrase(
datetime(2026, month, day, hour, 45, 9))
for word in phrase.split():
assert word in ("_", "__") or word in announce.WORDS, word
# ---------------------------------------------------------------------------
# Synthesis
# ---------------------------------------------------------------------------
def _lpc_formants(x, fs, order=12):
x = x - x.mean()
x = np.append(x[0], x[1:] - 0.97 * x[:-1]) * np.hamming(x.size)
r = np.correlate(x, x, "full")[x.size - 1:x.size - 1 + order + 1]
if r[0] == 0:
return []
a = np.zeros(order + 1)
a[0], e = 1.0, r[0]
for i in range(1, order + 1):
k = -(a[:i] @ r[i:0:-1]) / e
a[:i + 1] = (np.concatenate([a[:i], [0]])
+ k * np.concatenate([[0], a[i - 1::-1]]))
e *= (1 - k * k)
if e <= 0:
break
roots = [z for z in np.roots(a) if np.imag(z) > 0.01]
return [f for f in sorted(float(np.angle(z) * fs / (2 * np.pi))
for z in roots) if 120 < f < 5000]
@pytest.mark.parametrize("vowel", ["iy", "ih", "eh", "ae", "aa", "ao",
"uw", "uh", "ah", "er"])
def test_vowels_land_on_their_formant_targets(vowel):
"""Intelligibility rests on the formants being where they are meant to be.
Summing resonators in parallel instead of cascading them loses the first
formant entirely, and every vowel then sounds the same.
"""
spec = announce.PHONEMES[vowel]
announce.WORDS["_probe_"] = vowel
audio = announce.synthesize("_probe_", 16000)
seg = audio[int(audio.size * 0.35):][:800]
formants = _lpc_formants(seg, 16000)
assert len(formants) >= 2, f"{vowel}: found {formants}"
assert abs(formants[0] - spec.f1) < 130, f"{vowel} F1: {formants}"
assert abs(formants[1] - spec.f2) < 250, f"{vowel} F2: {formants}"
def test_speech_is_in_the_voice_band():
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5))
n = 1 << 14
spec = np.abs(np.fft.rfft(audio[:n] * np.hanning(n))) ** 2
freqs = np.fft.rfftfreq(n, 1 / 16000)
band = (freqs >= 250) & (freqs < 3400)
assert spec[band].sum() / spec.sum() > 0.6
def test_the_announcement_reads_as_speech():
"""The voice detector should hear the announcement as a voice."""
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5))
m = voice_metrics(audio, 16000)
assert m.score > 0.6, m.describe()
assert 80 < m.pitch_hz < 200
def test_synthesis_is_fast_enough_to_run_inline():
import time
t0 = time.perf_counter()
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5))
elapsed = time.perf_counter() - t0
assert elapsed < 0.25 * audio.size / 16000, f"{elapsed:.2f}s"
def test_unknown_words_do_not_crash():
assert announce.synthesize("gibberishwordnotinvocabulary", 16000).size == 0
# ---------------------------------------------------------------------------
# Appending
# ---------------------------------------------------------------------------
def test_a_wav_stays_valid_after_every_append(tmp_path):
"""A scan can be stopped at any moment; the file so far must still play."""
path = tmp_path / "acc.wav"
for i in range(1, 5):
append_wav(path, np.full(1600, 0.1 * i, dtype=np.float32), 16000)
audio, rate = read_wav(path)
assert rate == 16000
assert audio.size == 1600 * i
import wave
with wave.open(str(path)) as w: # the standard reader must agree
assert w.getnframes() == 1600 * i
def test_appending_a_different_rate_is_refused(tmp_path):
path = tmp_path / "acc.wav"
append_wav(path, np.zeros(160, dtype=np.float32), 16000)
with pytest.raises(ValueError, match="16000"):
append_wav(path, np.zeros(160, dtype=np.float32), 32000)
def test_frequency_log_groups_nearby_receptions(tmp_path):
log = FrequencyLog(tmp_path, tolerance_hz=6250.0, announce=False)
audio = np.full(1600, 0.2, dtype=np.float32)
log.add(146_520_040.0, audio, 16000)
log.add(146_519_800.0, audio, 16000) # 240 Hz away: same channel
log.add(147_100_000.0, audio, 16000) # far away: its own file
files = sorted(p.name for p in tmp_path.glob("*.wav"))
assert len(files) == 2, files
assert log.appended == 3
def test_frequency_log_resamples_a_mismatched_capture(tmp_path):
log = FrequencyLog(tmp_path, announce=False)
log.add(146_520_000.0, np.full(16000, 0.2, dtype=np.float32), 16000)
log.add(146_520_000.0, np.full(32000, 0.2, dtype=np.float32), 32000)
audio, rate = read_wav(log.files[0])
assert rate == 16000
# one second at each rate, plus the gaps between them
assert 2.0 <= audio.size / rate <= 3.0
def test_announcement_is_placed_before_each_recording(tmp_path):
quiet = FrequencyLog(tmp_path / "a", announce=False)
spoken = FrequencyLog(tmp_path / "b", announce=True)
body = np.full(16000, 0.2, dtype=np.float32)
quiet.add(146_520_000.0, body, 16000, when=datetime(2026, 8, 21, 14, 38, 5))
spoken.add(146_520_000.0, body, 16000, when=datetime(2026, 8, 21, 14, 38, 5))
short, _ = read_wav(quiet.files[0])
long_, rate = read_wav(spoken.files[0])
assert long_.size > short.size + 2 * rate, "no announcement was inserted"
# the recording is a steady 0.2; the announcement is not, and comes first
assert voice_metrics(long_[:int(3 * rate)], rate).score > 0.5
def test_an_existing_file_from_an_earlier_run_is_continued(tmp_path):
first = FrequencyLog(tmp_path, announce=False)
first.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
before = read_wav(first.files[0])[0].size
second = FrequencyLog(tmp_path, announce=False)
second.add(146_520_000.0, np.full(1600, 0.2, dtype=np.float32), 16000)
after = read_wav(second.files[0])[0].size
assert after > before, "a later run started a new file instead of appending"
assert len(list(tmp_path.glob("*.wav"))) == 1
def test_the_date_is_only_spoken_when_it_changes(tmp_path):
"""Repeating the date before every over takes longer than most overs last."""
log = FrequencyLog(tmp_path, announce=True)
body = np.full(16000, 0.2, dtype=np.float32)
lengths = []
previous = 0
for when in (datetime(2026, 8, 21, 14, 38, 5),
datetime(2026, 8, 21, 14, 41, 0),
datetime(2026, 8, 22, 9, 2, 0)):
log.add(146_520_000.0, body, 16000, when=when)
size = read_wav(log.files[0])[0].size
lengths.append(size - previous)
previous = size
first, same_day, next_day = lengths
assert same_day < first * 0.7, "the date was repeated needlessly"
assert next_day > same_day * 1.5, "a new day should get the full date"
def test_time_only_announcement_is_shorter():
when = datetime(2026, 8, 21, 14, 38, 5)
full = announce.speak_timestamp(when, 16000, with_date=True)
brief = announce.speak_timestamp(when, 16000, with_date=False)
assert brief.size < full.size
assert brief.size > 8000, "the time itself must still be spoken"
# ---------------------------------------------------------------------------
# Installed engines
# ---------------------------------------------------------------------------
HAVE_ENGINE = announce.available_engine() is not None
needs_engine = pytest.mark.skipif(not HAVE_ENGINE,
reason="no text-to-speech program installed")
def test_engine_detection_does_not_throw():
engine = announce.available_engine()
assert engine is None or engine in announce.ENGINES
@pytest.mark.parametrize("when", [
datetime(2026, 8, 21, 14, 38, 5),
datetime(2026, 1, 3, 9, 5, 0),
datetime(2026, 12, 31, 23, 59, 59),
])
def test_engine_text_avoids_the_forms_engines_misread(when):
"""Punctuation is what gives an engine its phrasing, and the obvious
spellings are traps: a colon makes espeak read 14:38:05 as "fourteen
thirty, eight zero five", and an ISO date has its dashes read aloud."""
text = announce.timestamp_text(when)
assert ":" not in text
assert "-" not in text
assert text.count(",") >= 2, text # date, year and time separated
assert "twenty twenty six" in text # not "two thousand and ..."
def test_engine_text_covers_the_time_only_case():
text = announce.timestamp_text(datetime(2026, 8, 21, 9, 5, 0),
with_date=False)
assert "August" not in text
assert "09 05" in text
@needs_engine
def test_installed_engine_renders_speech():
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), 16000,
engine="auto")
assert audio.size > 16000, "suspiciously short"
assert audio.dtype == np.float32
m = voice_metrics(audio, 16000)
assert m.score > 0.6, m.describe()
@needs_engine
@pytest.mark.parametrize("rate", [8000, 16000, 32000, 48000])
def test_installed_engine_is_resampled_to_the_asked_for_rate(rate):
"""Engines render at their own rate; espeak-ng uses 22050 Hz."""
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), rate,
engine="auto")
seconds = audio.size / rate
assert 2.0 < seconds < 12.0, f"{seconds:.2f}s at {rate} Hz"
@needs_engine
def test_both_engines_produce_the_same_level():
"""Switching engines must not change how loud announcements are."""
when = datetime(2026, 8, 21, 14, 38, 5)
external = announce.speak_timestamp(when, 16000, engine="auto")
builtin = announce.speak_timestamp(when, 16000, engine="builtin")
assert abs(float(np.abs(external).max())
- float(np.abs(builtin).max())) < 0.05
@needs_engine
def test_dropping_the_date_shortens_the_announcement():
when = datetime(2026, 8, 21, 14, 38, 5)
full = announce.speak_timestamp(when, 16000, engine="auto")
brief = announce.speak_timestamp(when, 16000, engine="auto",
with_date=False)
assert brief.size < full.size * 0.75
def test_an_engine_that_is_not_installed_falls_back_to_the_builtin():
"""The feature must keep working with nothing else on the machine."""
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), 16000,
engine="definitely-not-installed")
assert audio.size > 0
assert voice_metrics(audio, 16000).score > 0.6
def test_a_failing_engine_falls_back_rather_than_crashing(monkeypatch):
monkeypatch.setattr(announce, "_external", lambda *a, **k: None)
audio = announce.speak_timestamp(datetime(2026, 8, 21, 14, 38, 5), 16000,
engine="auto")
assert audio.size > 0

50
tests/test_classify.py Normal file
View file

@ -0,0 +1,50 @@
import pytest
from bandsaunter.classify import classify, extract_features
from signals import FS, make
CASES = [
("cw", "cw", 14.05e6), ("nfm", "nfm", 146.52e6), ("am", "am", 121.5e6),
("usb", "ssb", 14.2e6), ("lsb", "ssb", 7.2e6),
("fsk2", "fsk", 929.5e6), ("fsk4", "fsk", 460.0e6),
("psk4", "psk", 450.0e6), ("psk2", "psk", 437.0e6),
("carrier", "carrier", 446.0e6), ("noise", "unknown", 300e6),
]
@pytest.mark.parametrize("kind,family,freq", CASES)
@pytest.mark.parametrize("seed", [3, 11, 42])
def test_modulation_family(kind, family, freq, seed):
c = classify(make(kind, seed=seed), FS, freq_hz=freq, snr_db=28)
assert c.family == family, f"{kind} -> {c.label!r} (family {c.family})"
def test_known_systems_are_named_from_frequency():
assert "airband" in classify(make("am"), FS, freq_hz=121.5e6,
snr_db=28).label.lower()
assert "broadcast" in classify(make("wfm"), FS, freq_hz=97.5e6,
snr_db=28).label.lower()
assert "weather" in classify(make("nfm"), FS, freq_hz=162.475e6,
snr_db=28).label.lower()
def test_ctcss_tone_recovered():
c = classify(make("nfm"), FS, freq_hz=146.52e6, snr_db=28)
assert c.features.ctcss_hz == pytest.approx(100.0, abs=0.5)
def test_low_snr_reduces_confidence():
strong = classify(make("nfm", snr_db=30), FS, freq_hz=146.52e6, snr_db=30)
weak = classify(make("nfm", snr_db=5), FS, freq_hz=146.52e6, snr_db=5)
assert weak.confidence < strong.confidence
def test_fsk_level_count():
assert extract_features(make("fsk2"), FS, snr_db=28).freq_modes == 2
assert extract_features(make("fsk4"), FS, snr_db=28).freq_modes == 4
def test_symbol_rate_estimate():
f = extract_features(make("fsk4"), FS, snr_db=28)
# The cyclostationary line lands on the baud rate or a low harmonic of it.
assert f.baud > 1000

114
tests/test_dsp.py Normal file
View file

@ -0,0 +1,114 @@
import numpy as np
import pytest
from bandsaunter import dsp
def test_decimator_is_stateful_across_blocks():
"""Block-by-block filtering must equal one-shot filtering exactly."""
rng = np.random.default_rng(0)
x = (rng.standard_normal(20000) + 1j * rng.standard_normal(20000)).astype(np.complex64)
d1 = dsp.FIRDecimator(8)
blocked = np.concatenate([d1(x[:7000]), d1(x[7000:13000]), d1(x[13000:])])
d2 = dsp.FIRDecimator(8)
assert np.allclose(blocked, d2(x), atol=1e-9)
def test_decimation_chain_factors():
assert dsp.design_decimation(128) == [8, 8, 2]
assert dsp.design_decimation(1) == []
chain = dsp.DecimationChain(64)
out = chain(np.ones(6400, dtype=np.complex64))
assert out.size == 100
def test_mixer_keeps_phase_continuous():
fs = 48000.0
m = dsp.Mixer(1000.0, fs)
a = m(np.ones(1000, dtype=np.complex64))
b = m(np.ones(1000, dtype=np.complex64))
joined = np.concatenate([a, b])
one_shot = dsp.frequency_shift(np.ones(2000, dtype=np.complex64), 1000.0, fs)
assert np.allclose(joined, one_shot, atol=1e-4)
def test_noise_floor_curve_ignores_signals():
"""A carrier must not raise the floor it is being measured against."""
psd = np.full(1024, -80.0)
psd[500:504] = -20.0
floor = dsp.noise_floor_curve(psd)
assert floor[502] == pytest.approx(-80.0, abs=1.0)
assert (psd - floor)[502] > 55.0
def test_peak_hold_beats_averaging_on_bursts():
fs = 2_048_000
x = np.zeros(102400, dtype=np.complex64)
t = np.arange(102400) / fs
x[:8000] = 0.5 * np.exp(2j * np.pi * 100_000 * t[:8000])
rng = np.random.default_rng(1)
x += 0.01 * (rng.standard_normal(102400) + 1j * rng.standard_normal(102400))
_, p_avg = dsp.welch_psd(x, 1024, combine="mean")
_, p_max = dsp.welch_psd(x, 1024, combine="max")
avg = (dsp.db(p_avg) - dsp.noise_floor_curve(dsp.db(p_avg))).max()
peak = (dsp.db(p_max) - dsp.noise_floor_curve(dsp.db(p_max))).max()
assert peak > avg
def test_occupied_bandwidth_and_flatness():
psd = np.zeros(1024)
psd[500:524] = 1.0
bw, offset = dsp.occupied_bandwidth(psd, 100.0)
assert bw == pytest.approx(2400.0, rel=0.15)
assert dsp.spectral_flatness(np.ones(256)) == pytest.approx(1.0)
tone = np.full(256, 1e-9)
tone[128] = 1.0
assert dsp.spectral_flatness(tone) < 0.01
def test_decimator_matches_a_direct_filter_reference():
"""The polyphase form must equal filtering then discarding samples.
It replaced an lfilter that computed every output and threw most away;
that was slow enough to stop captures keeping up with real time, which
shows up as recordings that play too fast.
"""
from scipy import signal as sps
rng = np.random.default_rng(4)
x = (rng.standard_normal(30000) + 1j * rng.standard_normal(30000)).astype(np.complex64)
for factor in (2, 4, 8):
d = dsp.FIRDecimator(factor)
got = d(x)
ref = sps.lfilter(d.taps.astype(np.float64), [1.0],
x.astype(np.complex128))[::factor]
assert np.allclose(got, ref[:got.size], atol=1e-4), f"factor {factor}"
def test_quarter_rate_mixer_matches_an_explicit_reference():
"""The trig-free shortcut must be exact, and stay phase-continuous."""
rng = np.random.default_rng(5)
n = 9000
x = (rng.standard_normal(n) + 1j * rng.standard_normal(n)).astype(np.complex64)
fs = 2_048_000.0
mixer = dsp.Mixer(fs / 4, fs)
assert mixer._is_quarter_rate
got = np.concatenate([mixer(x[:3000]), mixer(x[3000:5000]), mixer(x[5000:])])
ref = x * np.exp(-2j * np.pi * (fs / 4) * np.arange(n) / fs)
assert np.allclose(got, ref, atol=1e-5)
def test_decimation_is_fast_enough_for_realtime():
"""A 50 ms block must decimate in well under 50 ms of CPU."""
import time
sr, n = 2_048_000, 102_400
x = (np.random.default_rng(6).standard_normal(n)
+ 1j * np.random.default_rng(7).standard_normal(n)).astype(np.complex64)
chain = dsp.DecimationChain(64)
chain(x)
t0 = time.perf_counter()
for _ in range(5):
chain(x)
per_block = (time.perf_counter() - t0) / 5
budget = n / sr
assert per_block < 0.35 * budget, \
f"{per_block*1000:.1f} ms per {budget*1000:.0f} ms block"

50
tests/test_morse.py Normal file
View file

@ -0,0 +1,50 @@
import numpy as np
import pytest
from morse_gen import morse_audio
from bandsaunter.morse import decode_morse, encode_morse
FS = 16000
MESSAGES = [
("CQ CQ DE W1AW K", 18, 25), ("SOS SOS", 12, 20),
("TEST DE N0CALL 599 TU", 25, 18), ("HELLO WORLD 73", 35, 15),
("PARIS PARIS", 8, 25), ("VVV DE K1ABC", 22, 12),
("QRZ? DE VE3XYZ/M", 20, 20), ("R 5NN TU 73 GL", 30, 22),
("MAYDAY MAYDAY", 15, 8), ("THE QUICK BROWN FOX 1234567890", 28, 20),
("CQ DX DE W1AW", 18, 6),
]
@pytest.mark.parametrize("msg,wpm,snr", MESSAGES)
def test_decodes_exactly(msg, wpm, snr):
r = decode_morse(morse_audio(msg, wpm, FS, snr), FS)
assert r.text.strip() == msg
assert r.is_morse
@pytest.mark.parametrize("wpm", [8, 15, 22, 30, 40])
def test_speed_estimate_is_accurate(wpm):
r = decode_morse(morse_audio("CQ DE W1AW TEST", wpm, FS, 20), FS)
assert r.wpm == pytest.approx(wpm, rel=0.10)
def test_tone_frequency_found():
r = decode_morse(morse_audio("CQ TEST", 20, FS, 20, tone=1100.0), FS)
assert r.tone_hz == pytest.approx(1100.0, abs=25)
def test_noise_is_not_morse():
rng = np.random.default_rng(0)
r = decode_morse(0.1 * rng.standard_normal(FS * 3), FS)
assert not r.is_morse
assert r.text == ""
def test_silence_is_rejected():
assert not decode_morse(np.zeros(FS * 2), FS).is_morse
def test_encode_round_trip():
assert encode_morse("SOS") == "... --- ..."
assert encode_morse("A B") == ".- / -..."

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}"

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

510
tests/test_scanner.py Normal file
View 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"]

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

378
tests/test_transcribe.py Normal file
View file

@ -0,0 +1,378 @@
"""Speech to text: the engine plumbing, and how captures reach it."""
import time
from datetime import datetime
from pathlib import Path
import numpy as np
import pytest
from bandsaunter import transcribe as tr
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
@pytest.fixture
def fake_engine(monkeypatch):
"""A recogniser that reports what it was given, so the plumbing is testable
on a machine with none installed."""
seen = []
def engine(audio, rate, model, language):
seen.append({"samples": audio.size, "rate": rate, "model": model,
"language": language})
return tr.Transcript(text="this is the transcribed text",
engine="fake", language=language or "en")
monkeypatch.setitem(tr._DISPATCH, "fake", engine)
monkeypatch.setattr(tr, "ENGINES", ("fake",) + tr.ENGINES)
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
return seen
def _speech(seconds=3.0, rate=16000):
import sys
sys.path.insert(0, str(Path(__file__).parent))
from speech import synth_speech
return synth_speech(seconds, rate, 120, 0)
# ---------------------------------------------------------------------------
# Engines
# ---------------------------------------------------------------------------
def test_no_engine_reports_rather_than_failing(monkeypatch):
"""Every capture failing noisily would be worse than saying so once."""
monkeypatch.setattr(tr, "_is_present", lambda name: False)
assert tr.available_engine() is None
assert tr.transcribe(np.zeros(1000, np.float32), 16000) is None
def test_engine_listing_is_complete():
listed = {name for name, _, _ in tr.describe_engines()}
assert listed == set(tr.ENGINES)
for _, _, how in tr.describe_engines():
assert how, "every engine should say how to get it"
def test_a_failing_engine_is_reported_not_raised(monkeypatch):
def boom(audio, rate, model, language):
raise RuntimeError("model file is corrupt")
monkeypatch.setitem(tr._DISPATCH, "fake", boom)
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
result = tr.transcribe(np.zeros(16000, np.float32), 16000, engine="fake")
assert result is not None and not result
assert "corrupt" in result.note
def test_audio_is_resampled_to_what_the_engines_expect(fake_engine):
for rate in (8000, 16000, 32000, 48000):
tr.transcribe(np.zeros(int(rate * 2), np.float32), rate, engine="fake")
assert [s["samples"] for s in fake_engine] == [32000] * 4
def test_the_model_and_language_reach_the_engine(fake_engine):
tr.transcribe(np.zeros(16000, np.float32), 16000, engine="fake",
model="small.en", language="fr")
assert fake_engine[-1]["model"] == "small.en"
assert fake_engine[-1]["language"] == "fr"
# ---------------------------------------------------------------------------
# The worker
# ---------------------------------------------------------------------------
def _drain(worker, timeout=20.0):
worker.close(timeout=timeout)
def test_worker_writes_a_transcript_beside_the_recording(tmp_path, fake_engine):
worker = tr.TranscriptionWorker(engine="fake")
worker.start()
out = tmp_path / "0146.520000MHz--2026-08-21_12_00_00-nfm_transcription.txt"
worker.submit(_speech(), 16000, out, datetime(2026, 8, 21, 12, 0, 0),
146.52e6)
_drain(worker)
assert out.exists()
assert "transcribed text" in out.read_text()
assert worker.written == 1
def test_worker_appends_with_a_timestamp_when_combining(tmp_path, fake_engine):
worker = tr.TranscriptionWorker(engine="fake")
worker.start()
out = tmp_path / "0146.520000MHz_transcription.txt"
for minute in (0, 5, 9):
worker.submit(_speech(), 16000, out,
datetime(2026, 8, 21, 12, minute, 0), 146.52e6,
append=True)
_drain(worker)
lines = out.read_text().strip().split("\n")
assert len(lines) == 3
assert lines[0].startswith("[2026-08-21 12:00:00] ")
assert lines[2].startswith("[2026-08-21 12:09:00] ")
def test_nothing_recognised_writes_no_file_at_all(tmp_path, monkeypatch):
"""A directory of placeholder files is worse than no file."""
monkeypatch.setitem(tr._DISPATCH, "fake",
lambda a, r, m, l: tr.Transcript(text="", engine="fake"))
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
worker = tr.TranscriptionWorker(engine="fake")
worker.start()
out = tmp_path / "quiet_transcription.txt"
worker.submit(np.zeros(16000, np.float32), 16000, out, datetime.now(), 1e6)
_drain(worker)
assert not out.exists()
assert not list(tmp_path.iterdir())
assert worker.empty == 1 and worker.written == 0
def test_whitespace_only_speech_writes_no_file(tmp_path, monkeypatch):
monkeypatch.setitem(tr._DISPATCH, "fake",
lambda a, r, m, l: tr.Transcript(text=" \n ",
engine="fake"))
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
worker = tr.TranscriptionWorker(engine="fake")
worker.start()
out = tmp_path / "blank_transcription.txt"
worker.submit(np.zeros(16000, np.float32), 16000, out, datetime.now(), 1e6)
_drain(worker)
assert not out.exists() and worker.empty == 1
def test_an_empty_result_adds_no_line_when_combining(tmp_path, monkeypatch):
"""Combined transcripts must not fill up with empty timestamps."""
texts = iter(["something was said", "", "and something else"])
monkeypatch.setitem(tr._DISPATCH, "fake",
lambda a, r, m, l: tr.Transcript(text=next(texts),
engine="fake"))
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
worker = tr.TranscriptionWorker(engine="fake")
worker.start()
out = tmp_path / "0146.520000MHz_transcription.txt"
for minute in (0, 5, 9):
worker.submit(np.zeros(16000, np.float32), 16000, out,
datetime(2026, 8, 21, 12, minute, 0), 1e6, append=True)
_drain(worker)
lines = out.read_text().strip().split("\n")
assert len(lines) == 2, lines
assert "12:00:00" in lines[0] and "12:09:00" in lines[1]
def test_worker_does_not_hold_up_the_caller(tmp_path, monkeypatch):
"""Recognition takes seconds; a scan must not wait for it."""
def slow(audio, rate, model, language):
time.sleep(1.0)
return tr.Transcript(text="eventually", engine="fake")
monkeypatch.setitem(tr._DISPATCH, "fake", slow)
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
worker = tr.TranscriptionWorker(engine="fake")
worker.start()
started = time.perf_counter()
for i in range(3):
worker.submit(_speech(), 16000, tmp_path / f"{i}.txt", datetime.now(),
1e6)
assert time.perf_counter() - started < 0.5, "submitting blocked"
_drain(worker)
assert worker.written == 3
def test_a_full_queue_is_counted_not_blocked(tmp_path, monkeypatch):
def slow(audio, rate, model, language):
time.sleep(0.4)
return tr.Transcript(text="x", engine="fake")
monkeypatch.setitem(tr._DISPATCH, "fake", slow)
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
worker = tr.TranscriptionWorker(engine="fake", max_queue=2)
worker.start()
accepted = sum(worker.submit(_speech(0.5), 16000, tmp_path / f"{i}.txt",
datetime.now(), 1e6) for i in range(12))
assert accepted < 12 and worker.dropped > 0
_drain(worker)
def test_empty_audio_is_not_submitted(tmp_path, fake_engine):
worker = tr.TranscriptionWorker(engine="fake")
worker.start()
assert not worker.submit(np.zeros(0, np.float32), 16000,
tmp_path / "x.txt", datetime.now(), 1e6)
_drain(worker)
assert worker.written == 0
# ---------------------------------------------------------------------------
# Through a scan
# ---------------------------------------------------------------------------
def _scan(tmp_path, transmitters, **over):
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
output_dir=str(tmp_path), record_seconds=4.0,
hang_seconds=1.0, threshold_db=12, dwell_seconds=0.05,
max_cycles=1, revisit_seconds=0.2, transcribe=True,
transcribe_engine="fake")
for k, v in over.items():
setattr(cfg, k, v)
hits = []
scanner = Scanner(cfg, device=SimulatedDevice(transmitters=transmitters).open(),
callbacks=ScannerCallbacks(on_record_end=hits.append))
scanner.prepare()
scanner.run()
return scanner, [h for h in hits if h.kept]
def test_a_voice_capture_is_transcribed(tmp_path, fake_engine, monkeypatch):
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")])
assert hits and hits[0].category == "voice"
written = list(tmp_path.glob("*_transcription.txt"))
assert written, "no transcript written"
assert written[0].stem.startswith(hits[0].filename)
assert "transcribed text" in written[0].read_text()
def test_morse_and_data_are_not_transcribed(tmp_path, fake_engine, monkeypatch):
"""Running a recogniser over CW or a data burst wastes seconds per capture."""
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
scanner, hits = _scan(tmp_path, [
V(146_520_000, "fsk4", 0.4, 12_500, "data", baud=4800, deviation=1800)])
assert hits and hits[0].category == "digital"
assert not list(tmp_path.glob("*_transcription.txt"))
def test_short_captures_are_skipped(tmp_path, fake_engine, monkeypatch):
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
transcribe_min_seconds=60.0)
assert hits
assert not list(tmp_path.glob("*_transcription.txt"))
def test_the_transcript_is_recorded_in_the_metadata(tmp_path, fake_engine,
monkeypatch):
import json
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")])
meta = json.loads(Path(hits[0].meta_path).read_text())
assert meta["hit"]["transcript_path"].endswith("_transcription.txt")
assert "transcribed text" in meta["hit"]["transcript"]
def test_the_metadata_never_names_a_transcript_that_was_not_written(
tmp_path, monkeypatch):
"""Recording a path for a file that never appears would be a lie."""
import json
monkeypatch.setitem(tr._DISPATCH, "fake",
lambda a, r, m, l: tr.Transcript(text="", engine="fake"))
monkeypatch.setattr(tr, "_is_present", lambda name: name == "fake")
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")])
assert hits
assert not list(tmp_path.glob("*_transcription.txt"))
meta = json.loads(Path(hits[0].meta_path).read_text())
assert not meta["hit"].get("transcript_path")
def test_combined_recordings_get_one_transcript_per_frequency(tmp_path,
fake_engine,
monkeypatch):
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
scanner, hits = _scan(
tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
combine_by_frequency=True, announce_timestamps=False,
record_seconds=2.0, max_cycles=3, revisit_seconds=0.05)
assert len(hits) >= 2
written = list(tmp_path.glob("*_transcription.txt"))
assert len(written) == 1, written
lines = written[0].read_text().strip().split("\n")
assert len(lines) == len(hits)
assert all(line.startswith("[") for line in lines)
def test_transcription_off_writes_nothing(tmp_path, fake_engine, monkeypatch):
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: "fake")
scanner, hits = _scan(tmp_path, [V(146_520_000, "nfm", 0.4, 12_500, "v")],
transcribe=False)
assert hits
assert not list(tmp_path.glob("*_transcription.txt"))
def test_missing_engine_says_so_once(tmp_path, monkeypatch):
monkeypatch.setattr("bandsaunter.scanner.available_engine", lambda: None)
notes = []
cfg = ScanConfig(ranges=parse_range_list("146.4M-146.6M"),
output_dir=str(tmp_path), record_seconds=2.0,
threshold_db=12, max_cycles=1, transcribe=True)
scanner = Scanner(cfg,
device=SimulatedDevice(transmitters=[
V(146_520_000, "nfm", 0.4, 12_500, "v")]).open(),
callbacks=ScannerCallbacks(on_status=notes.append))
scanner.prepare()
scanner.run()
assert any("no speech recogniser" in n for n in notes), notes
assert scanner.transcriber is None
# ---------------------------------------------------------------------------
# Installed engines, when there are any
# ---------------------------------------------------------------------------
INSTALLED = tr.available_engine()
needs_engine = pytest.mark.skipif(INSTALLED is None,
reason="no speech recogniser installed")
@needs_engine
def test_an_installed_engine_recognises_synthesised_speech():
"""Round trip: say something, then read it back off the audio."""
from bandsaunter.announce import say
audio = say("one two three four five six seven eight nine", 16000)
result = tr.transcribe(audio, 16000, engine="auto",
model="base.en", language="en")
assert result is not None
assert not result.note, result.note
# Engines are free to write numbers as digits, and whisper does.
text = result.text.lower()
spelled = ("one", "two", "three", "four", "five", "six", "seven",
"eight", "nine")
hits = sum((word in text) or (str(i) in text)
for i, word in enumerate(spelled, 1))
assert hits >= 4, f"only {hits} of nine numbers recognised: {result.text!r}"
@needs_engine
@pytest.mark.parametrize("rate", [8000, 16000, 32000])
def test_an_installed_engine_copes_with_any_rate(rate):
from bandsaunter.announce import say
audio = say("testing one two three", rate)
result = tr.transcribe(audio, rate, engine="auto", model="base.en",
language="en")
assert result is not None and not result.note, result.note
@needs_engine
def test_silence_produces_no_transcript_rather_than_invention():
result = tr.transcribe(np.zeros(16000 * 3, np.float32), 16000,
engine="auto", model="base.en", language="en")
assert result is not None
assert not result.text.strip(), f"invented {result.text!r} from silence"
@pytest.mark.skipif(not tr._is_present("vosk"), reason="vosk not installed")
def test_vosk_falls_back_when_given_a_whisper_model_name():
"""The model setting is shared with whisper, whose names are not paths."""
from bandsaunter.announce import say
result = tr.transcribe(say("one two three", 16000), 16000, engine="vosk",
model="base.en", language="en")
assert result is not None
assert not result.note, result.note
@pytest.mark.skipif(not tr._is_present("vosk"), reason="vosk not installed")
def test_vosk_accepts_the_plain_language_code():
"""Vosk names its models by region and rejects a bare "en"."""
from bandsaunter.announce import say
result = tr.transcribe(say("one two three", 16000), 16000, engine="vosk",
language="en")
assert result is not None and not result.note, result.note

206
tests/test_tui.py Normal file
View file

@ -0,0 +1,206 @@
"""The in-application menus, driven by scripted answers."""
import pytest
from rich.console import Console
from bandsaunter import settings as st, tui
from bandsaunter.config import ScanConfig
@pytest.fixture
def console():
return Console(width=100, file=open("/dev/null", "w"), force_terminal=False)
def drive(monkeypatch, answers):
"""Feed the menus a fixed list of answers, then stop."""
script = list(answers)
def fake_ask(console, prompt, default=""):
if not script:
raise _Done()
return script.pop(0)
monkeypatch.setattr(tui, "_ask", fake_ask)
monkeypatch.setattr(tui.Confirm, "ask", lambda *a, **k: True)
return script
class _Done(Exception):
"""Raised to break out when the script runs out."""
def run(monkeypatch, console, func, answers, *args):
drive(monkeypatch, answers)
try:
return func(console, *args)
except _Done:
return None
def test_settings_menu_edits_a_value(monkeypatch, console):
cfg = ScanConfig()
run(monkeypatch, console, tui.settings_menu, ["1", "2", "6", "", ""], cfg)
assert cfg.hang_seconds == 6.0
def test_settings_menu_finds_a_setting_by_search(monkeypatch, console):
cfg = ScanConfig()
run(monkeypatch, console, tui.settings_menu, ["voice score", "0.3", ""], cfg)
assert cfg.min_voice_score == 0.3
def test_a_bad_value_is_refused_and_the_old_one_kept(monkeypatch, console):
cfg = ScanConfig()
before = cfg.hang_seconds
run(monkeypatch, console, tui.settings_menu,
["1", "2", "banana", "", "", ""], cfg)
assert cfg.hang_seconds == before
def test_a_value_failing_validation_is_rolled_back(monkeypatch, console):
"""min_record longer than record would mean nothing is ever kept."""
cfg = ScanConfig()
cfg.record_seconds = 5.0
run(monkeypatch, console, tui.settings_menu,
["1", "4", "9", "", "", ""], cfg)
assert cfg.min_record_seconds != 9.0
assert not [e for e in cfg.validate() if "ranges" not in e]
def test_every_group_can_be_opened_and_left(monkeypatch, console):
cfg = ScanConfig()
for i in range(1, len(st.GROUPS) + 1):
run(monkeypatch, console, tui.settings_menu, [str(i), "", ""], cfg)
def test_every_setting_renders_its_help(console):
"""Built-in help must exist and render for every single setting."""
cfg = ScanConfig()
for s in st.SETTINGS:
tui.setting_help(console, s, cfg)
def test_reset_a_group_restores_defaults(monkeypatch, console):
cfg = ScanConfig()
cfg.hang_seconds = 99.0
cfg.record_seconds = 99.0
run(monkeypatch, console, tui.settings_menu, ["1", "d", "", ""], cfg)
assert cfg.hang_seconds == ScanConfig().hang_seconds
assert cfg.record_seconds == ScanConfig().record_seconds
def test_help_screen_shows_every_topic(monkeypatch, console):
for topic in tui._TOPICS:
run(monkeypatch, console, tui.help_screen, [topic, ""])
def test_help_screen_looks_up_settings(monkeypatch, console):
run(monkeypatch, console, tui.help_screen, ["hang", ""])
def test_ranges_can_be_added_from_the_band_plan(monkeypatch, console):
cfg = ScanConfig()
run(monkeypatch, console, tui.choose_presets, ["gmrs", "1", ""], cfg)
assert cfg.ranges, "no range was added"
def test_ranges_can_be_typed_in(monkeypatch, console):
cfg = ScanConfig()
run(monkeypatch, console, tui.add_manual_ranges,
["144M", "148M", "nfm", ""], cfg)
assert len(cfg.ranges) == 1
assert cfg.ranges[0].start == 144e6 and cfg.ranges[0].stop == 148e6
assert cfg.ranges[0].mode == "nfm"
def test_a_bad_frequency_is_refused(monkeypatch, console):
cfg = ScanConfig()
run(monkeypatch, console, tui.add_manual_ranges, ["banana", ""], cfg)
assert not cfg.ranges
def test_main_menu_can_start_a_scan(monkeypatch, console):
cfg = ScanConfig()
from bandsaunter.ranges import parse_range_list
cfg.ranges = parse_range_list("144M-148M")
out = run(monkeypatch, console, tui.run_tui, ["s"], cfg)
assert out is cfg
def test_main_menu_refuses_to_start_without_ranges(monkeypatch, console):
cfg = ScanConfig()
out = run(monkeypatch, console, tui.run_tui, ["s", "q"], cfg)
assert out is None, "started a scan with nothing to scan"
def test_main_menu_quits(monkeypatch, console):
assert run(monkeypatch, console, tui.run_tui, ["q"], ScanConfig()) is None
def test_closed_input_unwinds_instead_of_looping(monkeypatch, console):
"""With no input left, the menus must give up rather than spin forever."""
def eof(*a, **k):
raise EOFError
monkeypatch.setattr(tui.Prompt, "ask", eof)
assert tui.run_tui(console, ScanConfig()) is None
with pytest.raises(tui.TUIAbort):
tui.settings_menu(console, ScanConfig())
def test_interrupt_unwinds_too(monkeypatch, console):
def interrupt(*a, **k):
raise KeyboardInterrupt
monkeypatch.setattr(tui.Prompt, "ask", interrupt)
assert tui.run_tui(console, ScanConfig()) is None
# ---------------------------------------------------------------------------
# First run
# ---------------------------------------------------------------------------
def test_first_run_is_detected_and_then_not(tmp_path):
from bandsaunter.config import ScanConfig, is_first_run, save_default
assert is_first_run(tmp_path)
save_default(ScanConfig(), tmp_path)
assert not is_first_run(tmp_path)
def test_first_run_asks_where_to_save_and_remembers(monkeypatch, console,
tmp_path):
from bandsaunter import tui
from bandsaunter.config import load_default
wanted = tmp_path / "my recordings"
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": str(wanted))
monkeypatch.setattr(tui, "save_default",
lambda cfg: __import__("bandsaunter.config",
fromlist=["x"]).save_default(
cfg, tmp_path))
cfg = ScanConfig()
assert tui.first_run_setup(console, cfg)
assert cfg.output_dir == str(wanted)
assert wanted.is_dir(), "the directory should be created"
back, _ = load_default(tmp_path)
assert back.output_dir == str(wanted)
def test_first_run_accepts_the_suggested_default(monkeypatch, console,
tmp_path):
from bandsaunter import tui
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": "")
monkeypatch.setattr(tui, "save_default", lambda cfg: tmp_path / "x.yaml")
monkeypatch.setattr(tui, "DEFAULT_OUTPUT_DIR", str(tmp_path / "default"))
cfg = ScanConfig()
tui.first_run_setup(console, cfg)
assert cfg.output_dir == str(tmp_path / "default")
def test_first_run_rejects_a_directory_it_cannot_write(monkeypatch, console,
tmp_path):
"""Better to say so now than to fail on the first recording."""
from bandsaunter import tui
answers = iter(["/proc/nonsense/cannot-create", str(tmp_path / "ok")])
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": next(answers))
monkeypatch.setattr(tui, "save_default", lambda cfg: tmp_path / "x.yaml")
cfg = ScanConfig()
tui.first_run_setup(console, cfg)
assert cfg.output_dir == str(tmp_path / "ok")

147
tests/test_ui.py Normal file
View file

@ -0,0 +1,147 @@
"""The live display, and keeping the driver from writing over it."""
import os
import subprocess
import sys
import tempfile
import time
import numpy as np
import pytest
from rich.console import Console
from bandsaunter.config import ScanConfig
from bandsaunter.ranges import parse_range_list
from bandsaunter.recorder import HitRecord
from bandsaunter.scanner import Scanner
from bandsaunter.simulator import SimulatedDevice
from bandsaunter.ui import ScanDisplay, _sparkline
def _display(height: int, hits: int, recording: bool) -> ScanDisplay:
console = Console(width=100, height=height,
file=open(os.devnull, "w"))
cfg = ScanConfig(ranges=parse_range_list("144M-148M"),
output_dir=tempfile.mkdtemp())
scanner = Scanner(cfg, device=SimulatedDevice().open())
scanner.prepare()
display = ScanDisplay(scanner, console=console)
display.attach()
step = scanner.plan[0]
display.on_step(0, 6, step, np.full(1024, -70.0),
np.linspace(step.low, step.high, 1024))
for i in range(hits):
display.hits.append(HitRecord(
frequency=146e6 + i * 1e5, started_at=time.time(), duration=5.0,
snr_db=20.0, classification="FM broadcast station (stereo)"))
display._rec.active = recording
display._rec.frequency = 146.52e6
display._rec.mode = "nfm"
return display
def _rendered_height(display: ScanDisplay) -> int:
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
record=True)
probe.print(display.render())
return len(probe.export_text().rstrip("\n").split("\n"))
@pytest.mark.parametrize("height", [16, 20, 24, 30, 40, 60])
@pytest.mark.parametrize("hits,recording", [(0, False), (3, False),
(12, False), (12, True)])
def test_the_display_never_outgrows_the_terminal(height, hits, recording):
"""A frame taller than the terminal cannot be redrawn in place.
Every refresh then scrolls another copy into the scrollback, which is why
the header ends up on screen several times over.
"""
display = _display(height, hits, recording)
assert _rendered_height(display) <= height, \
f"{hits} hits, rec={recording}: overflowed a {height}-line terminal"
def test_older_hits_are_dropped_not_the_layout():
"""When space runs short the list shortens; the panels stay."""
display = _display(20, 12, True)
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
record=True)
probe.print(display.render())
text = probe.export_text()
assert "receiver" in text and "sweep" in text
assert "more above" in text, "no sign that hits were trimmed"
def test_a_tall_terminal_shows_every_hit():
display = _display(60, 12, False)
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
record=True)
probe.print(display.render())
assert "more above" not in probe.export_text()
def test_sparkline_keeps_narrow_carriers_visible():
values = np.full(200, -70.0)
values[100] = -20.0
assert "" in _sparkline(values, 60)
# ---------------------------------------------------------------------------
# The driver writes its own messages straight to file descriptor 2
# ---------------------------------------------------------------------------
def test_quiet_driver_suppresses_writes_to_fd_2():
"""librtlsdr prints from C, so Python-level redirection cannot catch it."""
code = (
"import os, sys; sys.path.insert(0, %r)\n"
"from bandsaunter.device import quiet_driver\n"
"with quiet_driver():\n"
" os.write(2, b'CHATTER\\n')\n"
"os.write(2, b'AFTERWARDS\\n')\n"
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, timeout=30)
assert "CHATTER" not in out.stderr
assert "AFTERWARDS" in out.stderr, "stderr was not restored"
def test_quiet_driver_restores_on_an_exception():
code = (
"import os, sys; sys.path.insert(0, %r)\n"
"from bandsaunter.device import quiet_driver\n"
"try:\n"
" with quiet_driver():\n"
" raise ValueError('boom')\n"
"except ValueError:\n"
" pass\n"
"os.write(2, b'RESTORED\\n')\n"
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, timeout=30)
assert "RESTORED" in out.stderr
def test_driver_messages_can_be_turned_back_on_for_debugging():
code = (
"import os, sys; sys.path.insert(0, %r)\n"
"from bandsaunter.device import quiet_driver\n"
"with quiet_driver():\n"
" os.write(2, b'CHATTER\\n')\n"
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
env = dict(os.environ, BANDSAUNTER_DRIVER_MESSAGES="1")
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, timeout=30, env=env)
assert "CHATTER" in out.stderr
def test_python_errors_still_reach_stderr():
"""Silencing the driver must not swallow a traceback."""
code = (
"import sys; sys.path.insert(0, %r)\n"
"from bandsaunter.device import quiet_driver\n"
"with quiet_driver():\n"
" pass\n"
"raise RuntimeError('visible')\n"
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
text=True, timeout=30)
assert "visible" in out.stderr