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 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-27 22:09:26 +01:00
parent 547f253837
commit af9f2e092e
4 changed files with 132 additions and 12 deletions
+33 -1
View File
@@ -233,6 +233,20 @@ async fn wait_for_shutdown(mut shutdown_rx: watch::Receiver<bool>) {
} }
} }
/// 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. /// Parse a `RigMode` from a string slice.
/// Falls back to `initial_mode` when the string is "auto" or unrecognised. /// Falls back to `initial_mode` when the string is "auto" or unrecognised.
#[cfg(feature = "soapysdr")] #[cfg(feature = "soapysdr")]
@@ -267,7 +281,7 @@ fn build_sdr_rig_from_instance(
use trx_core::rig::AudioSource; use trx_core::rig::AudioSource;
let args = rig_cfg.rig.access.args.as_deref().unwrap_or(""); 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 .sdr
.channels .channels
.iter() .iter()
@@ -278,6 +292,22 @@ fn build_sdr_rig_from_instance(
}) })
.collect(); .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( let sdr_rig = trx_backend::SoapySdrRig::new_with_config(
args, args,
&channels, &channels,
@@ -290,6 +320,8 @@ fn build_sdr_rig_from_instance(
}, },
rig_cfg.rig.initial_mode.clone(), rig_cfg.rig.initial_mode.clone(),
rig_cfg.sdr.sample_rate, rig_cfg.sdr.sample_rate,
rig_cfg.sdr.bandwidth,
rig_cfg.sdr.center_offset_hz,
)?; )?;
let pcm_rx = sdr_rig.subscribe_pcm(); let pcm_rx = sdr_rig.subscribe_pcm();
@@ -38,6 +38,12 @@ pub trait IqSource: Send + 'static {
fn is_blocking(&self) -> bool { fn is_blocking(&self) -> bool {
false 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, lpf_i: BlockFirFilter,
/// FFT-based FIR low-pass filter applied to Q component before decimation. /// FFT-based FIR low-pass filter applied to Q component before decimation.
lpf_q: BlockFirFilter, lpf_q: BlockFirFilter,
/// SDR capture sample rate — kept for filter rebuilds.
sdr_sample_rate: u32,
/// Decimation factor: `sdr_sample_rate / audio_sample_rate`. /// Decimation factor: `sdr_sample_rate / audio_sample_rate`.
pub decim_factor: usize, pub decim_factor: usize,
/// Accumulator for output PCM frames. /// Accumulator for output PCM frames.
@@ -312,6 +320,7 @@ impl ChannelDsp {
demodulator: Demodulator::for_mode(mode), demodulator: Demodulator::for_mode(mode),
lpf_i: BlockFirFilter::new(cutoff_norm, taps, IQ_BLOCK_SIZE), lpf_i: BlockFirFilter::new(cutoff_norm, taps, IQ_BLOCK_SIZE),
lpf_q: BlockFirFilter::new(cutoff_norm, taps, IQ_BLOCK_SIZE), lpf_q: BlockFirFilter::new(cutoff_norm, taps, IQ_BLOCK_SIZE),
sdr_sample_rate,
decim_factor, decim_factor,
frame_buf: Vec::with_capacity(frame_size * 2), frame_buf: Vec::with_capacity(frame_size * 2),
frame_size, frame_size,
@@ -326,6 +335,21 @@ impl ChannelDsp {
self.demodulator = Demodulator::for_mode(mode); 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. /// 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, /// 1. **Batch mixer**: compute the full LO signal for the block at once,
@@ -407,6 +431,9 @@ pub struct SdrPipeline {
pub spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>, pub spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
/// SDR capture sample rate, needed by `SoapySdrRig::get_spectrum`. /// SDR capture sample rate, needed by `SoapySdrRig::get_spectrum`.
pub sdr_sample_rate: u32, 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<std::sync::Mutex<Option<f64>>>,
} }
impl SdrPipeline { impl SdrPipeline {
@@ -444,11 +471,21 @@ impl SdrPipeline {
let thread_dsps: Vec<Arc<Mutex<ChannelDsp>>> = channel_dsps.clone(); let thread_dsps: Vec<Arc<Mutex<ChannelDsp>>> = channel_dsps.clone();
let spectrum_buf: Arc<Mutex<Option<Vec<f32>>>> = Arc::new(Mutex::new(None)); let spectrum_buf: Arc<Mutex<Option<Vec<f32>>>> = Arc::new(Mutex::new(None));
let thread_spectrum_buf = spectrum_buf.clone(); let thread_spectrum_buf = spectrum_buf.clone();
let retune_cmd: Arc<std::sync::Mutex<Option<f64>>> =
Arc::new(std::sync::Mutex::new(None));
let thread_retune_cmd = retune_cmd.clone();
std::thread::Builder::new() std::thread::Builder::new()
.name("sdr-iq-read".to_string()) .name("sdr-iq-read".to_string())
.spawn(move || { .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"); .expect("failed to spawn sdr-iq-read thread");
@@ -457,6 +494,7 @@ impl SdrPipeline {
channel_dsps, channel_dsps,
spectrum_buf, spectrum_buf,
sdr_sample_rate, sdr_sample_rate,
retune_cmd,
} }
} }
} }
@@ -479,6 +517,7 @@ fn iq_read_loop(
channel_dsps: Vec<Arc<Mutex<ChannelDsp>>>, channel_dsps: Vec<Arc<Mutex<ChannelDsp>>>,
iq_tx: broadcast::Sender<Vec<Complex<f32>>>, iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>, spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
retune_cmd: Arc<std::sync::Mutex<Option<f64>>>,
) { ) {
let mut block = vec![Complex::new(0.0_f32, 0.0_f32); IQ_BLOCK_SIZE]; let mut block = vec![Complex::new(0.0_f32, 0.0_f32); IQ_BLOCK_SIZE];
let block_duration_ms = if sdr_sample_rate > 0 { let block_duration_ms = if sdr_sample_rate > 0 {
@@ -500,6 +539,17 @@ fn iq_read_loop(
let mut spectrum_counter: usize = 0; let mut spectrum_counter: usize = 0;
loop { 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) { let n = match source.read_into(&mut block) {
Ok(n) => n, Ok(n) => n,
Err(e) => { Err(e) => {
@@ -30,6 +30,11 @@ pub struct SoapySdrRig {
fir_taps: 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.
/// 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<std::sync::Mutex<Option<f64>>>,
} }
impl SoapySdrRig { impl SoapySdrRig {
@@ -51,6 +56,10 @@ impl SoapySdrRig {
/// - `initial_freq`: initial dial frequency reported by `get_status`. /// - `initial_freq`: initial dial frequency reported by `get_status`.
/// - `initial_mode`: initial demodulation mode. /// - `initial_mode`: initial demodulation mode.
/// - `sdr_sample_rate`: IQ capture rate (Hz). /// - `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)] #[allow(clippy::too_many_arguments)]
pub fn new_with_config( pub fn new_with_config(
args: &str, args: &str,
@@ -62,6 +71,8 @@ impl SoapySdrRig {
initial_freq: Freq, initial_freq: Freq,
initial_mode: RigMode, initial_mode: RigMode,
sdr_sample_rate: u32, sdr_sample_rate: u32,
bandwidth_hz: u32,
center_offset_hz: i64,
) -> DynResult<Self> { ) -> DynResult<Self> {
tracing::info!( tracing::info!(
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={})", "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. // Create real IQ source from hardware device.
let iq_source: Box<dyn dsp::IqSource> = let iq_source: Box<dyn dsp::IqSource> =
Box::new(real_iq_source::RealIqSource::new( Box::new(real_iq_source::RealIqSource::new(
args, args,
initial_freq.hz as f64, hardware_center_hz as f64,
sdr_sample_rate as f64, sdr_sample_rate as f64,
1_500_000.0, // default 1.5 MHz bandwidth bandwidth_hz as f64,
gain_db, gain_db,
)?); )?);
@@ -145,6 +160,7 @@ impl SoapySdrRig {
.unwrap_or((3000, 64)); .unwrap_or((3000, 64));
let spectrum_buf = pipeline.spectrum_buf.clone(); let spectrum_buf = pipeline.spectrum_buf.clone();
let retune_cmd = pipeline.retune_cmd.clone();
Ok(Self { Ok(Self {
info, info,
@@ -155,6 +171,8 @@ impl SoapySdrRig {
bandwidth_hz, bandwidth_hz,
fir_taps, fir_taps,
spectrum_buf, spectrum_buf,
center_offset_hz,
retune_cmd,
}) })
} }
@@ -172,6 +190,8 @@ impl SoapySdrRig {
Freq { hz: 144_300_000 }, Freq { hz: 144_300_000 },
RigMode::USB, RigMode::USB,
1_920_000, 1_920_000,
1_500_000, // bandwidth_hz
0, // center_offset_hz
) )
} }
} }
@@ -222,6 +242,11 @@ impl RigCat for SoapySdrRig {
Box::pin(async move { Box::pin(async move {
tracing::debug!("SoapySdrRig: set_freq -> {} Hz", freq.hz); tracing::debug!("SoapySdrRig: set_freq -> {} Hz", freq.hz);
self.freq = freq; 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(()) Ok(())
}) })
} }
@@ -340,6 +365,12 @@ impl RigCat for SoapySdrRig {
Box::pin(async move { Box::pin(async move {
tracing::debug!("SoapySdrRig: set_bandwidth -> {} Hz", bandwidth_hz); tracing::debug!("SoapySdrRig: set_bandwidth -> {} Hz", bandwidth_hz);
self.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(()) Ok(())
}) })
} }
@@ -351,6 +382,12 @@ impl RigCat for SoapySdrRig {
Box::pin(async move { Box::pin(async move {
tracing::debug!("SoapySdrRig: set_fir_taps -> {}", taps); tracing::debug!("SoapySdrRig: set_fir_taps -> {}", taps);
self.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(()) Ok(())
}) })
} }
@@ -365,9 +402,11 @@ impl RigCat for SoapySdrRig {
fn get_spectrum(&self) -> Option<SpectrumData> { fn get_spectrum(&self) -> Option<SpectrumData> {
let bins = self.spectrum_buf.lock().ok()?.clone()?; 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 { Some(SpectrumData {
bins, bins,
center_hz: self.freq.hz, center_hz,
sample_rate: self.pipeline.sdr_sample_rate, sample_rate: self.pipeline.sdr_sample_rate,
}) })
} }
@@ -150,13 +150,6 @@ impl RealIqSource {
is_blocking: true, 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 { impl IqSource for RealIqSource {
@@ -173,4 +166,10 @@ impl IqSource for RealIqSource {
fn is_blocking(&self) -> bool { fn is_blocking(&self) -> bool {
self.is_blocking 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))
}
} }