Skip to main content

ferraus_analysis/
util.rs

1//! Shared DSP helpers: windows, STFT, Welch spectra, biquads, mel filterbanks, FFT
2//! cross-correlation, and small statistics. Public so sibling crates can reuse them.
3
4use realfft::RealFftPlanner;
5use rustfft::num_complex::Complex;
6use std::f64::consts::PI;
7
8/// Decibels of a power value, floored at -240 dB (never `-inf`).
9#[inline]
10pub fn pow_db(p: f64) -> f64 {
11    if p <= 1e-24 {
12        -240.0
13    } else {
14        10.0 * p.log10()
15    }
16}
17
18/// Decibels of an amplitude, floored at -240 dB.
19#[inline]
20pub fn amp_db(a: f64) -> f64 {
21    ferraus_core::lin_to_db(a)
22}
23
24/// Periodic Hann window of length `n`.
25pub fn hann(n: usize) -> Vec<f32> {
26    (0..n).map(|i| (0.5 - 0.5 * (2.0 * PI * i as f64 / n as f64).cos()) as f32).collect()
27}
28
29/// Smallest power of two `>= n`.
30pub fn next_pow2(n: usize) -> usize {
31    n.max(1).next_power_of_two()
32}
33
34/// Complex STFT frames (one-sided, `n_fft/2+1` bins). Frames are centered: frame `i` is
35/// centered on sample `i * hop` (the signal is zero-padded by `n_fft/2` on both sides).
36pub fn stft_complex(x: &[f32], n_fft: usize, hop: usize) -> Vec<Vec<Complex<f32>>> {
37    let mut planner = RealFftPlanner::<f32>::new();
38    let fft = planner.plan_fft_forward(n_fft);
39    let win = hann(n_fft);
40    let half = n_fft / 2;
41    let hop = hop.max(1);
42    let n_frames = if x.is_empty() { 0 } else { x.len() / hop + 1 };
43    let mut input = fft.make_input_vec();
44    let mut out = fft.make_output_vec();
45    let mut scratch = fft.make_scratch_vec();
46    let mut frames = Vec::with_capacity(n_frames);
47    for f in 0..n_frames {
48        let start = (f * hop) as isize - half as isize;
49        for (k, slot) in input.iter_mut().enumerate() {
50            let idx = start + k as isize;
51            *slot = if idx >= 0 && (idx as usize) < x.len() { x[idx as usize] * win[k] } else { 0.0 };
52        }
53        fft.process_with_scratch(&mut input, &mut out, &mut scratch).expect("fft sizes match");
54        frames.push(out.clone());
55    }
56    frames
57}
58
59/// Magnitude STFT (see [`stft_complex`] for framing).
60pub fn stft_mag(x: &[f32], n_fft: usize, hop: usize) -> Vec<Vec<f32>> {
61    stft_complex(x, n_fft, hop).into_iter().map(|fr| fr.iter().map(|c| c.norm()).collect()).collect()
62}
63
64/// Welch power spectrum: mean of `|X|^2` over Hann-windowed frames with 50% overlap,
65/// each frame mean-removed (so DC offset does not leak into low bins), summed over the
66/// given channels and averaged. Returns `(power, scale)` where `power[k] * scale` is the
67/// mean-square contribution of bin `k` (so summing over bins gives the signal's mean
68/// square, excluding DC).
69pub fn welch_power(channels: &[&[f32]], n_fft: usize) -> (Vec<f64>, f64) {
70    let mut planner = RealFftPlanner::<f32>::new();
71    let fft = planner.plan_fft_forward(n_fft);
72    let win = hann(n_fft);
73    let wss: f64 = win.iter().map(|w| (*w as f64).powi(2)).sum();
74    let hop = n_fft / 2;
75    let nb = n_fft / 2 + 1;
76    let mut acc = vec![0.0f64; nb];
77    let mut count = 0usize;
78    let mut input = fft.make_input_vec();
79    let mut out = fft.make_output_vec();
80    let mut scratch = fft.make_scratch_vec();
81    for ch in channels {
82        let n = ch.len();
83        if n == 0 {
84            continue;
85        }
86        let starts: Vec<usize> = if n < n_fft { vec![0] } else { (0..=(n - n_fft) / hop).map(|i| i * hop).collect() };
87        for start in starts {
88            let end = (start + n_fft).min(n);
89            let len = end - start;
90            let mean: f64 = ch[start..end].iter().map(|v| *v as f64).sum::<f64>() / len as f64;
91            for k in 0..n_fft {
92                input[k] = if k < len { ((ch[start + k] as f64 - mean) as f32) * win[k] } else { 0.0 };
93            }
94            fft.process_with_scratch(&mut input, &mut out, &mut scratch).expect("fft sizes match");
95            for (a, c) in acc.iter_mut().zip(out.iter()) {
96                *a += c.norm_sqr() as f64;
97            }
98            count += 1;
99        }
100    }
101    if count > 0 {
102        for a in acc.iter_mut() {
103            *a /= count as f64;
104        }
105    }
106    // one-sided: double all but DC and Nyquist; normalise by N*sum(w^2)
107    let scale = 2.0 / (n_fft as f64 * wss);
108    (acc, scale)
109}
110
111/// A biquad section (direct form I, f64 state).
112#[derive(Clone, Copy, Debug)]
113pub struct Biquad {
114    pub b0: f64,
115    pub b1: f64,
116    pub b2: f64,
117    pub a1: f64,
118    pub a2: f64,
119}
120
121impl Biquad {
122    /// RBJ cookbook low-pass.
123    pub fn lowpass(sr: f64, f0: f64, q: f64) -> Biquad {
124        let w = 2.0 * PI * (f0 / sr).min(0.49);
125        let (s, c) = w.sin_cos();
126        let alpha = s / (2.0 * q);
127        let a0 = 1.0 + alpha;
128        Biquad {
129            b0: (1.0 - c) / 2.0 / a0,
130            b1: (1.0 - c) / a0,
131            b2: (1.0 - c) / 2.0 / a0,
132            a1: -2.0 * c / a0,
133            a2: (1.0 - alpha) / a0,
134        }
135    }
136
137    /// RBJ cookbook high-pass.
138    pub fn highpass(sr: f64, f0: f64, q: f64) -> Biquad {
139        let w = 2.0 * PI * (f0 / sr).min(0.49);
140        let (s, c) = w.sin_cos();
141        let alpha = s / (2.0 * q);
142        let a0 = 1.0 + alpha;
143        Biquad {
144            b0: (1.0 + c) / 2.0 / a0,
145            b1: -(1.0 + c) / a0,
146            b2: (1.0 + c) / 2.0 / a0,
147            a1: -2.0 * c / a0,
148            a2: (1.0 - alpha) / a0,
149        }
150    }
151
152    /// Filter a signal (fresh state), returning f32.
153    pub fn run(&self, x: &[f32]) -> Vec<f32> {
154        let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0, 0.0, 0.0);
155        x.iter()
156            .map(|&v| {
157                let v = v as f64;
158                let y = self.b0 * v + self.b1 * x1 + self.b2 * x2 - self.a1 * y1 - self.a2 * y2;
159                x2 = x1;
160                x1 = v;
161                y2 = y1;
162                y1 = y;
163                y as f32
164            })
165            .collect()
166    }
167
168    /// Filter a signal (fresh state), returning f64.
169    pub fn run_f64(&self, x: &[f64]) -> Vec<f64> {
170        let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0, 0.0, 0.0);
171        x.iter()
172            .map(|&v| {
173                let y = self.b0 * v + self.b1 * x1 + self.b2 * x2 - self.a1 * y1 - self.a2 * y2;
174                x2 = x1;
175                x1 = v;
176                y2 = y1;
177                y1 = y;
178                y
179            })
180            .collect()
181    }
182}
183
184/// 4th-order Butterworth low-pass (two cascaded biquads).
185pub fn lowpass4(x: &[f32], sr: f64, f0: f64) -> Vec<f32> {
186    let a = Biquad::lowpass(sr, f0, 0.541_196_100_146_197);
187    let b = Biquad::lowpass(sr, f0, 1.306_562_964_876_376_4);
188    b.run(&a.run(x))
189}
190
191/// 4th-order Butterworth high-pass (two cascaded biquads).
192pub fn highpass4(x: &[f32], sr: f64, f0: f64) -> Vec<f32> {
193    let a = Biquad::highpass(sr, f0, 0.541_196_100_146_197);
194    let b = Biquad::highpass(sr, f0, 1.306_562_964_876_376_4);
195    b.run(&a.run(x))
196}
197
198/// Hz → mel (HTK formula).
199pub fn hz_to_mel(f: f64) -> f64 {
200    2595.0 * (1.0 + f / 700.0).log10()
201}
202
203/// Mel → Hz (HTK formula).
204pub fn mel_to_hz(m: f64) -> f64 {
205    700.0 * (10f64.powf(m / 2595.0) - 1.0)
206}
207
208/// Triangular mel filterbank: `n_mels` filters over `[fmin, fmax]` for an `n_fft` FFT.
209/// Each filter is a sparse list of `(bin, weight)`; every filter covers at least one bin.
210pub fn mel_filterbank(sr: f64, n_fft: usize, n_mels: usize, fmin: f64, fmax: f64) -> Vec<Vec<(usize, f32)>> {
211    let nb = n_fft / 2 + 1;
212    let df = sr / n_fft as f64;
213    let fmax = fmax.min(sr / 2.0);
214    let (m0, m1) = (hz_to_mel(fmin), hz_to_mel(fmax));
215    let pts: Vec<f64> = (0..n_mels + 2).map(|i| mel_to_hz(m0 + (m1 - m0) * i as f64 / (n_mels + 1) as f64)).collect();
216    (0..n_mels)
217        .map(|m| {
218            let (lo, c, hi) = (pts[m], pts[m + 1], pts[m + 2]);
219            let mut f = Vec::new();
220            for k in 0..nb {
221                let fk = k as f64 * df;
222                let w = if fk > lo && fk <= c {
223                    (fk - lo) / (c - lo)
224                } else if fk > c && fk < hi {
225                    (hi - fk) / (hi - c)
226                } else {
227                    0.0
228                };
229                if w > 0.0 {
230                    f.push((k, w as f32));
231                }
232            }
233            if f.is_empty() {
234                let k = ((c / df).round() as usize).min(nb - 1);
235                f.push((k, 1.0));
236            }
237            f
238        })
239        .collect()
240}
241
242/// Apply a sparse filterbank to one magnitude/power frame.
243pub fn apply_bank(bank: &[Vec<(usize, f32)>], frame: &[f32]) -> Vec<f32> {
244    bank.iter().map(|f| f.iter().map(|(k, w)| frame[*k] * w).sum()).collect()
245}
246
247/// Type-II DCT (orthonormal) of `x`, first `n_out` coefficients.
248pub fn dct2(x: &[f32], n_out: usize) -> Vec<f32> {
249    let n = x.len();
250    (0..n_out)
251        .map(|k| {
252            let s: f64 = x
253                .iter()
254                .enumerate()
255                .map(|(i, v)| *v as f64 * (PI * k as f64 * (i as f64 + 0.5) / n as f64).cos())
256                .sum();
257            let norm = if k == 0 { (1.0 / n as f64).sqrt() } else { (2.0 / n as f64).sqrt() };
258            (s * norm) as f32
259        })
260        .collect()
261}
262
263/// Full cross-correlation of `a` and `b` via FFT, searched over lags `-max_lag..=max_lag`.
264/// Returns `(lag, value, norm_value)` of the peak absolute correlation where a positive
265/// lag means `b` is delayed relative to `a` (`b[n] ≈ a[n - lag]`). `norm_value` is
266/// normalised by the signal energies (−1..1).
267pub fn xcorr_peak(a: &[f32], b: &[f32], max_lag: usize) -> (isize, f64, f64) {
268    if a.is_empty() || b.is_empty() {
269        return (0, 0.0, 0.0);
270    }
271    let n = next_pow2(a.len() + b.len());
272    let mut planner = RealFftPlanner::<f64>::new();
273    let fwd = planner.plan_fft_forward(n);
274    let inv = planner.plan_fft_inverse(n);
275    let mut ia = fwd.make_input_vec();
276    let mut ib = fwd.make_input_vec();
277    for (s, v) in ia.iter_mut().zip(a) {
278        *s = *v as f64;
279    }
280    for (s, v) in ib.iter_mut().zip(b) {
281        *s = *v as f64;
282    }
283    let mut fa = fwd.make_output_vec();
284    let mut fb = fwd.make_output_vec();
285    fwd.process(&mut ia, &mut fa).expect("fft");
286    fwd.process(&mut ib, &mut fb).expect("fft");
287    let mut prod: Vec<Complex<f64>> = fa.iter().zip(&fb).map(|(x, y)| x.conj() * y).collect();
288    // DC and Nyquist bins must be real for the inverse real FFT
289    if let Some(f) = prod.first_mut() {
290        f.im = 0.0;
291    }
292    if let Some(l) = prod.last_mut() {
293        l.im = 0.0;
294    }
295    let mut c = inv.make_output_vec();
296    inv.process(&mut prod, &mut c).expect("ifft");
297    let scale = 1.0 / n as f64;
298    let max_pos = max_lag.min(b.len().saturating_sub(1));
299    let max_neg = max_lag.min(a.len().saturating_sub(1));
300    let mut best = (0isize, 0.0f64);
301    for (lag, cv) in c.iter().enumerate().take(max_pos + 1) {
302        let v = cv * scale;
303        if v.abs() > best.1.abs() {
304            best = (lag as isize, v);
305        }
306    }
307    for lag in 1..=max_neg {
308        let v = c[n - lag] * scale;
309        if v.abs() > best.1.abs() {
310            best = (-(lag as isize), v);
311        }
312    }
313    let ea: f64 = a.iter().map(|v| (*v as f64).powi(2)).sum();
314    let eb: f64 = b.iter().map(|v| (*v as f64).powi(2)).sum();
315    let norm = (ea * eb).sqrt();
316    (best.0, best.1, if norm > 0.0 { best.1 / norm } else { 0.0 })
317}
318
319/// Percentile (0..100) of a slice by linear interpolation (copies and sorts).
320pub fn percentile(x: &[f64], p: f64) -> f64 {
321    if x.is_empty() {
322        return f64::NAN;
323    }
324    let mut v: Vec<f64> = x.to_vec();
325    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
326    let pos = (p / 100.0).clamp(0.0, 1.0) * (v.len() - 1) as f64;
327    let lo = pos.floor() as usize;
328    let hi = pos.ceil() as usize;
329    v[lo] + (v[hi] - v[lo]) * (pos - lo as f64)
330}
331
332/// Median of a slice.
333pub fn median(x: &[f64]) -> f64 {
334    percentile(x, 50.0)
335}
336
337/// Mean of a slice (0 when empty).
338pub fn mean(x: &[f64]) -> f64 {
339    if x.is_empty() {
340        0.0
341    } else {
342        x.iter().sum::<f64>() / x.len() as f64
343    }
344}
345
346/// Population standard deviation.
347pub fn std_dev(x: &[f64]) -> f64 {
348    if x.len() < 2 {
349        return 0.0;
350    }
351    let m = mean(x);
352    (x.iter().map(|v| (v - m).powi(2)).sum::<f64>() / x.len() as f64).sqrt()
353}
354
355/// Pearson correlation of two equal-length vectors.
356pub fn pearson(a: &[f64], b: &[f64]) -> f64 {
357    let n = a.len().min(b.len());
358    if n < 2 {
359        return 0.0;
360    }
361    let (ma, mb) = (mean(&a[..n]), mean(&b[..n]));
362    let (mut sab, mut saa, mut sbb) = (0.0, 0.0, 0.0);
363    for i in 0..n {
364        let (x, y) = (a[i] - ma, b[i] - mb);
365        sab += x * y;
366        saa += x * x;
367        sbb += y * y;
368    }
369    if saa <= 0.0 || sbb <= 0.0 {
370        return if saa <= 0.0 && sbb <= 0.0 { 1.0 } else { 0.0 };
371    }
372    sab / (saa * sbb).sqrt()
373}
374
375/// Round to a tidy number of digits for JSON output: 4 decimals, or 4 significant digits
376/// for small magnitudes, at most 7 decimals (so values below 5e-8 become 0). Non-finite
377/// values pass through (they serialise as `null`).
378pub fn tidy(x: f64) -> f64 {
379    if !x.is_finite() {
380        return x;
381    }
382    if x == 0.0 {
383        return 0.0;
384    }
385    let mag = x.abs().log10().floor() as i32;
386    let decimals = (3 - mag).clamp(4, 7);
387    let f = 10f64.powi(decimals);
388    let r = (x * f).round() / f;
389    if r == 0.0 {
390        0.0
391    } else {
392        r
393    }
394}
395
396/// Recursively round every float in a JSON value with [`tidy`].
397pub fn tidy_json(v: &mut serde_json::Value) {
398    match v {
399        serde_json::Value::Number(n) => {
400            if n.is_f64() {
401                if let Some(f) = n.as_f64() {
402                    if let Some(t) = serde_json::Number::from_f64(tidy(f)) {
403                        *n = t;
404                    }
405                }
406            }
407        }
408        serde_json::Value::Array(a) => a.iter_mut().for_each(tidy_json),
409        serde_json::Value::Object(o) => o.values_mut().for_each(tidy_json),
410        _ => {}
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn xcorr_finds_delay() {
420        let a: Vec<f32> = (0..2000).map(|i| ((i * 7919) % 101) as f32 / 50.0 - 1.0).collect();
421        let mut b = vec![0.0f32; 37];
422        b.extend_from_slice(&a[..2000 - 37]);
423        let (lag, _, norm) = xcorr_peak(&a, &b, 200);
424        assert_eq!(lag, 37);
425        assert!(norm > 0.9);
426        let (lag, _, _) = xcorr_peak(&b, &a, 200);
427        assert_eq!(lag, -37);
428    }
429
430    #[test]
431    fn welch_parseval_sine() {
432        let sr = 48000.0;
433        let x: Vec<f32> = (0..48000).map(|i| (0.5 * (2.0 * PI * 1000.0 * i as f64 / sr).sin()) as f32).collect();
434        let (p, scale) = welch_power(&[&x], 4096);
435        let ms: f64 = p.iter().map(|v| v * scale).sum();
436        assert!((ms - 0.125).abs() < 0.005, "{ms}");
437    }
438
439    #[test]
440    fn tidy_rounds() {
441        assert_eq!(tidy(-23.012345678), -23.0123);
442        assert_eq!(tidy(0.000123456789), 0.0001235);
443    }
444}