From af9f2e092ea820af33b3f1e6175de87df9bb43e8 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Fri, 27 Feb 2026 22:09:26 +0100 Subject: [PATCH] fix(trx-backend-soapysdr,trx-server): fix hardware tuning and add live filter update - Compute hardware center as dial - center_offset_hz (was ignoring offset) - Pass configured bandwidth_hz to device instead of hardcoded 1.5 MHz - Add retune_cmd channel so set_freq repoints SDR hardware in real time - Auto-add default channel with mode-appropriate bandwidth when [[sdr.channels]] is empty, preventing silent audio with minimal config - Add ChannelDsp::set_filter to rebuild FIR LPFs at runtime; wire set_bandwidth and set_fir_taps to call it so UI filter changes take effect Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- src/trx-server/src/main.rs | 34 +++++++++++- .../trx-backend-soapysdr/src/dsp.rs | 52 ++++++++++++++++++- .../trx-backend-soapysdr/src/lib.rs | 45 ++++++++++++++-- .../src/real_iq_source.rs | 13 +++-- 4 files changed, 132 insertions(+), 12 deletions(-) diff --git a/src/trx-server/src/main.rs b/src/trx-server/src/main.rs index 7bb89fa..9d807a9 100644 --- a/src/trx-server/src/main.rs +++ b/src/trx-server/src/main.rs @@ -233,6 +233,20 @@ async fn wait_for_shutdown(mut shutdown_rx: watch::Receiver) { } } +/// Sensible default audio filter bandwidth (Hz) for each demodulation mode. +#[cfg(feature = "soapysdr")] +fn default_audio_bandwidth_for_mode(mode: &trx_core::rig::state::RigMode) -> u32 { + use trx_core::rig::state::RigMode; + match mode { + RigMode::LSB | RigMode::USB | RigMode::PKT | RigMode::DIG => 3_000, + RigMode::CW | RigMode::CWR => 500, + RigMode::AM => 6_000, + RigMode::FM => 12_500, + RigMode::WFM => 75_000, + RigMode::Other(_) => 3_000, + } +} + /// Parse a `RigMode` from a string slice. /// Falls back to `initial_mode` when the string is "auto" or unrecognised. #[cfg(feature = "soapysdr")] @@ -267,7 +281,7 @@ fn build_sdr_rig_from_instance( use trx_core::rig::AudioSource; let args = rig_cfg.rig.access.args.as_deref().unwrap_or(""); - let channels: Vec<(f64, trx_core::rig::state::RigMode, u32, usize)> = rig_cfg + let mut channels: Vec<(f64, trx_core::rig::state::RigMode, u32, usize)> = rig_cfg .sdr .channels .iter() @@ -278,6 +292,22 @@ fn build_sdr_rig_from_instance( }) .collect(); + // Ensure at least one demodulation channel so audio is available. + if channels.is_empty() { + tracing::warn!( + "[{}] No [[sdr.channels]] configured; adding a default primary channel. \ + Add [[sdr.channels]] to your config for full control.", + rig_cfg.id + ); + let default_bw = default_audio_bandwidth_for_mode(&rig_cfg.rig.initial_mode); + channels.push(( + rig_cfg.sdr.center_offset_hz as f64, + rig_cfg.rig.initial_mode.clone(), + default_bw, + 64, + )); + } + let sdr_rig = trx_backend::SoapySdrRig::new_with_config( args, &channels, @@ -290,6 +320,8 @@ fn build_sdr_rig_from_instance( }, rig_cfg.rig.initial_mode.clone(), rig_cfg.sdr.sample_rate, + rig_cfg.sdr.bandwidth, + rig_cfg.sdr.center_offset_hz, )?; let pcm_rx = sdr_rig.subscribe_pcm(); 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 ca6b850..d8ab88b 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 @@ -38,6 +38,12 @@ pub trait IqSource: Send + 'static { fn is_blocking(&self) -> bool { false } + + /// Retune the hardware center frequency. Default implementation is a + /// no-op (used by `MockIqSource`). + fn set_center_freq(&mut self, _hz: f64) -> Result<(), String> { + Ok(()) + } } // --------------------------------------------------------------------------- @@ -252,6 +258,8 @@ pub struct ChannelDsp { lpf_i: BlockFirFilter, /// FFT-based FIR low-pass filter applied to Q component before decimation. lpf_q: BlockFirFilter, + /// SDR capture sample rate — kept for filter rebuilds. + sdr_sample_rate: u32, /// Decimation factor: `sdr_sample_rate / audio_sample_rate`. pub decim_factor: usize, /// Accumulator for output PCM frames. @@ -312,6 +320,7 @@ impl ChannelDsp { demodulator: Demodulator::for_mode(mode), lpf_i: BlockFirFilter::new(cutoff_norm, taps, IQ_BLOCK_SIZE), lpf_q: BlockFirFilter::new(cutoff_norm, taps, IQ_BLOCK_SIZE), + sdr_sample_rate, decim_factor, frame_buf: Vec::with_capacity(frame_size * 2), frame_size, @@ -326,6 +335,21 @@ impl ChannelDsp { self.demodulator = Demodulator::for_mode(mode); } + /// Rebuild the FIR low-pass filters with new bandwidth and tap count. + /// + /// Changes take effect on the next call to `process_block`. + pub fn set_filter(&mut self, bandwidth_hz: u32, taps: usize) { + let cutoff_norm = if self.sdr_sample_rate == 0 { + 0.1 + } else { + (bandwidth_hz as f32 / 2.0) / self.sdr_sample_rate as f32 + } + .min(0.499); + let taps = taps.max(1); + self.lpf_i = BlockFirFilter::new(cutoff_norm, taps, IQ_BLOCK_SIZE); + self.lpf_q = BlockFirFilter::new(cutoff_norm, taps, IQ_BLOCK_SIZE); + } + /// Process a block of raw IQ samples through the full DSP chain. /// /// 1. **Batch mixer**: compute the full LO signal for the block at once, @@ -407,6 +431,9 @@ pub struct SdrPipeline { pub spectrum_buf: Arc>>>, /// SDR capture sample rate, needed by `SoapySdrRig::get_spectrum`. pub sdr_sample_rate: u32, + /// Write `Some(hz)` here to retune the hardware center frequency. + /// The IQ read loop picks it up on the next iteration. + pub retune_cmd: Arc>>, } impl SdrPipeline { @@ -444,11 +471,21 @@ impl SdrPipeline { let thread_dsps: Vec>> = channel_dsps.clone(); let spectrum_buf: Arc>>> = Arc::new(Mutex::new(None)); let thread_spectrum_buf = spectrum_buf.clone(); + let retune_cmd: Arc>> = + Arc::new(std::sync::Mutex::new(None)); + let thread_retune_cmd = retune_cmd.clone(); std::thread::Builder::new() .name("sdr-iq-read".to_string()) .spawn(move || { - iq_read_loop(source, sdr_sample_rate, thread_dsps, iq_tx, thread_spectrum_buf); + iq_read_loop( + source, + sdr_sample_rate, + thread_dsps, + iq_tx, + thread_spectrum_buf, + thread_retune_cmd, + ); }) .expect("failed to spawn sdr-iq-read thread"); @@ -457,6 +494,7 @@ impl SdrPipeline { channel_dsps, spectrum_buf, sdr_sample_rate, + retune_cmd, } } } @@ -479,6 +517,7 @@ fn iq_read_loop( channel_dsps: Vec>>, iq_tx: broadcast::Sender>>, spectrum_buf: Arc>>>, + retune_cmd: Arc>>, ) { let mut block = vec![Complex::new(0.0_f32, 0.0_f32); IQ_BLOCK_SIZE]; let block_duration_ms = if sdr_sample_rate > 0 { @@ -500,6 +539,17 @@ fn iq_read_loop( let mut spectrum_counter: usize = 0; loop { + // Apply any pending hardware retune before the next read. + if let Ok(mut cmd) = retune_cmd.try_lock() { + if let Some(hz) = cmd.take() { + if let Err(e) = source.set_center_freq(hz) { + tracing::warn!("SDR retune to {:.0} Hz failed: {}", hz, e); + } else { + tracing::info!("SDR retuned to {:.0} Hz", hz); + } + } + } + let n = match source.read_into(&mut block) { Ok(n) => n, Err(e) => { 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 b39c916..042012b 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 @@ -30,6 +30,11 @@ pub struct SoapySdrRig { 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. + /// The DSP mixer compensates for this offset to demodulate the dial frequency. + center_offset_hz: i64, + /// Used to send hardware retune commands to the IQ read loop. + retune_cmd: Arc>>, } impl SoapySdrRig { @@ -51,6 +56,10 @@ impl SoapySdrRig { /// - `initial_freq`: initial dial frequency reported by `get_status`. /// - `initial_mode`: initial demodulation mode. /// - `sdr_sample_rate`: IQ capture rate (Hz). + /// - `bandwidth_hz`: hardware IF filter bandwidth to apply to the device. + /// - `center_offset_hz`: the hardware is tuned this many Hz *below* the + /// dial frequency so the desired signal lands off-DC. The DSP mixer + /// shifts it back. Pass 0 to tune exactly to the dial frequency. #[allow(clippy::too_many_arguments)] pub fn new_with_config( args: &str, @@ -62,6 +71,8 @@ impl SoapySdrRig { initial_freq: Freq, initial_mode: RigMode, sdr_sample_rate: u32, + bandwidth_hz: u32, + center_offset_hz: i64, ) -> DynResult { tracing::info!( "initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={})", @@ -78,13 +89,17 @@ impl SoapySdrRig { ); } + // The hardware tunes `center_offset_hz` below the dial frequency so + // the desired signal avoids the DC spike. The DSP mixer compensates. + let hardware_center_hz = initial_freq.hz as i64 - center_offset_hz; + // Create real IQ source from hardware device. let iq_source: Box = Box::new(real_iq_source::RealIqSource::new( args, - initial_freq.hz as f64, + hardware_center_hz as f64, sdr_sample_rate as f64, - 1_500_000.0, // default 1.5 MHz bandwidth + bandwidth_hz as f64, gain_db, )?); @@ -145,6 +160,7 @@ impl SoapySdrRig { .unwrap_or((3000, 64)); let spectrum_buf = pipeline.spectrum_buf.clone(); + let retune_cmd = pipeline.retune_cmd.clone(); Ok(Self { info, @@ -155,6 +171,8 @@ impl SoapySdrRig { bandwidth_hz, fir_taps, spectrum_buf, + center_offset_hz, + retune_cmd, }) } @@ -172,6 +190,8 @@ impl SoapySdrRig { Freq { hz: 144_300_000 }, RigMode::USB, 1_920_000, + 1_500_000, // bandwidth_hz + 0, // center_offset_hz ) } } @@ -222,6 +242,11 @@ impl RigCat for SoapySdrRig { Box::pin(async move { tracing::debug!("SoapySdrRig: set_freq -> {} Hz", freq.hz); self.freq = freq; + // Retune the hardware center to keep the dial frequency off-DC. + let hardware_hz = freq.hz as i64 - self.center_offset_hz; + if let Ok(mut cmd) = self.retune_cmd.lock() { + *cmd = Some(hardware_hz as f64); + } Ok(()) }) } @@ -340,6 +365,12 @@ impl RigCat for SoapySdrRig { Box::pin(async move { tracing::debug!("SoapySdrRig: set_bandwidth -> {} Hz", bandwidth_hz); self.bandwidth_hz = bandwidth_hz; + if let Some(dsp_arc) = self.pipeline.channel_dsps.get(self.primary_channel_idx) { + dsp_arc + .lock() + .unwrap() + .set_filter(bandwidth_hz, self.fir_taps as usize); + } Ok(()) }) } @@ -351,6 +382,12 @@ impl RigCat for SoapySdrRig { Box::pin(async move { tracing::debug!("SoapySdrRig: set_fir_taps -> {}", taps); self.fir_taps = taps; + if let Some(dsp_arc) = self.pipeline.channel_dsps.get(self.primary_channel_idx) { + dsp_arc + .lock() + .unwrap() + .set_filter(self.bandwidth_hz, taps as usize); + } Ok(()) }) } @@ -365,9 +402,11 @@ impl RigCat for SoapySdrRig { fn get_spectrum(&self) -> Option { let bins = self.spectrum_buf.lock().ok()?.clone()?; + // Report the actual hardware center frequency, not the dial frequency. + let center_hz = (self.freq.hz as i64 - self.center_offset_hz) as u64; Some(SpectrumData { bins, - center_hz: self.freq.hz, + center_hz, sample_rate: self.pipeline.sdr_sample_rate, }) } diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/real_iq_source.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/real_iq_source.rs index db16613..fb69fb5 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/real_iq_source.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/real_iq_source.rs @@ -150,13 +150,6 @@ impl RealIqSource { is_blocking: true, }) } - - /// Retune the SDR hardware center frequency without recreating the stream. - pub fn set_center_freq(&self, freq_hz: f64) -> Result<(), String> { - self.device - .set_frequency(soapysdr::Direction::Rx, 0, freq_hz, ()) - .map_err(|e| format!("Failed to retune center frequency: {}", e)) - } } impl IqSource for RealIqSource { @@ -173,4 +166,10 @@ impl IqSource for RealIqSource { fn is_blocking(&self) -> bool { self.is_blocking } + + fn set_center_freq(&mut self, hz: f64) -> Result<(), String> { + self.device + .set_frequency(soapysdr::Direction::Rx, 0, hz, ()) + .map_err(|e| format!("Failed to retune SDR center frequency: {}", e)) + } }