Skip to main content

ferraus_analysis/
stereo.rs

1//! Stereo image metrics: L/R correlation (broadband and low band), mid/side balance,
2//! width and correlation per band, and a mono-compatibility score.
3
4use crate::util::{self, pow_db};
5use ferraus_core::AudioBuffer;
6use serde::Serialize;
7
8/// Stereo metrics for one band.
9#[derive(Clone, Debug, Serialize)]
10pub struct StereoBand {
11    pub low_hz: f64,
12    pub high_hz: f64,
13    /// L/R correlation −1..1 (1 = mono, 0 = uncorrelated, −1 = out of phase).
14    pub correlation: f64,
15    /// Side energy / (mid + side) energy: 0 = mono, 0.5 = uncorrelated, 1 = out of phase.
16    pub width: f64,
17    /// Level change of this band when summed to mono (dB, 0 for mono content).
18    pub mono_loss_db: f64,
19    /// Band level (mean of L and R energy) in dB relative to full scale.
20    pub level_db: f64,
21}
22
23/// Result of [`stereo_analysis`].
24#[derive(Clone, Debug, Serialize)]
25pub struct StereoReport {
26    pub channels: usize,
27    /// Broadband L/R correlation.
28    pub correlation: f64,
29    /// L/R correlation below 120 Hz.
30    pub correlation_below_120hz: f64,
31    /// Side level relative to mid level (dB); `-inf` (null) for mono content.
32    pub side_to_mid_db: f64,
33    /// Broadband width (side / (mid + side) energy).
34    pub width: f64,
35    /// Level change when summed to mono (dB).
36    pub mono_loss_db: f64,
37    /// 0..1: energy-weighted per-band score (1 = folds to mono without loss).
38    pub mono_compatibility: f64,
39    /// Left level minus right level (dB).
40    pub balance_db: f64,
41    pub bands: Vec<StereoBand>,
42}
43
44/// Band edges used for per-band stereo metrics.
45pub const STEREO_BANDS: [(f64, f64); 8] = [
46    (20.0, 120.0),
47    (120.0, 250.0),
48    (250.0, 500.0),
49    (500.0, 1000.0),
50    (1000.0, 2000.0),
51    (2000.0, 4000.0),
52    (4000.0, 8000.0),
53    (8000.0, 20000.0),
54];
55
56/// Zero-lag correlation coefficient `Σab / sqrt(Σa² Σb²)` (1.0 when both are silent).
57pub fn correlation(a: &[f32], b: &[f32]) -> f64 {
58    let (mut ab, mut aa, mut bb) = (0.0f64, 0.0f64, 0.0f64);
59    for (x, y) in a.iter().zip(b) {
60        let (x, y) = (*x as f64, *y as f64);
61        ab += x * y;
62        aa += x * x;
63        bb += y * y;
64    }
65    if aa <= 1e-20 || bb <= 1e-20 {
66        return if aa <= 1e-20 && bb <= 1e-20 { 1.0 } else { 0.0 };
67    }
68    ab / (aa * bb).sqrt()
69}
70
71/// L/R correlation of the content below `cutoff_hz` (4th-order low-pass). 1.0 for mono.
72pub fn low_correlation(buf: &AudioBuffer, cutoff_hz: f64) -> f64 {
73    if buf.channels() < 2 {
74        return 1.0;
75    }
76    let sr = buf.sample_rate as f64;
77    // band-limit to 20 Hz .. cutoff so DC offsets do not dominate
78    let l = util::highpass4(&util::lowpass4(&buf.data[0], sr, cutoff_hz), sr, 20.0);
79    let r = util::highpass4(&util::lowpass4(&buf.data[1], sr, cutoff_hz), sr, 20.0);
80    correlation(&l, &r)
81}
82
83/// Stereo analysis of the first two channels (mono input reports a perfect mono image).
84pub fn stereo_analysis(buf: &AudioBuffer) -> StereoReport {
85    let sr = buf.sample_rate as f64;
86    let nyq = sr / 2.0;
87    if buf.channels() < 2 {
88        let e: f64 = buf.data[0].iter().map(|v| (*v as f64).powi(2)).sum::<f64>() / buf.frames().max(1) as f64;
89        return StereoReport {
90            channels: buf.channels(),
91            correlation: 1.0,
92            correlation_below_120hz: 1.0,
93            side_to_mid_db: f64::NEG_INFINITY,
94            width: 0.0,
95            mono_loss_db: 0.0,
96            mono_compatibility: 1.0,
97            balance_db: 0.0,
98            bands: STEREO_BANDS
99                .iter()
100                .filter(|(lo, _)| *lo < nyq)
101                .map(|(lo, hi)| StereoBand {
102                    low_hz: *lo,
103                    high_hz: hi.min(nyq),
104                    correlation: 1.0,
105                    width: 0.0,
106                    mono_loss_db: 0.0,
107                    level_db: pow_db(e),
108                })
109                .collect(),
110        };
111    }
112    // remove DC so an offset on one channel does not skew the image metrics
113    let dc = |c: &[f32]| (c.iter().map(|v| *v as f64).sum::<f64>() / c.len().max(1) as f64) as f32;
114    let (dl, dr) = (dc(&buf.data[0]), dc(&buf.data[1]));
115    let l: Vec<f32> = buf.data[0].iter().map(|v| v - dl).collect();
116    let r: Vec<f32> = buf.data[1].iter().map(|v| v - dr).collect();
117    let (l, r) = (&l, &r);
118    let n = buf.frames().max(1) as f64;
119    let (mut el, mut er, mut em, mut es) = (0.0f64, 0.0, 0.0, 0.0);
120    for (a, b) in l.iter().zip(r) {
121        let (a, b) = (*a as f64, *b as f64);
122        el += a * a;
123        er += b * b;
124        em += ((a + b) / 2.0).powi(2);
125        es += ((a - b) / 2.0).powi(2);
126    }
127    let corr = correlation(l, r);
128    let corr_low = low_correlation(buf, 120.0);
129    let width = if em + es > 1e-20 { es / (em + es) } else { 0.0 };
130    let mono_loss = if el + er > 1e-20 { pow_db(2.0 * em / (el + er)).max(-120.0) } else { 0.0 };
131
132    // per band from an STFT
133    let n_fft = 4096.min(util::next_pow2(buf.frames().max(512)));
134    let hop = n_fft / 2;
135    let fl = util::stft_complex(l, n_fft, hop);
136    let fr = util::stft_complex(r, n_fft, hop);
137    let df = sr / n_fft as f64;
138    let mut bands = Vec::new();
139    let mut score_acc = 0.0;
140    let mut weight_acc = 0.0;
141    for (lo, hi) in STEREO_BANDS.iter().filter(|(lo, _)| *lo < nyq) {
142        let hi = hi.min(nyq);
143        let k0 = ((lo / df).ceil() as usize).max(1);
144        let k1 = ((hi / df).floor() as usize).min(n_fft / 2);
145        let (mut sl, mut sr_, mut x, mut sm, mut ss) = (0.0f64, 0.0, 0.0, 0.0, 0.0);
146        for (a, b) in fl.iter().zip(&fr) {
147            for k in k0..=k1.max(k0) {
148                let (p, q) = (a[k], b[k]);
149                sl += p.norm_sqr() as f64;
150                sr_ += q.norm_sqr() as f64;
151                x += (p * q.conj()).re as f64;
152                sm += ((p + q) * 0.5).norm_sqr() as f64;
153                ss += ((p - q) * 0.5).norm_sqr() as f64;
154            }
155        }
156        let c = if sl > 1e-20 && sr_ > 1e-20 { x / (sl * sr_).sqrt() } else { 1.0 };
157        let w = if sm + ss > 1e-20 { ss / (sm + ss) } else { 0.0 };
158        let loss = if sl + sr_ > 1e-20 { pow_db(2.0 * sm / (sl + sr_)).max(-120.0) } else { 0.0 };
159        let energy = (sl + sr_) / 2.0;
160        // normalise band energy to mean square per sample
161        let ms = energy * 2.0 / (n_fft as f64 * (3.0 * n_fft as f64 / 8.0)) / fl.len().max(1) as f64;
162        score_acc += energy * (1.0 + loss / 6.0).clamp(0.0, 1.0);
163        weight_acc += energy;
164        bands.push(StereoBand {
165            low_hz: *lo,
166            high_hz: hi,
167            correlation: c,
168            width: w,
169            mono_loss_db: loss,
170            level_db: pow_db(ms),
171        });
172    }
173    let mono_compat = if weight_acc > 0.0 { score_acc / weight_acc } else { 1.0 };
174    StereoReport {
175        channels: buf.channels(),
176        correlation: corr,
177        correlation_below_120hz: corr_low,
178        side_to_mid_db: if em > 1e-20 && es > 1e-20 {
179            10.0 * (es / em).log10()
180        } else if es <= 1e-20 {
181            f64::NEG_INFINITY
182        } else {
183            f64::INFINITY
184        },
185        width,
186        mono_loss_db: mono_loss,
187        mono_compatibility: mono_compat,
188        balance_db: pow_db(el / n) - pow_db(er / n),
189        bands,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use ferraus_core::testing::{noise, sine, stereo};
197
198    #[test]
199    fn mono_identical_and_out_of_phase() {
200        let s = stereo(sine(48000, 440.0, 1.0, 0.5));
201        let r = stereo_analysis(&s);
202        assert!((r.correlation - 1.0).abs() < 1e-6);
203        assert!(r.width < 1e-6);
204        assert!(r.mono_compatibility > 0.99);
205        let mut o = s.clone();
206        o.data[1].iter_mut().for_each(|v| *v = -*v);
207        let r = stereo_analysis(&o);
208        assert!(r.correlation < -0.99);
209        assert!(r.width > 0.99);
210        assert!(r.mono_compatibility < 0.05);
211        let m = stereo_analysis(&sine(48000, 440.0, 1.0, 0.5));
212        assert_eq!(m.correlation, 1.0);
213    }
214
215    #[test]
216    fn uncorrelated_and_low_band() {
217        let a = noise(48000, 2.0, 0.3, 1);
218        let b = noise(48000, 2.0, 0.3, 2);
219        let buf = AudioBuffer::from_planar(48000, vec![a.data[0].clone(), b.data[0].clone()]);
220        let r = stereo_analysis(&buf);
221        assert!(r.correlation.abs() < 0.05);
222        assert!((r.width - 0.5).abs() < 0.05);
223        assert!((r.mono_loss_db + 3.0).abs() < 0.3);
224        assert!((r.mono_compatibility - 0.5).abs() < 0.1);
225        // out-of-phase bass under in-phase highs
226        let bass = sine(48000, 60.0, 2.0, 0.4).data[0].clone();
227        let hi = sine(48000, 2000.0, 2.0, 0.4).data[0].clone();
228        let l: Vec<f32> = bass.iter().zip(&hi).map(|(b, h)| b + h).collect();
229        let rr: Vec<f32> = bass.iter().zip(&hi).map(|(b, h)| -b + h).collect();
230        let buf = AudioBuffer::from_planar(48000, vec![l, rr]);
231        let r = stereo_analysis(&buf);
232        assert!(r.correlation_below_120hz < -0.9, "{}", r.correlation_below_120hz);
233        assert!(r.bands[0].correlation < -0.9);
234        assert!(r.bands[5].correlation > 0.9);
235    }
236}