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