use rustfft::{num_complex::Complex, FftPlanner};
use crate::demod::FFT_LEN;
pub(crate) struct SnrEstimator {
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
hann_long: Vec<f32>,
fft_buf: Vec<Complex<f32>>,
scratch: Vec<Complex<f32>>,
}
impl SnrEstimator {
pub fn new() -> Self {
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(FFT_LEN);
let scratch_len = fft.get_inplace_scratch_len();
Self {
fft,
hann_long: crate::dsp::build_hann(FFT_LEN),
fft_buf: vec![Complex { re: 0.0, im: 0.0 }; FFT_LEN],
scratch: vec![Complex { re: 0.0, im: 0.0 }; scratch_len.max(FFT_LEN)],
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
pub fn estimate(&mut self, audio: &[f32], center_sample: i64, hedr_shift_hz: f64) -> f64 {
let half = (FFT_LEN as i64) / 2;
for i in 0..FFT_LEN {
let idx = center_sample - half + i as i64;
let s = if idx >= 0 && (idx as usize) < audio.len() {
audio[idx as usize]
} else {
0.0
};
self.fft_buf[i] = Complex {
re: s * self.hann_long[i],
im: 0.0,
};
}
self.fft
.process_with_scratch(&mut self.fft_buf, &mut self.scratch[..]);
let bin_for = |hz: f64| -> usize {
crate::dsp::get_bin(hz, FFT_LEN, crate::resample::WORKING_SAMPLE_RATE_HZ)
.min(FFT_LEN / 2 - 1)
};
let video_lo = bin_for(1500.0 + hedr_shift_hz);
let video_hi = bin_for(2300.0 + hedr_shift_hz);
let mut p_video_plus_noise = 0.0_f64;
for n in video_lo..=video_hi {
p_video_plus_noise += crate::dsp::power(self.fft_buf[n]);
}
let n_lo_a = bin_for(400.0 + hedr_shift_hz);
let n_hi_a = bin_for(800.0 + hedr_shift_hz);
let n_lo_b = bin_for(2700.0 + hedr_shift_hz);
let n_hi_b = bin_for(3400.0 + hedr_shift_hz);
let mut p_noise_only = 0.0_f64;
for n in n_lo_a..=n_hi_a {
p_noise_only += crate::dsp::power(self.fft_buf[n]);
}
for n in n_lo_b..=n_hi_b {
p_noise_only += crate::dsp::power(self.fft_buf[n]);
}
let video_plus_noise_bins = bin_for(2300.0) - bin_for(1500.0) + 1;
let noise_only_bins =
(bin_for(800.0) - bin_for(400.0) + 1) + (bin_for(3400.0) - bin_for(2700.0) + 1);
let receiver_bins = bin_for(3400.0) - bin_for(400.0);
if noise_only_bins == 0 {
return -20.0;
}
let p_noise = p_noise_only * (receiver_bins as f64) / (noise_only_bins as f64);
let p_signal = p_video_plus_noise
- p_noise_only * (video_plus_noise_bins as f64) / (noise_only_bins as f64);
if p_noise <= 0.0 || p_signal / p_noise < 0.01 {
-20.0
} else {
10.0 * (p_signal / p_noise).log10()
}
}
}
impl Default for SnrEstimator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap
)]
mod tests {
use super::*;
use crate::resample::WORKING_SAMPLE_RATE_HZ;
use std::f64::consts::PI;
fn synth_tone(freq_hz: f64, secs: f64) -> Vec<f32> {
let sr = f64::from(WORKING_SAMPLE_RATE_HZ);
let n = (secs * sr).round() as usize;
(0..n)
.map(|i| (2.0 * PI * freq_hz * (i as f64) / sr).sin() as f32)
.collect()
}
fn synth_noise(secs: f64, amp: f32, seed: u32) -> Vec<f32> {
let sr = f64::from(WORKING_SAMPLE_RATE_HZ);
let n = (secs * sr).round() as usize;
let mut s: u32 = seed.max(1);
(0..n)
.map(|_| {
s = s.wrapping_mul(1_103_515_245).wrapping_add(12_345);
let v = ((s >> 8) & 0xFFFF) as f32 / 32_768.0 - 1.0;
v * amp
})
.collect()
}
#[test]
fn snr_silence_floors_at_minus_twenty() {
let mut est = SnrEstimator::new();
let audio = vec![0.0_f32; 1024];
let snr = est.estimate(&audio, 512, 0.0);
assert!(
(snr - -20.0).abs() < 1e-9,
"silence should floor at -20 dB, got {snr}"
);
}
#[test]
fn snr_pure_video_tone_is_high() {
let mut est = SnrEstimator::new();
let audio = synth_tone(1900.0, 0.100);
let center = (audio.len() / 2) as i64;
let snr = est.estimate(&audio, center, 0.0);
assert!(snr > 25.0, "expected high SNR, got {snr}");
}
#[test]
fn snr_pure_noise_band_is_negative() {
let mut est = SnrEstimator::new();
let audio = synth_tone(600.0, 0.100);
let center = (audio.len() / 2) as i64;
let snr = est.estimate(&audio, center, 0.0);
assert!(snr <= 0.0, "expected ≤ 0 dB SNR, got {snr}");
}
#[test]
fn snr_tone_plus_noise_intermediate() {
let mut est = SnrEstimator::new();
let mut audio = synth_tone(1900.0, 0.100);
for (i, n) in synth_noise(0.100, 1.0, 0xCAFE).into_iter().enumerate() {
if i < audio.len() {
audio[i] = audio[i] * 0.3 + n;
}
}
let center = (audio.len() / 2) as i64;
let snr = est.estimate(&audio, center, 0.0);
assert!(
(-20.0..30.0).contains(&snr),
"intermediate SNR expected, got {snr}"
);
}
#[test]
fn snr_hedr_shift_tracks_band() {
let mut est = SnrEstimator::new();
let audio = synth_tone(1950.0, 0.100);
let center = (audio.len() / 2) as i64;
let snr_aligned = est.estimate(&audio, center, 50.0);
assert!(snr_aligned > 25.0, "aligned: got {snr_aligned}");
}
#[test]
fn snr_estimator_default_constructs() {
let _ = SnrEstimator::default();
}
#[test]
fn snr_bandwidth_correction_bins_at_finer_resolution() {
let get_bin =
|hz: f64| crate::dsp::get_bin(hz, FFT_LEN, crate::resample::WORKING_SAMPLE_RATE_HZ);
let video_lo = get_bin(1500.0);
let video_hi = get_bin(2300.0);
let n_lo_a = get_bin(400.0);
let n_hi_a = get_bin(800.0);
let n_lo_b = get_bin(2700.0);
let n_hi_b = get_bin(3400.0);
let video_plus_noise_bins = video_hi - video_lo + 1;
let noise_only_bins = (n_hi_a - n_lo_a + 1) + (n_hi_b - n_lo_b + 1);
let receiver_bins = n_hi_b - n_lo_a;
assert_eq!(
video_plus_noise_bins, 75,
"video+noise bins: got {video_plus_noise_bins}"
);
assert_eq!(
noise_only_bins, 104,
"noise-only bins: got {noise_only_bins}"
);
assert_eq!(receiver_bins, 278, "receiver bins: got {receiver_bins}");
let pnoise_mult = receiver_bins as f64 / noise_only_bins as f64;
assert!(
(pnoise_mult - 278.0 / 104.0).abs() < 1e-9,
"Pnoise mult: {pnoise_mult}"
);
let psignal_sub = video_plus_noise_bins as f64 / noise_only_bins as f64;
assert!(
(psignal_sub - 75.0 / 104.0).abs() < 1e-9,
"Psignal sub: {psignal_sub}"
);
}
#[test]
fn sync_target_bin_for_1200hz_is_27() {
let bin = crate::dsp::get_bin(
1200.0,
crate::sync::SYNC_FFT_LEN,
crate::resample::WORKING_SAMPLE_RATE_HZ,
);
assert_eq!(
bin, 27,
"sync_target_bin for 1200 Hz should be 27 (trunc), got {bin}"
);
}
}