Tune SSB to the carrier, and read the sideband from the signal

An SSB capture was tuned to the centroid of the detected energy, which is
what every other mode wants and the one thing SSB cannot use. Its
demodulator is a filter that opens at the suppressed carrier, so centring
on the middle of the voice filtered off its lower half and shifted the
rest down by the error. Measured against known transmitters that error was
+2445 Hz on 20 m and -2486 Hz on 80 m, against 35 Hz for NFM and 9 Hz for
AM, which do not care either way. On real speech the difference is a clean
transcript versus nothing recognisable at all.

Speech puts most of its power just above the carrier, so the occupied band
leans towards it. ssb_alignment() reads that lean: it locates the carrier
to within about 100 Hz and names the sideband at the same time, without
recourse to any convention. The offset is applied inside the demodulator
at the IF rate, where it costs a fraction of what shifting the full-rate
stream would, and the reported frequency becomes the carrier -- the
frequency an operator would dial in.

The sideband was also decided by "LSB below 10 MHz, USB above", which
overrode a band plan that already knew better and demodulated 60 m as LSB.
The measurement decides now, the band plan when the signal has no lean to
read, and the convention only when neither has anything to say.

The simulator was transmitting SSB unfiltered, several times wider than
anything on the air, because it applied no transmit audio filter -- and
that filter is what makes a signal single-sideband. Its absence hid the
whole problem from the tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
The Dust Council 2026-08-21 23:09:44 -07:00
parent af51b2657d
commit 2aa8739f25
6 changed files with 355 additions and 8 deletions

160
tests/test_ssb.py Normal file
View file

@ -0,0 +1,160 @@
"""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