Skip to main content

sva_samples/measure/
stereo.rs

1// Concern: measures the image two components carry, whole and per frame | Non-concern: level and spectrum per component (envelope.rs, spectrum.rs) | IO: (&[f64], &[f64], frame) -> StereoImage
2
3use crate::measure::envelope::rms;
4
5pub const RAIL_DB: f64 = 160.0;
6
7#[derive(Clone, Copy, Debug, PartialEq)]
8pub struct StereoFrame {
9    pub t_secs: f64,
10    pub correlation: f64,
11    pub mid_rms: f64,
12    pub side_rms: f64,
13    pub width: f64,
14    pub balance_db: f64,
15    pub mono_db: f64,
16}
17
18#[derive(Clone, Debug, PartialEq)]
19pub struct StereoImage {
20    pub channels: usize,
21    pub overall: StereoFrame,
22    pub frames: Vec<StereoFrame>,
23}
24
25pub fn analyze(
26    planes: &[&[f64]],
27    channels: usize,
28    sample_rate: f64,
29    start_secs: f64,
30    frame_secs: f64,
31) -> StereoImage {
32    let (l, r) = (planes[0], planes[1]);
33    let stride = ((frame_secs * sample_rate).round() as usize).max(1);
34    let frames = l
35        .chunks(stride)
36        .zip(r.chunks(stride))
37        .enumerate()
38        .map(|(n, (li, ri))| measure(li, ri, start_secs + (n * stride) as f64 / sample_rate))
39        .collect();
40    StereoImage {
41        channels,
42        overall: measure(l, r, start_secs),
43        frames,
44    }
45}
46
47fn measure(l: &[f64], r: &[f64], t_secs: f64) -> StereoFrame {
48    let mid: Vec<f64> = l.iter().zip(r).map(|(a, b)| (a + b) / 2.0).collect();
49    let side: Vec<f64> = l.iter().zip(r).map(|(a, b)| (a - b) / 2.0).collect();
50    let (mid_rms, side_rms) = (rms(&mid), rms(&side));
51    let (l_rms, r_rms) = (rms(l), rms(r));
52    StereoFrame {
53        t_secs,
54        correlation: pearson(l, r),
55        mid_rms,
56        side_rms,
57        width: ratio(side_rms, mid_rms),
58        balance_db: db(r_rms, l_rms),
59        mono_db: db(mid_rms, (l_rms + r_rms) / 2.0),
60    }
61}
62
63/// Silence over silence is 0, never NaN; something over silence is unbounded and says so.
64fn ratio(a: f64, b: f64) -> f64 {
65    match () {
66        () if b > 0.0 => a / b,
67        () if a > 0.0 => f64::INFINITY,
68        () => 0.0,
69    }
70}
71
72/// A rail rather than an infinity, so no reader has to special-case a null.
73fn db(a: f64, b: f64) -> f64 {
74    match ratio(a, b) {
75        r if r > 0.0 => (20.0 * r.log10()).clamp(-RAIL_DB, RAIL_DB),
76        _ if a > 0.0 => RAIL_DB,
77        _ if b > 0.0 => -RAIL_DB,
78        _ => 0.0,
79    }
80}
81
82/// Pearson, so a gain difference does not read as decorrelation — `balance_db` is for that.
83fn pearson(l: &[f64], r: &[f64]) -> f64 {
84    let n = l.len().min(r.len());
85    if n == 0 {
86        return 1.0;
87    }
88    let mean = |v: &[f64]| v[..n].iter().copied().sum::<f64>() / n as f64;
89    let (lm, rm) = (mean(l), mean(r));
90    let (mut cov, mut lv, mut rv) = (0.0, 0.0, 0.0);
91    for i in 0..n {
92        let (a, b) = (l[i] - lm, r[i] - rm);
93        cov += a * b;
94        lv += a * a;
95        rv += b * b;
96    }
97    if lv <= 0.0 || rv <= 0.0 {
98        return 1.0;
99    }
100    (cov / (lv * rv).sqrt()).clamp(-1.0, 1.0)
101}