//! Continuous stereo correlation meter. //! //! The detector integrates L², R² and L·R with the same time constant and //! derives the normalized correlation only afterwards. Its timing therefore //! does not depend on ALSA period size or browser frame rate. #[derive(Clone, Debug)] pub struct CorrelationMeter { sample_rate: u32, response_seconds: f32, alpha: f64, gate_alpha: f64, silence_threshold_db: f32, silence_threshold_power: f64, power_l: f64, power_r: f64, cross_power: f64, gate_power: f64, value: f32, negative_peak: f32, reset_token: u64, } impl CorrelationMeter { pub fn new( sample_rate: u32, response_seconds: f32, silence_threshold_db: f32, reset_token: u64, ) -> Self { let mut meter = Self { sample_rate: 0, response_seconds: 0.0, alpha: 1.0, gate_alpha: 1.0, silence_threshold_db: -75.0, silence_threshold_power: 10.0_f64.powf(-7.5), power_l: 0.0, power_r: 0.0, cross_power: 0.0, gate_power: 0.0, value: 0.0, negative_peak: 0.0, reset_token, }; meter.configure( sample_rate, response_seconds, silence_threshold_db, reset_token, ); meter } pub fn configure( &mut self, sample_rate: u32, response_seconds: f32, silence_threshold_db: f32, reset_token: u64, ) { let sample_rate = sample_rate.max(8_000); let response_seconds = normalize_response_seconds(response_seconds); let silence_threshold_db = normalize_silence_threshold_db(silence_threshold_db); if self.sample_rate != sample_rate || self.response_seconds != response_seconds { self.sample_rate = sample_rate; self.response_seconds = response_seconds; self.alpha = 1.0 - (-1.0 / (f64::from(sample_rate) * f64::from(response_seconds))).exp(); // The silence decision must react independently of the selected // correlation response time. A 50 ms RMS envelope avoids both // sample-zero chatter and multi-second threshold lag. self.gate_alpha = 1.0 - (-1.0 / (f64::from(sample_rate) * 0.05)).exp(); } if self.silence_threshold_db != silence_threshold_db { self.silence_threshold_db = silence_threshold_db; self.silence_threshold_power = 10.0_f64.powf(f64::from(silence_threshold_db) / 10.0); } if self.reset_token != reset_token { self.reset_token = reset_token; self.negative_peak = 0.0; } } pub fn process(&mut self, left: f32, right: f32) { let l = f64::from(left); let r = f64::from(right); self.power_l += self.alpha * (l * l - self.power_l); self.power_r += self.alpha * (r * r - self.power_r); self.cross_power += self.alpha * (l * r - self.cross_power); self.gate_power += self.gate_alpha * (0.5 * (l * l + r * r) - self.gate_power); // Below the configured mono RMS threshold the correlation indication // settles at its neutral position. const MIN_POWER: f64 = 1.0e-10; let denominator = (self.power_l * self.power_r).sqrt(); self.value = if self.gate_power >= self.silence_threshold_power && self.power_l > MIN_POWER && self.power_r > MIN_POWER && denominator > 0.0 { (self.cross_power / denominator).clamp(-1.0, 1.0) as f32 } else { 0.0 }; if self.value < self.negative_peak { self.negative_peak = self.value; } } pub fn value(&self) -> f32 { self.value } pub fn negative_peak(&self) -> f32 { self.negative_peak } } pub fn normalize_response_seconds(value: f32) -> f32 { if !value.is_finite() || value <= 0.0 { 1.0 } else if value < 0.75 { 0.5 } else if value < 1.75 { 1.0 } else { 2.5 } } pub fn normalize_silence_threshold_db(value: f32) -> f32 { if value.is_finite() { value.clamp(-90.0, -40.0) } else { -75.0 } } #[cfg(test)] mod tests { use super::*; fn run_signal(meter: &mut CorrelationMeter, seconds: usize, mut signal: F) where F: FnMut(usize) -> (f32, f32), { let count = meter.sample_rate as usize * seconds; for index in 0..count { let (left, right) = signal(index); meter.process(left, right); } } #[test] fn detects_positive_negative_and_quadrature_signals() { let sample_rate = 48_000; let phase_step = 2.0 * std::f32::consts::PI * 1_000.0 / sample_rate as f32; for (phase, expected) in [ (0.0, 1.0), (std::f32::consts::PI, -1.0), (std::f32::consts::FRAC_PI_2, 0.0), ] { let mut meter = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0); run_signal(&mut meter, 5, |index| { let angle = phase_step * index as f32; (angle.sin(), (angle + phase).sin()) }); assert!( (meter.value() - expected).abs() < 0.002, "phase {phase}: {}", meter.value() ); } } #[test] fn response_time_and_peak_reset_are_deterministic() { let sample_rate = 48_000; let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0); let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0); for index in 0..sample_rate as usize { let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; fast.process(sample, sample); slow.process(sample, sample); } assert!(fast.value() > 0.999); assert!(slow.value() > 0.999); run_signal(&mut fast, 2, |index| { let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; (sample, -sample) }); assert!(fast.negative_peak() < -0.7); fast.configure(sample_rate, 1.0, -75.0, 1); assert_eq!(fast.negative_peak(), 0.0); } #[test] fn fast_response_tracks_a_phase_reversal_before_slow_response() { let sample_rate = 48_000; let mut very_fast = CorrelationMeter::new(sample_rate, 0.5, -75.0, 0); let mut fast = CorrelationMeter::new(sample_rate, 1.0, -75.0, 0); let mut slow = CorrelationMeter::new(sample_rate, 2.5, -75.0, 0); for index in 0..sample_rate as usize * 5 { let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; very_fast.process(sample, sample); fast.process(sample, sample); slow.process(sample, sample); } for index in 0..sample_rate as usize { let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; very_fast.process(sample, -sample); fast.process(sample, -sample); slow.process(sample, -sample); } assert!(very_fast.value() < fast.value() - 0.35); assert!(fast.value() < slow.value() - 0.35); assert!(very_fast.negative_peak() < fast.negative_peak()); assert!(fast.negative_peak() < 0.0); assert_eq!(slow.negative_peak(), 0.0); } #[test] fn response_time_normalization_accepts_all_three_profiles() { assert_eq!(normalize_response_seconds(0.5), 0.5); assert_eq!(normalize_response_seconds(1.0), 1.0); assert_eq!(normalize_response_seconds(2.5), 2.5); assert_eq!(normalize_response_seconds(f32::NAN), 1.0); assert_eq!(normalize_response_seconds(0.0), 1.0); } #[test] fn configurable_silence_threshold_neutralizes_the_meter() { let sample_rate = 48_000; let amplitude = 10.0_f32.powf(-64.0 / 20.0); let mut meter = CorrelationMeter::new(sample_rate, 0.5, -75.0, 0); run_signal(&mut meter, 1, |index| { let sample = if index & 1 == 0 { amplitude } else { -amplitude }; (sample, sample) }); assert!(meter.value() > 0.99); meter.configure(sample_rate, 0.5, -50.0, 0); run_signal(&mut meter, 1, |index| { let sample = if index & 1 == 0 { amplitude } else { -amplitude }; (sample, sample) }); assert_eq!(meter.value(), 0.0); } #[test] fn silence_threshold_is_normalized_to_the_ui_range() { assert_eq!(normalize_silence_threshold_db(-50.0), -50.0); assert_eq!(normalize_silence_threshold_db(-100.0), -90.0); assert_eq!(normalize_silence_threshold_db(-20.0), -40.0); assert_eq!(normalize_silence_threshold_db(f32::NAN), -75.0); } #[test] fn silence_and_single_channel_are_neutral() { let mut meter = CorrelationMeter::new(48_000, 1.0, -75.0, 0); run_signal(&mut meter, 2, |_| (0.0, 0.0)); assert_eq!(meter.value(), 0.0); assert_eq!(meter.negative_peak(), 0.0); run_signal(&mut meter, 2, |index| { let sample = if index & 1 == 0 { 0.5 } else { -0.5 }; (sample, 0.0) }); assert_eq!(meter.value(), 0.0); } }