"""Single sideband: finding the carrier, reading the sideband, and the audio. SSB is the one mode where tuning has to be exact. An FM discriminator and an AM envelope detector do not care where in their passband a signal sits, but an SSB demodulator is a filter that opens at the suppressed carrier: tune to the middle of the voice instead and its lower half is filtered away while the rest comes out shifted down by the error. """ import numpy as np import pytest from bandsaunter.classify import ssb_alignment from bandsaunter.config import ScanConfig from bandsaunter.demod import make_demodulator from bandsaunter.ranges import parse_range_list from bandsaunter.scanner import Scanner, ScannerCallbacks from bandsaunter.simulator import (SimulatedDevice, VirtualTransmitter as V, _speech_loop) FS = 256_000.0 def ssb(audio, fs, sideband="usb"): """Analytic signal: single sideband with its carrier at 0 Hz.""" from scipy.signal import hilbert an = hilbert(audio).astype(np.complex64) return an if sideband == "usb" else np.conj(an) def at_offset(x, hz, fs=FS): """The same signal seen by a receiver tuned `hz` above its carrier.""" n = np.arange(x.size) return (x * np.exp(-2j * np.pi * hz * n / fs)).astype(np.complex64) def speech(seconds=0.8, fs=FS, band=(300.0, 2_800.0)): """The simulator's talker, resampled onto the test's sample rate.""" from scipy.signal import resample_poly loop = _speech_loop(7, 130.0, band=band) audio = resample_poly(loop, int(fs), 16_000) n = int(seconds * fs) return np.tile(audio, int(n / audio.size) + 1)[:n] # --------------------------------------------------------------------------- # Reading the signal # --------------------------------------------------------------------------- @pytest.mark.parametrize("sideband", ["usb", "lsb"]) @pytest.mark.parametrize("centre", [0.0, 1500.0, -1200.0, 2450.0]) def test_the_carrier_is_found_wherever_the_receiver_sits(sideband, centre): """Speech leans towards its carrier, which locates it and names the side.""" x = at_offset(ssb(speech(), FS, sideband), centre) al = ssb_alignment(x, FS, 3_000.0) assert al is not None assert al.sideband == sideband # The carrier is at -centre relative to where the receiver is tuned. assert al.carrier_for(al.sideband) == pytest.approx(-centre, abs=250.0) def test_a_signal_with_no_lean_gives_no_opinion(): """Flat noise cannot say which sideband it is, and must not pretend to. The caller falls back on the band plan, which does know. """ from scipy.signal import butter, lfilter rng = np.random.default_rng(0) b, a = butter(6, [300 / (FS / 2), 2_700 / (FS / 2)], btype="band") flat = lfilter(b, a, rng.standard_normal(int(FS * 0.8))) al = ssb_alignment(ssb(flat, FS, "usb"), FS, 3_000.0) assert al is None or al.confidence < 0.12 def test_something_far_too_wide_is_not_read_as_ssb(): rng = np.random.default_rng(1) wide = rng.standard_normal(int(FS * 0.5)) + 1j * rng.standard_normal(int(FS * 0.5)) assert ssb_alignment(wide.astype(np.complex64), FS, 3_000.0) is None # --------------------------------------------------------------------------- # Recovering the audio # --------------------------------------------------------------------------- def tones(audio, rate, want): """Level in dB of each wanted frequency in a block of audio.""" spec = np.abs(np.fft.rfft(audio * np.hanning(audio.size))) freqs = np.fft.rfftfreq(audio.size, 1.0 / rate) top = spec.max() return [20.0 * np.log10(max(spec[np.argmin(np.abs(freqs - f))], 1e-12) / max(top, 1e-12)) for f in want] def two_tone(fs=FS, seconds=1.5): t = np.arange(int(fs * seconds)) / fs return (np.exp(2j * np.pi * 500.0 * t) + 0.7 * np.exp(2j * np.pi * 1800.0 * t)).astype(np.complex64) def test_ssb_audio_is_intact_when_the_carrier_offset_is_given(): """Two known tones must come back at the frequencies they went in at.""" x = at_offset(two_tone(), 2_000.0) # receiver 2 kHz above carrier d = make_demodulator("usb", int(FS), 2_800, 16_000, carrier_offset_hz=-2_000.0) audio = d.process(x)[-16_000:] levels = tones(audio, d.audio_rate, [500.0, 1800.0]) assert max(levels) > -3.0, levels assert min(levels) > -12.0, levels def test_without_the_offset_the_same_capture_is_ruined(): """The bug this guards: the voice filtered away and the rest shifted.""" x = at_offset(two_tone(), 2_000.0) d = make_demodulator("usb", int(FS), 2_800, 16_000) audio = d.process(x)[-16_000:] assert max(tones(audio, d.audio_rate, [500.0, 1800.0])) < -20.0 # --------------------------------------------------------------------------- # End to end # --------------------------------------------------------------------------- def run(tmp_path, freq, mode, ranges, **over): dev = SimulatedDevice(transmitters=[V(freq, mode, 0.4, 2_800, "ssb tx")]).open() cfg = ScanConfig(ranges=parse_range_list(ranges), output_dir=str(tmp_path), record_seconds=3.0, hang_seconds=1.0, min_record_seconds=0.3, threshold_db=12, dwell_seconds=0.05, max_cycles=2, revisit_seconds=0.2) for k, v in over.items(): setattr(cfg, k, v) hits = [] s = Scanner(cfg, device=dev, callbacks=ScannerCallbacks(on_record_end=hits.append)) s.prepare() s.run() kept = [h for h in hits if h.kept] assert kept, "nothing recorded" return kept[0] def test_an_ssb_capture_lands_on_the_carrier(tmp_path): """Not on the middle of the voice, which is 2.5 kHz away from it.""" h = run(tmp_path, 14_250_000, "usb", "14.2M-14.3M") assert h.mode == "usb", h.classification assert h.frequency == pytest.approx(14_250_000, abs=400.0) def test_lower_sideband_lands_on_its_carrier_too(tmp_path): h = run(tmp_path, 3_800_000, "lsb", "3.75M-3.85M") assert h.mode == "lsb", h.classification assert h.frequency == pytest.approx(3_800_000, abs=400.0) def test_the_sideband_is_not_guessed_from_the_frequency(tmp_path): """60 m is upper sideband, well below the 10 MHz the convention splits on. Deciding by convention alone demodulated this as LSB. """ h = run(tmp_path, 5_357_000, "usb", "5.3M-5.4M") assert h.mode == "usb", h.classification assert "USB" in h.classification