diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/ssb.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/ssb.rs index fa7b997..c3bbb6c 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/ssb.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/ssb.rs @@ -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]) -> Vec { 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]) -> Vec { 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]) -> Vec { samples.iter().map(|sample| sample.re).collect() } diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs index 2722561..7c16ccc 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs @@ -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, diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/filter.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/filter.rs index 02292a8..b945ba1 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/filter.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/filter.rs @@ -100,7 +100,16 @@ type FirKernel = ( Arc>, ); -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> = 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], h_freq: &[FftComplex], 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)],