1use crate::cast;
9use crate::confidence::{Confidence, ConfidenceError, Level, NoSpread, Reason, ReasonCode, Signal};
10use crate::dsp::{ACTIVE_GATE_DB, ActiveFrames, Complex64, Stft, StftParams, frame_rms};
11use crate::units::{Correlation, Decibels, Degrees, Hz};
12
13use super::SampleRate;
14
15pub const SUB_EDGE_HZ: Hz = Hz::from_const(120.0);
17pub const HIGH_EDGE_HZ: Hz = Hz::from_const(1000.0);
20pub const SCREEN_FRAME_LEN: usize = 8192;
22pub const SCREEN_HARMONICS: usize = 20;
24pub const STEREO_CONVENTION: &str = "rho: Pearson correlation at zero lag; sm_db: \
2620 log10(rms(S) / rms(M)) with M = (L + R) / 2 and S = (L - R) / 2; per band from the \
278192-point Hann STFT over bins inside the band, DC excluded; ipd_deg: abs(angle(L conj R)) \
28at the largest cross-spectrum bin within 2 bins of h f0, h = 1..20 inside the band, \
29weighted by |L conj R|, then the mean over active frames, whose RMS over both channels, \
30sqrt((rms_L^2 + rms_R^2) / 2), lies within 40 dB of the loudest; bands: sub below 120 Hz, \
31low_mid 120 Hz to 1 kHz, high above 1 kHz";
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
35pub enum Band {
36 Full,
38 Sub,
40 LowMid,
42 High,
44}
45
46impl Band {
47 pub const ALL: [Self; 4] = [Self::Full, Self::Sub, Self::LowMid, Self::High];
49
50 #[must_use]
53 pub fn contains(self, frequency: f64) -> bool {
54 frequency > 0.0
55 && match self {
56 Self::Full => true,
57 Self::Sub => frequency < SUB_EDGE_HZ.get(),
58 Self::LowMid => (SUB_EDGE_HZ.get()..HIGH_EDGE_HZ.get()).contains(&frequency),
59 Self::High => frequency >= HIGH_EDGE_HZ.get(),
60 }
61 }
62}
63
64#[derive(Clone, Copy, Debug, Default, PartialEq)]
67pub struct StereoStats {
68 pub rho: Option<Correlation>,
70 pub sm_db: Option<Decibels>,
72 pub ipd_deg: Option<Degrees>,
74}
75
76#[derive(Clone, Debug, PartialEq)]
78pub struct StereoScreen {
79 bands: [StereoStats; 4],
80 confidence: Confidence<NoSpread>,
81}
82
83impl StereoScreen {
84 pub fn one_channel() -> Result<Self, ConfidenceError> {
90 Ok(Self {
91 bands: [StereoStats::default(); 4],
92 confidence: Confidence::no_estimate(vec![Reason::new(
93 ReasonCode::OneChannel,
94 Signal::Count(1),
95 )?])?,
96 })
97 }
98
99 #[must_use]
101 pub fn band(&self, band: Band) -> StereoStats {
102 self.bands[match band {
103 Band::Full => 0,
104 Band::Sub => 1,
105 Band::LowMid => 2,
106 Band::High => 3,
107 }]
108 }
109
110 #[must_use]
112 pub fn confidence(&self) -> &Confidence<NoSpread> {
113 &self.confidence
114 }
115}
116
117pub fn stereo_screen(
125 left: &[f64],
126 right: &[f64],
127 rate: SampleRate,
128 f0: Option<Hz>,
129) -> Result<StereoScreen, ConfidenceError> {
130 let len = left.len().min(right.len());
131 let (left, right) = (&left[..len], &right[..len]);
132 let params = StftParams::fixed(SCREEN_FRAME_LEN);
133 let energy: Vec<f64> = frame_rms(left, params)
136 .iter()
137 .zip(frame_rms(right, params))
138 .map(|(l, r)| f64::midpoint(l * l, r * r).sqrt())
139 .collect();
140 let active = ActiveFrames::from_rms(&energy, ACTIVE_GATE_DB);
141 let bin_hz = params.bin_width(rate).get();
142 let nyquist = rate.nyquist().get();
143 let f0 = f0.map(Hz::get).filter(|f| *f > 0.0);
144
145 let mut sums = [[0.0_f64; 3]; 4];
147 let mut phases: [Vec<f64>; 4] = Default::default();
149 let mut stft_left = Stft::new(params);
150 let mut stft_right = Stft::new(params);
151 for frame in 0..params.frame_count(len) {
152 let l = stft_left.frame(left, frame).to_vec();
153 let r = stft_right.frame(right, frame);
154 let cross: Vec<Complex64> = l.iter().zip(r).map(|(a, b)| a * b.conj()).collect();
155 for (k, ((a, c), b)) in l.iter().zip(&cross).zip(r).enumerate() {
156 let f = cast::usize_to_f64(k) * bin_hz;
157 for (index, band) in Band::ALL.iter().enumerate() {
158 if band.contains(f) {
159 sums[index][0] += a.norm_sqr();
160 sums[index][1] += b.norm_sqr();
161 sums[index][2] += c.re;
162 }
163 }
164 }
165 if let (Some(f0), true) = (f0, active.is_active(frame)) {
166 for (index, band) in Band::ALL.iter().enumerate() {
167 if let Some(phase) = frame_phase(&cross, f0, bin_hz, nyquist, *band) {
168 phases[index].push(phase);
169 }
170 }
171 }
172 }
173
174 let full = StereoStats {
175 rho: pearson(left, right),
176 sm_db: side_over_mid(left, right),
177 ipd_deg: mean_phase(&phases[0]),
178 };
179 let mut bands = [full; 4];
180 for index in 1..4 {
181 let [ll, rr, lr] = sums[index];
182 bands[index] = StereoStats {
183 rho: (ll > 0.0 && rr > 0.0).then(|| Correlation::clamped(lr / (ll * rr).sqrt())),
184 sm_db: band_side_over_mid(ll, rr, lr),
185 ipd_deg: mean_phase(&phases[index]),
186 };
187 }
188 let reasons = vec![
189 Reason::new(ReasonCode::StereoBlindSpot, Signal::Flag(true))?,
190 Reason::new(
191 ReasonCode::ActiveFrames,
192 Signal::Count(u64::try_from(active.count()).unwrap_or(u64::MAX)),
193 )?,
194 ];
195 let confidence = if active.count() == 0 {
196 Confidence::no_estimate(vec![Reason::new(
197 ReasonCode::SilentStem,
198 Signal::Flag(true),
199 )?])?
200 } else {
201 Confidence::estimate(Level::Medium, None, reasons)?
202 };
203 Ok(StereoScreen { bands, confidence })
204}
205
206fn frame_phase(cross: &[Complex64], f0: f64, bin_hz: f64, nyquist: f64, band: Band) -> Option<f64> {
209 let (mut weighted, mut total) = (0.0, 0.0);
210 for h in 1..=SCREEN_HARMONICS {
211 let centre_hz = cast::usize_to_f64(h) * f0;
212 if centre_hz >= nyquist || !band.contains(centre_hz) {
213 continue;
214 }
215 let centre = cast::round_to_usize(centre_hz / bin_hz).unwrap_or(0);
216 let lo = centre.saturating_sub(2);
217 let hi = (centre + 2).min(cross.len().saturating_sub(1));
218 let Some(peak) = cross[lo..=hi]
219 .iter()
220 .max_by(|a, b| a.norm().total_cmp(&b.norm()))
221 else {
222 continue;
223 };
224 let weight = peak.norm();
225 weighted += weight * peak.arg().abs().to_degrees();
226 total += weight;
227 }
228 (total > 0.0).then(|| weighted / total)
229}
230
231fn mean_phase(values: &[f64]) -> Option<Degrees> {
232 (!values.is_empty())
233 .then(|| Degrees::clamped(values.iter().sum::<f64>() / cast::usize_to_f64(values.len())))
234}
235
236fn pearson(left: &[f64], right: &[f64]) -> Option<Correlation> {
239 if left.is_empty() {
240 return None;
241 }
242 let n = cast::usize_to_f64(left.len());
243 let mean_l = left.iter().sum::<f64>() / n;
244 let mean_r = right.iter().sum::<f64>() / n;
245 let (mut ll, mut rr, mut lr) = (0.0, 0.0, 0.0);
246 for (l, r) in left.iter().zip(right) {
247 let (dl, dr) = (l - mean_l, r - mean_r);
248 ll += dl * dl;
249 rr += dr * dr;
250 lr += dl * dr;
251 }
252 (ll > 0.0 && rr > 0.0).then(|| Correlation::clamped(lr / (ll * rr).sqrt()))
253}
254
255fn side_over_mid(left: &[f64], right: &[f64]) -> Option<Decibels> {
257 let (mut mid, mut side) = (0.0, 0.0);
258 for (l, r) in left.iter().zip(right) {
259 mid += ((l + r) / 2.0).powi(2);
260 side += ((l - r) / 2.0).powi(2);
261 }
262 power_ratio_db(side, mid)
263}
264
265fn band_side_over_mid(ll: f64, rr: f64, lr: f64) -> Option<Decibels> {
267 power_ratio_db((ll + rr - 2.0 * lr).max(0.0), (ll + rr + 2.0 * lr).max(0.0))
268}
269
270fn power_ratio_db(numerator: f64, denominator: f64) -> Option<Decibels> {
271 let floor = 1e-24 * numerator.max(denominator);
273 (numerator > floor && denominator > floor)
274 .then(|| Decibels::from_power_ratio(numerator / denominator).ok())
275 .flatten()
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn the_constants_pass_the_unit_check() {
284 assert_eq!(SUB_EDGE_HZ, Hz::new(120.0).unwrap());
285 assert_eq!(HIGH_EDGE_HZ, Hz::new(1000.0).unwrap());
286 }
287
288 fn rate() -> SampleRate {
289 SampleRate::new(44_100).unwrap()
290 }
291
292 fn tone(len: usize) -> Vec<f64> {
293 (0..len)
294 .map(|n| {
295 (cast::usize_to_f64(n) * 0.031).sin() + 0.3 * (cast::usize_to_f64(n) * 0.17).sin()
296 })
297 .collect()
298 }
299
300 #[test]
301 fn equal_channels_are_fully_correlated_with_no_side_and_no_phase() {
302 let x = tone(20_000);
303 let f0 = Hz::new(0.031 * 44_100.0 / std::f64::consts::TAU).ok();
304 let screen = stereo_screen(&x, &x, rate(), f0).unwrap();
305 let full = screen.band(Band::Full);
306 assert!((full.rho.unwrap().get() - 1.0).abs() < 1e-12);
307 assert_eq!(full.sm_db, None);
308 assert!(full.ipd_deg.unwrap().get().abs() < 1e-9);
309 }
310
311 #[test]
312 fn opposite_channels_are_anti_correlated_with_no_mid() {
313 let x = tone(20_000);
314 let negated: Vec<f64> = x.iter().map(|v| -v).collect();
315 let screen = stereo_screen(&x, &negated, rate(), None).unwrap();
316 let full = screen.band(Band::Full);
317 assert!((full.rho.unwrap().get() + 1.0).abs() < 1e-12);
318 assert_eq!(full.sm_db, None);
319 assert_eq!(full.ipd_deg, None);
320 assert!(screen.confidence().is_estimate());
323 let f0 = Hz::new(0.031 * 44_100.0 / std::f64::consts::TAU).unwrap();
324 let with_f0 = stereo_screen(&x, &negated, rate(), Some(f0)).unwrap();
325 let phase = with_f0.band(Band::Full).ipd_deg.unwrap().get();
326 assert!((phase - 180.0).abs() < 1e-6, "{phase}");
327 }
328
329 #[test]
330 fn a_silent_channel_has_no_correlation_and_one_channel_no_values() {
331 let x = tone(20_000);
332 let screen = stereo_screen(&x, &vec![0.0; 20_000], rate(), None).unwrap();
333 assert_eq!(screen.band(Band::Full).rho, None);
334 let mono = StereoScreen::one_channel().unwrap();
335 assert_eq!(mono.band(Band::High), StereoStats::default());
336 assert!(!mono.confidence().is_estimate());
337 }
338
339 #[test]
340 fn bands_split_at_120_hz_and_1_khz() {
341 assert!(Band::Sub.contains(119.9) && !Band::Sub.contains(120.0));
342 assert!(Band::LowMid.contains(120.0) && !Band::LowMid.contains(1000.0));
343 assert!(Band::High.contains(1000.0));
344 assert!(!Band::Full.contains(0.0));
345 }
346}