[feat](trx-backend-soapysdr): implement SoapySdrRig with AudioSource and DSP wiring
Replace the stub SoapySdrRig with a full implementation: wire up SdrPipeline from dsp.rs, implement AudioSource::subscribe_pcm on the primary channel, add gain control (manual/auto with fallback warning), and track primary channel freq/mode so set_freq/set_mode update the live DSP pipeline. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -27,7 +27,7 @@ This document specifies the requirements for a SoapySDR-based RX-only backend (`
|
|||||||
| SDR-04 | `[x]` | Create crate scaffold: `Cargo.toml` (deps: `soapysdr`, `num-complex`, `tokio`), empty `lib.rs` | `src/trx-server/trx-backend/trx-backend-soapysdr/` | SDR-01, SDR-02 |
|
| SDR-04 | `[x]` | Create crate scaffold: `Cargo.toml` (deps: `soapysdr`, `num-complex`, `tokio`), empty `lib.rs` | `src/trx-server/trx-backend/trx-backend-soapysdr/` | SDR-01, SDR-02 |
|
||||||
| SDR-05 | `[x]` | Implement `demod.rs`: SSB (USB/LSB), AM envelope, FM quadrature, CW narrow BPF+envelope | `…/src/demod.rs` | SDR-04 |
|
| SDR-05 | `[x]` | Implement `demod.rs`: SSB (USB/LSB), AM envelope, FM quadrature, CW narrow BPF+envelope | `…/src/demod.rs` | SDR-04 |
|
||||||
| SDR-06 | `[x]` | Implement `dsp.rs`: IQ broadcast loop (SoapySDR read thread → `broadcast::Sender<Vec<Complex<f32>>>`); per-channel mixer → FIR LPF → decimator → demod → frame accumulator → `broadcast::Sender<Vec<f32>>` | `…/src/dsp.rs` | SDR-04, SDR-05 |
|
| SDR-06 | `[x]` | Implement `dsp.rs`: IQ broadcast loop (SoapySDR read thread → `broadcast::Sender<Vec<Complex<f32>>>`); per-channel mixer → FIR LPF → decimator → demod → frame accumulator → `broadcast::Sender<Vec<f32>>` | `…/src/dsp.rs` | SDR-04, SDR-05 |
|
||||||
| SDR-07 | `[ ]` | Implement `SoapySdrRig` in `lib.rs`: `RigCat` (RX methods + `not_supported` stubs for TX), `AudioSource`, gain control (manual/auto with fallback), primary channel freq/mode tracking | `…/src/lib.rs` | SDR-03, SDR-06 |
|
| SDR-07 | `[x]` | Implement `SoapySdrRig` in `lib.rs`: `RigCat` (RX methods + `not_supported` stubs for TX), `AudioSource`, gain control (manual/auto with fallback), primary channel freq/mode tracking | `…/src/lib.rs` | SDR-03, SDR-06 |
|
||||||
|
|
||||||
### Server integration
|
### Server integration
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::pin::Pin;
|
|||||||
|
|
||||||
use trx_core::radio::freq::{Band, Freq};
|
use trx_core::radio::freq::{Band, Freq};
|
||||||
use trx_core::rig::{
|
use trx_core::rig::{
|
||||||
Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigStatusFuture,
|
AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigStatusFuture,
|
||||||
};
|
};
|
||||||
use trx_core::rig::response::RigError;
|
use trx_core::rig::response::RigError;
|
||||||
use trx_core::{DynResult, RigMode};
|
use trx_core::{DynResult, RigMode};
|
||||||
@@ -19,23 +19,71 @@ pub struct SoapySdrRig {
|
|||||||
info: RigInfo,
|
info: RigInfo,
|
||||||
freq: Freq,
|
freq: Freq,
|
||||||
mode: RigMode,
|
mode: RigMode,
|
||||||
|
pipeline: dsp::SdrPipeline,
|
||||||
|
/// Index of the primary channel in `pipeline.channel_dsps`.
|
||||||
|
primary_channel_idx: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SoapySdrRig {
|
impl SoapySdrRig {
|
||||||
/// Construct a new `SoapySdrRig` from a SoapySDR device args string.
|
/// Full constructor. All channel configuration is passed as plain
|
||||||
|
/// parameters so this crate does not need to depend on `trx-server`
|
||||||
|
/// (which is a binary, not a library crate).
|
||||||
///
|
///
|
||||||
/// The `args` value follows SoapySDR's key=value comma-separated convention
|
/// # Parameters
|
||||||
/// (e.g. `"driver=rtlsdr"` or `"driver=airspy,serial=00000001"`).
|
/// - `args`: SoapySDR device args string (e.g. `"driver=rtlsdr"`).
|
||||||
pub fn new(args: &str) -> DynResult<Self> {
|
/// Currently reserved — the pipeline uses `MockIqSource`.
|
||||||
tracing::info!("initialising SoapySDR backend (args={:?})", args);
|
/// - `channels`: per-channel tuples of
|
||||||
|
/// `(channel_if_hz, initial_mode, audio_bandwidth_hz, fir_taps)`.
|
||||||
|
/// - `gain_mode`: `"auto"` or `"manual"`.
|
||||||
|
/// - `gain_db`: gain in dB; used when `gain_mode == "manual"`.
|
||||||
|
/// When `gain_mode == "auto"` hardware AGC is not yet wired, so this
|
||||||
|
/// value acts as the fallback.
|
||||||
|
/// - `audio_sample_rate`: output PCM rate (Hz).
|
||||||
|
/// - `frame_duration_ms`: output frame length (ms).
|
||||||
|
/// - `initial_freq`: initial dial frequency reported by `get_status`.
|
||||||
|
/// - `initial_mode`: initial demodulation mode.
|
||||||
|
/// - `sdr_sample_rate`: IQ capture rate (Hz).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new_with_config(
|
||||||
|
args: &str,
|
||||||
|
channels: &[(f64, RigMode, u32, usize)],
|
||||||
|
gain_mode: &str,
|
||||||
|
gain_db: f64,
|
||||||
|
audio_sample_rate: u32,
|
||||||
|
frame_duration_ms: u16,
|
||||||
|
initial_freq: Freq,
|
||||||
|
initial_mode: RigMode,
|
||||||
|
sdr_sample_rate: u32,
|
||||||
|
) -> DynResult<Self> {
|
||||||
|
tracing::info!(
|
||||||
|
"initialising SoapySDR backend (args={:?}, gain_mode={:?}, gain_db={})",
|
||||||
|
args,
|
||||||
|
gain_mode,
|
||||||
|
gain_db,
|
||||||
|
);
|
||||||
|
|
||||||
|
if gain_mode == "auto" {
|
||||||
|
tracing::warn!(
|
||||||
|
"SoapySDR hardware AGC is not yet implemented (pending real SoapySDR device \
|
||||||
|
wiring); falling back to configured gain of {} dB",
|
||||||
|
gain_db,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pipeline = dsp::SdrPipeline::start(
|
||||||
|
Box::new(dsp::MockIqSource),
|
||||||
|
sdr_sample_rate,
|
||||||
|
audio_sample_rate,
|
||||||
|
frame_duration_ms,
|
||||||
|
channels,
|
||||||
|
);
|
||||||
|
|
||||||
let info = RigInfo {
|
let info = RigInfo {
|
||||||
manufacturer: "SoapySDR".to_string(),
|
manufacturer: "SoapySDR".to_string(),
|
||||||
model: "Generic SDR".to_string(),
|
model: args.to_string(),
|
||||||
revision: "".to_string(),
|
revision: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
capabilities: RigCapabilities {
|
capabilities: RigCapabilities {
|
||||||
min_freq_step_hz: 1,
|
min_freq_step_hz: 1,
|
||||||
// Broad RX-only coverage: DC through 6 GHz as a single band.
|
|
||||||
supported_bands: vec![Band {
|
supported_bands: vec![Band {
|
||||||
low_hz: 0,
|
low_hz: 0,
|
||||||
high_hz: 6_000_000_000,
|
high_hz: 6_000_000_000,
|
||||||
@@ -53,17 +101,15 @@ impl SoapySdrRig {
|
|||||||
RigMode::PKT,
|
RigMode::PKT,
|
||||||
],
|
],
|
||||||
num_vfos: 1,
|
num_vfos: 1,
|
||||||
|
lock: false,
|
||||||
lockable: false,
|
lockable: false,
|
||||||
attenuator: false,
|
attenuator: false,
|
||||||
preamp: false,
|
preamp: false,
|
||||||
rit: false,
|
rit: false,
|
||||||
rpt: false,
|
rpt: false,
|
||||||
split: false,
|
split: false,
|
||||||
lock: false,
|
|
||||||
},
|
},
|
||||||
// There is no serial/TCP access for SDR devices; use a dummy TCP
|
// No serial/TCP access for SDR devices; carry args in addr field.
|
||||||
// placeholder so `RigAccessMethod` (which has no SDR variant) can
|
|
||||||
// still carry the args string in a human-readable form.
|
|
||||||
access: RigAccessMethod::Tcp {
|
access: RigAccessMethod::Tcp {
|
||||||
addr: format!("soapysdr:{}", args),
|
addr: format!("soapysdr:{}", args),
|
||||||
},
|
},
|
||||||
@@ -71,10 +117,29 @@ impl SoapySdrRig {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
info,
|
info,
|
||||||
freq: Freq { hz: 14_074_000 },
|
freq: initial_freq,
|
||||||
mode: RigMode::USB,
|
mode: initial_mode,
|
||||||
|
pipeline,
|
||||||
|
primary_channel_idx: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Simple constructor for backward compatibility with the factory function.
|
||||||
|
/// Creates a pipeline with no channels — the DSP loop runs but produces no
|
||||||
|
/// PCM frames.
|
||||||
|
pub fn new(args: &str) -> DynResult<Self> {
|
||||||
|
Self::new_with_config(
|
||||||
|
args,
|
||||||
|
&[], // no channels — pipeline does nothing
|
||||||
|
"auto",
|
||||||
|
30.0,
|
||||||
|
48_000,
|
||||||
|
20,
|
||||||
|
Freq { hz: 144_300_000 },
|
||||||
|
RigMode::USB,
|
||||||
|
1_920_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -87,6 +152,24 @@ impl Rig for SoapySdrRig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AudioSource
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
impl AudioSource for SoapySdrRig {
|
||||||
|
fn subscribe_pcm(&self) -> tokio::sync::broadcast::Receiver<Vec<f32>> {
|
||||||
|
if let Some(sender) = self.pipeline.pcm_senders.get(self.primary_channel_idx) {
|
||||||
|
sender.subscribe()
|
||||||
|
} else {
|
||||||
|
// No channels configured — return a receiver that will never
|
||||||
|
// produce frames (drop the sender immediately).
|
||||||
|
let (tx, rx) = tokio::sync::broadcast::channel(1);
|
||||||
|
drop(tx);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RigCat
|
// RigCat
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -115,7 +198,11 @@ impl RigCat for SoapySdrRig {
|
|||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
|
tracing::debug!("SoapySdrRig: set_mode -> {:?}", mode);
|
||||||
self.mode = mode;
|
self.mode = mode.clone();
|
||||||
|
// Update the primary channel's demodulator in the live pipeline.
|
||||||
|
if let Some(dsp_arc) = self.pipeline.channel_dsps.get(self.primary_channel_idx) {
|
||||||
|
dsp_arc.lock().unwrap().set_mode(&mode);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -123,8 +210,8 @@ impl RigCat for SoapySdrRig {
|
|||||||
fn get_signal_strength<'a>(
|
fn get_signal_strength<'a>(
|
||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
||||||
// RSSI mapping will be implemented in SDR-07; return 0 for now.
|
// RSSI from real device pending SDR hardware wiring; return 0 for now.
|
||||||
Box::pin(async move { Ok(0) })
|
Box::pin(async move { Ok(0u8) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- TX / unsupported methods -------------------------------------------
|
// -- TX / unsupported methods -------------------------------------------
|
||||||
@@ -134,7 +221,8 @@ impl RigCat for SoapySdrRig {
|
|||||||
_ptt: bool,
|
_ptt: bool,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Err(Box::new(RigError::not_supported("set_ptt")) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(RigError::not_supported("set_ptt"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +230,8 @@ impl RigCat for SoapySdrRig {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Err(Box::new(RigError::not_supported("power_on")) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(RigError::not_supported("power_on"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +239,8 @@ impl RigCat for SoapySdrRig {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Err(Box::new(RigError::not_supported("power_off")) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(RigError::not_supported("power_off"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +248,8 @@ impl RigCat for SoapySdrRig {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Err(Box::new(RigError::not_supported("get_tx_power")) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(RigError::not_supported("get_tx_power"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +257,8 @@ impl RigCat for SoapySdrRig {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Err(Box::new(RigError::not_supported("get_tx_limit")) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(RigError::not_supported("get_tx_limit"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +267,8 @@ impl RigCat for SoapySdrRig {
|
|||||||
_limit: u8,
|
_limit: u8,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Err(Box::new(RigError::not_supported("set_tx_limit")) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(RigError::not_supported("set_tx_limit"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +276,8 @@ impl RigCat for SoapySdrRig {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Err(Box::new(RigError::not_supported("toggle_vfo")) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(RigError::not_supported("toggle_vfo"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +285,8 @@ impl RigCat for SoapySdrRig {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Err(Box::new(RigError::not_supported("lock")) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(RigError::not_supported("lock"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,13 +294,13 @@ impl RigCat for SoapySdrRig {
|
|||||||
&'a mut self,
|
&'a mut self,
|
||||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
Err(Box::new(RigError::not_supported("unlock")) as Box<dyn std::error::Error + Send + Sync>)
|
Err(Box::new(RigError::not_supported("unlock"))
|
||||||
|
as Box<dyn std::error::Error + Send + Sync>)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `None` for now; will be overridden with `Some(self)` in SDR-07
|
/// Override: this backend provides demodulated PCM audio.
|
||||||
/// once the IQ DSP pipeline is in place.
|
fn as_audio_source(&self) -> Option<&dyn AudioSource> {
|
||||||
fn as_audio_source(&self) -> Option<&dyn trx_core::rig::AudioSource> {
|
Some(self)
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user