[feat](trx-backend-soapysdr): implement C-QUAM stereo demodulator

Add CquamDemod (demod/amcquam.rs) with first-order IIR carrier phase
tracker (τ = 50 ms) that rotates baseband IQ to align I with the sum
audio and Q with the difference audio, then DC-blocks each channel to
yield L/R stereo PCM.

Wire AmCQuam into the Demodulator enum, add ChannelDsp::cquam_decoder
field initialized for RigMode::AMC, and insert the C-QUAM audio path
between the WFM and fallback branches in process_block. Update all
mode-dispatch tables (agc_for_mode, iq_agc_for_mode, dc_for_mode,
default_bandwidth_for_mode, lib.rs, vchan_impl.rs) with AMC arms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-03-17 00:10:31 +01:00
parent 6be8644d45
commit 3564a48ced
5 changed files with 166 additions and 7 deletions
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: BSD-2-Clause // SPDX-License-Identifier: BSD-2-Clause
mod am; mod am;
mod amcquam;
mod fm; mod fm;
mod math; mod math;
mod math_arm; mod math_arm;
@@ -13,6 +14,7 @@ mod wfm;
use num_complex::Complex; use num_complex::Complex;
use trx_core::rig::state::RigMode; use trx_core::rig::state::RigMode;
pub use self::amcquam::CquamDemod;
pub use self::wfm::WfmStereoDecoder; pub use self::wfm::WfmStereoDecoder;
/// Shared DC blocker used by narrowband and WFM audio paths. /// Shared DC blocker used by narrowband and WFM audio paths.
@@ -145,6 +147,8 @@ pub enum Demodulator {
Cw, Cw,
/// Pass-through (DIG, PKT): same as USB. /// Pass-through (DIG, PKT): same as USB.
Passthrough, Passthrough,
/// AM C-QUAM stereo: synchronous IQ detection with carrier phase tracking.
AmCQuam,
} }
impl Demodulator { impl Demodulator {
@@ -154,6 +158,7 @@ impl Demodulator {
RigMode::USB => Self::Usb, RigMode::USB => Self::Usb,
RigMode::LSB => Self::Lsb, RigMode::LSB => Self::Lsb,
RigMode::AM => Self::Am, RigMode::AM => Self::Am,
RigMode::AMC => Self::AmCQuam,
RigMode::FM => Self::Fm, RigMode::FM => Self::Fm,
RigMode::WFM => Self::Wfm, RigMode::WFM => Self::Wfm,
RigMode::AIS | RigMode::VDES => Self::Fm, RigMode::AIS | RigMode::VDES => Self::Fm,
@@ -170,7 +175,7 @@ impl Demodulator {
match self { match self {
Self::Usb | Self::Passthrough => ssb::demod_usb(samples), Self::Usb | Self::Passthrough => ssb::demod_usb(samples),
Self::Lsb => ssb::demod_lsb(samples), Self::Lsb => ssb::demod_lsb(samples),
Self::Am => am::demod_am(samples), Self::Am | Self::AmCQuam => am::demod_am(samples),
Self::Fm | Self::Wfm => fm::demod_fm(samples), Self::Fm | Self::Wfm => fm::demod_fm(samples),
Self::Cw => ssb::demod_cw(samples), Self::Cw => ssb::demod_cw(samples),
} }
@@ -196,6 +201,7 @@ mod tests {
Demodulator::Passthrough Demodulator::Passthrough
); );
assert_eq!(Demodulator::for_mode(&RigMode::PKT), Demodulator::Fm); assert_eq!(Demodulator::for_mode(&RigMode::PKT), Demodulator::Fm);
assert_eq!(Demodulator::for_mode(&RigMode::AMC), Demodulator::AmCQuam);
} }
#[test] #[test]
@@ -209,6 +215,7 @@ mod tests {
Demodulator::Wfm, Demodulator::Wfm,
Demodulator::Cw, Demodulator::Cw,
Demodulator::Passthrough, Demodulator::Passthrough,
Demodulator::AmCQuam,
]; ];
for demod in &demodulators { for demod in &demodulators {
assert!( assert!(
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use num_complex::Complex;
use super::DcBlocker;
/// C-QUAM (Compatible Quadrature AM) stereo demodulator.
///
/// Tracks the AM carrier phase using a first-order IIR filter on the baseband
/// DC component (τ ≈ 50 ms), rotates each sample to align I with the sum
/// audio and Q with the difference audio, then reconstructs L/R stereo.
///
/// Input: AGC-normalised baseband IQ samples at audio sample rate.
/// Output: interleaved stereo PCM [L0, R0, L1, R1, …]
pub struct CquamDemod {
/// IIR-tracked in-phase carrier estimate.
carrier_re: f32,
/// IIR-tracked quadrature carrier estimate.
carrier_im: f32,
/// IIR smoothing coefficient (close to 1 → slow tracking).
alpha: f32,
/// DC blocker for left channel output (removes the carrier-level DC).
dc_l: DcBlocker,
/// DC blocker for right channel output.
dc_r: DcBlocker,
}
impl CquamDemod {
/// Create a new C-QUAM demodulator for the given audio sample rate.
pub fn new(audio_sample_rate: u32) -> Self {
let sr = audio_sample_rate.max(1) as f32;
// 50 ms tracking time constant — slow enough not to follow audio
// modulation (lowest speech fundamental ~100 Hz → period 10 ms),
// fast enough to follow SDR frequency offset drift.
let alpha = (-1.0f32 / (0.05 * sr)).exp();
Self {
carrier_re: 1.0,
carrier_im: 0.0,
alpha,
dc_l: DcBlocker::new(0.999),
dc_r: DcBlocker::new(0.999),
}
}
/// Demodulate a block of AGC-normalised baseband IQ samples into
/// interleaved stereo audio.
pub fn demodulate_stereo(&mut self, samples: &[Complex<f32>]) -> Vec<f32> {
let mut out = Vec::with_capacity(samples.len() * 2);
let alpha = self.alpha;
let one_minus_alpha = 1.0 - alpha;
for &s in samples {
// Advance the carrier IIR tracker. In steady state the DC
// component of s is the carrier phasor e^{jφ}.
self.carrier_re = alpha * self.carrier_re + one_minus_alpha * s.re;
self.carrier_im = alpha * self.carrier_im + one_minus_alpha * s.im;
// Rotate s by −φ to phase-align I with (1 + m_s) and Q with m_d.
let mag_sq =
self.carrier_re * self.carrier_re + self.carrier_im * self.carrier_im;
let (i_corr, q_corr) = if mag_sq > 1e-8 {
let inv = mag_sq.sqrt().recip();
let cos_phi = self.carrier_re * inv;
let sin_phi = self.carrier_im * inv;
// s · e^{-jφ}
(
s.re * cos_phi + s.im * sin_phi,
-s.re * sin_phi + s.im * cos_phi,
)
} else {
(s.re, s.im)
};
// Stereo decode.
// I ≈ 1 + (L+R)/2, Q ≈ (LR)/2
// L_raw = I + Q = 1 + L → DC-block → L audio
// R_raw = I Q = 1 + R → DC-block → R audio
out.push(self.dc_l.process(i_corr + q_corr));
out.push(self.dc_r.process(i_corr - q_corr));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cquam_silence_is_silent() {
let mut demod = CquamDemod::new(8_000);
let samples = vec![Complex::new(0.0f32, 0.0); 256];
let out = demod.demodulate_stereo(&samples);
assert_eq!(out.len(), 512);
for &s in &out {
assert!(s.abs() < 1e-5, "silence should produce near-zero output, got {s}");
}
}
#[test]
fn test_cquam_pure_am_mono() {
// A pure AM carrier (no Q modulation) should produce equal L and R.
let mut demod = CquamDemod::new(8_000);
// Let the carrier tracker settle for 1 s worth of samples.
let settle: Vec<Complex<f32>> = (0..8_000)
.map(|i| {
let t = i as f32 / 8_000.0;
let audio = 0.5 * (2.0 * std::f32::consts::PI * 440.0 * t).sin();
Complex::new(1.0 + audio, 0.0)
})
.collect();
let out = demod.demodulate_stereo(&settle);
// After settling, L and R should be roughly equal (within 0.02 amplitude).
for chunk in out.chunks_exact(2).skip(4_000) {
let l = chunk[0];
let r = chunk[1];
assert!(
(l - r).abs() < 0.02,
"pure AM mono should have L ≈ R, got L={l:.4} R={r:.4}"
);
}
}
}
@@ -6,7 +6,7 @@ use num_complex::Complex;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use trx_core::rig::state::{RdsData, RigMode, WfmDenoiseLevel}; use trx_core::rig::state::{RdsData, RigMode, WfmDenoiseLevel};
use crate::demod::{DcBlocker, Demodulator, SoftAgc, WfmStereoDecoder}; use crate::demod::{CquamDemod, DcBlocker, Demodulator, SoftAgc, WfmStereoDecoder};
use super::{BlockFirFilterPair, IQ_BLOCK_SIZE}; use super::{BlockFirFilterPair, IQ_BLOCK_SIZE};
@@ -114,7 +114,7 @@ fn agc_for_mode(mode: &RigMode, audio_sample_rate: u32) -> SoftAgc {
let sr = audio_sample_rate.max(1) as f32; let sr = audio_sample_rate.max(1) as f32;
match mode { match mode {
RigMode::CW | RigMode::CWR => SoftAgc::new(sr, 1.0, 50.0, 0.5, 30.0), RigMode::CW | RigMode::CWR => SoftAgc::new(sr, 1.0, 50.0, 0.5, 30.0),
RigMode::AM => SoftAgc::new(sr, 5.0, 200.0, 0.5, 36.0), RigMode::AM | RigMode::AMC => SoftAgc::new(sr, 5.0, 200.0, 0.5, 36.0),
_ => SoftAgc::new(sr, 5.0, 500.0, 0.5, 30.0), _ => SoftAgc::new(sr, 5.0, 500.0, 0.5, 30.0),
} }
} }
@@ -128,7 +128,7 @@ fn iq_agc_for_mode(mode: &RigMode, sample_rate: u32) -> Option<SoftAgc> {
// DC blocker always sees the same steady-state bias (~0.7) regardless // DC blocker always sees the same steady-state bias (~0.7) regardless
// of RF signal strength. Fast attack (0.5 ms) catches sudden carrier // of RF signal strength. Fast attack (0.5 ms) catches sudden carrier
// appearance; 50 ms release tracks slow fading without distorting audio. // appearance; 50 ms release tracks slow fading without distorting audio.
RigMode::AM => Some(SoftAgc::new(sr, 0.5, 50.0, 0.7, 30.0)), RigMode::AM | RigMode::AMC => Some(SoftAgc::new(sr, 0.5, 50.0, 0.7, 30.0)),
RigMode::WFM => None, RigMode::WFM => None,
_ => None, _ => None,
} }
@@ -137,6 +137,8 @@ fn iq_agc_for_mode(mode: &RigMode, sample_rate: u32) -> Option<SoftAgc> {
fn dc_for_mode(mode: &RigMode) -> Option<DcBlocker> { fn dc_for_mode(mode: &RigMode) -> Option<DcBlocker> {
match mode { match mode {
RigMode::WFM => None, RigMode::WFM => None,
// AMC: DC is handled inside CquamDemod per channel (L and R separately).
RigMode::AMC => None,
// AM: the envelope detector output has a large carrier-amplitude DC // AM: the envelope detector output has a large carrier-amplitude DC
// bias (A_c). r=0.999 gives τ≈125 ms at 8 kHz, tracking carrier // bias (A_c). r=0.999 gives τ≈125 ms at 8 kHz, tracking carrier
// level ~10× faster than r=0.9999 while still passing all audio // level ~10× faster than r=0.9999 while still passing all audio
@@ -151,7 +153,7 @@ fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000, RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
RigMode::PKT => 25_000, RigMode::PKT => 25_000,
RigMode::CW | RigMode::CWR => 500, RigMode::CW | RigMode::CWR => 500,
RigMode::AM => 9_000, RigMode::AM | RigMode::AMC => 9_000,
RigMode::FM => 12_500, RigMode::FM => 12_500,
RigMode::WFM => 180_000, RigMode::WFM => 180_000,
RigMode::AIS => 25_000, RigMode::AIS => 25_000,
@@ -204,6 +206,7 @@ pub struct ChannelDsp {
resample_phase: f64, resample_phase: f64,
resample_phase_inc: f64, resample_phase_inc: f64,
wfm_decoder: Option<WfmStereoDecoder>, wfm_decoder: Option<WfmStereoDecoder>,
cquam_decoder: Option<CquamDemod>,
iq_agc: Option<SoftAgc>, iq_agc: Option<SoftAgc>,
audio_agc: SoftAgc, audio_agc: SoftAgc,
audio_dc: Option<DcBlocker>, audio_dc: Option<DcBlocker>,
@@ -290,6 +293,11 @@ impl ChannelDsp {
} else { } else {
self.wfm_decoder = None; self.wfm_decoder = None;
} }
if self.mode == RigMode::AMC {
self.cquam_decoder = Some(CquamDemod::new(self.audio_sample_rate));
} else {
self.cquam_decoder = None;
}
self.iq_agc = iq_agc_for_mode(&self.mode, channel_sample_rate); self.iq_agc = iq_agc_for_mode(&self.mode, channel_sample_rate);
self.audio_agc = agc_for_mode(&self.mode, self.audio_sample_rate); self.audio_agc = agc_for_mode(&self.mode, self.audio_sample_rate);
self.audio_dc = dc_for_mode(&self.mode); self.audio_dc = dc_for_mode(&self.mode);
@@ -382,6 +390,11 @@ impl ChannelDsp {
} else { } else {
None None
}, },
cquam_decoder: if *mode == RigMode::AMC {
Some(CquamDemod::new(audio_sample_rate))
} else {
None
},
iq_agc: iq_agc_for_mode(mode, channel_sample_rate), iq_agc: iq_agc_for_mode(mode, channel_sample_rate),
audio_agc: agc_for_mode(mode, audio_sample_rate), audio_agc: agc_for_mode(mode, audio_sample_rate),
audio_dc: dc_for_mode(mode), audio_dc: dc_for_mode(mode),
@@ -582,6 +595,21 @@ impl ChannelDsp {
*sample = (*sample * WFM_OUTPUT_GAIN).clamp(-1.0, 1.0); *sample = (*sample * WFM_OUTPUT_GAIN).clamp(-1.0, 1.0);
} }
out out
} else if let Some(decoder) = self.cquam_decoder.as_mut() {
let stereo = decoder.demodulate_stereo(decimated);
// Apply stereo-aware AGC (shared gain preserves L/R balance).
let mut out = Vec::with_capacity(stereo.len());
let mut it = stereo.chunks_exact(2);
for chunk in it.by_ref() {
let (l, r) = self.audio_agc.process_pair(chunk[0], chunk[1]);
if self.output_channels >= 2 && !self.force_mono_pcm {
out.push(l);
out.push(r);
} else {
out.push((l + r) * 0.5);
}
}
out
} else { } else {
let mut raw = self.demodulator.demodulate(decimated); let mut raw = self.demodulator.demodulate(decimated);
for sample in &mut raw { for sample in &mut raw {
@@ -73,7 +73,7 @@ impl SoapySdrRig {
RigMode::PKT | RigMode::AIS => 25_000, RigMode::PKT | RigMode::AIS => 25_000,
RigMode::VDES => 100_000, RigMode::VDES => 100_000,
RigMode::CW | RigMode::CWR => 500, RigMode::CW | RigMode::CWR => 500,
RigMode::AM => 9_000, RigMode::AM | RigMode::AMC => 9_000,
RigMode::FM => 12_500, RigMode::FM => 12_500,
RigMode::WFM => 180_000, RigMode::WFM => 180_000,
RigMode::Other(_) => 3_000, RigMode::Other(_) => 3_000,
@@ -43,7 +43,7 @@ fn default_bandwidth_hz(mode: &RigMode) -> u32 {
match mode { match mode {
RigMode::CW | RigMode::CWR => 500, RigMode::CW | RigMode::CWR => 500,
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000, RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
RigMode::AM => 9_000, RigMode::AM | RigMode::AMC => 9_000,
RigMode::FM => 12_500, RigMode::FM => 12_500,
RigMode::WFM => 180_000, RigMode::WFM => 180_000,
RigMode::PKT | RigMode::AIS => 25_000, RigMode::PKT | RigMode::AIS => 25_000,