[fix](trx-backend-soapysdr): fix SSB demodulation with asymmetric complex BPF

Three bugs caused USB/LSB to sound like AM:

1. The IQ low-pass filter was symmetric (passband ±BW/2), so both
   sidebands were passed equally — taking .re then produced DSB-SC
   rather than SSB audio.

2. cutoff_hz was computed as bandwidth_hz/2, halving the usable audio
   bandwidth (1500 Hz for a 3 kHz USB channel).

3. demod_lsb claimed spectrum inversion was "handled upstream by
   negating channel_if_hz", but that negation was never applied; USB
   and LSB were functionally identical.

Fix: add a shift_norm parameter to build_fir_kernel / BlockFirFilterPair
that complex-modulates the time-domain FIR coefficients by
e^{j·2π·shift_norm·n}, shifting the passband in the frequency domain.
A new ssb_shift_norm() helper returns +cutoff_norm for USB/CW/DIG
([0, BW] Hz passband) and -cutoff_norm for LSB/CWR ([-BW, 0] Hz
passband); all other modes get 0.0 (symmetric LPF unchanged).

After the one-sided filter, taking .re correctly reconstructs the
selected sideband. No IF negation is needed for LSB.

Also fix two unit tests missing the force_mono_pcm argument introduced
after they were last updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-15 08:34:55 +01:00
committed by Stan Grams
parent 342adf476c
commit ab3bf9120e
3 changed files with 61 additions and 9 deletions
@@ -4,17 +4,26 @@
use num_complex::Complex;
/// USB demodulator: take the real part of each IQ sample.
/// USB demodulator: take the real part of each baseband IQ sample.
///
/// Sideband selection is performed upstream by the asymmetric complex BPF
/// (passband [0, BW] Hz), so only the upper sideband reaches this point.
pub(super) fn demod_usb(samples: &[Complex<f32>]) -> Vec<f32> {
samples.iter().map(|sample| sample.re).collect()
}
/// LSB demodulator: mixing is handled upstream by negating `channel_if_hz`.
/// LSB demodulator: take the real part of each baseband IQ sample.
///
/// Sideband selection is performed upstream by the asymmetric complex BPF
/// (passband [-BW, 0] Hz), so only the lower sideband reaches this point.
pub(super) fn demod_lsb(samples: &[Complex<f32>]) -> Vec<f32> {
samples.iter().map(|sample| sample.re).collect()
}
/// CW demodulator: take the real part of each baseband IQ sample.
///
/// Sideband selection is performed upstream by the asymmetric complex BPF
/// (CW: [0, BW], CWR: [-BW, 0] Hz).
pub(super) fn demod_cw(samples: &[Complex<f32>]) -> Vec<f32> {
samples.iter().map(|sample| sample.re).collect()
}
@@ -92,6 +92,24 @@ impl VirtualSquelch {
}
}
/// Frequency shift for the IQ bandpass filter, expressed as a fraction of Fs.
///
/// For SSB modes the symmetric LPF (cutoff ±BW/2) is modulated by ±cutoff_norm
/// to produce a one-sided passband:
/// USB / CW → [0, BW] Hz (shift up by +cutoff_norm)
/// LSB / CWR → [-BW, 0] Hz (shift down by -cutoff_norm)
/// Everything else → symmetric LPF (shift_norm = 0)
///
/// After filtering, `demod_usb` / `demod_lsb` take `.re`, which correctly
/// reconstructs the audio from the one-sided complex signal.
fn ssb_shift_norm(mode: &RigMode, cutoff_norm: f32) -> f32 {
match mode {
RigMode::USB | RigMode::DIG | RigMode::CW | RigMode::Other(_) => cutoff_norm,
RigMode::LSB | RigMode::CWR => -cutoff_norm,
_ => 0.0,
}
}
fn agc_for_mode(mode: &RigMode, audio_sample_rate: u32) -> SoftAgc {
let sr = audio_sample_rate.max(1) as f32;
match mode {
@@ -226,7 +244,7 @@ impl ChannelDsp {
} else {
(cutoff_hz / self.sdr_sample_rate as f32).min(0.499)
};
self.lpf_iq = BlockFirFilterPair::new(cutoff_norm, self.fir_taps, IQ_BLOCK_SIZE);
self.lpf_iq = BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(&self.mode, cutoff_norm), self.fir_taps, IQ_BLOCK_SIZE);
let rate_changed = self.decim_factor != next_decim_factor;
self.decim_factor = next_decim_factor;
self.decim_counter = 0;
@@ -303,7 +321,7 @@ impl ChannelDsp {
channel_if_hz,
demodulator: Demodulator::for_mode(mode),
mode: mode.clone(),
lpf_iq: BlockFirFilterPair::new(cutoff_norm, taps, IQ_BLOCK_SIZE),
lpf_iq: BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(mode, cutoff_norm), taps, IQ_BLOCK_SIZE),
sdr_sample_rate,
audio_sample_rate,
audio_bandwidth_hz,
@@ -606,6 +624,7 @@ mod tests {
75,
true,
31,
false,
VirtualSquelchConfig::default(),
pcm_tx,
iq_tx,
@@ -629,6 +648,7 @@ mod tests {
75,
true,
31,
false,
VirtualSquelchConfig::default(),
pcm_tx,
iq_tx,
@@ -100,7 +100,16 @@ type FirKernel = (
Arc<dyn Fft<f32>>,
);
fn build_fir_kernel(cutoff_norm: f32, taps: usize, block_size: usize) -> FirKernel {
/// Build an FFT-domain FIR kernel.
///
/// `shift_norm` shifts the passband by that fraction of Fs via complex
/// modulation of the time-domain coefficients:
/// h_shifted[n] = h_lpf[n] · e^{j·2π·shift_norm·n}
///
/// Setting `shift_norm = +cutoff_norm` produces a one-sided USB filter
/// `[0, BW]`; `shift_norm = -cutoff_norm` produces a one-sided LSB filter
/// `[-BW, 0]`; `shift_norm = 0` leaves the kernel symmetric (AM/FM/WFM).
fn build_fir_kernel(cutoff_norm: f32, shift_norm: f32, taps: usize, block_size: usize) -> FirKernel {
let coeffs = windowed_sinc_coeffs(cutoff_norm, taps);
let fft_size = (block_size + taps - 1).next_power_of_two();
@@ -110,7 +119,16 @@ fn build_fir_kernel(cutoff_norm: f32, taps: usize, block_size: usize) -> FirKern
let mut h_buf: Vec<FftComplex<f32>> = coeffs
.iter()
.map(|&coeff| FftComplex::new(coeff, 0.0))
.enumerate()
.map(|(n, &coeff)| {
if shift_norm == 0.0 {
FftComplex::new(coeff, 0.0)
} else {
let phase = 2.0 * PI * shift_norm * n as f32;
let (sin_p, cos_p) = phase.sin_cos();
FftComplex::new(coeff * cos_p, coeff * sin_p)
}
})
.collect();
fft.process({
h_buf.resize(fft_size, FftComplex::new(0.0, 0.0));
@@ -178,7 +196,7 @@ fn mul_freq_domain(buf: &mut [FftComplex<f32>], h_freq: &[FftComplex<f32>], scal
impl BlockFirFilter {
pub fn new(cutoff_norm: f32, taps: usize, block_size: usize) -> Self {
let taps = taps.max(1);
let (h_buf, fft_size, fft, ifft) = build_fir_kernel(cutoff_norm, taps, block_size);
let (h_buf, fft_size, fft, ifft) = build_fir_kernel(cutoff_norm, 0.0, taps, block_size);
Self {
h_freq: h_buf,
@@ -235,9 +253,14 @@ impl BlockFirFilter {
}
impl BlockFirFilterPair {
pub fn new(cutoff_norm: f32, taps: usize, block_size: usize) -> Self {
/// Create an IQ filter pair.
///
/// `shift_norm` shifts the passband (see `build_fir_kernel`). Pass `0.0`
/// for a symmetric LPF (AM/FM/WFM); pass `+cutoff_norm` for USB/CW; pass
/// `-cutoff_norm` for LSB/CWR.
pub fn new(cutoff_norm: f32, shift_norm: f32, taps: usize, block_size: usize) -> Self {
let taps = taps.max(1);
let (h_buf, fft_size, fft, ifft) = build_fir_kernel(cutoff_norm, taps, block_size);
let (h_buf, fft_size, fft, ifft) = build_fir_kernel(cutoff_norm, shift_norm, taps, block_size);
Self {
h_freq: h_buf,
overlap: vec![FftComplex::new(0.0, 0.0); taps.saturating_sub(1)],