[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:
@@ -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)],
|
||||
|
||||
Reference in New Issue
Block a user