bandsaunter/tests/test_morse.py
The Dust Council db3e0c79b9 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>
2026-08-21 20:50:20 -07:00

50 lines
1.4 KiB
Python

import numpy as np
import pytest
from morse_gen import morse_audio
from bandsaunter.morse import decode_morse, encode_morse
FS = 16000
MESSAGES = [
("CQ CQ DE W1AW K", 18, 25), ("SOS SOS", 12, 20),
("TEST DE N0CALL 599 TU", 25, 18), ("HELLO WORLD 73", 35, 15),
("PARIS PARIS", 8, 25), ("VVV DE K1ABC", 22, 12),
("QRZ? DE VE3XYZ/M", 20, 20), ("R 5NN TU 73 GL", 30, 22),
("MAYDAY MAYDAY", 15, 8), ("THE QUICK BROWN FOX 1234567890", 28, 20),
("CQ DX DE W1AW", 18, 6),
]
@pytest.mark.parametrize("msg,wpm,snr", MESSAGES)
def test_decodes_exactly(msg, wpm, snr):
r = decode_morse(morse_audio(msg, wpm, FS, snr), FS)
assert r.text.strip() == msg
assert r.is_morse
@pytest.mark.parametrize("wpm", [8, 15, 22, 30, 40])
def test_speed_estimate_is_accurate(wpm):
r = decode_morse(morse_audio("CQ DE W1AW TEST", wpm, FS, 20), FS)
assert r.wpm == pytest.approx(wpm, rel=0.10)
def test_tone_frequency_found():
r = decode_morse(morse_audio("CQ TEST", 20, FS, 20, tone=1100.0), FS)
assert r.tone_hz == pytest.approx(1100.0, abs=25)
def test_noise_is_not_morse():
rng = np.random.default_rng(0)
r = decode_morse(0.1 * rng.standard_normal(FS * 3), FS)
assert not r.is_morse
assert r.text == ""
def test_silence_is_rejected():
assert not decode_morse(np.zeros(FS * 2), FS).is_morse
def test_encode_round_trip():
assert encode_morse("SOS") == "... --- ..."
assert encode_morse("A B") == ".- / -..."