Files
trx-rs/src/trx-server/trx-backend/trx-backend-soapysdr/src/demod.rs
T
sjg 815efdb1ae [fix](trx-backend-soapysdr): match sum/diff filters and simplify stereo blend
- Set STEREO_DIFF_BW_HZ = AUDIO_BW_HZ so both filter paths have
  identical group delay (improves multitone separation by ~10 dB).
- Zero out STEREO_SEPARATION_PHASE_TRIM (unnecessary with matched filters).
- Replace gradual blend ramp with binary blend: full stereo at pilot
  lock, mono when unlocked. The hysteresis thresholds already handle
  noisy signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-03-01 01:48:16 +01:00

1605 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use num_complex::Complex;
use trx_core::rig::state::{RdsData, RigMode};
use trx_rds::RdsDecoder;
const RDS_SUBCARRIER_HZ: f32 = 57_000.0;
const RDS_BPF_Q: f32 = 10.0;
/// Pilot tone frequency (Hz).
const PILOT_HZ: f32 = 19_000.0;
/// Audio bandwidth for WFM (Hz).
/// 15.8 kHz leaves guard band below the 19 kHz pilot and reduces top-end
/// artifacts on strong signals while preserving the useful broadcast range.
const AUDIO_BW_HZ: f32 = 15_800.0;
/// Stereo L-R subchannel bandwidth for WFM (Hz).
/// Must match AUDIO_BW_HZ so the sum and diff filter paths have identical
/// group delay, which is critical for stereo separation across all frequencies.
const STEREO_DIFF_BW_HZ: f32 = AUDIO_BW_HZ;
/// Q values for a proper 4th-order Butterworth cascade (two 2nd-order stages).
/// Stage 1: Q = 1 / (2 cos(π/8))
const BW4_Q1: f32 = 0.5412;
/// Stage 2: Q = 1 / (2 cos(3π/8))
const BW4_Q2: f32 = 1.3066;
/// Q for the 19 kHz pilot notch (~3.8 kHz 3 dB bandwidth).
const PILOT_NOTCH_Q: f32 = 5.0;
/// Narrow 19 kHz band-pass used to derive zero-crossings for switching stereo demod.
const PILOT_BPF_Q: f32 = 20.0;
/// Fixed phase trim on the recovered L-R channel to compensate pilot-path delay.
const STEREO_SEPARATION_PHASE_TRIM: f32 = 0.0;
/// Fixed gain trim on the recovered L-R channel.
const STEREO_SEPARATION_GAIN: f32 = 1.000;
/// Extra headroom in the stereo matrix to reduce stereo-only clipping/IMD on
/// strong program material. This keeps bass excursions from flattening treble.
const STEREO_MATRIX_GAIN: f32 = 0.50;
/// Gentle high-pass memory for the stereo L-R path.
/// This trims only very low-frequency difference energy that can eat headroom
/// and modulate higher-frequency stereo detail.
const STEREO_DIFF_DC_R: f32 = 0.9995;
/// Fractional-resampler FIR taps for WFM audio reconstruction.
const WFM_RESAMP_TAPS: usize = 16;
/// Polyphase slots for the WFM fractional FIR resampler.
const WFM_RESAMP_PHASES: usize = 32;
/// Slightly sub-Nyquist sinc cutoff to tame top-end imaging.
const WFM_RESAMP_CUTOFF: f32 = 0.94;
fn build_wfm_resample_bank() -> [[f32; WFM_RESAMP_TAPS]; WFM_RESAMP_PHASES] {
let mut bank = [[0.0; WFM_RESAMP_TAPS]; WFM_RESAMP_PHASES];
let anchor = (WFM_RESAMP_TAPS / 2 - 1) as f32;
for (phase_idx, phase) in bank.iter_mut().enumerate() {
let frac = phase_idx as f32 / WFM_RESAMP_PHASES as f32;
let center = anchor + frac;
let mut sum = 0.0_f32;
for (tap_idx, coeff) in phase.iter_mut().enumerate() {
let x = tap_idx as f32 - center;
let sinc = if x.abs() < 1e-6 {
WFM_RESAMP_CUTOFF
} else {
let arg = std::f32::consts::PI * x * WFM_RESAMP_CUTOFF;
arg.sin() / (std::f32::consts::PI * x)
};
let window = if WFM_RESAMP_TAPS == 1 {
1.0
} else {
let pos = tap_idx as f32 / (WFM_RESAMP_TAPS - 1) as f32;
0.54 - 0.46 * (2.0 * std::f32::consts::PI * pos).cos()
};
*coeff = sinc * window;
sum += *coeff;
}
if sum.abs() > 1e-9 {
let inv = 1.0 / sum;
for coeff in phase.iter_mut() {
*coeff *= inv;
}
}
}
bank
}
#[inline]
fn shift_append<const N: usize>(hist: &mut [f32; N], sample: f32) {
hist.rotate_left(1);
hist[N - 1] = sample;
}
#[inline]
fn polyphase_resample(
hist: &[f32; WFM_RESAMP_TAPS],
bank: &[[f32; WFM_RESAMP_TAPS]; WFM_RESAMP_PHASES],
frac: f32,
) -> f32 {
let phase = (frac.clamp(0.0, 0.999_999) * WFM_RESAMP_PHASES as f32).round() as usize;
let phase = phase.min(WFM_RESAMP_PHASES - 1);
dot_product(&hist[..], &bank[phase][..])
}
#[inline]
fn dot_product_scalar(a: &[f32], b: &[f32]) -> f32 {
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
#[target_feature(enable = "avx2")]
unsafe fn dot_product_avx2(a: &[f32], b: &[f32]) -> f32 {
#[cfg(target_arch = "x86")]
use std::arch::x86::*;
#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;
let len = a.len().min(b.len());
let mut acc = _mm256_setzero_ps();
let mut i = 0usize;
while i + 8 <= len {
let av = unsafe { _mm256_loadu_ps(a.as_ptr().add(i)) };
let bv = unsafe { _mm256_loadu_ps(b.as_ptr().add(i)) };
acc = _mm256_add_ps(acc, _mm256_mul_ps(av, bv));
i += 8;
}
let mut lanes = [0.0_f32; 8];
unsafe { _mm256_storeu_ps(lanes.as_mut_ptr(), acc) };
lanes.iter().sum::<f32>() + dot_product_scalar(&a[i..len], &b[i..len])
}
#[inline]
fn dot_product(a: &[f32], b: &[f32]) -> f32 {
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
{
if std::arch::is_x86_feature_detected!("avx2") {
return unsafe { dot_product_avx2(a, b) };
}
}
dot_product_scalar(a, b)
}
fn smoothstep01(x: f32) -> f32 {
let x = x.clamp(0.0, 1.0);
x * x * (3.0 - 2.0 * x)
}
#[inline]
fn fast_atan2(y: f32, x: f32) -> f32 {
if x == 0.0 {
if y > 0.0 {
return std::f32::consts::FRAC_PI_2;
}
if y < 0.0 {
return -std::f32::consts::FRAC_PI_2;
}
return 0.0;
}
#[inline]
fn fast_atan(z: f32) -> f32 {
let abs_z = z.abs();
if abs_z <= 1.0 {
z * (std::f32::consts::FRAC_PI_4 + 0.273 * (1.0 - abs_z))
} else {
let inv = 1.0 / z;
let base = inv * (std::f32::consts::FRAC_PI_4 + 0.273 * (1.0 - inv.abs()));
if z > 0.0 {
std::f32::consts::FRAC_PI_2 - base
} else {
-std::f32::consts::FRAC_PI_2 - base
}
}
}
let angle = if x > 0.0 {
fast_atan(y / x)
} else if x < 0.0 {
if y >= 0.0 {
fast_atan(y / x) + std::f32::consts::PI
} else {
fast_atan(y / x) - std::f32::consts::PI
}
} else {
0.0
};
angle
}
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn fast_atan_8_avx2(z: std::arch::x86_64::__m256) -> std::arch::x86_64::__m256 {
use std::arch::x86_64::*;
let zero = _mm256_set1_ps(0.0);
let one = _mm256_set1_ps(1.0);
let sign_mask = _mm256_set1_ps(-0.0);
let pi4 = _mm256_set1_ps(std::f32::consts::FRAC_PI_4);
let pi2 = _mm256_set1_ps(std::f32::consts::FRAC_PI_2);
let c = _mm256_set1_ps(0.273);
let abs_z = _mm256_andnot_ps(sign_mask, z);
let small_term = _mm256_add_ps(pi4, _mm256_mul_ps(c, _mm256_sub_ps(one, abs_z)));
let small = _mm256_mul_ps(z, small_term);
let inv = _mm256_div_ps(one, z);
let abs_inv = _mm256_andnot_ps(sign_mask, inv);
let large_term = _mm256_add_ps(pi4, _mm256_mul_ps(c, _mm256_sub_ps(one, abs_inv)));
let base = _mm256_mul_ps(inv, large_term);
let pos_large = _mm256_sub_ps(pi2, base);
let neg_large = _mm256_sub_ps(_mm256_sub_ps(zero, pi2), base);
let z_pos = _mm256_cmp_ps(z, zero, _CMP_GT_OQ);
let large = _mm256_blendv_ps(neg_large, pos_large, z_pos);
let le_one = _mm256_cmp_ps(abs_z, one, _CMP_LE_OQ);
_mm256_blendv_ps(large, small, le_one)
}
#[cfg(target_arch = "x86")]
#[target_feature(enable = "avx2")]
unsafe fn fast_atan_8_avx2(z: std::arch::x86::__m256) -> std::arch::x86::__m256 {
use std::arch::x86::*;
let zero = _mm256_set1_ps(0.0);
let one = _mm256_set1_ps(1.0);
let sign_mask = _mm256_set1_ps(-0.0);
let pi4 = _mm256_set1_ps(std::f32::consts::FRAC_PI_4);
let pi2 = _mm256_set1_ps(std::f32::consts::FRAC_PI_2);
let c = _mm256_set1_ps(0.273);
let abs_z = _mm256_andnot_ps(sign_mask, z);
let small_term = _mm256_add_ps(pi4, _mm256_mul_ps(c, _mm256_sub_ps(one, abs_z)));
let small = _mm256_mul_ps(z, small_term);
let inv = _mm256_div_ps(one, z);
let abs_inv = _mm256_andnot_ps(sign_mask, inv);
let large_term = _mm256_add_ps(pi4, _mm256_mul_ps(c, _mm256_sub_ps(one, abs_inv)));
let base = _mm256_mul_ps(inv, large_term);
let pos_large = _mm256_sub_ps(pi2, base);
let neg_large = _mm256_sub_ps(_mm256_sub_ps(zero, pi2), base);
let z_pos = _mm256_cmp_ps(z, zero, _CMP_GT_OQ);
let large = _mm256_blendv_ps(neg_large, pos_large, z_pos);
let le_one = _mm256_cmp_ps(abs_z, one, _CMP_LE_OQ);
_mm256_blendv_ps(large, small, le_one)
}
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn fast_atan2_8_avx2(
y: std::arch::x86_64::__m256,
x: std::arch::x86_64::__m256,
) -> std::arch::x86_64::__m256 {
use std::arch::x86_64::*;
let zero = _mm256_set1_ps(0.0);
let pi = _mm256_set1_ps(std::f32::consts::PI);
let pi2 = _mm256_set1_ps(std::f32::consts::FRAC_PI_2);
let neg_pi2 = _mm256_sub_ps(zero, pi2);
let z = _mm256_div_ps(y, x);
let base = fast_atan_8_avx2(z);
let x_pos = _mm256_cmp_ps(x, zero, _CMP_GT_OQ);
let x_neg = _mm256_cmp_ps(x, zero, _CMP_LT_OQ);
let y_nonneg = _mm256_cmp_ps(y, zero, _CMP_GE_OQ);
let y_pos = _mm256_cmp_ps(y, zero, _CMP_GT_OQ);
let y_neg = _mm256_cmp_ps(y, zero, _CMP_LT_OQ);
let x_zero = _mm256_cmp_ps(x, zero, _CMP_EQ_OQ);
let xneg_pos = _mm256_add_ps(base, pi);
let xneg_neg = _mm256_sub_ps(base, pi);
let xneg = _mm256_blendv_ps(xneg_neg, xneg_pos, y_nonneg);
let mut angle = _mm256_blendv_ps(xneg, base, x_pos);
angle = _mm256_blendv_ps(angle, zero, _mm256_andnot_ps(_mm256_or_ps(x_pos, x_neg), _mm256_castsi256_ps(_mm256_set1_epi32(-1))));
let zero_case = _mm256_blendv_ps(_mm256_blendv_ps(zero, neg_pi2, y_neg), pi2, y_pos);
_mm256_blendv_ps(angle, zero_case, x_zero)
}
#[cfg(target_arch = "x86")]
#[target_feature(enable = "avx2")]
unsafe fn fast_atan2_8_avx2(
y: std::arch::x86::__m256,
x: std::arch::x86::__m256,
) -> std::arch::x86::__m256 {
use std::arch::x86::*;
let zero = _mm256_set1_ps(0.0);
let pi = _mm256_set1_ps(std::f32::consts::PI);
let pi2 = _mm256_set1_ps(std::f32::consts::FRAC_PI_2);
let neg_pi2 = _mm256_sub_ps(zero, pi2);
let z = _mm256_div_ps(y, x);
let base = fast_atan_8_avx2(z);
let x_pos = _mm256_cmp_ps(x, zero, _CMP_GT_OQ);
let x_neg = _mm256_cmp_ps(x, zero, _CMP_LT_OQ);
let y_nonneg = _mm256_cmp_ps(y, zero, _CMP_GE_OQ);
let y_pos = _mm256_cmp_ps(y, zero, _CMP_GT_OQ);
let y_neg = _mm256_cmp_ps(y, zero, _CMP_LT_OQ);
let x_zero = _mm256_cmp_ps(x, zero, _CMP_EQ_OQ);
let xneg_pos = _mm256_add_ps(base, pi);
let xneg_neg = _mm256_sub_ps(base, pi);
let xneg = _mm256_blendv_ps(xneg_neg, xneg_pos, y_nonneg);
let mut angle = _mm256_blendv_ps(xneg, base, x_pos);
angle = _mm256_blendv_ps(angle, zero, _mm256_andnot_ps(_mm256_or_ps(x_pos, x_neg), _mm256_castsi256_ps(_mm256_set1_epi32(-1))));
let zero_case = _mm256_blendv_ps(_mm256_blendv_ps(zero, neg_pi2, y_neg), pi2, y_pos);
_mm256_blendv_ps(angle, zero_case, x_zero)
}
#[derive(Debug, Clone)]
struct OnePoleLowPass {
alpha: f32,
y: f32,
}
#[derive(Debug, Clone)]
struct BiquadBandPass {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
x1: f32,
x2: f32,
y1: f32,
y2: f32,
}
#[derive(Debug, Clone)]
pub(crate) struct DcBlocker {
r: f32,
x1: f32,
y1: f32,
}
impl DcBlocker {
pub(crate) fn new(r: f32) -> Self {
Self {
r: r.clamp(0.9, 0.9999),
x1: 0.0,
y1: 0.0,
}
}
pub(crate) fn process(&mut self, x: f32) -> f32 {
let y = x - self.x1 + self.r * self.y1;
self.x1 = x;
self.y1 = y;
y
}
pub(crate) fn reset(&mut self) {
self.x1 = 0.0;
self.y1 = 0.0;
}
}
/// 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),
}
}
fn update_gain(&mut self, level: f32) -> f32 {
// Update envelope tracker (peak-hold with attack/release).
let env_coeff = if level > self.envelope {
self.attack_coeff
} else {
self.release_coeff
};
self.envelope += env_coeff * (level - 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);
}
self.gain
}
pub(crate) fn process(&mut self, x: f32) -> f32 {
let gain = self.update_gain(x.abs());
(x * gain).clamp(-1.0, 1.0)
}
pub(crate) fn process_pair(&mut self, left: f32, right: f32) -> (f32, f32) {
let gain = self.update_gain(left.abs().max(right.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> {
let gain = self.update_gain((x.re * x.re + x.im * x.im).sqrt());
let mut y = x * gain;
let mag = (y.re * y.re + y.im * y.im).sqrt();
if mag > 1.0 {
y /= mag;
}
y
}
}
impl BiquadBandPass {
fn new(sample_rate: f32, center_hz: f32, q: f32) -> Self {
let sr = sample_rate.max(1.0);
let center = center_hz.clamp(100.0, sr * 0.45);
let q = q.max(0.2);
let w0 = 2.0 * std::f32::consts::PI * center / sr;
let alpha = w0.sin() / (2.0 * q);
let cos_w0 = w0.cos();
let a0 = 1.0 + alpha;
let inv_a0 = 1.0 / a0;
// RBJ band-pass, constant skirt gain.
let b0 = alpha * inv_a0;
let b1 = 0.0;
let b2 = -alpha * inv_a0;
let a1 = (-2.0 * cos_w0) * inv_a0;
let a2 = (1.0 - alpha) * inv_a0;
Self {
b0,
b1,
b2,
a1,
a2,
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
}
}
fn process(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2 - self.a1 * self.y1 - self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = x;
self.y2 = self.y1;
self.y1 = y;
y
}
fn reset(&mut self) {
self.x1 = 0.0;
self.x2 = 0.0;
self.y1 = 0.0;
self.y2 = 0.0;
}
}
/// 2nd-order IIR low-pass filter (RBJ cookbook). Use [`BW4_Q1`]/[`BW4_Q2`] in
/// cascade for a proper 4th-order Butterworth response.
#[derive(Debug, Clone)]
struct BiquadLowPass {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
x1: f32,
x2: f32,
y1: f32,
y2: f32,
}
impl BiquadLowPass {
fn new(sample_rate: f32, cutoff_hz: f32, q: f32) -> Self {
let sr = sample_rate.max(1.0);
let fc = cutoff_hz.clamp(1.0, sr * 0.45);
let q = q.max(0.1);
let w0 = 2.0 * std::f32::consts::PI * fc / sr;
let alpha = w0.sin() / (2.0 * q);
let cos_w0 = w0.cos();
let a0_inv = 1.0 / (1.0 + alpha);
let b0 = (1.0 - cos_w0) * 0.5 * a0_inv;
let b1 = (1.0 - cos_w0) * a0_inv;
let b2 = b0;
let a1 = -2.0 * cos_w0 * a0_inv;
let a2 = (1.0 - alpha) * a0_inv;
Self { b0, b1, b2, a1, a2, x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0 }
}
fn process(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1 - self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = x;
self.y2 = self.y1;
self.y1 = y;
y
}
fn reset(&mut self) {
self.x1 = 0.0;
self.x2 = 0.0;
self.y1 = 0.0;
self.y2 = 0.0;
}
}
/// 2nd-order IIR notch (band-reject) filter (RBJ cookbook).
#[derive(Debug, Clone)]
struct BiquadNotch {
b0: f32,
b1: f32,
b2: f32,
a1: f32,
a2: f32,
x1: f32,
x2: f32,
y1: f32,
y2: f32,
}
impl BiquadNotch {
fn new(sample_rate: f32, center_hz: f32, q: f32) -> Self {
let sr = sample_rate.max(1.0);
let fc = center_hz.clamp(1.0, sr * 0.45);
let q = q.max(0.1);
let w0 = 2.0 * std::f32::consts::PI * fc / sr;
let alpha = w0.sin() / (2.0 * q);
let cos_w0 = w0.cos();
let a0_inv = 1.0 / (1.0 + alpha);
let b0 = a0_inv;
let b1 = -2.0 * cos_w0 * a0_inv;
let b2 = a0_inv;
let a1 = b1;
let a2 = (1.0 - alpha) * a0_inv;
Self { b0, b1, b2, a1, a2, x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0 }
}
fn process(&mut self, x: f32) -> f32 {
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1 - self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = x;
self.y2 = self.y1;
self.y1 = y;
y
}
fn reset(&mut self) {
self.x1 = 0.0;
self.x2 = 0.0;
self.y1 = 0.0;
self.y2 = 0.0;
}
}
impl OnePoleLowPass {
fn new(sample_rate: f32, cutoff_hz: f32) -> Self {
let sr = sample_rate.max(1.0);
let cutoff = cutoff_hz.clamp(1.0, sr * 0.49);
let dt = 1.0 / sr;
let rc = 1.0 / (2.0 * std::f32::consts::PI * cutoff);
let alpha = dt / (rc + dt);
Self { alpha, y: 0.0 }
}
fn process(&mut self, x: f32) -> f32 {
self.y += self.alpha * (x - self.y);
self.y
}
fn reset(&mut self) {
self.y = 0.0;
}
}
#[derive(Debug, Clone)]
struct Deemphasis {
alpha: f32,
y: f32,
}
impl Deemphasis {
fn new(sample_rate: f32, tau_us: f32) -> Self {
let sr = sample_rate.max(1.0);
let tau = (tau_us.max(1.0)) * 1e-6;
let alpha = 1.0 - (-1.0 / (sr * tau)).exp();
Self { alpha, y: 0.0 }
}
fn process(&mut self, x: f32) -> f32 {
self.y += self.alpha * (x - self.y);
self.y
}
fn reset(&mut self) {
self.y = 0.0;
}
}
#[derive(Debug, Clone)]
pub struct WfmStereoDecoder {
output_channels: usize,
stereo_enabled: bool,
rds_decoder: RdsDecoder,
rds_bpf: BiquadBandPass,
rds_dc: DcBlocker,
prev_iq: Option<Complex<f32>>,
pilot_phase: f32,
pilot_freq: f32,
pilot_i_lp: OnePoleLowPass,
pilot_q_lp: OnePoleLowPass,
pilot_abs_lp: OnePoleLowPass,
pilot_bpf: BiquadBandPass,
/// 4th-order Butterworth cascade for L+R (two 2nd-order stages, Q = BW4_Q1/BW4_Q2).
sum_lpf1: BiquadLowPass,
sum_lpf2: BiquadLowPass,
/// Notch at 19 kHz for the mono output path — keeps pilot tone out of mono
/// audio without introducing phase mismatch with the diff channel.
sum_notch: BiquadNotch,
/// 4th-order Butterworth cascade for L-R (matched to sum path for stereo phase accuracy).
diff_lpf1: BiquadLowPass,
diff_lpf2: BiquadLowPass,
/// Quadrature companion of the L-R path used for phase trim / crosstalk adjustment.
diff_q_lpf1: BiquadLowPass,
diff_q_lpf2: BiquadLowPass,
/// Gentle high-pass on the stereo-difference path to reduce bass-driven IMD.
diff_dc: DcBlocker,
diff_q_dc: DcBlocker,
/// DC blockers on audio outputs — remove carrier-offset DC from the FM discriminator.
dc_m: DcBlocker,
dc_l: DcBlocker,
dc_r: DcBlocker,
deemph_m: Deemphasis,
deemph_l: Deemphasis,
deemph_r: Deemphasis,
/// Smoothed pilot-derived stereo detection strength in [0, 1].
stereo_detect_level: f32,
/// Hysteretic pilot-lock result used by the UI.
stereo_detected: bool,
/// FM discriminator gain normalization.
///
/// `demod_fm` outputs `atan2(…)/π ≈ 2·Δf/fs` for small deviations.
/// For standard WFM ±75 kHz deviation we want ±1.0 at full deviation,
/// so `fm_gain = fs / (2 · 75_000)`.
fm_gain: f32,
/// Shared coefficient bank for the polyphase fractional audio resampler.
resample_bank: [[f32; WFM_RESAMP_TAPS]; WFM_RESAMP_PHASES],
/// History ring for polyphase FIR resampling of the sum channel.
sum_hist: [f32; WFM_RESAMP_TAPS],
/// History ring for polyphase FIR resampling of the diff channel.
diff_hist: [f32; WFM_RESAMP_TAPS],
/// History ring for polyphase FIR resampling of the quadrature diff channel.
diff_q_hist: [f32; WFM_RESAMP_TAPS],
/// Previous pilot blend sample for simple linear interpolation.
prev_blend: f32,
/// Fractional phase increment per composite sample = audio_rate / composite_rate.
/// Avoids integer-division rate error when composite_rate is not an exact
/// multiple of audio_rate (e.g. 250 kHz composite → 48 kHz audio).
output_phase_inc: f64,
/// Fractional phase accumulator (0 .. 1). Emits an output sample whenever
/// it crosses 1.0, ensuring the long-term rate is exactly audio_rate.
output_phase: f64,
}
impl WfmStereoDecoder {
pub fn new(
composite_rate: u32,
audio_rate: u32,
output_channels: usize,
stereo_enabled: bool,
deemphasis_us: u32,
) -> Self {
let composite_rate_f = composite_rate.max(1) as f32;
let output_phase_inc = audio_rate.max(1) as f64 / composite_rate.max(1) as f64;
let deemphasis_us = deemphasis_us as f32;
Self {
output_channels: output_channels.max(1),
stereo_enabled,
rds_decoder: RdsDecoder::new(composite_rate),
rds_bpf: BiquadBandPass::new(composite_rate_f, RDS_SUBCARRIER_HZ, RDS_BPF_Q),
rds_dc: DcBlocker::new(0.995),
prev_iq: None,
pilot_phase: 0.0,
pilot_freq: 2.0 * std::f32::consts::PI * PILOT_HZ / composite_rate_f,
pilot_i_lp: OnePoleLowPass::new(composite_rate_f, 400.0),
pilot_q_lp: OnePoleLowPass::new(composite_rate_f, 400.0),
pilot_abs_lp: OnePoleLowPass::new(composite_rate_f, 400.0),
pilot_bpf: BiquadBandPass::new(composite_rate_f, PILOT_HZ, PILOT_BPF_Q),
// 4th-order Butterworth: two cascaded biquads with BW4_Q1/BW4_Q2.
// At 19 kHz (pilot): ≈ 12 dB; at 38 kHz (DSB carrier): ≈ 32 dB.
sum_lpf1: BiquadLowPass::new(composite_rate_f, AUDIO_BW_HZ, BW4_Q1),
sum_lpf2: BiquadLowPass::new(composite_rate_f, AUDIO_BW_HZ, BW4_Q2),
sum_notch: BiquadNotch::new(composite_rate_f, PILOT_HZ, PILOT_NOTCH_Q),
diff_lpf1: BiquadLowPass::new(composite_rate_f, STEREO_DIFF_BW_HZ, BW4_Q1),
diff_lpf2: BiquadLowPass::new(composite_rate_f, STEREO_DIFF_BW_HZ, BW4_Q2),
diff_q_lpf1: BiquadLowPass::new(composite_rate_f, STEREO_DIFF_BW_HZ, BW4_Q1),
diff_q_lpf2: BiquadLowPass::new(composite_rate_f, STEREO_DIFF_BW_HZ, BW4_Q2),
diff_dc: DcBlocker::new(STEREO_DIFF_DC_R),
diff_q_dc: DcBlocker::new(STEREO_DIFF_DC_R),
dc_m: DcBlocker::new(0.9999),
dc_l: DcBlocker::new(0.9999),
dc_r: DcBlocker::new(0.9999),
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_r: Deemphasis::new(audio_rate.max(1) as f32, deemphasis_us),
stereo_detect_level: 0.0,
stereo_detected: false,
fm_gain: composite_rate_f / (2.0 * 75_000.0),
resample_bank: build_wfm_resample_bank(),
sum_hist: [0.0; WFM_RESAMP_TAPS],
diff_hist: [0.0; WFM_RESAMP_TAPS],
diff_q_hist: [0.0; WFM_RESAMP_TAPS],
prev_blend: 0.0,
output_phase_inc,
output_phase: 0.0,
}
}
pub fn process_iq(&mut self, samples: &[Complex<f32>]) -> Vec<f32> {
if samples.is_empty() {
return Vec::new();
}
let inv_pi = std::f32::consts::FRAC_1_PI;
let mut output = Vec::with_capacity(
((samples.len() as f64 * self.output_phase_inc).ceil() as usize + 1)
* self.output_channels.max(1),
);
let (trim_sin, trim_cos) = STEREO_SEPARATION_PHASE_TRIM.sin_cos();
for &sample in samples {
let x = if let Some(prev_sample) = self.prev_iq {
let product = sample * prev_sample.conj();
fast_atan2(product.im, product.re) * inv_pi
} else {
0.0
};
self.prev_iq = Some(sample);
// Normalize discriminator output so ±75 kHz deviation maps to ±1.0.
let x = x * self.fm_gain;
let pilot_tone = self.pilot_bpf.process(x);
// --- Pilot phase estimator ---
let (sin_p, cos_p) = self.pilot_phase.sin_cos();
let i = self.pilot_i_lp.process(pilot_tone * cos_p);
let q = self.pilot_q_lp.process(pilot_tone * -sin_p);
let phase_err = fast_atan2(q, i);
let pilot_phase_est = self.pilot_phase + phase_err;
self.pilot_phase += self.pilot_freq;
self.pilot_phase = self.pilot_phase.rem_euclid(std::f32::consts::TAU);
let pilot_mag = (i * i + q * q).sqrt();
let pilot_abs = self.pilot_abs_lp.process(pilot_tone.abs());
let pilot_coherence = (pilot_mag / (pilot_abs + 1e-4)).clamp(0.0, 1.0);
let pilot_lock = ((pilot_coherence - 0.4) / 0.2).clamp(0.0, 1.0);
let stereo_drive = (pilot_mag * pilot_lock * 120.0).clamp(0.0, 1.0);
let detect_coeff = if stereo_drive > self.stereo_detect_level {
0.0008
} else {
0.00005
};
self.stereo_detect_level += detect_coeff * (stereo_drive - self.stereo_detect_level);
if self.stereo_detected {
if self.stereo_detect_level < 0.22 {
self.stereo_detected = false;
}
} else if self.stereo_detect_level > 0.6 {
self.stereo_detected = true;
}
let stereo_blend_target = if self.stereo_detected {
1.0
} else {
0.0
};
// --- RDS ---
let rds_quality = (0.35 + pilot_mag * 20.0).clamp(0.35, 1.0);
let rds_band = self.rds_bpf.process(x);
let rds_clean = self.rds_dc.process(rds_band);
let _ = self.rds_decoder.process_sample(rds_clean, rds_quality);
// --- L+R (sum): 4th-order Butterworth ---
// The pilot notch is NOT applied here so the sum and diff paths have
// identical phase responses, which is required for good stereo separation.
// The notch is applied only on the mono output path where phase matching
// with the diff channel is irrelevant.
let sum = self.sum_lpf2.process(self.sum_lpf1.process(x));
// --- L-R (diff): 38 kHz demod + 4th-order Butterworth (unblended) ---
// Blend is applied per-band at audio rate in the emit step below.
let (sin_2p, cos_2p) = (2.0 * pilot_phase_est).sin_cos();
let diff_i = self
.diff_dc
.process(self.diff_lpf2.process(self.diff_lpf1.process(x * (cos_2p * 2.0))));
let diff_q = self
.diff_q_dc
.process(self.diff_q_lpf2.process(self.diff_q_lpf1.process(x * (-sin_2p * 2.0))));
// --- Polyphase FIR fractional resampling ---
// This uses a short windowed-sinc bank instead of cubic interpolation
// to reduce top-end overshoot/ringing near the audio cutoff.
shift_append(&mut self.sum_hist, sum);
shift_append(&mut self.diff_hist, diff_i);
shift_append(&mut self.diff_q_hist, diff_q);
let prev_phase = self.output_phase;
self.output_phase += self.output_phase_inc;
if self.output_phase < 1.0 {
self.prev_blend = stereo_blend_target;
continue;
}
self.output_phase -= 1.0;
// `frac` positions the output sample within the current fractional
// interval. The FIR bank reconstructs a band-limited sample using
// a fixed two-sample lookahead in the decoder.
let frac = ((1.0 - prev_phase) / self.output_phase_inc) as f32;
let sum_i = polyphase_resample(&self.sum_hist, &self.resample_bank, frac);
let diff_i = polyphase_resample(&self.diff_hist, &self.resample_bank, frac);
let diff_q = polyphase_resample(&self.diff_q_hist, &self.resample_bank, frac);
let blend_i =
(self.prev_blend + frac * (stereo_blend_target - self.prev_blend)).clamp(0.0, 1.0);
self.prev_blend = stereo_blend_target;
let diff_i = (diff_i * trim_cos + diff_q * trim_sin) * STEREO_SEPARATION_GAIN;
// --- Deemphasis + DC block + output ---
if self.output_channels >= 2 && self.stereo_enabled {
let diff = diff_i * blend_i;
let left_corr = (sum_i + diff) * STEREO_MATRIX_GAIN;
let right_corr = (sum_i - diff) * STEREO_MATRIX_GAIN;
let left = self
.dc_l
.process(self.deemph_l.process(left_corr))
.clamp(-1.0, 1.0);
let right = self
.dc_r
.process(self.deemph_r.process(right_corr))
.clamp(-1.0, 1.0);
output.push(left);
output.push(right);
} else {
// Mono path: apply the pilot notch here so the 19 kHz pilot tone
// does not leak into mono audio. Phase matching with diff is not
// a concern for mono, so the notch can sit anywhere in the chain.
let mono = self
.dc_m
.process(self.deemph_m.process(self.sum_notch.process(sum_i)))
.clamp(-1.0, 1.0);
output.push(mono);
if self.output_channels >= 2 {
output.push(mono);
}
}
}
output
}
pub fn set_stereo_enabled(&mut self, enabled: bool) {
self.stereo_enabled = enabled;
}
pub fn rds_data(&self) -> Option<RdsData> {
self.rds_decoder.snapshot()
}
pub fn reset_rds(&mut self) {
self.rds_decoder.reset();
}
pub fn reset_stereo_detect(&mut self) {
self.stereo_detect_level = 0.0;
self.stereo_detected = false;
}
pub fn reset_demod_state(&mut self) {
self.prev_iq = None;
}
pub fn reset_state(&mut self) {
self.rds_decoder.reset();
self.rds_bpf.reset();
self.rds_dc.reset();
self.prev_iq = None;
self.pilot_phase = 0.0;
self.pilot_i_lp.reset();
self.pilot_q_lp.reset();
self.pilot_abs_lp.reset();
self.pilot_bpf.reset();
self.sum_lpf1.reset();
self.sum_lpf2.reset();
self.sum_notch.reset();
self.diff_lpf1.reset();
self.diff_lpf2.reset();
self.diff_q_lpf1.reset();
self.diff_q_lpf2.reset();
self.diff_dc.reset();
self.diff_q_dc.reset();
self.dc_m.reset();
self.dc_l.reset();
self.dc_r.reset();
self.deemph_m.reset();
self.deemph_l.reset();
self.deemph_r.reset();
self.stereo_detect_level = 0.0;
self.stereo_detected = false;
self.sum_hist = [0.0; WFM_RESAMP_TAPS];
self.diff_hist = [0.0; WFM_RESAMP_TAPS];
self.diff_q_hist = [0.0; WFM_RESAMP_TAPS];
self.prev_blend = 0.0;
self.output_phase = 0.0;
}
pub fn stereo_detected(&self) -> bool {
self.stereo_detected
}
}
/// Selects the demodulation algorithm for a channel.
#[derive(Debug, Clone, PartialEq)]
pub enum Demodulator {
/// Upper sideband SSB: take the real part of baseband IQ.
Usb,
/// Lower sideband SSB: negate imaginary part before taking real part.
Lsb,
/// AM envelope detector: magnitude of IQ, DC-removed.
Am,
/// Narrow-band FM: instantaneous frequency via quadrature (arg of s[n]*conj(s[n-1])).
Fm,
/// Wide-band FM: same algorithm as FM, wider pre-filter (handled upstream in DSP).
Wfm,
/// CW: magnitude of IQ after narrow BPF (BPF applied upstream), envelope.
Cw,
/// Pass-through (DIG, PKT): same as USB.
Passthrough,
}
impl Demodulator {
/// Construct the appropriate demodulator for a [`RigMode`].
pub fn for_mode(mode: &RigMode) -> Self {
match mode {
RigMode::USB => Self::Usb,
RigMode::LSB => Self::Lsb,
RigMode::AM => Self::Am,
RigMode::FM => Self::Fm,
RigMode::WFM => Self::Wfm,
RigMode::CW | RigMode::CWR => Self::Cw,
RigMode::DIG => Self::Passthrough,
// VHF/UHF packet radio (APRS, AX.25) is FM-encoded AFSK.
// FM-demodulate the signal before passing audio to the APRS decoder.
RigMode::PKT => Self::Fm,
RigMode::Other(_) => Self::Usb,
}
}
/// Demodulate a block of baseband IQ samples.
///
/// `samples`: complex f32 IQ already centred at 0 Hz.
/// Returns real f32 audio samples, same length as input.
pub fn demodulate(&self, samples: &[Complex<f32>]) -> Vec<f32> {
match self {
Self::Usb | Self::Passthrough => demod_usb(samples),
Self::Lsb => demod_lsb(samples),
Self::Am => demod_am(samples),
Self::Fm | Self::Wfm => demod_fm(samples),
Self::Cw => demod_cw(samples),
}
}
}
// ---------------------------------------------------------------------------
// USB / Passthrough
// ---------------------------------------------------------------------------
/// USB demodulator: take the real part of each IQ sample.
fn demod_usb(samples: &[Complex<f32>]) -> Vec<f32> {
samples.iter().map(|s| s.re).collect()
}
// ---------------------------------------------------------------------------
// LSB
// ---------------------------------------------------------------------------
/// LSB demodulator: LSB mixing is handled upstream by negating `channel_if_hz`;
/// the demodulator itself is identical to USB — just take `.re`.
fn demod_lsb(samples: &[Complex<f32>]) -> Vec<f32> {
samples.iter().map(|s| s.re).collect()
}
// ---------------------------------------------------------------------------
// AM
// ---------------------------------------------------------------------------
/// 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> {
samples
.iter()
.map(|s| (s.re * s.re + s.im * s.im).sqrt())
.collect()
}
// ---------------------------------------------------------------------------
// FM / WFM
// ---------------------------------------------------------------------------
/// FM quadrature discriminator: instantaneous frequency via arg(s[n] * conj(s[n-1])).
/// Output is in radians/sample, scaled by 1/π to normalise to [-1, 1].
fn demod_fm_with_prev(
samples: &[Complex<f32>],
prev: &mut Option<Complex<f32>>,
) -> Vec<f32> {
if samples.is_empty() {
return Vec::new();
}
let inv_pi = std::f32::consts::FRAC_1_PI;
let mut output = Vec::with_capacity(samples.len());
if let Some(prev_sample) = prev.as_ref().copied() {
let product = samples[0] * prev_sample.conj();
let angle = fast_atan2(product.im, product.re);
output.push(angle * inv_pi);
} else {
output.push(0.0_f32);
}
let mut i = 1usize;
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
if std::arch::is_x86_feature_detected!("avx2") {
i = demod_fm_body_avx2(samples, i, inv_pi, &mut output);
}
for idx in i..samples.len() {
let product = samples[idx] * samples[idx - 1].conj();
let angle = fast_atan2(product.im, product.re);
output.push(angle * inv_pi);
}
*prev = samples.last().copied();
output
}
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
fn demod_fm_body_avx2(
samples: &[Complex<f32>],
start: usize,
inv_pi: f32,
output: &mut Vec<f32>,
) -> usize {
unsafe { demod_fm_body_avx2_impl(samples, start, inv_pi, output) }
}
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
#[target_feature(enable = "avx2")]
unsafe fn demod_fm_body_avx2_impl(
samples: &[Complex<f32>],
start: usize,
inv_pi: f32,
output: &mut Vec<f32>,
) -> usize {
#[cfg(target_arch = "x86")]
use std::arch::x86::*;
#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;
let len = samples.len();
let mut i = start;
let mut cur_re = [0.0_f32; 8];
let mut cur_im = [0.0_f32; 8];
let mut prev_re = [0.0_f32; 8];
let mut prev_im = [0.0_f32; 8];
let mut angles = [0.0_f32; 8];
let inv_pi_v = _mm256_set1_ps(inv_pi);
while i + 8 <= len {
for lane in 0..8 {
let cur = samples[i + lane];
let prev = samples[i + lane - 1];
cur_re[lane] = cur.re;
cur_im[lane] = cur.im;
prev_re[lane] = prev.re;
prev_im[lane] = prev.im;
}
let cur_re_v = _mm256_loadu_ps(cur_re.as_ptr());
let cur_im_v = _mm256_loadu_ps(cur_im.as_ptr());
let prev_re_v = _mm256_loadu_ps(prev_re.as_ptr());
let prev_im_v = _mm256_loadu_ps(prev_im.as_ptr());
let re_v = _mm256_add_ps(
_mm256_mul_ps(cur_re_v, prev_re_v),
_mm256_mul_ps(cur_im_v, prev_im_v),
);
let im_v = _mm256_sub_ps(
_mm256_mul_ps(cur_im_v, prev_re_v),
_mm256_mul_ps(cur_re_v, prev_im_v),
);
let angle_v = _mm256_mul_ps(fast_atan2_8_avx2(im_v, re_v), inv_pi_v);
_mm256_storeu_ps(angles.as_mut_ptr(), angle_v);
output.extend_from_slice(&angles);
i += 8;
}
i
}
/// FM quadrature discriminator: instantaneous frequency via arg(s[n] * conj(s[n-1])).
/// Output is in radians/sample, scaled by 1/π to normalise to [-1, 1].
fn demod_fm(samples: &[Complex<f32>]) -> Vec<f32> {
let mut prev = None;
demod_fm_with_prev(samples, &mut prev)
}
// ---------------------------------------------------------------------------
// CW
// ---------------------------------------------------------------------------
/// CW demodulator: take the real part of each baseband IQ sample.
///
/// The upstream FIR filter centres the CW carrier at the configured audio
/// offset (e.g. 700 Hz), so demodulating identically to USB produces the
/// characteristic CW side-tone. Level normalisation is handled downstream
/// by the per-channel AGC.
fn demod_cw(samples: &[Complex<f32>]) -> Vec<f32> {
samples.iter().map(|s| s.re).collect()
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use num_complex::Complex;
fn complex_tone(freq_norm: f32, len: usize) -> Vec<Complex<f32>> {
use std::f32::consts::TAU;
(0..len)
.map(|n| Complex::from_polar(1.0, TAU * freq_norm * n as f32))
.collect()
}
fn assert_approx_eq(a: f32, b: f32, tol: f32, label: &str) {
assert!(
(a - b).abs() <= tol,
"{}: expected {} ≈ {} (tol {})",
label,
a,
b,
tol
);
}
// Test 1: USB and Passthrough return the real part of each sample.
#[test]
fn test_usb_passthrough_takes_real_part() {
let input = vec![
Complex::new(1.0_f32, 2.0),
Complex::new(3.0, 4.0),
Complex::new(-1.0, 0.0),
Complex::new(0.0, -1.0),
];
let expected = vec![1.0_f32, 3.0, -1.0, 0.0];
let usb_out = Demodulator::Usb.demodulate(&input);
assert_eq!(usb_out, expected, "USB should return real parts");
let pass_out = Demodulator::Passthrough.demodulate(&input);
assert_eq!(pass_out, expected, "Passthrough should return real parts");
}
// Test 2: LSB returns the real part (mixing handled upstream).
#[test]
fn test_lsb_takes_real_part() {
let input = vec![
Complex::new(1.0_f32, 2.0),
Complex::new(3.0, 4.0),
Complex::new(-1.0, 0.0),
Complex::new(0.0, -1.0),
];
let expected = vec![1.0_f32, 3.0, -1.0, 0.0];
let lsb_out = Demodulator::Lsb.demodulate(&input);
assert_eq!(lsb_out, expected, "LSB should return real parts");
}
// 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_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, 1.0, 1e-6, &format!("AM raw magnitude sample {}", i));
}
}
// Test 4: AM on alternating-magnitude signal returns the raw envelope.
#[test]
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.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 raw magnitude sample {}", i));
}
}
// Test 5: FM discriminator on a pure tone at 0.25 cycles/sample.
// arg(s[n]*conj(s[n-1])) = 2π*0.25 = π/2; scaled by 1/π → 0.5.
#[test]
fn test_fm_tone_frequency() {
let input = complex_tone(0.25, 16);
let out = Demodulator::Fm.demodulate(&input);
assert_eq!(out.len(), 16);
// First sample is 0.0 by convention.
assert_approx_eq(out[0], 0.0, 1e-6, "FM tone sample 0 (zero by convention)");
// Remaining samples should be approximately 0.5.
for (i, &sample) in out.iter().enumerate().skip(1) {
assert_approx_eq(sample, 0.5, 0.01, &format!("FM tone sample {}", i));
}
}
// Test 6: FM discriminator on a DC (constant-phase) signal outputs all zeros.
#[test]
fn test_fm_silence_is_zero() {
let input: Vec<Complex<f32>> = (0..8).map(|_| Complex::new(1.0, 0.0)).collect();
let out = Demodulator::Fm.demodulate(&input);
assert_eq!(out.len(), 8);
for (i, &v) in out.iter().enumerate() {
assert_approx_eq(v, 0.0, 1e-6, &format!("FM silence sample {}", i));
}
}
// Test 7: CW demodulator returns the real part (same as USB).
#[test]
fn test_cw_takes_real_part() {
let input = vec![
Complex::new(3.0_f32, 4.0),
Complex::new(0.0, 0.0),
Complex::new(1.0, 0.0),
];
let out = Demodulator::Cw.demodulate(&input);
assert_eq!(out.len(), 3);
assert_approx_eq(out[0], 3.0, 1e-6, "CW sample 0");
assert_approx_eq(out[1], 0.0, 1e-6, "CW sample 1");
assert_approx_eq(out[2], 1.0, 1e-6, "CW sample 2");
}
// Test 8: Demodulator::for_mode maps each RigMode to the correct variant.
#[test]
fn test_demodulator_for_mode_mapping() {
assert_eq!(Demodulator::for_mode(&RigMode::USB), Demodulator::Usb);
assert_eq!(Demodulator::for_mode(&RigMode::LSB), Demodulator::Lsb);
assert_eq!(Demodulator::for_mode(&RigMode::AM), Demodulator::Am);
assert_eq!(Demodulator::for_mode(&RigMode::FM), Demodulator::Fm);
assert_eq!(Demodulator::for_mode(&RigMode::WFM), Demodulator::Wfm);
assert_eq!(Demodulator::for_mode(&RigMode::CW), Demodulator::Cw);
assert_eq!(Demodulator::for_mode(&RigMode::CWR), Demodulator::Cw);
assert_eq!(
Demodulator::for_mode(&RigMode::DIG),
Demodulator::Passthrough
);
assert_eq!(Demodulator::for_mode(&RigMode::PKT), Demodulator::Fm);
}
// Test 9: Synthetic FM stereo — verify L/R separation.
//
// Generate a composite FM stereo signal with a 1 kHz tone on L only:
// L = sin(2π·1000·t), R = 0
// sum = L + R = sin(2π·1000·t)
// diff = L - R = sin(2π·1000·t)
// composite = sum + 0.1·cos(2π·19000·t) + diff·cos(2π·38000·t)
//
// FM-modulate at 240 kHz composite rate, run through WfmStereoDecoder,
// and verify L has significant energy while R is near zero.
#[test]
fn test_wfm_stereo_separation() {
use std::f32::consts::TAU;
let composite_rate: u32 = 240_000;
let audio_rate: u32 = 48_000;
let fs = composite_rate as f32;
let duration_secs = 0.5_f32; // 500 ms — enough for PLL lock + measurement
let num_samples = (fs * duration_secs) as usize;
// --- Build composite baseband ---
let audio_freq = 1000.0_f32;
let pilot_freq = 19_000.0_f32;
let carrier_freq = 38_000.0_f32;
let mut composite = vec![0.0_f32; num_samples];
for n in 0..num_samples {
let t = n as f32 / fs;
let audio = (TAU * audio_freq * t).sin(); // L = audio, R = 0
let sum = audio; // L + R
let diff = audio; // L - R
let pilot = 0.1 * (TAU * pilot_freq * t).cos();
let carrier = (TAU * carrier_freq * t).cos();
composite[n] = sum + pilot + diff * carrier;
}
// --- FM-modulate composite → IQ samples ---
// deviation: peak composite ≈ ±2.1, map to ±75 kHz
// phase per sample = 2π · (75000 / peak_composite) · composite[n] / fs
let peak_composite = 2.1_f32; // sum(1) + pilot(0.1) + diff(1)
let deviation_hz = 75_000.0_f32;
let mod_index = TAU * deviation_hz / (peak_composite * fs);
let mut phase: f32 = 0.0;
let mut iq = Vec::with_capacity(num_samples);
for &c in &composite {
phase += mod_index * c;
iq.push(Complex::from_polar(1.0, phase));
}
// --- Decode ---
let mut decoder = WfmStereoDecoder::new(
composite_rate,
audio_rate,
2, // stereo output
true, // stereo enabled
50, // 50 µs deemphasis
);
let output = decoder.process_iq(&iq);
// Output is interleaved L, R. Skip the first 200 ms for PLL lock.
let skip_samples = (0.2 * audio_rate as f32) as usize;
let stereo_pairs = output.len() / 2;
assert!(
stereo_pairs > skip_samples + 100,
"not enough output samples: {} pairs, need > {}",
stereo_pairs,
skip_samples + 100
);
// Measure RMS of L and R channels in the measurement window.
let mut l_energy = 0.0_f64;
let mut r_energy = 0.0_f64;
let mut count = 0_u64;
for i in skip_samples..stereo_pairs {
let l = output[2 * i] as f64;
let r = output[2 * i + 1] as f64;
l_energy += l * l;
r_energy += r * r;
count += 1;
}
let l_rms = (l_energy / count as f64).sqrt();
let r_rms = (r_energy / count as f64).sqrt();
eprintln!("L RMS = {l_rms:.6}, R RMS = {r_rms:.6}");
// L should have significant energy (tone is present).
assert!(
l_rms > 0.01,
"L channel has no energy: L_rms = {l_rms:.6}"
);
// R should be much smaller than L (>20 dB separation).
let separation_db = if r_rms > 1e-10 {
20.0 * (l_rms / r_rms).log10()
} else {
f64::INFINITY
};
eprintln!("stereo separation = {separation_db:.1} dB");
assert!(
separation_db > 20.0,
"stereo separation too low: {separation_db:.1} dB (L_rms={l_rms:.6}, R_rms={r_rms:.6})"
);
}
/// Multi-tone stereo separation test.
///
/// Generates a composite FM stereo signal with tones at 400, 2000, 8000
/// and 14000 Hz on a single channel (L-only then R-only), demodulates,
/// and verifies that the silent channel stays quiet across the full
/// audio band. This catches group-delay and phase-trim problems that
/// a single 1 kHz tone would miss.
#[test]
fn test_wfm_stereo_separation_multitone() {
use std::f32::consts::TAU;
let composite_rate: u32 = 240_000;
let audio_rate: u32 = 48_000;
let fs = composite_rate as f32;
let duration_secs = 0.8_f32;
let num_samples = (fs * duration_secs) as usize;
let freqs = [400.0_f32, 2_000.0, 8_000.0, 14_000.0];
let pilot_freq = 19_000.0_f32;
let carrier_freq = 38_000.0_f32;
// Test both L-only (diff = +audio) and R-only (diff = -audio).
for (label, diff_sign) in [("L-only", 1.0_f32), ("R-only", -1.0_f32)] {
let mut composite = vec![0.0_f32; num_samples];
for n in 0..num_samples {
let t = n as f32 / fs;
let audio: f32 = freqs.iter().map(|&f| (TAU * f * t).sin()).sum::<f32>()
/ freqs.len() as f32;
let sum = audio; // L + R (same for both cases)
let diff = audio * diff_sign; // L - R
let pilot = 0.1 * (TAU * pilot_freq * t).cos();
let carrier = (TAU * carrier_freq * t).cos();
composite[n] = sum + pilot + diff * carrier;
}
let peak_composite = 2.1_f32;
let deviation_hz = 75_000.0_f32;
let mod_index = TAU * deviation_hz / (peak_composite * fs);
let mut phase: f32 = 0.0;
let mut iq = Vec::with_capacity(num_samples);
for &c in &composite {
phase += mod_index * c;
iq.push(Complex::from_polar(1.0, phase));
}
let mut decoder = WfmStereoDecoder::new(
composite_rate,
audio_rate,
2,
true,
50,
);
let output = decoder.process_iq(&iq);
let skip_samples = (0.3 * audio_rate as f32) as usize;
let stereo_pairs = output.len() / 2;
assert!(stereo_pairs > skip_samples + 100,
"{label}: not enough output samples");
let mut active_energy = 0.0_f64;
let mut silent_energy = 0.0_f64;
let mut count = 0_u64;
for i in skip_samples..stereo_pairs {
let l = output[2 * i] as f64;
let r = output[2 * i + 1] as f64;
if diff_sign > 0.0 {
// L-only: L is active, R is silent
active_energy += l * l;
silent_energy += r * r;
} else {
// R-only: R is active, L is silent
active_energy += r * r;
silent_energy += l * l;
}
count += 1;
}
let active_rms = (active_energy / count as f64).sqrt();
let silent_rms = (silent_energy / count as f64).sqrt();
let separation_db = if silent_rms > 1e-10 {
20.0 * (active_rms / silent_rms).log10()
} else {
f64::INFINITY
};
eprintln!("{label}: active RMS = {active_rms:.6}, silent RMS = {silent_rms:.6}, separation = {separation_db:.1} dB");
assert!(active_rms > 0.01,
"{label}: active channel has no energy: {active_rms:.6}");
assert!(separation_db > 15.0,
"{label}: multitone stereo separation too low: {separation_db:.1} dB");
}
}
#[test]
fn test_wfm_no_pilot_stays_mono_detect() {
use std::f32::consts::TAU;
let composite_rate: u32 = 240_000;
let audio_rate: u32 = 48_000;
let fs = composite_rate as f32;
let duration_secs = 0.5_f32;
let num_samples = (fs * duration_secs) as usize;
let audio_freq = 1000.0_f32;
let mut composite = vec![0.0_f32; num_samples];
for (n, sample) in composite.iter_mut().enumerate() {
let t = n as f32 / fs;
*sample = (TAU * audio_freq * t).sin();
}
let deviation_hz = 75_000.0_f32;
let mod_index = TAU * deviation_hz / fs;
let mut phase: f32 = 0.0;
let mut iq = Vec::with_capacity(num_samples);
for &c in &composite {
phase += mod_index * c;
iq.push(Complex::from_polar(1.0, phase));
}
let mut decoder = WfmStereoDecoder::new(
composite_rate,
audio_rate,
2,
true,
50,
);
let output = decoder.process_iq(&iq);
assert!(
!decoder.stereo_detected(),
"decoder should not detect stereo without a 19 kHz pilot"
);
let skip_samples = (0.2 * audio_rate as f32) as usize;
let stereo_pairs = output.len() / 2;
assert!(stereo_pairs > skip_samples + 100);
let mut diff_energy = 0.0_f64;
let mut count = 0_u64;
for i in skip_samples..stereo_pairs {
let l = output[2 * i] as f64;
let r = output[2 * i + 1] as f64;
let d = l - r;
diff_energy += d * d;
count += 1;
}
let diff_rms = (diff_energy / count as f64).sqrt();
assert!(
diff_rms < 0.01,
"mono signal without pilot should not develop audible stereo difference: diff_rms={diff_rms:.6}"
);
}
// Test 10: All demodulators return an empty Vec for empty input without panicking.
#[test]
fn test_empty_input() {
let empty: Vec<Complex<f32>> = Vec::new();
let demodulators = [
Demodulator::Usb,
Demodulator::Lsb,
Demodulator::Am,
Demodulator::Fm,
Demodulator::Wfm,
Demodulator::Cw,
Demodulator::Passthrough,
];
for demod in &demodulators {
let out = demod.demodulate(&empty);
assert!(
out.is_empty(),
"{:?} should return empty Vec for empty input",
demod
);
}
}
}