[feat](trx-backend-soapysdr): add AGC and DC blocking to all demod modes

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 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-28 16:04:26 +01:00
parent 5096e01a60
commit ba9881ff62
2 changed files with 160 additions and 60 deletions
@@ -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. 110 ms)
/// - `release_ms`: envelope follower release time (slow, e.g. 501000 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<f32>]) -> Vec<f32> {
// 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<f32>]) -> Vec<f32> {
if samples.is_empty() {
return Vec::new();
}
// Compute envelope (magnitude).
let mag: Vec<f32> = samples
samples
.iter()
.map(|s| (s.re * s.re + s.im * s.im).sqrt())
.collect();
// Remove DC offset.
let mean = mag.iter().copied().sum::<f32>() / mag.len() as f32;
let mut output: Vec<f32> = 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<f32>]) -> Vec<f32> {
// 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<f32>]) -> Vec<f32> {
if samples.is_empty() {
return Vec::new();
}
let mut output: Vec<f32> = 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<Complex<f32>> = (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.
@@ -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<DcBlocker> {
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<WfmStereoDecoder>,
/// 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<DcBlocker>,
}
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 ------------------------------------