[feat](trx-rs): split VDES frontend and decoder path

Add a dedicated VDES plugin tab and live bar, stop reusing the AIS vessel UI, and serve a separate VDES frontend script. Rework the SDR backend so VDES receives a single 100 kHz IQ tap, then replace the fake AIS-clone decoder path with an early M.2092-1 oriented complex-baseband scaffold using burst detection, coarse pi/4-QPSK slicing, and TER-MCS-1.100 frame heuristics.

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-03-03 00:32:32 +01:00
parent 92423f1e02
commit 6e558303a7
16 changed files with 850 additions and 522 deletions
@@ -82,6 +82,7 @@ impl IqSource for MockIqSource {
pub struct SdrPipeline {
pub pcm_senders: Vec<broadcast::Sender<Vec<f32>>>,
pub iq_senders: Vec<broadcast::Sender<Vec<Complex<f32>>>>,
pub channel_dsps: Vec<Arc<Mutex<ChannelDsp>>>,
/// Latest FFT magnitude bins (dBFS, FFT-shifted), updated ~10 Hz.
pub spectrum_buf: Arc<Mutex<Option<Vec<f32>>>>,
@@ -113,10 +114,12 @@ impl SdrPipeline {
const PCM_BROADCAST_CAPACITY: usize = 32;
let mut pcm_senders = Vec::with_capacity(channels.len());
let mut iq_senders = Vec::with_capacity(channels.len());
let mut channel_dsps: Vec<Arc<Mutex<ChannelDsp>>> = Vec::with_capacity(channels.len());
for &(channel_if_hz, ref mode, audio_bandwidth_hz, fir_taps) in channels {
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(PCM_BROADCAST_CAPACITY);
let (iq_tx, _iq_rx) = broadcast::channel::<Vec<Complex<f32>>>(IQ_BROADCAST_CAPACITY);
let dsp = ChannelDsp::new(
channel_if_hz,
mode,
@@ -129,8 +132,10 @@ impl SdrPipeline {
wfm_stereo,
fir_taps,
pcm_tx.clone(),
iq_tx.clone(),
);
pcm_senders.push(pcm_tx);
iq_senders.push(iq_tx);
channel_dsps.push(Arc::new(Mutex::new(dsp)));
}
@@ -159,6 +164,7 @@ impl SdrPipeline {
Self {
pcm_senders,
iq_senders,
channel_dsps,
spectrum_buf,
sdr_sample_rate,
@@ -44,7 +44,8 @@ fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
RigMode::AM => 9_000,
RigMode::FM => 12_500,
RigMode::WFM => 180_000,
RigMode::AIS | RigMode::VDES => 25_000,
RigMode::AIS => 25_000,
RigMode::VDES => 100_000,
RigMode::Other(_) => 3_000,
}
}
@@ -68,14 +69,17 @@ pub struct ChannelDsp {
frame_buf_offset: usize,
pub frame_size: usize,
pub pcm_tx: broadcast::Sender<Vec<f32>>,
pub iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
scratch_mixed_i: Vec<f32>,
scratch_mixed_q: Vec<f32>,
scratch_filtered_i: Vec<f32>,
scratch_filtered_q: Vec<f32>,
scratch_decimated: Vec<Complex<f32>>,
scratch_iq_tap: Vec<Complex<f32>>,
pub mixer_phase: f64,
pub mixer_phase_inc: f64,
decim_counter: usize,
iq_tap_counter: usize,
resample_phase: f64,
resample_phase_inc: f64,
wfm_decoder: Option<WfmStereoDecoder>,
@@ -141,6 +145,7 @@ impl ChannelDsp {
let rate_changed = self.decim_factor != next_decim_factor;
self.decim_factor = next_decim_factor;
self.decim_counter = 0;
self.iq_tap_counter = 0;
self.resample_phase = 0.0;
self.resample_phase_inc = if self.sdr_sample_rate == 0 {
1.0
@@ -181,6 +186,7 @@ impl ChannelDsp {
wfm_stereo: bool,
fir_taps: usize,
pcm_tx: broadcast::Sender<Vec<f32>>,
iq_tx: broadcast::Sender<Vec<Complex<f32>>>,
) -> Self {
let output_channels = output_channels.max(1);
let audio_bandwidth_hz = Self::clamp_bandwidth_for_mode(mode, audio_bandwidth_hz);
@@ -224,14 +230,17 @@ impl ChannelDsp {
frame_buf_offset: 0,
frame_size,
pcm_tx,
iq_tx,
scratch_mixed_i: Vec::with_capacity(IQ_BLOCK_SIZE),
scratch_mixed_q: Vec::with_capacity(IQ_BLOCK_SIZE),
scratch_filtered_i: Vec::with_capacity(IQ_BLOCK_SIZE),
scratch_filtered_q: Vec::with_capacity(IQ_BLOCK_SIZE),
scratch_decimated: Vec::with_capacity(IQ_BLOCK_SIZE / decim_factor.max(1) + 1),
scratch_iq_tap: Vec::with_capacity(IQ_BLOCK_SIZE / decim_factor.max(1) + 1),
mixer_phase: 0.0,
mixer_phase_inc,
decim_counter: 0,
iq_tap_counter: 0,
resample_phase: 0.0,
resample_phase_inc: if sdr_sample_rate == 0 {
1.0
@@ -357,6 +366,25 @@ impl ChannelDsp {
self.scratch_decimated
.reserve(capacity - self.scratch_decimated.capacity());
}
if self.mode == RigMode::VDES && self.iq_tx.receiver_count() > 0 {
self.scratch_iq_tap.clear();
if self.scratch_iq_tap.capacity() < capacity {
self.scratch_iq_tap
.reserve(capacity - self.scratch_iq_tap.capacity());
}
for idx in 0..n {
self.iq_tap_counter += 1;
if self.iq_tap_counter >= self.decim_factor {
self.iq_tap_counter = 0;
let fi = filtered_i.get(idx).copied().unwrap_or(0.0);
let fq = filtered_q.get(idx).copied().unwrap_or(0.0);
self.scratch_iq_tap.push(Complex::new(fi, fq));
}
}
if !self.scratch_iq_tap.is_empty() {
let _ = self.iq_tx.send(self.scratch_iq_tap.clone());
}
}
let decimated = &mut self.scratch_decimated;
if self.wfm_decoder.is_some() {
for idx in 0..n {
@@ -57,7 +57,8 @@ impl SoapySdrRig {
fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
match mode {
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
RigMode::PKT | RigMode::AIS | RigMode::VDES => 25_000,
RigMode::PKT | RigMode::AIS => 25_000,
RigMode::VDES => 100_000,
RigMode::CW | RigMode::CWR => 500,
RigMode::AM => 9_000,
RigMode::FM => 12_500,
@@ -297,6 +298,19 @@ impl SoapySdrRig {
}
}
}
pub fn subscribe_iq_channel(
&self,
channel_idx: usize,
) -> tokio::sync::broadcast::Receiver<Vec<num_complex::Complex<f32>>> {
if let Some(sender) = self.pipeline.iq_senders.get(channel_idx) {
sender.subscribe()
} else {
let (tx, rx) = tokio::sync::broadcast::channel(1);
drop(tx);
rx
}
}
}
// ---------------------------------------------------------------------------
@@ -353,7 +367,7 @@ impl RigCat for SoapySdrRig {
let half_span_hz = i128::from(self.pipeline.sdr_sample_rate) / 2;
let current_center_hz = i128::from(self.center_hz);
let target_lo_hz = i128::from(freq.hz);
let target_hi_hz = if matches!(self.mode, RigMode::AIS | RigMode::VDES) {
let target_hi_hz = if self.mode == RigMode::AIS {
i128::from(freq.hz) + i128::from(AIS_CHANNEL_SPACING_HZ)
} else {
i128::from(freq.hz)