[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;
|
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> {
|
pub(super) fn demod_usb(samples: &[Complex<f32>]) -> Vec<f32> {
|
||||||
samples.iter().map(|sample| sample.re).collect()
|
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> {
|
pub(super) fn demod_lsb(samples: &[Complex<f32>]) -> Vec<f32> {
|
||||||
samples.iter().map(|sample| sample.re).collect()
|
samples.iter().map(|sample| sample.re).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CW demodulator: take the real part of each baseband IQ sample.
|
/// 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> {
|
pub(super) fn demod_cw(samples: &[Complex<f32>]) -> Vec<f32> {
|
||||||
samples.iter().map(|sample| sample.re).collect()
|
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 {
|
fn agc_for_mode(mode: &RigMode, audio_sample_rate: u32) -> SoftAgc {
|
||||||
let sr = audio_sample_rate.max(1) as f32;
|
let sr = audio_sample_rate.max(1) as f32;
|
||||||
match mode {
|
match mode {
|
||||||
@@ -226,7 +244,7 @@ impl ChannelDsp {
|
|||||||
} else {
|
} else {
|
||||||
(cutoff_hz / self.sdr_sample_rate as f32).min(0.499)
|
(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;
|
let rate_changed = self.decim_factor != next_decim_factor;
|
||||||
self.decim_factor = next_decim_factor;
|
self.decim_factor = next_decim_factor;
|
||||||
self.decim_counter = 0;
|
self.decim_counter = 0;
|
||||||
@@ -303,7 +321,7 @@ impl ChannelDsp {
|
|||||||
channel_if_hz,
|
channel_if_hz,
|
||||||
demodulator: Demodulator::for_mode(mode),
|
demodulator: Demodulator::for_mode(mode),
|
||||||
mode: mode.clone(),
|
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,
|
sdr_sample_rate,
|
||||||
audio_sample_rate,
|
audio_sample_rate,
|
||||||
audio_bandwidth_hz,
|
audio_bandwidth_hz,
|
||||||
@@ -606,6 +624,7 @@ mod tests {
|
|||||||
75,
|
75,
|
||||||
true,
|
true,
|
||||||
31,
|
31,
|
||||||
|
false,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
iq_tx,
|
iq_tx,
|
||||||
@@ -629,6 +648,7 @@ mod tests {
|
|||||||
75,
|
75,
|
||||||
true,
|
true,
|
||||||
31,
|
31,
|
||||||
|
false,
|
||||||
VirtualSquelchConfig::default(),
|
VirtualSquelchConfig::default(),
|
||||||
pcm_tx,
|
pcm_tx,
|
||||||
iq_tx,
|
iq_tx,
|
||||||
|
|||||||
@@ -100,7 +100,16 @@ type FirKernel = (
|
|||||||
Arc<dyn Fft<f32>>,
|
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 coeffs = windowed_sinc_coeffs(cutoff_norm, taps);
|
||||||
let fft_size = (block_size + taps - 1).next_power_of_two();
|
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
|
let mut h_buf: Vec<FftComplex<f32>> = coeffs
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
fft.process({
|
fft.process({
|
||||||
h_buf.resize(fft_size, FftComplex::new(0.0, 0.0));
|
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 {
|
impl BlockFirFilter {
|
||||||
pub fn new(cutoff_norm: f32, taps: usize, block_size: usize) -> Self {
|
pub fn new(cutoff_norm: f32, taps: usize, block_size: usize) -> Self {
|
||||||
let taps = taps.max(1);
|
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 {
|
Self {
|
||||||
h_freq: h_buf,
|
h_freq: h_buf,
|
||||||
@@ -235,9 +253,14 @@ impl BlockFirFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl BlockFirFilterPair {
|
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 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 {
|
Self {
|
||||||
h_freq: h_buf,
|
h_freq: h_buf,
|
||||||
overlap: vec![FftComplex::new(0.0, 0.0); taps.saturating_sub(1)],
|
overlap: vec![FftComplex::new(0.0, 0.0); taps.saturating_sub(1)],
|
||||||
|
|||||||
Reference in New Issue
Block a user