From ba9881ff620594653c0c71c4e53ee90521ee506f Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 28 Feb 2026 16:04:26 +0100 Subject: [PATCH] [feat](trx-backend-soapysdr): add AGC and DC blocking to all demod modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SoftAgc — a fast-attack/slow-release envelope AGC with a max-gain cap — to all demodulated audio paths so that switching between modes (WFM, AM, SSB, CW, FM) no longer produces large volume jumps. AGC is applied after every demodulator, including WFM, with a shared target level of 0.5. Add per-mode DC blocking (DcBlocker) for USB/LSB/AM/FM/DIG paths to remove carrier frequency-offset DC from the FM discriminator and LO bleedthrough in SSB. CW is excluded because high-passing a non-negative envelope creates negative-going artifacts on each key release; WFM already has internal DC blockers on each output channel. AGC time constants are tuned per mode: CW/CWR – 1 ms attack / 50 ms release (follows individual dots/dashes) AM – 5 ms attack / 200 ms release (tracks fading carriers) all else– 5 ms attack / 500 ms release (suits voice and data) Simplify demod_am and demod_cw: remove per-block peak normalisation and DC removal that caused block-boundary level discontinuities ("pumping"). Both now return raw magnitudes and rely on the downstream DC blocker and AGC for normalisation. DIG is already wired as Passthrough (identical to USB); no change needed. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- .../trx-backend-soapysdr/src/demod.rs | 156 +++++++++++------- .../trx-backend-soapysdr/src/dsp.rs | 64 ++++++- 2 files changed, 160 insertions(+), 60 deletions(-) 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 a8aac59..bfeeb33 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 @@ -40,14 +40,14 @@ struct BiquadBandPass { } #[derive(Debug, Clone)] -struct DcBlocker { +pub(crate) struct DcBlocker { r: f32, x1: f32, y1: f32, } impl DcBlocker { - fn new(r: f32) -> Self { + pub(crate) fn new(r: f32) -> Self { Self { r: r.clamp(0.9, 0.9999), x1: 0.0, @@ -55,7 +55,7 @@ impl DcBlocker { } } - fn process(&mut self, x: f32) -> f32 { + pub(crate) fn process(&mut self, x: f32) -> f32 { let y = x - self.x1 + self.r * self.y1; self.x1 = x; self.y1 = y; @@ -63,6 +63,76 @@ impl DcBlocker { } } +/// Soft AGC with a fast-attack / slow-release envelope follower. +/// +/// Tracks the signal envelope and adjusts gain so the output level converges +/// toward `target`. Gain decreases quickly when the signal is louder than +/// the target (prevents clipping) and recovers slowly during quieter periods +/// (avoids pumping noise). A `max_gain` cap prevents excessive amplification +/// of noise during silence. +#[derive(Debug, Clone)] +pub(crate) struct SoftAgc { + gain: f32, + envelope: f32, + attack_coeff: f32, + release_coeff: f32, + target: f32, + max_gain: f32, +} + +impl SoftAgc { + /// Create a new `SoftAgc`. + /// + /// - `sample_rate`: audio sample rate in Hz + /// - `attack_ms`: envelope follower attack time (fast, e.g. 1–10 ms) + /// - `release_ms`: envelope follower release time (slow, e.g. 50–1000 ms) + /// - `target`: desired peak output level (e.g. `0.5`) + /// - `max_gain_db`: maximum gain cap in dB (e.g. `30.0`) + pub(crate) fn new( + sample_rate: f32, + attack_ms: f32, + release_ms: f32, + target: f32, + max_gain_db: f32, + ) -> Self { + let sr = sample_rate.max(1.0); + let attack_coeff = 1.0 - (-1.0 / (attack_ms * 1e-3 * sr)).exp(); + let release_coeff = 1.0 - (-1.0 / (release_ms * 1e-3 * sr)).exp(); + Self { + gain: 1.0, + envelope: 0.0, + attack_coeff, + release_coeff, + target: target.max(0.01), + max_gain: 10.0_f32.powf(max_gain_db / 20.0), + } + } + + pub(crate) fn process(&mut self, x: f32) -> f32 { + // Update envelope tracker (peak-hold with attack/release). + let abs_x = x.abs(); + let env_coeff = if abs_x > self.envelope { + self.attack_coeff + } else { + self.release_coeff + }; + self.envelope += env_coeff * (abs_x - self.envelope); + + // Compute desired gain; fast response when reducing, slow when recovering. + if self.envelope > 1e-6 { + let desired = (self.target / self.envelope).min(self.max_gain); + let gain_coeff = if desired < self.gain { + self.attack_coeff + } else { + self.release_coeff + }; + self.gain += gain_coeff * (desired - self.gain); + } + + (x * self.gain).clamp(-1.0, 1.0) + } +} + impl BiquadBandPass { fn new(sample_rate: f32, center_hz: f32, q: f32) -> Self { let sr = sample_rate.max(1.0); @@ -468,32 +538,15 @@ fn demod_lsb(samples: &[Complex]) -> Vec { // AM // --------------------------------------------------------------------------- -/// AM envelope detector: magnitude of IQ, DC-removed, peak-normalised to ≤ 1.0. +/// AM envelope detector: magnitude of IQ. +/// +/// Returns the raw envelope amplitude. DC removal (carrier offset) and level +/// normalisation are handled downstream by the per-channel DC blocker and AGC. fn demod_am(samples: &[Complex]) -> Vec { - if samples.is_empty() { - return Vec::new(); - } - - // Compute envelope (magnitude). - let mag: Vec = samples + samples .iter() .map(|s| (s.re * s.re + s.im * s.im).sqrt()) - .collect(); - - // Remove DC offset. - let mean = mag.iter().copied().sum::() / mag.len() as f32; - let mut output: Vec = mag.iter().map(|&m| m - mean).collect(); - - // Normalise peak to ≤ 1.0 (only if max > 1.0, to avoid amplifying noise). - let max_abs = output.iter().copied().map(f32::abs).fold(0.0_f32, f32::max); - if max_abs > 1.0 { - let inv = 1.0 / max_abs; - for sample in &mut output { - *sample *= inv; - } - } - - output + .collect() } // --------------------------------------------------------------------------- @@ -524,28 +577,17 @@ fn demod_fm(samples: &[Complex]) -> Vec { // CW // --------------------------------------------------------------------------- -/// CW envelope detector: magnitude of IQ, peak-normalised to ≤ 1.0. -/// Narrow BPF is applied upstream. +/// CW envelope detector: magnitude of IQ. +/// +/// Returns the raw envelope amplitude. Level normalisation is handled +/// downstream by the per-channel AGC. No DC blocker is applied to CW because +/// the envelope is always non-negative; high-pass filtering would create +/// negative-going artifacts on each key release. fn demod_cw(samples: &[Complex]) -> Vec { - if samples.is_empty() { - return Vec::new(); - } - - let mut output: Vec = samples + samples .iter() .map(|s| (s.re * s.re + s.im * s.im).sqrt()) - .collect(); - - // Normalise peak to ≤ 1.0. - let max_abs = output.iter().copied().fold(0.0_f32, f32::max); - if max_abs > 1.0 { - let inv = 1.0 / max_abs; - for sample in &mut output { - *sample *= inv; - } - } - - output + .collect() } // --------------------------------------------------------------------------- @@ -608,31 +650,32 @@ mod tests { assert_eq!(lsb_out, expected, "LSB should return real parts"); } - // Test 3: AM on a constant-magnitude signal produces all zeros (DC removed). + // Test 3: AM on a constant-magnitude signal returns the raw envelope (1.0). + // DC removal and normalization are handled downstream by the DC blocker and AGC. #[test] - fn test_am_dc_removed() { + fn test_am_raw_magnitude_constant() { let input: Vec> = (0..8).map(|_| Complex::new(1.0, 0.0)).collect(); let out = Demodulator::Am.demodulate(&input); assert_eq!(out.len(), 8); for (i, &v) in out.iter().enumerate() { - assert_approx_eq(v, 0.0, 1e-6, &format!("AM DC removed sample {}", i)); + assert_approx_eq(v, 1.0, 1e-6, &format!("AM raw magnitude sample {}", i)); } } - // Test 4: AM on alternating-magnitude signal produces DC-centered output. + // Test 4: AM on alternating-magnitude signal returns the raw envelope. #[test] - fn test_am_varying_envelope() { + fn test_am_raw_magnitude_varying() { let input = vec![ Complex::new(0.0_f32, 0.0), Complex::new(1.0, 0.0), Complex::new(0.0, 0.0), Complex::new(1.0, 0.0), ]; - let expected = [-0.5_f32, 0.5, -0.5, 0.5]; + let expected = [0.0_f32, 1.0, 0.0, 1.0]; let out = Demodulator::Am.demodulate(&input); assert_eq!(out.len(), 4); for (i, (&got, &exp)) in out.iter().zip(expected.iter()).enumerate() { - assert_approx_eq(got, exp, 1e-6, &format!("AM varying envelope sample {}", i)); + assert_approx_eq(got, exp, 1e-6, &format!("AM raw magnitude sample {}", i)); } } @@ -662,9 +705,10 @@ mod tests { } } - // Test 7: CW envelope detector normalises peak to 1.0. + // Test 7: CW envelope detector returns raw magnitudes. + // Normalisation is handled downstream by the AGC. #[test] - fn test_cw_magnitude_envelope() { + fn test_cw_raw_magnitude() { let input = vec![ Complex::new(3.0_f32, 4.0), // magnitude 5.0 Complex::new(0.0, 0.0), // magnitude 0.0 @@ -672,9 +716,9 @@ mod tests { ]; let out = Demodulator::Cw.demodulate(&input); assert_eq!(out.len(), 3); - assert_approx_eq(out[0], 1.0, 1e-6, "CW sample 0"); + assert_approx_eq(out[0], 5.0, 1e-6, "CW sample 0"); assert_approx_eq(out[1], 0.0, 1e-6, "CW sample 1"); - assert_approx_eq(out[2], 0.2, 1e-6, "CW sample 2"); + assert_approx_eq(out[2], 1.0, 1e-6, "CW sample 2"); } // Test 8: Demodulator::for_mode maps each RigMode to the correct variant. diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs index 2497ee6..ebe901f 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/dsp.rs @@ -20,7 +20,7 @@ use rustfft::{Fft, FftPlanner}; use tokio::sync::broadcast; use trx_core::rig::state::{RdsData, RigMode}; -use crate::demod::{Demodulator, WfmStereoDecoder}; +use crate::demod::{DcBlocker, Demodulator, SoftAgc, WfmStereoDecoder}; // --------------------------------------------------------------------------- // IQ source abstraction @@ -249,6 +249,39 @@ impl BlockFirFilter { } } +// --------------------------------------------------------------------------- +// Per-mode post-processing helpers (DC block + AGC) +// --------------------------------------------------------------------------- + +/// Build the AGC for a given mode. +/// +/// CW uses fast attack/release to follow individual dots and dashes. +/// AM uses a moderate release so the AGC tracks fading carriers. +/// All other modes use a longer release suitable for voice and data signals. +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, 30.0), + _ => SoftAgc::new(sr, 5.0, 500.0, 0.5, 30.0), + } +} + +/// Build the DC blocker for a given mode, or `None` if not applicable. +/// +/// CW and WFM are excluded: CW envelope is always non-negative (DC blocking +/// would create negative artifacts on key releases), and WFM has its own +/// internal DC blockers on each output channel. +/// AM uses a slightly faster blocker (r = 0.999, corner ≈ 7.6 Hz @ 48 kHz) +/// so it can track slow carrier-amplitude fading. +fn dc_for_mode(mode: &RigMode) -> Option { + match mode { + RigMode::CW | RigMode::CWR | RigMode::WFM => None, + RigMode::AM => Some(DcBlocker::new(0.999)), + _ => Some(DcBlocker::new(0.9999)), + } +} + // --------------------------------------------------------------------------- // Channel DSP context // --------------------------------------------------------------------------- @@ -300,6 +333,11 @@ pub struct ChannelDsp { resample_phase_inc: f64, /// Dedicated WFM decoder that preserves the FM composite baseband. wfm_decoder: Option, + /// Soft AGC applied to all demodulated audio for consistent cross-mode levels. + audio_agc: SoftAgc, + /// DC blocker for modes whose demodulator output can carry a DC offset + /// (USB/LSB/AM/FM/DIG). None for CW and WFM. + audio_dc: Option, } impl ChannelDsp { @@ -375,6 +413,8 @@ impl ChannelDsp { } else { self.wfm_decoder = None; } + self.audio_agc = agc_for_mode(&self.mode, self.audio_sample_rate); + self.audio_dc = dc_for_mode(&self.mode); self.frame_buf.clear(); } @@ -456,6 +496,8 @@ impl ChannelDsp { } else { None }, + audio_agc: agc_for_mode(mode, audio_sample_rate), + audio_dc: dc_for_mode(mode), } } @@ -579,11 +621,25 @@ impl ChannelDsp { return; } - // --- 4. Demodulate -------------------------------------------------- + // --- 4. Demodulate + post-process ----------------------------------- + // WFM: full composite decoder (handles its own DC blocks + deemphasis). + // All other modes: stateless demodulator → DC blocker (where enabled) → AGC. + // AGC is applied to WFM output too so all modes share the same target level. let audio = if let Some(decoder) = self.wfm_decoder.as_mut() { - decoder.process_iq(&decimated) + let mut out = decoder.process_iq(&decimated); + for s in &mut out { + *s = self.audio_agc.process(*s); + } + out } else { - self.demodulator.demodulate(&decimated) + let mut raw = self.demodulator.demodulate(&decimated); + for s in &mut raw { + if let Some(dc) = &mut self.audio_dc { + *s = dc.process(*s); + } + *s = self.audio_agc.process(*s); + } + raw }; // --- 5. Emit complete PCM frames ------------------------------------