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

52
tests/speech.py Normal file
View file

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