diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs index 1aeb3c4..3799186 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs @@ -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!( diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/amcquam.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/amcquam.rs new file mode 100644 index 0000000..dc42a8a --- /dev/null +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod/amcquam.rs @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2025 Stanislaw Grams +// +// 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]) -> Vec { + 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> = (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}" + ); + } + } +} diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs index ac745d1..5ccc518 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp/channel.rs @@ -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 { // 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 { fn dc_for_mode(mode: &RigMode) -> Option { 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, + cquam_decoder: Option, iq_agc: Option, audio_agc: SoftAgc, audio_dc: Option, @@ -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 { diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs index 8ff019b..611aca6 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs @@ -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, diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs index 9c85468..f54af88 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs @@ -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,