"""Tests for the content gate: what counts as a signal worth recording.""" import numpy as np import pytest from scipy.signal import butter, lfilter from bandsaunter.classify import classify from bandsaunter.demod import make_demodulator from bandsaunter.morse import decode_morse from bandsaunter.quality import (RAYLEIGH_CV, assess, digital_structure, noise_likeness, voice_metrics) from bandsaunter import dsp from bandsaunter.simulator import SimulatedDevice, VirtualTransmitter as V from speech import synth_speech from signals import FS, make AUDIO_FS = 16000 def _tone(hz, seconds=5.0, fs=AUDIO_FS): t = np.arange(int(seconds * fs)) / fs return np.sin(2 * np.pi * hz * t) # --------------------------------------------------------------------------- # Voice detection # --------------------------------------------------------------------------- @pytest.mark.parametrize("pitch,seed", [(110, 0), (150, 1), (210, 2)]) def test_speech_scores_high(pitch, seed): m = voice_metrics(synth_speech(5.0, AUDIO_FS, pitch, seed), AUDIO_FS) assert m.score > 0.6, m.describe() assert m.pitch_hz == pytest.approx(pitch, rel=0.2) assert m.voiced_fraction > 0.2 @pytest.mark.parametrize("name,signal", [ ("white noise", np.random.default_rng(0).standard_normal(AUDIO_FS * 5)), ("steady tone", _tone(1000.0)), ("mains-style hum", sum(_tone(120.0 * k) / k for k in range(1, 6))), ("silence", np.zeros(AUDIO_FS * 3)), ]) def test_non_speech_scores_low(name, signal): assert voice_metrics(signal, AUDIO_FS).score < 0.42, name def test_static_scores_below_speech(): b, a = butter(4, [300 / (AUDIO_FS / 2), 3000 / (AUDIO_FS / 2)], btype="band") static = lfilter(b, a, np.random.default_rng(1).standard_normal(AUDIO_FS * 5)) speech = synth_speech(5.0, AUDIO_FS, 120, 0) assert voice_metrics(static, AUDIO_FS).score < \ voice_metrics(speech, AUDIO_FS).score - 0.3 def test_pitch_variation_separates_voice_from_a_buzz(): """A talker's pitch wanders; a hum sits on one frequency.""" assert voice_metrics(synth_speech(5.0, AUDIO_FS, 120, 0), AUDIO_FS).pitch_variation > 0.02 hum = sum(_tone(120.0 * k) / k for k in range(1, 6)) assert voice_metrics(hum, AUDIO_FS).pitch_variation < 0.01 # --------------------------------------------------------------------------- # Noise and structure scoring # --------------------------------------------------------------------------- def test_noise_likeness_high_for_noise_low_for_signals(): from bandsaunter.classify import extract_features noise = noise_likeness(extract_features(make("noise"), FS, snr_db=0)) assert noise > 0.7 for kind in ("nfm", "fsk4", "cw", "am"): assert noise_likeness(extract_features(make(kind), FS, snr_db=28)) < noise def test_rayleigh_constant(): """Complex Gaussian noise has this exact envelope coefficient of variation.""" rng = np.random.default_rng(0) x = (rng.standard_normal(200000) + 1j * rng.standard_normal(200000)) env = np.abs(x) assert env.std() / env.mean() == pytest.approx(RAYLEIGH_CV, rel=0.02) def test_symbol_rate_alone_is_not_digital(): """A baud estimate with no identified keying proves nothing.""" from bandsaunter.classify import extract_features f = extract_features(make("noise"), FS, snr_db=0) score, bits = digital_structure(f) assert score == 0.0 and bits == [] def test_fsk_and_ook_are_digital(): from bandsaunter.classify import extract_features for kind in ("fsk2", "fsk4"): score, bits = digital_structure(extract_features(make(kind), FS, snr_db=28)) assert score > 0.3, f"{kind}: {bits}" # --------------------------------------------------------------------------- # End-to-end verdicts through the real RF chain # --------------------------------------------------------------------------- def _through_radio(mode, freq_hz, demod_mode, bw=12_500, seconds=3.0, **kw): """Run a simulated transmitter through tune, mix, decimate and demodulate.""" sr = 2_048_000 tx = [] if mode is None else [V(146_520_000, mode, 0.4, bw, "tx", **kw)] dev = SimulatedDevice(transmitters=tx).open() dev.tune(146_520_000 - sr * 0.25) mixer = dsp.Mixer(sr * 0.25, sr) demod = make_demodulator(demod_mode, sr, bw, 16_000) audio, iq = [], [] for _ in range(int(seconds / 0.05)): a, i = demod.step(mixer(dev.read_samples(102400))) audio.append(a) iq.append(i) audio = np.concatenate(audio) iq = np.concatenate(iq) cls = classify(iq, demod.if_rate, freq_hz=freq_hz, snr_db=28) morse = None if cls.family in ("cw", "ook", "carrier"): cw = make_demodulator("cw", int(demod.if_rate), 800.0, 16_000) morse = decode_morse(cw.process(iq), cw.audio_rate) return assess(cls, audio, demod.audio_rate, morse=morse, freq_hz=freq_hz) @pytest.mark.parametrize("mode,freq,dmode,bw,kw,want", [ ("nfm", 146.52e6, "nfm", 12_500, {"ctcss": 100.0}, "voice"), ("nfm", 146.52e6, "nfm", 12_500, {}, "voice"), ("am", 121.5e6, "am", 8_000, {}, "voice"), ("usb", 14.2e6, "usb", 3_000, {}, "voice"), ("wfm", 97.5e6, "wfm", 180_000, {"deviation": 75_000}, "voice"), ("carrier", 446.0e6, "nfm", 12_500, {}, "carrier"), ("fsk4", 460.0e6, "nfm", 12_500, {"baud": 4800, "deviation": 1800}, "digital"), ("fsk2", 929.6e6, "nfm", 12_500, {"baud": 1200, "deviation": 2400}, "digital"), ("ook", 433.92e6, "nfm", 40_000, {"baud": 2000}, "digital"), ("cw", 14.05e6, "cw", 800, {"wpm": 18}, "cw"), (None, 300.0e6, "nfm", 12_500, {}, "noise"), ]) def test_content_categories(mode, freq, dmode, bw, kw, want): v = _through_radio(mode, freq, dmode, bw, **kw) assert v.category == want, f"{mode} -> {v.category}: {v.reason}" def test_accept_list_controls_what_is_kept(): v = _through_radio("carrier", 446.0e6, "nfm") assert v.category == "carrier" and not v.accept v2 = _through_radio("nfm", 146.52e6, "nfm") assert v2.category == "voice" and v2.accept def test_broadcast_leniency_does_not_apply_outside_the_fm_band(): """A wideband hump at 250 MHz is not a broadcast station.""" v = _through_radio("wfm", 250.0e6, "wfm", 180_000, deviation=75_000) assert v.category != "voice" or v.voice.score >= 0.45 # --------------------------------------------------------------------------- # Regressions: each of these let static through at some point. # --------------------------------------------------------------------------- def test_voicing_is_necessary_not_merely_weighted(): """Noise can satisfy dynamics, syllable rate and band -- but not pitch. Weighting voicing alongside the other three let hiss score over the line on their strength alone, which is how static ended up recorded as speech. """ rng = np.random.default_rng(7) n = AUDIO_FS * 5 t = np.arange(n) / AUDIO_FS b, a = butter(4, [300 / (AUDIO_FS / 2), 3000 / (AUDIO_FS / 2)], btype="band") hiss = lfilter(b, a, rng.standard_normal(n)) # Give it speech-like dynamics and a 4 Hz syllabic envelope. hiss *= (0.15 + 0.85 * np.abs(np.sin(2 * np.pi * 4.0 * t))) * \ (np.sin(2 * np.pi * 0.3 * t) > -0.4) m = voice_metrics(hiss, AUDIO_FS) assert m.dynamic_range_db > 10, "test signal should have big dynamics" assert m.syllabic > 0.2, "test signal should have syllabic modulation" assert m.voiced_fraction < 0.15, "but no pitch track" assert m.score < 0.45, f"shaped hiss scored {m.score:.2f} as voice" def test_pitch_pinned_at_the_search_limit_is_not_a_pitch(): """A maximum on the edge of the search range is the search running out.""" rng = np.random.default_rng(3) m = voice_metrics(rng.standard_normal(AUDIO_FS * 4), AUDIO_FS) assert not (0 < m.pitch_hz < 75) and m.pitch_hz < 395 or m.pitch_hz == 0.0 def test_symbol_rate_must_hold_across_the_capture(): """Real data keeps one symbol rate; an estimator on noise does not.""" from bandsaunter.classify import extract_features for kind in ("fsk2", "fsk4", "psk2", "psk4"): f = extract_features(make(kind), FS, snr_db=28) assert f.baud_stability > 0.8, f"{kind}: {f.baud_stability}" for kind in ("noise", "nfm", "carrier"): f = extract_features(make(kind), FS, snr_db=28) assert f.baud_stability < 0.8, f"{kind}: {f.baud_stability}" def test_keying_needs_a_regular_grid(): """On/off contrast alone is not keying: a fading carrier produces plenty.""" from bandsaunter.classify import extract_features keyed = extract_features(make("cw"), FS, snr_db=28) noise = extract_features(make("noise"), FS, snr_db=0) assert keyed.keying_regularity > noise.keying_regularity def test_a_fading_carrier_is_not_digital(): """A carrier drifting across the squelch has contrast but no symbol grid.""" from bandsaunter.classify import extract_features rng = np.random.default_rng(5) n = 64000 t = np.arange(n) / FS # Slow random fading, nothing quantised about it. fade = np.interp(t, np.linspace(0, t[-1], 40), rng.random(40) ** 2) x = (fade * np.exp(2j * np.pi * 300 * t)).astype(np.complex64) x += 0.02 * (rng.standard_normal(n) + 1j * rng.standard_normal(n)) score, bits = digital_structure(extract_features(x, FS, snr_db=20)) assert score <= 0.3, f"fading carrier scored as digital: {bits}"