[feat](trx-rs): add WFM RDS and playback controls
Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -449,6 +449,16 @@ async fn process_command(
|
||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||
return snapshot_from(ctx.state);
|
||||
}
|
||||
RigCommand::SetWfmDeemphasis(deemphasis_us) => {
|
||||
if let Err(e) = ctx.rig.set_wfm_deemphasis(deemphasis_us).await {
|
||||
return Err(RigError::communication(format!("set_wfm_deemphasis: {e}")));
|
||||
}
|
||||
if let Some(f) = ctx.state.filter.as_mut() {
|
||||
f.wfm_deemphasis_us = deemphasis_us;
|
||||
}
|
||||
let _ = ctx.state_tx.send(ctx.state.clone());
|
||||
return snapshot_from(ctx.state);
|
||||
}
|
||||
RigCommand::SetCenterFreq(freq) => {
|
||||
if let Err(e) = ctx.rig.set_center_freq(freq).await {
|
||||
return Err(RigError::communication(format!("set_center_freq: {e}")));
|
||||
|
||||
@@ -10,6 +10,7 @@ license = "BSD-2-Clause"
|
||||
|
||||
[dependencies]
|
||||
trx-core = { path = "../../../trx-core" }
|
||||
trx-rds = { path = "../../../decoders/trx-rds" }
|
||||
tokio = { workspace = true, features = ["sync", "rt"] }
|
||||
serde = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
use num_complex::Complex;
|
||||
use trx_core::rig::state::RigMode;
|
||||
use trx_core::rig::state::{RdsData, RigMode};
|
||||
use trx_rds::RdsDecoder;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct OnePoleLowPass {
|
||||
@@ -50,6 +51,7 @@ impl Deemphasis {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WfmStereoDecoder {
|
||||
output_channels: usize,
|
||||
rds_decoder: RdsDecoder,
|
||||
pilot_phase: f32,
|
||||
pilot_freq: f32,
|
||||
pilot_freq_err: f32,
|
||||
@@ -65,11 +67,18 @@ pub struct WfmStereoDecoder {
|
||||
}
|
||||
|
||||
impl WfmStereoDecoder {
|
||||
pub fn new(composite_rate: u32, audio_rate: u32, output_channels: usize) -> Self {
|
||||
pub fn new(
|
||||
composite_rate: u32,
|
||||
audio_rate: u32,
|
||||
output_channels: usize,
|
||||
deemphasis_us: u32,
|
||||
) -> Self {
|
||||
let composite_rate_f = composite_rate.max(1) as f32;
|
||||
let output_decim = (composite_rate / audio_rate.max(1)).max(1) as usize;
|
||||
let deemphasis_us = deemphasis_us as f32;
|
||||
Self {
|
||||
output_channels: output_channels.max(1),
|
||||
rds_decoder: RdsDecoder::new(composite_rate),
|
||||
pilot_phase: 0.0,
|
||||
pilot_freq: 2.0 * std::f32::consts::PI * 19_000.0 / composite_rate_f,
|
||||
pilot_freq_err: 0.0,
|
||||
@@ -77,9 +86,9 @@ impl WfmStereoDecoder {
|
||||
pilot_q_lp: OnePoleLowPass::new(composite_rate_f, 400.0),
|
||||
sum_lp: OnePoleLowPass::new(composite_rate_f, 15_000.0),
|
||||
diff_lp: OnePoleLowPass::new(composite_rate_f, 15_000.0),
|
||||
deemph_m: Deemphasis::new(audio_rate.max(1) as f32, 75.0),
|
||||
deemph_l: Deemphasis::new(audio_rate.max(1) as f32, 75.0),
|
||||
deemph_r: Deemphasis::new(audio_rate.max(1) as f32, 75.0),
|
||||
deemph_m: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||
deemph_l: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||
deemph_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||
output_decim,
|
||||
output_counter: 0,
|
||||
}
|
||||
@@ -90,6 +99,7 @@ impl WfmStereoDecoder {
|
||||
if composite.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let _ = self.rds_decoder.process_samples(&composite);
|
||||
|
||||
let mut output = Vec::with_capacity(
|
||||
(composite.len() / self.output_decim.max(1)) * self.output_channels.max(1),
|
||||
@@ -129,6 +139,10 @@ impl WfmStereoDecoder {
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
pub fn rds_data(&self) -> Option<RdsData> {
|
||||
self.rds_decoder.snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the demodulation algorithm for a channel.
|
||||
|
||||
@@ -18,7 +18,7 @@ use num_complex::Complex;
|
||||
use rustfft::num_complex::Complex as FftComplex;
|
||||
use rustfft::{Fft, FftPlanner};
|
||||
use tokio::sync::broadcast;
|
||||
use trx_core::rig::state::RigMode;
|
||||
use trx_core::rig::state::{RdsData, RigMode};
|
||||
|
||||
use crate::demod::{Demodulator, WfmStereoDecoder};
|
||||
|
||||
@@ -266,6 +266,8 @@ pub struct ChannelDsp {
|
||||
audio_bandwidth_hz: u32,
|
||||
/// FIR tap count used when rebuilding filters.
|
||||
fir_taps: usize,
|
||||
/// WFM deemphasis time constant in microseconds.
|
||||
wfm_deemphasis_us: u32,
|
||||
/// Decimation factor: `sdr_sample_rate / audio_sample_rate`.
|
||||
pub decim_factor: usize,
|
||||
/// Number of PCM channels emitted in each frame.
|
||||
@@ -338,6 +340,7 @@ impl ChannelDsp {
|
||||
channel_sample_rate,
|
||||
self.audio_sample_rate,
|
||||
self.output_channels,
|
||||
self.wfm_deemphasis_us,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
@@ -354,6 +357,7 @@ impl ChannelDsp {
|
||||
output_channels: usize,
|
||||
frame_duration_ms: u16,
|
||||
audio_bandwidth_hz: u32,
|
||||
wfm_deemphasis_us: u32,
|
||||
fir_taps: usize,
|
||||
pcm_tx: broadcast::Sender<Vec<f32>>,
|
||||
) -> Self {
|
||||
@@ -390,6 +394,7 @@ impl ChannelDsp {
|
||||
audio_sample_rate,
|
||||
audio_bandwidth_hz,
|
||||
fir_taps: taps,
|
||||
wfm_deemphasis_us,
|
||||
decim_factor,
|
||||
output_channels,
|
||||
frame_buf: Vec::with_capacity(frame_size + output_channels),
|
||||
@@ -403,6 +408,7 @@ impl ChannelDsp {
|
||||
channel_sample_rate,
|
||||
audio_sample_rate,
|
||||
output_channels,
|
||||
wfm_deemphasis_us,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
@@ -425,6 +431,15 @@ impl ChannelDsp {
|
||||
self.rebuild_filters();
|
||||
}
|
||||
|
||||
pub fn set_wfm_deemphasis(&mut self, deemphasis_us: u32) {
|
||||
self.wfm_deemphasis_us = deemphasis_us;
|
||||
self.rebuild_filters();
|
||||
}
|
||||
|
||||
pub fn rds_data(&self) -> Option<RdsData> {
|
||||
self.wfm_decoder.as_ref().and_then(WfmStereoDecoder::rds_data)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -521,6 +536,7 @@ impl SdrPipeline {
|
||||
audio_sample_rate: u32,
|
||||
output_channels: usize,
|
||||
frame_duration_ms: u16,
|
||||
wfm_deemphasis_us: u32,
|
||||
channels: &[(f64, RigMode, u32, usize)],
|
||||
) -> Self {
|
||||
const IQ_BROADCAST_CAPACITY: usize = 64;
|
||||
@@ -541,6 +557,7 @@ impl SdrPipeline {
|
||||
output_channels,
|
||||
frame_duration_ms,
|
||||
audio_bandwidth_hz,
|
||||
wfm_deemphasis_us,
|
||||
fir_taps,
|
||||
pcm_tx.clone(),
|
||||
);
|
||||
@@ -760,7 +777,8 @@ mod tests {
|
||||
#[test]
|
||||
fn channel_dsp_processes_silence() {
|
||||
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(8);
|
||||
let mut dsp = ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 31, pcm_tx);
|
||||
let mut dsp =
|
||||
ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 75, 31, pcm_tx);
|
||||
let block = vec![Complex::new(0.0_f32, 0.0_f32); 4096];
|
||||
dsp.process_block(&block);
|
||||
}
|
||||
@@ -768,7 +786,8 @@ mod tests {
|
||||
#[test]
|
||||
fn channel_dsp_set_mode() {
|
||||
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8);
|
||||
let mut dsp = ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 31, pcm_tx);
|
||||
let mut dsp =
|
||||
ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 75, 31, pcm_tx);
|
||||
assert_eq!(dsp.demodulator, Demodulator::Usb);
|
||||
dsp.set_mode(&RigMode::FM);
|
||||
assert_eq!(dsp.demodulator, Demodulator::Fm);
|
||||
@@ -782,6 +801,7 @@ mod tests {
|
||||
48_000,
|
||||
1,
|
||||
20,
|
||||
75,
|
||||
&[(200_000.0, RigMode::USB, 3000, 64)],
|
||||
);
|
||||
assert_eq!(pipeline.pcm_senders.len(), 1);
|
||||
@@ -790,7 +810,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pipeline_empty_channels() {
|
||||
let pipeline = SdrPipeline::start(Box::new(MockIqSource), 1_920_000, 48_000, 1, 20, &[]);
|
||||
let pipeline = SdrPipeline::start(Box::new(MockIqSource), 1_920_000, 48_000, 1, 20, 75, &[]);
|
||||
assert_eq!(pipeline.pcm_senders.len(), 0);
|
||||
assert_eq!(pipeline.channel_dsps.len(), 0);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ pub struct SoapySdrRig {
|
||||
center_hz: i64,
|
||||
/// Used to send hardware retune commands to the IQ read loop.
|
||||
retune_cmd: Arc<std::sync::Mutex<Option<f64>>>,
|
||||
/// Current WFM deemphasis setting in microseconds.
|
||||
wfm_deemphasis_us: u32,
|
||||
}
|
||||
|
||||
impl SoapySdrRig {
|
||||
@@ -111,6 +113,7 @@ impl SoapySdrRig {
|
||||
audio_sample_rate,
|
||||
audio_channels,
|
||||
frame_duration_ms,
|
||||
75,
|
||||
channels,
|
||||
);
|
||||
|
||||
@@ -177,6 +180,7 @@ impl SoapySdrRig {
|
||||
center_offset_hz,
|
||||
center_hz: hardware_center_hz,
|
||||
retune_cmd,
|
||||
wfm_deemphasis_us: 75,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -295,6 +299,25 @@ impl RigCat for SoapySdrRig {
|
||||
})
|
||||
}
|
||||
|
||||
fn set_wfm_deemphasis<'a>(
|
||||
&'a mut self,
|
||||
deemphasis_us: u32,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<()>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
let deemphasis_us = match deemphasis_us {
|
||||
50 | 75 => deemphasis_us,
|
||||
other => {
|
||||
return Err(format!("unsupported WFM deemphasis {}", other).into());
|
||||
}
|
||||
};
|
||||
self.wfm_deemphasis_us = deemphasis_us;
|
||||
if let Some(dsp_arc) = self.pipeline.channel_dsps.get(self.primary_channel_idx) {
|
||||
dsp_arc.lock().unwrap().set_wfm_deemphasis(deemphasis_us);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn get_signal_strength<'a>(
|
||||
&'a mut self,
|
||||
) -> Pin<Box<dyn std::future::Future<Output = DynResult<u8>> + Send + 'a>> {
|
||||
@@ -426,15 +449,22 @@ impl RigCat for SoapySdrRig {
|
||||
bandwidth_hz: self.bandwidth_hz,
|
||||
fir_taps: self.fir_taps,
|
||||
cw_center_hz: 700,
|
||||
wfm_deemphasis_us: self.wfm_deemphasis_us,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_spectrum(&self) -> Option<SpectrumData> {
|
||||
let bins = self.spectrum_buf.lock().ok()?.clone()?;
|
||||
let rds = self
|
||||
.pipeline
|
||||
.channel_dsps
|
||||
.get(self.primary_channel_idx)
|
||||
.and_then(|dsp| dsp.lock().ok().and_then(|d| d.rds_data()));
|
||||
Some(SpectrumData {
|
||||
bins,
|
||||
center_hz: self.center_hz.max(0) as u64,
|
||||
sample_rate: self.pipeline.sdr_sample_rate,
|
||||
rds,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user