[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:
@@ -3,6 +3,7 @@
|
||||
// SPDX-License-Identifier: BSD-2-Clause
|
||||
|
||||
mod am;
|
||||
mod amcquam;
|
||||
mod fm;
|
||||
mod math;
|
||||
mod math_arm;
|
||||
@@ -13,6 +14,7 @@ mod wfm;
|
||||
use num_complex::Complex;
|
||||
use trx_core::rig::state::RigMode;
|
||||
|
||||
pub use self::amcquam::CquamDemod;
|
||||
pub use self::wfm::WfmStereoDecoder;
|
||||
|
||||
/// Shared DC blocker used by narrowband and WFM audio paths.
|
||||
@@ -145,6 +147,8 @@ pub enum Demodulator {
|
||||
Cw,
|
||||
/// Pass-through (DIG, PKT): same as USB.
|
||||
Passthrough,
|
||||
/// AM C-QUAM stereo: synchronous IQ detection with carrier phase tracking.
|
||||
AmCQuam,
|
||||
}
|
||||
|
||||
impl Demodulator {
|
||||
@@ -154,6 +158,7 @@ impl Demodulator {
|
||||
RigMode::USB => Self::Usb,
|
||||
RigMode::LSB => Self::Lsb,
|
||||
RigMode::AM => Self::Am,
|
||||
RigMode::AMC => Self::AmCQuam,
|
||||
RigMode::FM => Self::Fm,
|
||||
RigMode::WFM => Self::Wfm,
|
||||
RigMode::AIS | RigMode::VDES => Self::Fm,
|
||||
@@ -170,7 +175,7 @@ impl Demodulator {
|
||||
match self {
|
||||
Self::Usb | Self::Passthrough => ssb::demod_usb(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::Cw => ssb::demod_cw(samples),
|
||||
}
|
||||
@@ -196,6 +201,7 @@ mod tests {
|
||||
Demodulator::Passthrough
|
||||
);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::PKT), Demodulator::Fm);
|
||||
assert_eq!(Demodulator::for_mode(&RigMode::AMC), Demodulator::AmCQuam);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -209,6 +215,7 @@ mod tests {
|
||||
Demodulator::Wfm,
|
||||
Demodulator::Cw,
|
||||
Demodulator::Passthrough,
|
||||
Demodulator::AmCQuam,
|
||||
];
|
||||
for demod in &demodulators {
|
||||
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 ≈ (L−R)/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 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};
|
||||
|
||||
@@ -114,7 +114,7 @@ fn agc_for_mode(mode: &RigMode, audio_sample_rate: u32) -> SoftAgc {
|
||||
let sr = audio_sample_rate.max(1) as f32;
|
||||
match mode {
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// of RF signal strength. Fast attack (0.5 ms) catches sudden carrier
|
||||
// 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,
|
||||
_ => 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> {
|
||||
match mode {
|
||||
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
|
||||
// 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
|
||||
@@ -151,7 +153,7 @@ fn default_bandwidth_for_mode(mode: &RigMode) -> u32 {
|
||||
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
||||
RigMode::PKT => 25_000,
|
||||
RigMode::CW | RigMode::CWR => 500,
|
||||
RigMode::AM => 9_000,
|
||||
RigMode::AM | RigMode::AMC => 9_000,
|
||||
RigMode::FM => 12_500,
|
||||
RigMode::WFM => 180_000,
|
||||
RigMode::AIS => 25_000,
|
||||
@@ -204,6 +206,7 @@ pub struct ChannelDsp {
|
||||
resample_phase: f64,
|
||||
resample_phase_inc: f64,
|
||||
wfm_decoder: Option<WfmStereoDecoder>,
|
||||
cquam_decoder: Option<CquamDemod>,
|
||||
iq_agc: Option<SoftAgc>,
|
||||
audio_agc: SoftAgc,
|
||||
audio_dc: Option<DcBlocker>,
|
||||
@@ -290,6 +293,11 @@ impl ChannelDsp {
|
||||
} else {
|
||||
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.audio_agc = agc_for_mode(&self.mode, self.audio_sample_rate);
|
||||
self.audio_dc = dc_for_mode(&self.mode);
|
||||
@@ -382,6 +390,11 @@ impl ChannelDsp {
|
||||
} else {
|
||||
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),
|
||||
audio_agc: agc_for_mode(mode, audio_sample_rate),
|
||||
audio_dc: dc_for_mode(mode),
|
||||
@@ -582,6 +595,21 @@ impl ChannelDsp {
|
||||
*sample = (*sample * WFM_OUTPUT_GAIN).clamp(-1.0, 1.0);
|
||||
}
|
||||
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 {
|
||||
let mut raw = self.demodulator.demodulate(decimated);
|
||||
for sample in &mut raw {
|
||||
|
||||
@@ -73,7 +73,7 @@ impl SoapySdrRig {
|
||||
RigMode::PKT | RigMode::AIS => 25_000,
|
||||
RigMode::VDES => 100_000,
|
||||
RigMode::CW | RigMode::CWR => 500,
|
||||
RigMode::AM => 9_000,
|
||||
RigMode::AM | RigMode::AMC => 9_000,
|
||||
RigMode::FM => 12_500,
|
||||
RigMode::WFM => 180_000,
|
||||
RigMode::Other(_) => 3_000,
|
||||
|
||||
@@ -43,7 +43,7 @@ fn default_bandwidth_hz(mode: &RigMode) -> u32 {
|
||||
match mode {
|
||||
RigMode::CW | RigMode::CWR => 500,
|
||||
RigMode::LSB | RigMode::USB | RigMode::DIG => 3_000,
|
||||
RigMode::AM => 9_000,
|
||||
RigMode::AM | RigMode::AMC => 9_000,
|
||||
RigMode::FM => 12_500,
|
||||
RigMode::WFM => 180_000,
|
||||
RigMode::PKT | RigMode::AIS => 25_000,
|
||||
|
||||
Reference in New Issue
Block a user