[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:
@@ -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)],
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user