Skip to main content

ferraus_analysis/
compare.rs

1//! Null-test comparison (`compare a b`) and perceptual similarity (`analyze similarity`).
2
3use crate::util::{self, amp_db, pow_db};
4use ferraus_core::resample::{resample, Quality};
5use ferraus_core::AudioBuffer;
6use serde::Serialize;
7
8/// Options for [`compare`].
9#[derive(Clone, Debug)]
10pub struct CompareOptions {
11    /// Largest offset searched when aligning `b` to `a` (seconds).
12    pub max_offset_s: f64,
13    /// Skip alignment (compare sample-for-sample).
14    pub no_align: bool,
15}
16
17impl Default for CompareOptions {
18    fn default() -> Self {
19        CompareOptions { max_offset_s: 1.0, no_align: false }
20    }
21}
22
23/// Null-test metrics of `b` against reference `a`.
24#[derive(Clone, Debug, Serialize)]
25pub struct CompareReport {
26    /// Samples by which `b` lags `a` (negative: `b` leads). Found by FFT cross-correlation.
27    pub offset_samples: i64,
28    pub offset_s: f64,
29    /// Normalised cross-correlation at the chosen offset (−1..1).
30    pub alignment_correlation: f64,
31    /// `b` is polarity-inverted relative to `a`.
32    pub polarity_inverted: bool,
33    /// Frames compared after alignment.
34    pub compared_frames: usize,
35    /// Duration of `b` minus duration of `a` (seconds).
36    pub length_difference_s: f64,
37    /// RMS of `a − b` over the aligned region (dBFS, floored at −240).
38    pub rms_diff_db: f64,
39    /// `rms_diff_db` relative to the RMS of `a` (how deep the null is).
40    pub null_depth_db: f64,
41    /// Peak of `a − b` (dBFS).
42    pub diff_peak_db: f64,
43    /// Peak of `b` minus peak of `a` (dB).
44    pub peak_delta_db: f64,
45    /// RMS of `b` minus RMS of `a` (dB).
46    pub rms_delta_db: f64,
47    /// Integrated loudness of `b` minus `a` (LU); null when either is unmeasurable.
48    pub loudness_delta_lu: f64,
49    /// Least-squares gain that best maps `a` onto `b` (dB).
50    pub gain_db: f64,
51    /// Scale-invariant SDR of `b` against `a` (dB, capped at 200).
52    pub si_sdr_db: f64,
53    /// Same length, zero offset, and bit-identical samples.
54    pub identical: bool,
55    /// `identical`, `transparent` (null below −90 dB), `gain_only`, `similar`, or `different`.
56    pub verdict: String,
57    #[serde(skip_serializing_if = "Vec::is_empty")]
58    pub notes: Vec<String>,
59}
60
61/// Bring `b` to `a`'s sample rate and channel count, noting what changed.
62fn conform(a: &AudioBuffer, b: &AudioBuffer, notes: &mut Vec<String>) -> AudioBuffer {
63    let mut b2 = b.clone();
64    if b2.sample_rate != a.sample_rate {
65        notes.push(format!("resampled b from {} Hz to {} Hz", b2.sample_rate, a.sample_rate));
66        b2 = resample(&b2, a.sample_rate, Quality::High);
67    }
68    if b2.channels() != a.channels() {
69        notes.push(format!("converted b from {} to {} channel(s)", b2.channels(), a.channels()));
70        b2 = b2.with_channels(a.channels());
71    }
72    b2
73}
74
75/// Offset (samples) by which `b` lags `a`, from FFT cross-correlation of the mono mixes
76/// over at most the first 30 s: returns `(lag, normalised_correlation)`.
77pub fn find_offset(a: &AudioBuffer, b: &AudioBuffer, max_offset_s: f64) -> (i64, f64) {
78    let sr = a.sample_rate as f64;
79    let max_lag = (max_offset_s.max(0.0) * sr).round() as usize;
80    let span = (30.0 * sr) as usize + max_lag;
81    let ma = a.mono_mix();
82    let mb = b.mono_mix();
83    let la = ma.len().min(span);
84    let lb = mb.len().min(span);
85    let (lag, _, norm) = util::xcorr_peak(&ma[..la], &mb[..lb], max_lag);
86    (lag as i64, norm)
87}
88
89fn si_sdr(reference: &[f64], estimate: &[f64]) -> f64 {
90    let dot: f64 = reference.iter().zip(estimate).map(|(r, e)| r * e).sum();
91    let rr: f64 = reference.iter().map(|r| r * r).sum();
92    if rr <= 1e-30 {
93        return if estimate.iter().all(|e| *e == 0.0) { 200.0 } else { -200.0 };
94    }
95    let alpha = dot / rr;
96    let (mut st, mut en) = (0.0f64, 0.0f64);
97    for (r, e) in reference.iter().zip(estimate) {
98        let t = alpha * r;
99        st += t * t;
100        en += (e - t).powi(2);
101    }
102    if en <= 0.0 {
103        return 200.0;
104    }
105    if st <= 0.0 {
106        return -200.0;
107    }
108    (10.0 * (st / en).log10()).clamp(-200.0, 200.0)
109}
110
111/// Compare `b` against reference `a`: align by cross-correlation, then measure the null.
112pub fn compare(a: &AudioBuffer, b: &AudioBuffer, opts: &CompareOptions) -> CompareReport {
113    let mut notes = Vec::new();
114    let b = conform(a, b, &mut notes);
115    let (lag, corr) = if opts.no_align { (0, 0.0) } else { find_offset(a, &b, opts.max_offset_s) };
116    // aligned region: a[i] vs b[i + lag]
117    let (a0, b0) = if lag >= 0 { (0usize, lag as usize) } else { ((-lag) as usize, 0usize) };
118    let len = a.frames().saturating_sub(a0).min(b.frames().saturating_sub(b0));
119    let ch = a.channels();
120    let mut ra = Vec::with_capacity(len * ch);
121    let mut rb = Vec::with_capacity(len * ch);
122    for c in 0..ch {
123        ra.extend(a.data[c][a0..a0 + len].iter().map(|v| *v as f64));
124        rb.extend(b.data[c][b0..b0 + len].iter().map(|v| *v as f64));
125    }
126    let n = ra.len().max(1) as f64;
127    let (mut sd, mut pd, mut sa, mut sb, mut ab) = (0.0f64, 0.0f64, 0.0f64, 0.0f64, 0.0f64);
128    for (x, y) in ra.iter().zip(&rb) {
129        let d = x - y;
130        sd += d * d;
131        pd = pd.max(d.abs());
132        sa += x * x;
133        sb += y * y;
134        ab += x * y;
135    }
136    let rms_diff_db = pow_db(sd / n);
137    let rms_a_db = pow_db(sa / n);
138    let la = crate::loudness::integrated_lufs(a);
139    let lb = crate::loudness::integrated_lufs(&b);
140    let gain = if sa > 0.0 { ab / sa } else { 0.0 };
141    let si = si_sdr(&ra, &rb);
142    let identical = lag == 0 && a.frames() == b.frames() && pd == 0.0 && notes.is_empty();
143    let null_depth = if sa > 0.0 { rms_diff_db - rms_a_db } else { rms_diff_db };
144    let gain_db = amp_db(gain.abs());
145    let verdict = if identical {
146        "identical"
147    } else if null_depth <= -90.0 {
148        "transparent"
149    } else if si >= 60.0 {
150        "gain_only"
151    } else if si >= 20.0 {
152        "similar"
153    } else {
154        "different"
155    };
156    CompareReport {
157        offset_samples: lag,
158        offset_s: lag as f64 / a.sample_rate as f64,
159        alignment_correlation: corr,
160        polarity_inverted: gain < 0.0,
161        compared_frames: len,
162        length_difference_s: b.duration_s() - a.duration_s(),
163        rms_diff_db,
164        null_depth_db: null_depth,
165        diff_peak_db: amp_db(pd),
166        peak_delta_db: amp_db(b.peak() as f64) - amp_db(a.peak() as f64),
167        rms_delta_db: pow_db(sb / n) - rms_a_db,
168        loudness_delta_lu: if la.is_finite() && lb.is_finite() { lb - la } else { f64::NAN },
169        gain_db,
170        si_sdr_db: si,
171        identical,
172        verdict: verdict.into(),
173        notes,
174    }
175}
176
177// ---------------------------------------------------------------------------------
178// Similarity
179// ---------------------------------------------------------------------------------
180
181/// Result of [`similarity`].
182#[derive(Clone, Debug, Serialize)]
183pub struct SimilarityReport {
184    /// Overall similarity 0..1 (1 = indistinguishable by these measures).
185    pub score: f64,
186    /// Cosine similarity of the mean-removed log third-octave spectra (clamped to 0..1).
187    pub spectral_similarity: f64,
188    /// RMS difference of the level-matched third-octave spectra (dB).
189    pub spectral_distance_db: f64,
190    /// Integrated loudness of `b` minus `a` (LU).
191    pub loudness_difference_lu: f64,
192    /// Euclidean distance between mean MFCC vectors (c1..c12, dB scale).
193    pub mfcc_distance: f64,
194    pub mfcc_similarity: f64,
195    /// `same` (score ≥ 0.9), `similar` (≥ 0.7), or `different`.
196    pub verdict: String,
197}
198
199/// Mel-frequency cepstral coefficients per frame (`n_coeffs` including c0), computed from
200/// 40 mel bands of a 46 ms STFT with 50% overlap; band energies in dB.
201pub fn mfcc(buf: &AudioBuffer, n_coeffs: usize) -> Vec<Vec<f32>> {
202    let sr = buf.sample_rate as f64;
203    let x = buf.mono_mix();
204    let n_fft = util::next_pow2((sr * 0.046) as usize).max(256);
205    let hop = n_fft / 2;
206    let bank = util::mel_filterbank(sr, n_fft, 40, 30.0, 16000.0f64.min(sr / 2.0));
207    let frames = util::stft_mag(&x, n_fft, hop);
208    let energies: Vec<f32> = frames.iter().map(|f| f.iter().map(|m| m * m).sum()).collect();
209    let emax = energies.iter().copied().fold(0.0f32, f32::max);
210    frames
211        .iter()
212        .zip(&energies)
213        .filter(|(_, e)| emax > 0.0 && **e > emax * 1e-6)
214        .map(|(f, _)| {
215            let p: Vec<f32> = f.iter().map(|m| m * m).collect();
216            let mel: Vec<f32> = util::apply_bank(&bank, &p).into_iter().map(|e| 10.0 * (e + 1e-10).log10()).collect();
217            util::dct2(&mel, n_coeffs)
218        })
219        .collect()
220}
221
222fn mean_mfcc(buf: &AudioBuffer) -> Option<Vec<f64>> {
223    let m = mfcc(buf, 13);
224    if m.is_empty() {
225        return None;
226    }
227    let mut acc = vec![0.0f64; 12];
228    for f in &m {
229        for (a, v) in acc.iter_mut().zip(&f[1..]) {
230            *a += *v as f64;
231        }
232    }
233    Some(acc.into_iter().map(|v| v / m.len() as f64).collect())
234}
235
236/// Spectral, cepstral, and loudness similarity between two buffers.
237pub fn similarity(a: &AudioBuffer, b: &AudioBuffer) -> SimilarityReport {
238    let sa = crate::spectrum::third_octave_levels(a);
239    let sb = crate::spectrum::third_octave_levels(b);
240    let pairs: Vec<(f64, f64)> = sa
241        .iter()
242        .filter_map(|x| sb.iter().find(|y| y.center_hz == x.center_hz).map(|y| (x.center_hz, x.db, y.db)))
243        .filter(|(c, x, y)| *c >= 31.0 && *c <= 16500.0 && (*x > -120.0 || *y > -120.0))
244        .map(|(_, x, y)| (x.max(-120.0), y.max(-120.0)))
245        .collect();
246    let (spec_sim, spec_dist) = if pairs.len() >= 3 {
247        let xa: Vec<f64> = pairs.iter().map(|p| p.0).collect();
248        let xb: Vec<f64> = pairs.iter().map(|p| p.1).collect();
249        let ma = util::mean(&xa);
250        let mb = util::mean(&xb);
251        let (mut dot, mut na, mut nb, mut dd) = (0.0, 0.0, 0.0, 0.0);
252        for (x, y) in xa.iter().zip(&xb) {
253            let (p, q) = (x - ma, y - mb);
254            dot += p * q;
255            na += p * p;
256            nb += q * q;
257            dd += (p - q).powi(2);
258        }
259        let cos = if na > 1e-12 && nb > 1e-12 {
260            dot / (na * nb).sqrt()
261        } else if na <= 1e-12 && nb <= 1e-12 {
262            1.0
263        } else {
264            0.0
265        };
266        (cos.clamp(0.0, 1.0), (dd / xa.len() as f64).sqrt())
267    } else {
268        (if pairs.is_empty() { 1.0 } else { 0.0 }, 0.0)
269    };
270    let la = crate::loudness::integrated_lufs(a);
271    let lb = crate::loudness::integrated_lufs(b);
272    let ldiff = if la.is_finite() && lb.is_finite() {
273        lb - la
274    } else if la.is_finite() != lb.is_finite() {
275        f64::INFINITY
276    } else {
277        0.0
278    };
279    let (mdist, msim) = match (mean_mfcc(a), mean_mfcc(b)) {
280        (Some(x), Some(y)) => {
281            let d = x.iter().zip(&y).map(|(p, q)| (p - q).powi(2)).sum::<f64>().sqrt();
282            (d, (-d / 25.0).exp())
283        }
284        (None, None) => (0.0, 1.0),
285        _ => (f64::INFINITY, 0.0),
286    };
287    let lsim = if ldiff.is_finite() { (-ldiff.abs() / 6.0).exp() } else { 0.0 };
288    let score = (0.45 * spec_sim + 0.4 * msim + 0.15 * lsim).clamp(0.0, 1.0);
289    let verdict = if score >= 0.9 {
290        "same"
291    } else if score >= 0.7 {
292        "similar"
293    } else {
294        "different"
295    };
296    SimilarityReport {
297        score,
298        spectral_similarity: spec_sim,
299        spectral_distance_db: spec_dist,
300        loudness_difference_lu: ldiff,
301        mfcc_distance: mdist,
302        mfcc_similarity: msim,
303        verdict: verdict.into(),
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use ferraus_core::testing::{add, chirp, noise, sine, stereo};
311
312    fn music_like() -> AudioBuffer {
313        let a = add(&sine(48000, 220.0, 2.0, 0.3), &noise(48000, 2.0, 0.05, 7));
314        stereo(add(&a, &chirp(48000, 300.0, 3000.0, 2.0, 0.2)))
315    }
316
317    #[test]
318    fn identical_files() {
319        let a = music_like();
320        let r = compare(&a, &a, &CompareOptions::default());
321        assert!(r.identical);
322        assert_eq!(r.offset_samples, 0);
323        assert_eq!(r.rms_diff_db, -240.0);
324        assert!(r.si_sdr_db >= 150.0, "{}", r.si_sdr_db);
325        assert_eq!(r.verdict, "identical");
326    }
327
328    #[test]
329    fn delayed_and_scaled() {
330        let a = music_like();
331        let mut b = AudioBuffer::new(48000, 2, 100);
332        b.append(&a);
333        b.scale(0.5);
334        let r = compare(&a, &b, &CompareOptions::default());
335        assert_eq!(r.offset_samples, 100);
336        assert!(r.si_sdr_db > 100.0, "{}", r.si_sdr_db);
337        assert!((r.gain_db + 6.02).abs() < 0.01, "{}", r.gain_db);
338        assert!(!r.polarity_inverted);
339        assert_eq!(r.verdict, "gain_only");
340        // b leading a
341        let r = compare(&b, &a, &CompareOptions::default());
342        assert_eq!(r.offset_samples, -100);
343        // polarity
344        let mut inv = a.clone();
345        inv.scale(-1.0);
346        let r = compare(&a, &inv, &CompareOptions::default());
347        assert!(r.polarity_inverted);
348        assert_eq!(r.offset_samples, 0);
349    }
350
351    #[test]
352    fn different_signals() {
353        let a = music_like();
354        let b = stereo(noise(48000, 2.0, 0.3, 99));
355        let r = compare(&a, &b, &CompareOptions::default());
356        assert_eq!(r.verdict, "different");
357        assert!(r.si_sdr_db < 0.0);
358    }
359
360    #[test]
361    fn similarity_scores() {
362        let a = music_like();
363        let mut a2 = a.clone();
364        a2.scale(0.9);
365        let s = similarity(&a, &a2);
366        assert!(s.score > 0.9, "{s:?}");
367        assert_eq!(s.verdict, "same");
368        let b = stereo(sine(48000, 5000.0, 2.0, 0.1));
369        let s = similarity(&a, &b);
370        assert!(s.score < 0.6, "{s:?}");
371        assert_eq!(s.verdict, "different");
372    }
373}