[feat](trx-backend-soapysdr): decode WFM stereo in SDR pipeline

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-27 23:40:25 +01:00
parent 8827b2fa21
commit e392e7ffa5
4 changed files with 255 additions and 34 deletions
+14 -6
View File
@@ -242,7 +242,7 @@ fn default_audio_bandwidth_for_mode(mode: &trx_core::rig::state::RigMode) -> u32
RigMode::CW | RigMode::CWR => 500, RigMode::CW | RigMode::CWR => 500,
RigMode::AM => 6_000, RigMode::AM => 6_000,
RigMode::FM => 12_500, RigMode::FM => 12_500,
RigMode::WFM => 75_000, RigMode::WFM => 180_000,
RigMode::Other(_) => 3_000, RigMode::Other(_) => 3_000,
} }
} }
@@ -314,6 +314,7 @@ fn build_sdr_rig_from_instance(
&rig_cfg.sdr.gain.mode, &rig_cfg.sdr.gain.mode,
rig_cfg.sdr.gain.value, rig_cfg.sdr.gain.value,
rig_cfg.audio.sample_rate, rig_cfg.audio.sample_rate,
rig_cfg.audio.channels as usize,
rig_cfg.audio.frame_duration_ms, rig_cfg.audio.frame_duration_ms,
Freq { Freq {
hz: rig_cfg.rig.initial_freq_hz, hz: rig_cfg.rig.initial_freq_hz,
@@ -492,6 +493,9 @@ fn spawn_rig_audio_stack(
let rx_audio_tx_sdr = rx_audio_tx.clone(); let rx_audio_tx_sdr = rx_audio_tx.clone();
let sdr_sample_rate = rig_cfg.audio.sample_rate; let sdr_sample_rate = rig_cfg.audio.sample_rate;
let sdr_channels = rig_cfg.audio.channels; let sdr_channels = rig_cfg.audio.channels;
let sdr_frame_samples = (rig_cfg.audio.sample_rate as usize
* rig_cfg.audio.frame_duration_ms as usize)
/ 1000;
let sdr_bitrate_bps = rig_cfg.audio.bitrate_bps; let sdr_bitrate_bps = rig_cfg.audio.bitrate_bps;
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
let opus_ch = match sdr_channels { let opus_ch = match sdr_channels {
@@ -520,12 +524,16 @@ fn spawn_rig_audio_stack(
let pcm_frame = match sdr_channels { let pcm_frame = match sdr_channels {
1 => frame, 1 => frame,
2 => { 2 => {
let mut stereo = Vec::with_capacity(frame.len() * 2); if frame.len() >= sdr_frame_samples * 2 {
for sample in frame { frame
stereo.push(sample); } else {
stereo.push(sample); let mut stereo = Vec::with_capacity(frame.len() * 2);
for sample in frame {
stereo.push(sample);
stereo.push(sample);
}
stereo
} }
stereo
} }
_ => unreachable!("validated above"), _ => unreachable!("validated above"),
}; };
@@ -5,6 +5,132 @@
use num_complex::Complex; use num_complex::Complex;
use trx_core::rig::state::RigMode; use trx_core::rig::state::RigMode;
#[derive(Debug, Clone)]
struct OnePoleLowPass {
alpha: f32,
y: f32,
}
impl OnePoleLowPass {
fn new(sample_rate: f32, cutoff_hz: f32) -> Self {
let sr = sample_rate.max(1.0);
let cutoff = cutoff_hz.clamp(1.0, sr * 0.49);
let dt = 1.0 / sr;
let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff);
let alpha = dt / (rc + dt);
Self { alpha, y: 0.0 }
}
fn process(&mut self, x: f32) -> f32 {
self.y += self.alpha * (x - self.y);
self.y
}
}
#[derive(Debug, Clone)]
struct Deemphasis {
alpha: f32,
y: f32,
}
impl Deemphasis {
fn new(sample_rate: f32, tau_us: f32) -> Self {
let sr = sample_rate.max(1.0);
let tau = (tau_us.max(1.0)) * 1e-6;
let alpha = 1.0 - (-1.0 / (sr * tau)).exp();
Self { alpha, y: 0.0 }
}
fn process(&mut self, x: f32) -> f32 {
self.y += self.alpha * (x - self.y);
self.y
}
}
#[derive(Debug, Clone)]
pub struct WfmStereoDecoder {
output_channels: usize,
pilot_phase: f32,
pilot_freq: f32,
pilot_freq_err: f32,
pilot_i_lp: OnePoleLowPass,
pilot_q_lp: OnePoleLowPass,
sum_lp: OnePoleLowPass,
diff_lp: OnePoleLowPass,
deemph_m: Deemphasis,
deemph_l: Deemphasis,
deemph_r: Deemphasis,
output_decim: usize,
output_counter: usize,
}
impl WfmStereoDecoder {
pub fn new(composite_rate: u32, audio_rate: u32, output_channels: usize) -> Self {
let composite_rate_f = composite_rate.max(1) as f32;
let output_decim = (composite_rate / audio_rate.max(1)).max(1) as usize;
Self {
output_channels: output_channels.max(1),
pilot_phase: 0.0,
pilot_freq: 2.0 * std::f32::consts::PI * 19_000.0 / composite_rate_f,
pilot_freq_err: 0.0,
pilot_i_lp: OnePoleLowPass::new(composite_rate_f, 400.0),
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),
output_decim,
output_counter: 0,
}
}
pub fn process_iq(&mut self, samples: &[Complex<f32>]) -> Vec<f32> {
let composite = demod_fm(samples);
if composite.is_empty() {
return Vec::new();
}
let mut output = Vec::with_capacity(
(composite.len() / self.output_decim.max(1)) * self.output_channels.max(1),
);
for x in composite {
let (sin_p, cos_p) = self.pilot_phase.sin_cos();
let i = self.pilot_i_lp.process(x * cos_p);
let q = self.pilot_q_lp.process(x * -sin_p);
let phase_err = q.atan2(i);
self.pilot_freq_err = (self.pilot_freq_err + phase_err * 0.00002).clamp(-0.02, 0.02);
self.pilot_phase += self.pilot_freq + self.pilot_freq_err + phase_err * 0.0015;
self.pilot_phase = self.pilot_phase.rem_euclid(std::f32::consts::TAU);
let pilot_mag = (i * i + q * q).sqrt();
let stereo_blend = (pilot_mag * 40.0).clamp(0.0, 1.0);
let sum = self.sum_lp.process(x);
let stereo_carrier = (2.0 * self.pilot_phase).cos() * 2.0;
let diff = self.diff_lp.process(x * stereo_carrier) * stereo_blend;
self.output_counter += 1;
if self.output_counter < self.output_decim {
continue;
}
self.output_counter = 0;
if self.output_channels >= 2 {
let left = self.deemph_l.process((sum + diff) * 0.5).clamp(-1.0, 1.0);
let right = self.deemph_r.process((sum - diff) * 0.5).clamp(-1.0, 1.0);
output.push(left);
output.push(right);
} else {
output.push(self.deemph_m.process(sum).clamp(-1.0, 1.0));
}
}
output
}
}
/// Selects the demodulation algorithm for a channel. /// Selects the demodulation algorithm for a channel.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum Demodulator { pub enum Demodulator {
@@ -20,7 +20,7 @@ use rustfft::{Fft, FftPlanner};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use trx_core::rig::state::RigMode; use trx_core::rig::state::RigMode;
use crate::demod::Demodulator; use crate::demod::{Demodulator, WfmStereoDecoder};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// IQ source abstraction // IQ source abstraction
@@ -252,14 +252,24 @@ pub struct ChannelDsp {
pub channel_if_hz: f64, pub channel_if_hz: f64,
/// Current demodulator (can be swapped via `set_mode`). /// Current demodulator (can be swapped via `set_mode`).
pub demodulator: Demodulator, pub demodulator: Demodulator,
/// Current rig mode so the decimation pipeline can be rebuilt.
mode: RigMode,
/// FFT-based FIR low-pass filter applied to I component before decimation. /// FFT-based FIR low-pass filter applied to I component before decimation.
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 capture sample rate — kept for filter rebuilds.
sdr_sample_rate: u32, sdr_sample_rate: u32,
/// Output audio sample rate.
audio_sample_rate: u32,
/// Requested audio bandwidth.
audio_bandwidth_hz: u32,
/// FIR tap count used when rebuilding filters.
fir_taps: usize,
/// Decimation factor: `sdr_sample_rate / audio_sample_rate`. /// Decimation factor: `sdr_sample_rate / audio_sample_rate`.
pub decim_factor: usize, pub decim_factor: usize,
/// Number of PCM channels emitted in each frame.
output_channels: usize,
/// Accumulator for output PCM frames. /// Accumulator for output PCM frames.
pub frame_buf: Vec<f32>, pub frame_buf: Vec<f32>,
/// Target frame size in samples. /// Target frame size in samples.
@@ -272,40 +282,97 @@ pub struct ChannelDsp {
pub mixer_phase_inc: f64, pub mixer_phase_inc: f64,
/// Decimation counter. /// Decimation counter.
decim_counter: usize, decim_counter: usize,
/// Dedicated WFM decoder that preserves the FM composite baseband.
wfm_decoder: Option<WfmStereoDecoder>,
} }
impl ChannelDsp { impl ChannelDsp {
fn pipeline_rates(
mode: &RigMode,
sdr_sample_rate: u32,
audio_sample_rate: u32,
audio_bandwidth_hz: u32,
) -> (usize, u32) {
if sdr_sample_rate == 0 {
return (1, audio_sample_rate.max(1));
}
let target_rate = if *mode == RigMode::WFM {
audio_bandwidth_hz.max(audio_sample_rate.saturating_mul(4)).max(228_000)
} else {
audio_sample_rate.max(1)
};
let decim_factor = (sdr_sample_rate / target_rate.max(1)).max(1) as usize;
let channel_sample_rate = (sdr_sample_rate / decim_factor as u32).max(1);
(decim_factor, channel_sample_rate)
}
fn rebuild_filters(&mut self) {
let (_, channel_sample_rate) = Self::pipeline_rates(
&self.mode,
self.sdr_sample_rate,
self.audio_sample_rate,
self.audio_bandwidth_hz,
);
let cutoff_hz = self
.audio_bandwidth_hz
.min(channel_sample_rate.saturating_sub(1)) as f32
/ 2.0;
let cutoff_norm = if self.sdr_sample_rate == 0 {
0.1
} else {
(cutoff_hz / self.sdr_sample_rate as f32).min(0.499)
};
self.lpf_i = BlockFirFilter::new(cutoff_norm, self.fir_taps, IQ_BLOCK_SIZE);
self.lpf_q = BlockFirFilter::new(cutoff_norm, self.fir_taps, IQ_BLOCK_SIZE);
self.decim_factor = Self::pipeline_rates(
&self.mode,
self.sdr_sample_rate,
self.audio_sample_rate,
self.audio_bandwidth_hz,
)
.0;
self.decim_counter = 0;
self.wfm_decoder = if self.mode == RigMode::WFM {
Some(WfmStereoDecoder::new(
channel_sample_rate,
self.audio_sample_rate,
self.output_channels,
))
} else {
None
};
self.frame_buf.clear();
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
channel_if_hz: f64, channel_if_hz: f64,
mode: &RigMode, mode: &RigMode,
sdr_sample_rate: u32, sdr_sample_rate: u32,
audio_sample_rate: u32, audio_sample_rate: u32,
output_channels: usize,
frame_duration_ms: u16, frame_duration_ms: u16,
audio_bandwidth_hz: u32, audio_bandwidth_hz: u32,
fir_taps: usize, fir_taps: usize,
pcm_tx: broadcast::Sender<Vec<f32>>, pcm_tx: broadcast::Sender<Vec<f32>>,
) -> Self { ) -> Self {
let decim_factor = if audio_sample_rate == 0 || sdr_sample_rate == 0 { let output_channels = output_channels.max(1);
1
} else {
(sdr_sample_rate / audio_sample_rate).max(1) as usize
};
let frame_size = if audio_sample_rate == 0 || frame_duration_ms == 0 { let frame_size = if audio_sample_rate == 0 || frame_duration_ms == 0 {
960 960 * output_channels
} else { } else {
(audio_sample_rate as usize * frame_duration_ms as usize) / 1000 (audio_sample_rate as usize * frame_duration_ms as usize * output_channels) / 1000
}; };
let taps = fir_taps.max(1);
let (decim_factor, channel_sample_rate) =
Self::pipeline_rates(mode, sdr_sample_rate, audio_sample_rate, audio_bandwidth_hz);
let cutoff_hz = audio_bandwidth_hz.min(channel_sample_rate.saturating_sub(1)) as f32 / 2.0;
let cutoff_norm = if sdr_sample_rate == 0 { let cutoff_norm = if sdr_sample_rate == 0 {
0.1 0.1
} else { } else {
(audio_bandwidth_hz as f32 / 2.0) / sdr_sample_rate as f32 (cutoff_hz / sdr_sample_rate as f32).min(0.499)
} };
.min(0.499);
let taps = fir_taps.max(1);
let mixer_phase_inc = if sdr_sample_rate == 0 { let mixer_phase_inc = if sdr_sample_rate == 0 {
0.0 0.0
@@ -316,36 +383,46 @@ impl ChannelDsp {
Self { Self {
channel_if_hz, channel_if_hz,
demodulator: Demodulator::for_mode(mode), demodulator: Demodulator::for_mode(mode),
mode: mode.clone(),
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, sdr_sample_rate,
audio_sample_rate,
audio_bandwidth_hz,
fir_taps: taps,
decim_factor, decim_factor,
frame_buf: Vec::with_capacity(frame_size * 2), output_channels,
frame_buf: Vec::with_capacity(frame_size + output_channels),
frame_size, frame_size,
pcm_tx, pcm_tx,
mixer_phase: 0.0, mixer_phase: 0.0,
mixer_phase_inc, mixer_phase_inc,
decim_counter: 0, decim_counter: 0,
wfm_decoder: if *mode == RigMode::WFM {
Some(WfmStereoDecoder::new(
channel_sample_rate,
audio_sample_rate,
output_channels,
))
} else {
None
},
} }
} }
pub fn set_mode(&mut self, mode: &RigMode) { pub fn set_mode(&mut self, mode: &RigMode) {
self.mode = mode.clone();
self.demodulator = Demodulator::for_mode(mode); self.demodulator = Demodulator::for_mode(mode);
self.rebuild_filters();
} }
/// Rebuild the FIR low-pass filters with new bandwidth and tap count. /// Rebuild the FIR low-pass filters with new bandwidth and tap count.
/// ///
/// Changes take effect on the next call to `process_block`. /// Changes take effect on the next call to `process_block`.
pub fn set_filter(&mut self, bandwidth_hz: u32, taps: usize) { pub fn set_filter(&mut self, bandwidth_hz: u32, taps: usize) {
let cutoff_norm = if self.sdr_sample_rate == 0 { self.audio_bandwidth_hz = bandwidth_hz;
0.1 self.fir_taps = taps.max(1);
} else { self.rebuild_filters();
(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.
@@ -406,7 +483,11 @@ impl ChannelDsp {
} }
// --- 4. Demodulate -------------------------------------------------- // --- 4. Demodulate --------------------------------------------------
let audio = self.demodulator.demodulate(&decimated); let audio = if let Some(decoder) = self.wfm_decoder.as_mut() {
decoder.process_iq(&decimated)
} else {
self.demodulator.demodulate(&decimated)
};
// --- 5. Emit complete PCM frames ------------------------------------ // --- 5. Emit complete PCM frames ------------------------------------
self.frame_buf.extend_from_slice(&audio); self.frame_buf.extend_from_slice(&audio);
@@ -438,6 +519,7 @@ impl SdrPipeline {
source: Box<dyn IqSource>, source: Box<dyn IqSource>,
sdr_sample_rate: u32, sdr_sample_rate: u32,
audio_sample_rate: u32, audio_sample_rate: u32,
output_channels: usize,
frame_duration_ms: u16, frame_duration_ms: u16,
channels: &[(f64, RigMode, u32, usize)], channels: &[(f64, RigMode, u32, usize)],
) -> Self { ) -> Self {
@@ -456,6 +538,7 @@ impl SdrPipeline {
mode, mode,
sdr_sample_rate, sdr_sample_rate,
audio_sample_rate, audio_sample_rate,
output_channels,
frame_duration_ms, frame_duration_ms,
audio_bandwidth_hz, audio_bandwidth_hz,
fir_taps, fir_taps,
@@ -677,7 +760,7 @@ mod tests {
#[test] #[test]
fn channel_dsp_processes_silence() { fn channel_dsp_processes_silence() {
let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(8); let (pcm_tx, _pcm_rx) = broadcast::channel::<Vec<f32>>(8);
let mut dsp = ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 20, 3000, 31, pcm_tx); let mut dsp = ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 31, pcm_tx);
let block = vec![Complex::new(0.0_f32, 0.0_f32); 4096]; let block = vec![Complex::new(0.0_f32, 0.0_f32); 4096];
dsp.process_block(&block); dsp.process_block(&block);
} }
@@ -685,7 +768,7 @@ mod tests {
#[test] #[test]
fn channel_dsp_set_mode() { fn channel_dsp_set_mode() {
let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8); let (pcm_tx, _) = broadcast::channel::<Vec<f32>>(8);
let mut dsp = ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 20, 3000, 31, pcm_tx); let mut dsp = ChannelDsp::new(0.0, &RigMode::USB, 48_000, 8_000, 1, 20, 3000, 31, pcm_tx);
assert_eq!(dsp.demodulator, Demodulator::Usb); assert_eq!(dsp.demodulator, Demodulator::Usb);
dsp.set_mode(&RigMode::FM); dsp.set_mode(&RigMode::FM);
assert_eq!(dsp.demodulator, Demodulator::Fm); assert_eq!(dsp.demodulator, Demodulator::Fm);
@@ -697,6 +780,7 @@ mod tests {
Box::new(MockIqSource), Box::new(MockIqSource),
1_920_000, 1_920_000,
48_000, 48_000,
1,
20, 20,
&[(200_000.0, RigMode::USB, 3000, 64)], &[(200_000.0, RigMode::USB, 3000, 64)],
); );
@@ -706,7 +790,7 @@ mod tests {
#[test] #[test]
fn pipeline_empty_channels() { fn pipeline_empty_channels() {
let pipeline = SdrPipeline::start(Box::new(MockIqSource), 1_920_000, 48_000, 20, &[]); let pipeline = SdrPipeline::start(Box::new(MockIqSource), 1_920_000, 48_000, 1, 20, &[]);
assert_eq!(pipeline.pcm_senders.len(), 0); assert_eq!(pipeline.pcm_senders.len(), 0);
assert_eq!(pipeline.channel_dsps.len(), 0); assert_eq!(pipeline.channel_dsps.len(), 0);
} }
@@ -69,6 +69,7 @@ impl SoapySdrRig {
gain_mode: &str, gain_mode: &str,
gain_db: f64, gain_db: f64,
audio_sample_rate: u32, audio_sample_rate: u32,
audio_channels: usize,
frame_duration_ms: u16, frame_duration_ms: u16,
initial_freq: Freq, initial_freq: Freq,
initial_mode: RigMode, initial_mode: RigMode,
@@ -108,6 +109,7 @@ impl SoapySdrRig {
iq_source, iq_source,
sdr_sample_rate, sdr_sample_rate,
audio_sample_rate, audio_sample_rate,
audio_channels,
frame_duration_ms, frame_duration_ms,
channels, channels,
); );
@@ -188,6 +190,7 @@ impl SoapySdrRig {
"auto", "auto",
30.0, 30.0,
48_000, 48_000,
1,
20, 20,
Freq { hz: 144_300_000 }, Freq { hz: 144_300_000 },
RigMode::USB, RigMode::USB,