282 lines
9.3 KiB
Rust
282 lines
9.3 KiB
Rust
//! 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<F>(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);
|
|
}
|
|
}
|