[fix](trx-backend-soapysdr): drop wfm limiter and widen cutoffs

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-01 00:20:41 +01:00
parent 3b277da243
commit a25182ea3f
2 changed files with 7 additions and 81 deletions
@@ -11,14 +11,13 @@ const RDS_BPF_Q: f32 = 10.0;
/// Pilot tone frequency (Hz). /// Pilot tone frequency (Hz).
const PILOT_HZ: f32 = 19_000.0; const PILOT_HZ: f32 = 19_000.0;
/// Audio bandwidth for WFM (Hz). /// Audio bandwidth for WFM (Hz).
/// 15.8 kHz leaves more guard band below the 19 kHz pilot and reduces /// 17 kHz preserves more top-end while still leaving guard band below the
/// top-end artifacts on strong signals while still preserving the useful /// 19 kHz pilot.
/// broadcast audio range. const AUDIO_BW_HZ: f32 = 17_000.0;
const AUDIO_BW_HZ: f32 = 15_800.0;
/// Stereo L-R subchannel bandwidth for WFM (Hz). /// Stereo L-R subchannel bandwidth for WFM (Hz).
/// Keep this a bit lower than the mono path because the recovered difference /// Keep this a bit lower than the mono path because the recovered difference
/// signal is noisier and more prone to high-frequency artifacts. /// signal is noisier and more prone to high-frequency artifacts.
const STEREO_DIFF_BW_HZ: f32 = 14_500.0; const STEREO_DIFF_BW_HZ: f32 = 15_800.0;
/// Q values for a proper 4th-order Butterworth cascade (two 2nd-order stages). /// Q values for a proper 4th-order Butterworth cascade (two 2nd-order stages).
/// Stage 1: Q = 1 / (2 cos(π/8)) /// Stage 1: Q = 1 / (2 cos(π/8))
const BW4_Q1: f32 = 0.5412; const BW4_Q1: f32 = 0.5412;
@@ -100,7 +99,6 @@ fn polyphase_resample(
.sum() .sum()
} }
#[inline]
fn smoothstep01(x: f32) -> f32 { fn smoothstep01(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0); let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x) x * x * (3.0 - 2.0 * x)
@@ -222,24 +220,6 @@ impl SoftAgc {
(x * gain).clamp(-1.0, 1.0) (x * gain).clamp(-1.0, 1.0)
} }
pub(crate) fn process_with_level(&mut self, x: f32, level: f32) -> f32 {
let gain = self.update_gain(level.abs());
(x * gain).clamp(-1.0, 1.0)
}
pub(crate) fn process_pair_with_level(
&mut self,
left: f32,
right: f32,
level: f32,
) -> (f32, f32) {
let gain = self.update_gain(level.abs());
(
(left * gain).clamp(-1.0, 1.0),
(right * gain).clamp(-1.0, 1.0),
)
}
pub(crate) fn process_complex(&mut self, x: Complex<f32>) -> Complex<f32> { pub(crate) fn process_complex(&mut self, x: Complex<f32>) -> Complex<f32> {
let gain = self.update_gain((x.re * x.re + x.im * x.im).sqrt()); let gain = self.update_gain((x.re * x.re + x.im * x.im).sqrt());
let mut y = x * gain; let mut y = x * gain;
@@ -267,15 +267,12 @@ impl BlockFirFilter {
/// 500 ms / 5 s only reacts to slow carrier-amplitude fading, not audio. /// 500 ms / 5 s only reacts to slow carrier-amplitude fading, not audio.
/// ///
/// CW uses a fast attack/release to follow individual dots and dashes. /// CW uses a fast attack/release to follow individual dots and dashes.
/// WFM uses a linked downward-only peak limiter (max gain = 1.0) so louder
/// stations are tamed without boosting quieter ones.
/// All other modes use 5 ms / 500 ms, suitable for SSB voice and FM. /// All other modes use 5 ms / 500 ms, suitable for SSB voice and FM.
fn agc_for_mode(mode: &RigMode, audio_sample_rate: u32) -> SoftAgc { fn agc_for_mode(mode: &RigMode, audio_sample_rate: u32) -> SoftAgc {
let sr = audio_sample_rate.max(1) as f32; let sr = audio_sample_rate.max(1) as f32;
match mode { match mode {
RigMode::CW | RigMode::CWR => SoftAgc::new(sr, 1.0, 50.0, 0.5, 30.0), RigMode::CW | RigMode::CWR => SoftAgc::new(sr, 1.0, 50.0, 0.5, 30.0),
RigMode::AM => SoftAgc::new(sr, 500.0, 5_000.0, 0.5, 30.0), RigMode::AM => SoftAgc::new(sr, 500.0, 5_000.0, 0.5, 30.0),
RigMode::WFM => SoftAgc::new(sr, 0.2, 80.0, 0.92, 0.0),
_ => SoftAgc::new(sr, 5.0, 500.0, 0.5, 30.0), _ => SoftAgc::new(sr, 5.0, 500.0, 0.5, 30.0),
} }
} }
@@ -305,10 +302,6 @@ fn dc_for_mode(mode: &RigMode) -> Option<DcBlocker> {
} }
} }
fn wfm_limiter_sidechain() -> Option<(DcBlocker, DcBlocker)> {
Some((DcBlocker::new(0.985), DcBlocker::new(0.985)))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Channel DSP context // Channel DSP context
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -366,9 +359,6 @@ pub struct ChannelDsp {
iq_agc: Option<SoftAgc>, iq_agc: Option<SoftAgc>,
/// Soft AGC applied to all demodulated audio for consistent cross-mode levels. /// Soft AGC applied to all demodulated audio for consistent cross-mode levels.
audio_agc: SoftAgc, audio_agc: SoftAgc,
/// Optional high-passed detector path for the WFM limiter so bass does not
/// dominate gain reduction and smear treble.
limiter_sidechain: Option<(DcBlocker, DcBlocker)>,
/// DC blocker for modes whose demodulator output can carry a DC offset /// DC blocker for modes whose demodulator output can carry a DC offset
/// (USB/LSB/AM/FM/DIG). None for CW and WFM. /// (USB/LSB/AM/FM/DIG). None for CW and WFM.
audio_dc: Option<DcBlocker>, audio_dc: Option<DcBlocker>,
@@ -450,11 +440,6 @@ impl ChannelDsp {
} }
self.iq_agc = iq_agc_for_mode(&self.mode, channel_sample_rate); 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_agc = agc_for_mode(&self.mode, self.audio_sample_rate);
self.limiter_sidechain = if self.mode == RigMode::WFM {
wfm_limiter_sidechain()
} else {
None
};
self.audio_dc = dc_for_mode(&self.mode); self.audio_dc = dc_for_mode(&self.mode);
self.frame_buf.clear(); self.frame_buf.clear();
} }
@@ -542,11 +527,6 @@ impl ChannelDsp {
}, },
iq_agc: iq_agc_for_mode(mode, channel_sample_rate), iq_agc: iq_agc_for_mode(mode, channel_sample_rate),
audio_agc: agc_for_mode(mode, audio_sample_rate), audio_agc: agc_for_mode(mode, audio_sample_rate),
limiter_sidechain: if *mode == RigMode::WFM {
wfm_limiter_sidechain()
} else {
None
},
audio_dc: dc_for_mode(mode), audio_dc: dc_for_mode(mode),
} }
} }
@@ -701,44 +681,10 @@ impl ChannelDsp {
// --- 4. Demodulate + post-process ----------------------------------- // --- 4. Demodulate + post-process -----------------------------------
// WFM: full composite decoder (handles its own DC blocks + deemphasis). // WFM: full composite decoder (handles its own DC blocks + deemphasis).
// All other modes: stateless demodulator → DC blocker (where enabled) → AGC. // All other modes: stateless demodulator → DC blocker (where enabled) → AGC.
// WFM uses linked audio AGC after stereo decode; all other modes use // WFM bypasses post-audio AGC so the deemphasized stereo path is
// the normal post-demod AGC path. // heard directly; all other modes use the normal post-demod AGC path.
let audio = if let Some(decoder) = self.wfm_decoder.as_mut() { let audio = if let Some(decoder) = self.wfm_decoder.as_mut() {
let mut out = decoder.process_iq(&decimated); decoder.process_iq(&decimated)
if !self.wfm_stereo && self.output_channels >= 2 {
for pair in out.chunks_exact_mut(2) {
let detect = if let Some((left_sc, _)) = &mut self.limiter_sidechain {
left_sc.process(pair[0]).abs()
} else {
pair[0].abs()
};
let mono = self.audio_agc.process_with_level(pair[0], detect);
pair[0] = mono;
pair[1] = mono;
}
} else if self.wfm_stereo && self.output_channels >= 2 {
for pair in out.chunks_exact_mut(2) {
let detect = if let Some((left_sc, right_sc)) = &mut self.limiter_sidechain {
left_sc.process(pair[0]).abs().max(right_sc.process(pair[1]).abs())
} else {
pair[0].abs().max(pair[1].abs())
};
let (left, right) =
self.audio_agc.process_pair_with_level(pair[0], pair[1], detect);
pair[0] = left;
pair[1] = right;
}
} else {
for s in &mut out {
let detect = if let Some((left_sc, _)) = &mut self.limiter_sidechain {
left_sc.process(*s).abs()
} else {
s.abs()
};
*s = self.audio_agc.process_with_level(*s, detect);
}
}
out
} else { } else {
let mut raw = self.demodulator.demodulate(&decimated); let mut raw = self.demodulator.demodulate(&decimated);
for s in &mut raw { for s in &mut raw {