[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_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::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
@@ -158,7 +158,7 @@ impl SdrPipeline {
let mut iq_senders = 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()
{
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(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<Vec<f32>>, broadcast::Sender<Vec<Complex<f32>>>) {
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);
@@ -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<Vec<f32>>,
@@ -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,
@@ -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<Mutex<Option<Vec<f32>>>>,
/// 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<f64>,
@@ -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<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);
.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
@@ -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)],
))
}