From 37a5600d998ced319deefef7825754943b76bdf8 Mon Sep 17 00:00:00 2001 From: Stanislaw Grams Date: Mon, 16 Mar 2026 23:17:23 +0100 Subject: [PATCH] [refactor](trx-backend-soapysdr): auto-calculate FIR taps from bandwidth Replace the static fir_taps parameter with auto_taps(cutoff_norm) which computes ceil(3.32 / cutoff_norm).clamp(63, 16383). This ensures the filter transition band equals one passband width regardless of SDR sample rate, giving correct image rejection when the user sets audio_bandwidth_hz. At 912 ksps with 3 kHz audio bandwidth this yields ~2018 taps instead of the previous hardcoded 64, eliminating the 114 kHz stopband gap that caused adjacent-band signals to alias into the audio output. Co-authored-by: Claude Sonnet 4.6 Signed-off-by: Stanislaw Grams --- .../trx-backend-soapysdr/src/dsp.rs | 9 ++-- .../trx-backend-soapysdr/src/dsp/channel.rs | 25 ++++++----- .../trx-backend-soapysdr/src/lib.rs | 42 ++++--------------- .../trx-backend-soapysdr/src/vchan_impl.rs | 8 ++-- 4 files changed, 29 insertions(+), 55 deletions(-) diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs index 7849f4f..cb1653a 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs @@ -147,7 +147,7 @@ impl SdrPipeline { wfm_deemphasis_us: u32, wfm_stereo: bool, squelch_cfg: VirtualSquelchConfig, - channels: &[(f64, RigMode, u32, usize)], + channels: &[(f64, RigMode, u32)], ) -> Self { const IQ_BROADCAST_CAPACITY: usize = 64; let (iq_tx, _iq_rx) = broadcast::channel::>>(IQ_BROADCAST_CAPACITY); @@ -158,7 +158,7 @@ impl SdrPipeline { let mut iq_senders = Vec::with_capacity(channels.len()); let mut channel_dsps_vec: Vec>> = Vec::with_capacity(channels.len()); - for (channel_idx, &(channel_if_hz, ref mode, audio_bandwidth_hz, fir_taps)) in + for (channel_idx, &(channel_if_hz, ref mode, audio_bandwidth_hz)) in channels.iter().enumerate() { let (pcm_tx, _pcm_rx) = broadcast::channel::>(PCM_BROADCAST_CAPACITY); @@ -178,7 +178,6 @@ impl SdrPipeline { audio_bandwidth_hz, wfm_deemphasis_us, wfm_stereo, - fir_taps, false, channel_squelch_cfg, pcm_tx.clone(), @@ -250,7 +249,6 @@ impl SdrPipeline { channel_if_hz: f64, mode: &RigMode, bandwidth_hz: u32, - fir_taps: usize, ) -> (broadcast::Sender>, broadcast::Sender>>) { const PCM_BROADCAST_CAPACITY: usize = 32; const IQ_BROADCAST_CAPACITY: usize = 64; @@ -266,7 +264,6 @@ impl SdrPipeline { bandwidth_hz, self.wfm_deemphasis_us, self.wfm_stereo, - fir_taps.max(1), false, VirtualSquelchConfig::default(), pcm_tx.clone(), @@ -558,7 +555,7 @@ mod tests { 75, true, VirtualSquelchConfig::default(), - &[(200_000.0, RigMode::USB, 3000, 64)], + &[(200_000.0, RigMode::USB, 3000)], ); assert_eq!(pipeline.pcm_senders.len(), 1); assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1); 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 2d5a3b3..ac745d1 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 @@ -160,6 +160,18 @@ fn default_bandwidth_for_mode(mode: &RigMode) -> u32 { } } +/// Calculate the FIR tap count automatically from the normalised cutoff frequency. +/// +/// Uses the Hann-windowed sinc rule-of-thumb: taps = ceil(3.32 / cutoff_norm), +/// clamped to [63, 16383]. This gives enough taps so the filter transition band +/// equals one passband width (image rejection starts at audio_bandwidth_hz). +fn auto_taps(cutoff_norm: f32) -> usize { + if cutoff_norm <= 0.0 { + return 63; + } + ((3.32 / cutoff_norm).ceil() as usize).clamp(63, 16383) +} + /// Per-channel DSP state: mixer, FFT-FIR, decimator, demodulator, frame accumulator. pub struct ChannelDsp { pub channel_if_hz: f64, @@ -169,7 +181,6 @@ pub struct ChannelDsp { sdr_sample_rate: u32, audio_sample_rate: u32, audio_bandwidth_hz: u32, - fir_taps: usize, wfm_deemphasis_us: u32, wfm_stereo: bool, wfm_denoise: WfmDenoiseLevel, @@ -254,7 +265,7 @@ impl ChannelDsp { } else { (cutoff_hz / self.sdr_sample_rate as f32).min(0.499) }; - self.lpf_iq = BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(&self.mode, cutoff_norm), self.fir_taps, IQ_BLOCK_SIZE); + self.lpf_iq = BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(&self.mode, cutoff_norm), auto_taps(cutoff_norm), IQ_BLOCK_SIZE); let rate_changed = self.decim_factor != next_decim_factor; self.decim_factor = next_decim_factor; self.decim_counter = 0; @@ -297,7 +308,6 @@ impl ChannelDsp { audio_bandwidth_hz: u32, wfm_deemphasis_us: u32, wfm_stereo: bool, - fir_taps: usize, force_mono_pcm: bool, squelch_cfg: VirtualSquelchConfig, pcm_tx: broadcast::Sender>, @@ -311,7 +321,6 @@ impl ChannelDsp { (audio_sample_rate as usize * frame_duration_ms as usize * output_channels) / 1000 }; - let taps = fir_taps.max(1); let (decim_factor, channel_sample_rate) = Self::pipeline_rates(mode, sdr_sample_rate, audio_sample_rate, audio_bandwidth_hz); let cutoff_hz = audio_bandwidth_hz.min(channel_sample_rate.saturating_sub(1)) as f32 / 2.0; @@ -331,11 +340,10 @@ impl ChannelDsp { channel_if_hz, demodulator: Demodulator::for_mode(mode), mode: mode.clone(), - lpf_iq: BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(mode, cutoff_norm), taps, IQ_BLOCK_SIZE), + lpf_iq: BlockFirFilterPair::new(cutoff_norm, ssb_shift_norm(mode, cutoff_norm), auto_taps(cutoff_norm), IQ_BLOCK_SIZE), sdr_sample_rate, audio_sample_rate, audio_bandwidth_hz, - fir_taps: taps, wfm_deemphasis_us, wfm_stereo, wfm_denoise: WfmDenoiseLevel::Auto, @@ -406,9 +414,8 @@ impl ChannelDsp { self.rebuild_filters(true); } - pub fn set_filter(&mut self, bandwidth_hz: u32, taps: usize) { + pub fn set_filter(&mut self, bandwidth_hz: u32) { self.audio_bandwidth_hz = Self::clamp_bandwidth_for_mode(&self.mode, bandwidth_hz); - self.fir_taps = taps.max(1); self.rebuild_filters(false); } @@ -633,7 +640,6 @@ mod tests { 3000, 75, true, - 31, false, VirtualSquelchConfig::default(), pcm_tx, @@ -657,7 +663,6 @@ mod tests { 3000, 75, true, - 31, false, VirtualSquelchConfig::default(), pcm_tx, diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs index 747493a..8ff019b 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs @@ -33,7 +33,6 @@ pub struct SoapySdrRig { primary_channel_idx: usize, /// Current filter state of the primary channel (for filter_controls support). bandwidth_hz: u32, - fir_taps: u32, /// Shared spectrum magnitude buffer populated by the IQ read loop. spectrum_buf: Arc>>>, /// How many Hz below the dial frequency the SDR hardware is actually tuned. @@ -89,7 +88,7 @@ impl SoapySdrRig { /// - `args`: SoapySDR device args string (e.g. `"driver=rtlsdr"`). /// Opens a real hardware device via SoapySDR. /// - `channels`: per-channel tuples of - /// `(channel_if_hz, initial_mode, audio_bandwidth_hz, fir_taps)`. + /// `(channel_if_hz, initial_mode, audio_bandwidth_hz)`. /// - `gain_mode`: `"auto"` or `"manual"`. /// - `gain_db`: gain in dB; used when `gain_mode == "manual"`. /// - `max_gain_db`: optional hard ceiling for the applied hardware gain. @@ -111,7 +110,7 @@ impl SoapySdrRig { #[allow(clippy::too_many_arguments)] pub fn new_with_config( args: &str, - channels: &[(f64, RigMode, u32, usize)], + channels: &[(f64, RigMode, u32)], gain_mode: &str, gain_db: f64, max_gain_db: Option, @@ -178,13 +177,11 @@ impl SoapySdrRig { (initial_freq.hz as i64 - hardware_center_hz) as f64, RigMode::FM, 25_000, - 96, )); all_channels.push(( (initial_freq.hz as i64 + AIS_CHANNEL_SPACING_HZ - hardware_center_hz) as f64, RigMode::FM, 25_000, - 96, )); let block_ms = if sdr_sample_rate == 0 { 0.0 @@ -259,10 +256,10 @@ impl SoapySdrRig { }; // Initialise filter state from primary channel config (index 0), or defaults. - let (bandwidth_hz, fir_taps) = channels + let bandwidth_hz = channels .first() - .map(|&(_, _, bw, taps)| (bw, taps as u32)) - .unwrap_or((3000, 64)); + .map(|&(_, _, bw)| bw) + .unwrap_or(3000); let spectrum_buf = pipeline.spectrum_buf.clone(); let retune_cmd = pipeline.retune_cmd.clone(); @@ -286,7 +283,6 @@ impl SoapySdrRig { pipeline, primary_channel_idx: 0, bandwidth_hz, - fir_taps, spectrum_buf, center_offset_hz, center_hz: hardware_center_hz, @@ -365,7 +361,7 @@ impl SoapySdrRig { dsp_arc .lock() .unwrap() - .set_filter(self.bandwidth_hz, self.fir_taps as usize); + .set_filter(self.bandwidth_hz); } } } @@ -537,7 +533,7 @@ impl RigCat for SoapySdrRig { if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) { let mut dsp = dsp_arc.lock().unwrap(); dsp.set_mode(&mode); - dsp.set_filter(self.bandwidth_hz, self.fir_taps as usize); + dsp.set_filter(self.bandwidth_hz); } } self.apply_ais_channel_activity(); @@ -755,28 +751,7 @@ impl RigCat for SoapySdrRig { dsp_arc .lock() .unwrap() - .set_filter(bandwidth_hz, self.fir_taps as usize); - } - } - self.apply_ais_channel_filters(); - Ok(()) - }) - } - - fn set_fir_taps<'a>( - &'a mut self, - taps: u32, - ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - tracing::debug!("SoapySdrRig: set_fir_taps -> {}", taps); - self.fir_taps = taps; - { - let dsps = self.pipeline.channel_dsps.read().unwrap(); - if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) { - dsp_arc - .lock() - .unwrap() - .set_filter(self.bandwidth_hz, taps as usize); + .set_filter(bandwidth_hz); } } self.apply_ais_channel_filters(); @@ -827,7 +802,6 @@ impl RigCat for SoapySdrRig { .unwrap_or(false); Some(RigFilterState { bandwidth_hz: self.bandwidth_hz, - fir_taps: self.fir_taps, cw_center_hz: 700, sdr_gain_db: Some( self.max_gain_db diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs index daa4f53..9c85468 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs @@ -39,8 +39,6 @@ use trx_core::vchan::{VChanError, VChannelInfo, VirtualChannelManager}; // Default DSP parameters for virtual channels // --------------------------------------------------------------------------- -const DEFAULT_FIR_TAPS: usize = 64; - fn default_bandwidth_hz(mode: &RigMode) -> u32 { match mode { RigMode::CW | RigMode::CWR => 500, @@ -181,7 +179,7 @@ impl SdrVirtualChannelManager { let bandwidth_hz = default_bandwidth_hz(mode); let (pcm_tx, iq_tx) = self.pipeline - .add_virtual_channel(if_hz as f64, mode, bandwidth_hz, DEFAULT_FIR_TAPS); + .add_virtual_channel(if_hz as f64, mode, bandwidth_hz); let pipeline_slot = self .pipeline @@ -344,7 +342,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager { let dsps = self.pipeline.channel_dsps.read().unwrap(); if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) { - dsp_arc.lock().unwrap().set_filter(bandwidth_hz, DEFAULT_FIR_TAPS); + dsp_arc.lock().unwrap().set_filter(bandwidth_hz); } Ok(()) } @@ -436,7 +434,7 @@ mod tests { 75, true, VirtualSquelchConfig::default(), - &[(0.0, RigMode::USB, 3_000, 64)], + &[(0.0, RigMode::USB, 3_000)], )) }