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:
@@ -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.
|
||||
/// 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();
|
||||
|
||||
@@ -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<Mutex<Option<Vec<f32>>>>,
|
||||
/// 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<std::sync::Mutex<Option<f64>>>,
|
||||
}
|
||||
|
||||
impl SdrPipeline {
|
||||
@@ -444,11 +471,21 @@ impl SdrPipeline {
|
||||
let thread_dsps: Vec<Arc<Mutex<ChannelDsp>>> = channel_dsps.clone();
|
||||
let spectrum_buf: Arc<Mutex<Option<Vec<f32>>>> = Arc::new(Mutex::new(None));
|
||||
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()
|
||||
.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<Arc<Mutex<ChannelDsp>>>,
|
||||
iq_tx: broadcast::Sender<Vec<Complex<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 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) => {
|
||||
|
||||
@@ -30,6 +30,11 @@ pub struct SoapySdrRig {
|
||||
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.
|
||||
/// 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 {
|
||||
@@ -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<Self> {
|
||||
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<dyn dsp::IqSource> =
|
||||
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<SpectrumData> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user