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>
50 lines
1.4 KiB
Python
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") == ".- / -..."
|