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