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