[feat](trx-backend-soapysdr): add multiband stereo denoising for WFM
Split the L-R diff channel into three frequency bands at audio rate and apply SNR-weighted blending per band driven by pilot magnitude: - 0–2 kHz: blend¹ (most stereo — low frequencies have best SNR) - 2–8 kHz: blend² (moderate noise reduction) - 8–15 kHz: blend⁴ (aggressive noise reduction — hiss-prone range) Move blend from composite rate to audio rate so the crossover filters (2nd-order Butterworth at 2 kHz and 8 kHz) operate at 48 kHz and the pilot blend is linearly interpolated per audio sample for smooth transitions. Unblended diff is now stored in prev_diff; prev_blend tracks the blend value for the same interpolation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -260,6 +260,52 @@ impl BiquadNotch {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Three-band stereo blend for WFM.
|
||||||
|
///
|
||||||
|
/// Splits the L-R diff signal into three bands at audio rate and applies
|
||||||
|
/// SNR-dependent blending per band. Weaker pilot → more aggressive noise
|
||||||
|
/// reduction at higher frequencies while low frequencies retain more stereo.
|
||||||
|
///
|
||||||
|
/// | Band | Frequency | Blend factor |
|
||||||
|
/// |-----------|-------------|--------------|
|
||||||
|
/// | Low | 0 – 2 kHz | `blend` |
|
||||||
|
/// | Mid | 2 – 8 kHz | `blend²` |
|
||||||
|
/// | High | 8 – 15 kHz | `blend⁴` |
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct MultibandStereoBlend {
|
||||||
|
/// 2nd-order Butterworth LPF at the low/mid crossover (2 kHz).
|
||||||
|
lo_lp: BiquadLowPass,
|
||||||
|
/// 2nd-order Butterworth LPF at the mid/high crossover (8 kHz).
|
||||||
|
hi_lp: BiquadLowPass,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultibandStereoBlend {
|
||||||
|
fn new(audio_rate: f32) -> Self {
|
||||||
|
// Q = 1/√2 ≈ 0.7071 — maximally flat (Butterworth) 2nd-order response.
|
||||||
|
let q = std::f32::consts::FRAC_1_SQRT_2;
|
||||||
|
Self {
|
||||||
|
lo_lp: BiquadLowPass::new(audio_rate, 2_000.0, q),
|
||||||
|
hi_lp: BiquadLowPass::new(audio_rate, 8_000.0, q),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply multiband blend to a single diff sample.
|
||||||
|
///
|
||||||
|
/// `blend` is the pilot SNR estimate in [0, 1] (0 = mono, 1 = full stereo).
|
||||||
|
fn process(&mut self, diff: f32, blend: f32) -> f32 {
|
||||||
|
// Band-split via complementary LPFs.
|
||||||
|
let lo = self.lo_lp.process(diff); // 0 – 2 kHz
|
||||||
|
let lo_hi = self.hi_lp.process(diff); // 0 – 8 kHz
|
||||||
|
let mid = lo_hi - lo; // 2 – 8 kHz
|
||||||
|
let hi = diff - lo_hi; // 8 – 15 kHz
|
||||||
|
|
||||||
|
let blend2 = blend * blend;
|
||||||
|
let blend4 = blend2 * blend2;
|
||||||
|
|
||||||
|
lo * blend + mid * blend2 + hi * blend4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl OnePoleLowPass {
|
impl OnePoleLowPass {
|
||||||
fn new(sample_rate: f32, cutoff_hz: f32) -> Self {
|
fn new(sample_rate: f32, cutoff_hz: f32) -> Self {
|
||||||
let sr = sample_rate.max(1.0);
|
let sr = sample_rate.max(1.0);
|
||||||
@@ -322,9 +368,14 @@ pub struct WfmStereoDecoder {
|
|||||||
deemph_m: Deemphasis,
|
deemph_m: Deemphasis,
|
||||||
deemph_l: Deemphasis,
|
deemph_l: Deemphasis,
|
||||||
deemph_r: Deemphasis,
|
deemph_r: Deemphasis,
|
||||||
|
/// Multiband stereo blending applied at audio rate to the L-R diff channel.
|
||||||
|
diff_denoise: MultibandStereoBlend,
|
||||||
/// Previous filtered sum/diff composite samples used for linear interpolation.
|
/// Previous filtered sum/diff composite samples used for linear interpolation.
|
||||||
prev_sum: f32,
|
prev_sum: f32,
|
||||||
|
/// Unblended L-R diff at the previous composite sample, for interpolation.
|
||||||
prev_diff: f32,
|
prev_diff: f32,
|
||||||
|
/// Pilot blend value at the previous composite sample, for interpolation.
|
||||||
|
prev_blend: f32,
|
||||||
/// Fractional phase increment per composite sample = audio_rate / composite_rate.
|
/// Fractional phase increment per composite sample = audio_rate / composite_rate.
|
||||||
/// Avoids integer-division rate error when composite_rate is not an exact
|
/// Avoids integer-division rate error when composite_rate is not an exact
|
||||||
/// multiple of audio_rate (e.g. 250 kHz composite → 48 kHz audio).
|
/// multiple of audio_rate (e.g. 250 kHz composite → 48 kHz audio).
|
||||||
@@ -367,8 +418,10 @@ impl WfmStereoDecoder {
|
|||||||
deemph_m: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
deemph_m: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||||
deemph_l: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
deemph_l: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||||
deemph_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
deemph_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
|
||||||
|
diff_denoise: MultibandStereoBlend::new(audio_rate.max(1) as f32),
|
||||||
prev_sum: 0.0,
|
prev_sum: 0.0,
|
||||||
prev_diff: 0.0,
|
prev_diff: 0.0,
|
||||||
|
prev_blend: 0.0,
|
||||||
output_phase_inc,
|
output_phase_inc,
|
||||||
output_phase: 0.0,
|
output_phase: 0.0,
|
||||||
}
|
}
|
||||||
@@ -407,17 +460,19 @@ impl WfmStereoDecoder {
|
|||||||
// --- L+R (sum): 4th-order Butterworth + pilot notch ---
|
// --- L+R (sum): 4th-order Butterworth + pilot notch ---
|
||||||
let sum = self.sum_notch.process(self.sum_lpf2.process(self.sum_lpf1.process(x)));
|
let sum = self.sum_notch.process(self.sum_lpf2.process(self.sum_lpf1.process(x)));
|
||||||
|
|
||||||
// --- L-R (diff): 38 kHz demod + 4th-order Butterworth ---
|
// --- L-R (diff): 38 kHz demod + 4th-order Butterworth (unblended) ---
|
||||||
|
// Blend is applied per-band at audio rate in the emit step below.
|
||||||
let stereo_carrier = (2.0 * self.pilot_phase).cos() * 2.0;
|
let stereo_carrier = (2.0 * self.pilot_phase).cos() * 2.0;
|
||||||
let diff = self.diff_lpf2.process(self.diff_lpf1.process(x * stereo_carrier))
|
let diff = self.diff_lpf2.process(self.diff_lpf1.process(x * stereo_carrier));
|
||||||
* stereo_blend;
|
|
||||||
|
|
||||||
// --- Linear interpolation resampling ---
|
// --- Linear interpolation resampling ---
|
||||||
// Track previous filtered values every composite sample for interpolation.
|
// Track previous filtered values and blend every composite sample.
|
||||||
let prev_sum = self.prev_sum;
|
let prev_sum = self.prev_sum;
|
||||||
let prev_diff = self.prev_diff;
|
let prev_diff = self.prev_diff;
|
||||||
|
let prev_blend = self.prev_blend;
|
||||||
self.prev_sum = sum;
|
self.prev_sum = sum;
|
||||||
self.prev_diff = diff;
|
self.prev_diff = diff;
|
||||||
|
self.prev_blend = stereo_blend;
|
||||||
|
|
||||||
let prev_phase = self.output_phase;
|
let prev_phase = self.output_phase;
|
||||||
self.output_phase += self.output_phase_inc;
|
self.output_phase += self.output_phase_inc;
|
||||||
@@ -431,14 +486,21 @@ impl WfmStereoDecoder {
|
|||||||
let frac = ((1.0 - prev_phase) / self.output_phase_inc) as f32;
|
let frac = ((1.0 - prev_phase) / self.output_phase_inc) as f32;
|
||||||
let sum_i = prev_sum + frac * (sum - prev_sum);
|
let sum_i = prev_sum + frac * (sum - prev_sum);
|
||||||
let diff_i = prev_diff + frac * (diff - prev_diff);
|
let diff_i = prev_diff + frac * (diff - prev_diff);
|
||||||
|
// Interpolate pilot blend for smooth stereo transitions at audio rate.
|
||||||
|
let blend_i = prev_blend + frac * (stereo_blend - prev_blend);
|
||||||
|
|
||||||
// --- Deemphasis + DC block + output ---
|
// --- Deemphasis + DC block + output ---
|
||||||
if self.output_channels >= 2 {
|
if self.output_channels >= 2 {
|
||||||
|
// Apply multiband stereo denoising at audio rate.
|
||||||
|
// Higher frequency bands of the diff are attenuated more aggressively
|
||||||
|
// when the pilot is weak, reducing stereo noise without collapsing
|
||||||
|
// the low-frequency stereo image.
|
||||||
|
let diff_denoised = self.diff_denoise.process(diff_i, blend_i);
|
||||||
let left = self.dc_l
|
let left = self.dc_l
|
||||||
.process(self.deemph_l.process((sum_i + diff_i) * 0.5))
|
.process(self.deemph_l.process((sum_i + diff_denoised) * 0.5))
|
||||||
.clamp(-1.0, 1.0);
|
.clamp(-1.0, 1.0);
|
||||||
let right = self.dc_r
|
let right = self.dc_r
|
||||||
.process(self.deemph_r.process((sum_i - diff_i) * 0.5))
|
.process(self.deemph_r.process((sum_i - diff_denoised) * 0.5))
|
||||||
.clamp(-1.0, 1.0);
|
.clamp(-1.0, 1.0);
|
||||||
output.push(left);
|
output.push(left);
|
||||||
output.push(right);
|
output.push(right);
|
||||||
|
|||||||
Reference in New Issue
Block a user