[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 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-03-16 23:17:23 +01:00
parent 1a8bfb3c4e
commit 37a5600d99
4 changed files with 29 additions and 55 deletions
@@ -147,7 +147,7 @@ impl SdrPipeline {
wfm_deemphasis_us: u32, wfm_deemphasis_us: u32,
wfm_stereo: bool, wfm_stereo: bool,
squelch_cfg: VirtualSquelchConfig, squelch_cfg: VirtualSquelchConfig,
channels: &[(f64, RigMode, u32, usize)], channels: &[(f64, RigMode, u32)],
) -> Self { ) -> Self {
const IQ_BROADCAST_CAPACITY: usize = 64; const IQ_BROADCAST_CAPACITY: usize = 64;
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY); let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
@@ -158,7 +158,7 @@ impl SdrPipeline {
let mut iq_senders = Vec::with_capacity(channels.len()); let mut iq_senders = Vec::with_capacity(channels.len());
let mut channel_dsps_vec: Vec<Arc<Mutex<ChannelDsp>>> = Vec::with_capacity(channels.len()); let mut channel_dsps_vec: Vec<Arc<Mutex<ChannelDsp>>> = 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() channels.iter().enumerate()
{ {
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(PCM_BROADCAST_CAPACITY); let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(PCM_BROADCAST_CAPACITY);
@@ -178,7 +178,6 @@ impl SdrPipeline {
audio_bandwidth_hz, audio_bandwidth_hz,
wfm_deemphasis_us, wfm_deemphasis_us,
wfm_stereo, wfm_stereo,
fir_taps,
false, false,
channel_squelch_cfg, channel_squelch_cfg,
pcm_tx.clone(), pcm_tx.clone(),
@@ -250,7 +249,6 @@ impl SdrPipeline {
channel_if_hz: f64, channel_if_hz: f64,
mode: &RigMode, mode: &RigMode,
bandwidth_hz: u32, bandwidth_hz: u32,
fir_taps: usize,
) -> (broadcast::Sender<Vec<f32>>, broadcast::Sender<Vec<Complex<f32>>>) { ) -> (broadcast::Sender<Vec<f32>>, broadcast::Sender<Vec<Complex<f32>>>) {
const PCM_BROADCAST_CAPACITY: usize = 32; const PCM_BROADCAST_CAPACITY: usize = 32;
const IQ_BROADCAST_CAPACITY: usize = 64; const IQ_BROADCAST_CAPACITY: usize = 64;
@@ -266,7 +264,6 @@ impl SdrPipeline {
bandwidth_hz, bandwidth_hz,
self.wfm_deemphasis_us, self.wfm_deemphasis_us,
self.wfm_stereo, self.wfm_stereo,
fir_taps.max(1),
false, false,
VirtualSquelchConfig::default(), VirtualSquelchConfig::default(),
pcm_tx.clone(), pcm_tx.clone(),
@@ -558,7 +555,7 @@ mod tests {
75, 75,
true, true,
VirtualSquelchConfig::default(), 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.pcm_senders.len(), 1);
assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1); assert_eq!(pipeline.channel_dsps.read().unwrap().len(), 1);
@@ -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. /// Per-channel DSP state: mixer, FFT-FIR, decimator, demodulator, frame accumulator.
pub struct ChannelDsp { pub struct ChannelDsp {
pub channel_if_hz: f64, pub channel_if_hz: f64,
@@ -169,7 +181,6 @@ pub struct ChannelDsp {
sdr_sample_rate: u32, sdr_sample_rate: u32,
audio_sample_rate: u32, audio_sample_rate: u32,
audio_bandwidth_hz: u32, audio_bandwidth_hz: u32,
fir_taps: usize,
wfm_deemphasis_us: u32, wfm_deemphasis_us: u32,
wfm_stereo: bool, wfm_stereo: bool,
wfm_denoise: WfmDenoiseLevel, wfm_denoise: WfmDenoiseLevel,
@@ -254,7 +265,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, 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; 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;
@@ -297,7 +308,6 @@ impl ChannelDsp {
audio_bandwidth_hz: u32, audio_bandwidth_hz: u32,
wfm_deemphasis_us: u32, wfm_deemphasis_us: u32,
wfm_stereo: bool, wfm_stereo: bool,
fir_taps: usize,
force_mono_pcm: bool, force_mono_pcm: bool,
squelch_cfg: VirtualSquelchConfig, squelch_cfg: VirtualSquelchConfig,
pcm_tx: broadcast::Sender<Vec<f32>>, pcm_tx: broadcast::Sender<Vec<f32>>,
@@ -311,7 +321,6 @@ impl ChannelDsp {
(audio_sample_rate as usize * frame_duration_ms as usize * output_channels) / 1000 (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) = let (decim_factor, channel_sample_rate) =
Self::pipeline_rates(mode, sdr_sample_rate, audio_sample_rate, audio_bandwidth_hz); 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; 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, 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, 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, sdr_sample_rate,
audio_sample_rate, audio_sample_rate,
audio_bandwidth_hz, audio_bandwidth_hz,
fir_taps: taps,
wfm_deemphasis_us, wfm_deemphasis_us,
wfm_stereo, wfm_stereo,
wfm_denoise: WfmDenoiseLevel::Auto, wfm_denoise: WfmDenoiseLevel::Auto,
@@ -406,9 +414,8 @@ impl ChannelDsp {
self.rebuild_filters(true); 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.audio_bandwidth_hz = Self::clamp_bandwidth_for_mode(&self.mode, bandwidth_hz);
self.fir_taps = taps.max(1);
self.rebuild_filters(false); self.rebuild_filters(false);
} }
@@ -633,7 +640,6 @@ mod tests {
3000, 3000,
75, 75,
true, true,
31,
false, false,
VirtualSquelchConfig::default(), VirtualSquelchConfig::default(),
pcm_tx, pcm_tx,
@@ -657,7 +663,6 @@ mod tests {
3000, 3000,
75, 75,
true, true,
31,
false, false,
VirtualSquelchConfig::default(), VirtualSquelchConfig::default(),
pcm_tx, pcm_tx,
@@ -33,7 +33,6 @@ pub struct SoapySdrRig {
primary_channel_idx: usize, primary_channel_idx: usize,
/// Current filter state of the primary channel (for filter_controls support). /// Current filter state of the primary channel (for filter_controls support).
bandwidth_hz: u32, bandwidth_hz: u32,
fir_taps: u32,
/// Shared spectrum magnitude buffer populated by the IQ read loop. /// Shared spectrum magnitude buffer populated by the IQ read loop.
spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>, spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
/// How many Hz below the dial frequency the SDR hardware is actually tuned. /// 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"`). /// - `args`: SoapySDR device args string (e.g. `"driver=rtlsdr"`).
/// Opens a real hardware device via SoapySDR. /// Opens a real hardware device via SoapySDR.
/// - `channels`: per-channel tuples of /// - `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_mode`: `"auto"` or `"manual"`.
/// - `gain_db`: gain in dB; used when `gain_mode == "manual"`. /// - `gain_db`: gain in dB; used when `gain_mode == "manual"`.
/// - `max_gain_db`: optional hard ceiling for the applied hardware gain. /// - `max_gain_db`: optional hard ceiling for the applied hardware gain.
@@ -111,7 +110,7 @@ impl SoapySdrRig {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn new_with_config( pub fn new_with_config(
args: &str, args: &str,
channels: &[(f64, RigMode, u32, usize)], channels: &[(f64, RigMode, u32)],
gain_mode: &str, gain_mode: &str,
gain_db: f64, gain_db: f64,
max_gain_db: Option<f64>, max_gain_db: Option<f64>,
@@ -178,13 +177,11 @@ impl SoapySdrRig {
(initial_freq.hz as i64 - hardware_center_hz) as f64, (initial_freq.hz as i64 - hardware_center_hz) as f64,
RigMode::FM, RigMode::FM,
25_000, 25_000,
96,
)); ));
all_channels.push(( all_channels.push((
(initial_freq.hz as i64 + AIS_CHANNEL_SPACING_HZ - hardware_center_hz) as f64, (initial_freq.hz as i64 + AIS_CHANNEL_SPACING_HZ - hardware_center_hz) as f64,
RigMode::FM, RigMode::FM,
25_000, 25_000,
96,
)); ));
let block_ms = if sdr_sample_rate == 0 { let block_ms = if sdr_sample_rate == 0 {
0.0 0.0
@@ -259,10 +256,10 @@ impl SoapySdrRig {
}; };
// Initialise filter state from primary channel config (index 0), or defaults. // Initialise filter state from primary channel config (index 0), or defaults.
let (bandwidth_hz, fir_taps) = channels let bandwidth_hz = channels
.first() .first()
.map(|&(_, _, bw, taps)| (bw, taps as u32)) .map(|&(_, _, bw)| bw)
.unwrap_or((3000, 64)); .unwrap_or(3000);
let spectrum_buf = pipeline.spectrum_buf.clone(); let spectrum_buf = pipeline.spectrum_buf.clone();
let retune_cmd = pipeline.retune_cmd.clone(); let retune_cmd = pipeline.retune_cmd.clone();
@@ -286,7 +283,6 @@ impl SoapySdrRig {
pipeline, pipeline,
primary_channel_idx: 0, primary_channel_idx: 0,
bandwidth_hz, bandwidth_hz,
fir_taps,
spectrum_buf, spectrum_buf,
center_offset_hz, center_offset_hz,
center_hz: hardware_center_hz, center_hz: hardware_center_hz,
@@ -365,7 +361,7 @@ impl SoapySdrRig {
dsp_arc dsp_arc
.lock() .lock()
.unwrap() .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) { if let Some(dsp_arc) = dsps.get(self.primary_channel_idx) {
let mut dsp = dsp_arc.lock().unwrap(); let mut dsp = dsp_arc.lock().unwrap();
dsp.set_mode(&mode); 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(); self.apply_ais_channel_activity();
@@ -755,28 +751,7 @@ impl RigCat for SoapySdrRig {
dsp_arc dsp_arc
.lock() .lock()
.unwrap() .unwrap()
.set_filter(bandwidth_hz, self.fir_taps as usize); .set_filter(bandwidth_hz);
}
}
self.apply_ais_channel_filters();
Ok(())
})
}
fn set_fir_taps<'a>(
&'a mut self,
taps: u32,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = DynResult<()>> + 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);
} }
} }
self.apply_ais_channel_filters(); self.apply_ais_channel_filters();
@@ -827,7 +802,6 @@ impl RigCat for SoapySdrRig {
.unwrap_or(false); .unwrap_or(false);
Some(RigFilterState { Some(RigFilterState {
bandwidth_hz: self.bandwidth_hz, bandwidth_hz: self.bandwidth_hz,
fir_taps: self.fir_taps,
cw_center_hz: 700, cw_center_hz: 700,
sdr_gain_db: Some( sdr_gain_db: Some(
self.max_gain_db self.max_gain_db
@@ -39,8 +39,6 @@ use trx_core::vchan::{VChanError, VChannelInfo, VirtualChannelManager};
// Default DSP parameters for virtual channels // Default DSP parameters for virtual channels
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DEFAULT_FIR_TAPS: usize = 64;
fn default_bandwidth_hz(mode: &RigMode) -> u32 { fn default_bandwidth_hz(mode: &RigMode) -> u32 {
match mode { match mode {
RigMode::CW | RigMode::CWR => 500, RigMode::CW | RigMode::CWR => 500,
@@ -181,7 +179,7 @@ impl SdrVirtualChannelManager {
let bandwidth_hz = default_bandwidth_hz(mode); let bandwidth_hz = default_bandwidth_hz(mode);
let (pcm_tx, iq_tx) = let (pcm_tx, iq_tx) =
self.pipeline 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 let pipeline_slot = self
.pipeline .pipeline
@@ -344,7 +342,7 @@ impl VirtualChannelManager for SdrVirtualChannelManager {
let dsps = self.pipeline.channel_dsps.read().unwrap(); let dsps = self.pipeline.channel_dsps.read().unwrap();
if let Some(dsp_arc) = dsps.get(ch.pipeline_slot) { 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(()) Ok(())
} }
@@ -436,7 +434,7 @@ mod tests {
75, 75,
true, true,
VirtualSquelchConfig::default(), VirtualSquelchConfig::default(),
&[(0.0, RigMode::USB, 3_000, 64)], &[(0.0, RigMode::USB, 3_000)],
)) ))
} }