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.
|
/// 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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user