Initial commit: bandsaunter, an RTL-SDR signal scanner

Sweeps any set of frequency ranges, records what it finds, and works out
what kind of signal it was.

- Frequency ranges entered by hand or picked from a 135-entry US band plan,
  including whole-band and all-CW sweeps that resolve the demodulator per
  segment.
- Detection calibrated against the peak-hold detector's own noise statistics,
  so the threshold means real margin over static rather than over the floor.
- A content gate: captures are kept only if they carry voice, decodable CW,
  or an identified digital keying scheme. Speech is recognised by a pitch
  track that drifts, which static cannot imitate.
- Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR,
  NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK.
- Gapless streaming capture, with the signal path fast enough to keep up in
  real time, so recordings play back at the right speed.
- Optional one-file-per-frequency recording with spoken timestamps, and
  speech-to-text transcription.
- Menus and command line generated from one settings table, so neither can
  offer something the other cannot; settings persist in ~/.config.

367 tests, run against synthetic signals, a built-in receiver simulator, and
real hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
The Dust Council 2026-08-21 20:50:20 -07:00
commit db3e0c79b9
39 changed files with 13473 additions and 0 deletions

114
tests/test_dsp.py Normal file
View file

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