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
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}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue