[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:
@@ -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<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 ------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user