Skip to main content

ferraus_core/
testing.rs

1//! Test helpers shared by every crate: synthetic signals generated by code (never binary
2//! fixtures in git), a ready-made context, and comparison metrics.
3
4use crate::buffer::AudioBuffer;
5use crate::ctx::{Globals, NoProgress, OpCtx};
6use crate::op::{Args, OpSpec};
7use crate::registry::Registry;
8use crate::wav::SimpleIo;
9use std::f64::consts::PI;
10
11pub static SIMPLE_IO: SimpleIo = SimpleIo;
12pub static NO_PROGRESS: NoProgress = NoProgress;
13
14/// A context with no inputs, SimpleIo, and no progress output.
15pub fn ctx() -> OpCtx<'static> {
16    OpCtx::new("test", vec![], Globals::default(), &SIMPLE_IO, &NO_PROGRESS)
17}
18
19/// A context with a registry attached (for composite ops).
20pub fn ctx_with(registry: &'static Registry) -> OpCtx<'static> {
21    let mut c = ctx();
22    c.registry = Some(registry);
23    c
24}
25
26/// Leak a registry for tests that need `'static`.
27pub fn leak_registry(ops: Vec<OpSpec>) -> &'static Registry {
28    let mut r = Registry::new();
29    r.extend(ops);
30    Box::leak(Box::new(r))
31}
32
33/// Build args for `spec` from `key=value` pairs (defaults applied).
34pub fn args(spec: &OpSpec, pairs: &[(&str, &str)]) -> Args {
35    Args::build(spec, pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), &|_, _| None)
36        .expect("valid test args")
37}
38
39/// Run a transform spec directly.
40pub fn run(spec: &OpSpec, pairs: &[(&str, &str)], input: AudioBuffer) -> crate::error::Result<AudioBuffer> {
41    let a = args(spec, pairs);
42    let mut c = ctx();
43    crate::exec::apply_spec(spec, &a, input, &mut c)
44}
45
46/// Run a transform spec and also return the context (to assert on metrics, notes, warnings).
47pub fn run_ctx(
48    spec: &OpSpec,
49    pairs: &[(&str, &str)],
50    input: AudioBuffer,
51) -> crate::error::Result<(AudioBuffer, OpCtx<'static>)> {
52    let a = args(spec, pairs);
53    let mut c = ctx();
54    let out = crate::exec::apply_spec(spec, &a, input, &mut c)?;
55    Ok((out, c))
56}
57
58pub fn sine(sr: u32, freq: f64, secs: f64, amp: f32) -> AudioBuffer {
59    let n = (sr as f64 * secs).round() as usize;
60    AudioBuffer::from_mono(sr, (0..n).map(|i| amp * (2.0 * PI * freq * i as f64 / sr as f64).sin() as f32).collect())
61}
62
63pub fn stereo(b: AudioBuffer) -> AudioBuffer {
64    b.with_channels(2)
65}
66
67/// Deterministic white noise in [-amp, amp].
68pub fn noise(sr: u32, secs: f64, amp: f32, seed: u64) -> AudioBuffer {
69    let n = (sr as f64 * secs).round() as usize;
70    let mut x = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407) | 1;
71    AudioBuffer::from_mono(
72        sr,
73        (0..n)
74            .map(|_| {
75                x ^= x << 13;
76                x ^= x >> 7;
77                x ^= x << 17;
78                amp * ((x >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0) as f32
79            })
80            .collect(),
81    )
82}
83
84/// Linear chirp from f0 to f1.
85pub fn chirp(sr: u32, f0: f64, f1: f64, secs: f64, amp: f32) -> AudioBuffer {
86    let n = (sr as f64 * secs).round() as usize;
87    let k = (f1 - f0) / secs;
88    AudioBuffer::from_mono(
89        sr,
90        (0..n)
91            .map(|i| {
92                let t = i as f64 / sr as f64;
93                amp * (2.0 * PI * (f0 * t + 0.5 * k * t * t)).sin() as f32
94            })
95            .collect(),
96    )
97}
98
99/// Click train: single-sample impulses every `period` seconds.
100pub fn clicks(sr: u32, period: f64, secs: f64, amp: f32) -> AudioBuffer {
101    let n = (sr as f64 * secs).round() as usize;
102    let step = (period * sr as f64).round() as usize;
103    let mut v = vec![0.0; n];
104    let mut i = 0;
105    while i < n {
106        v[i] = amp;
107        i += step.max(1);
108    }
109    AudioBuffer::from_mono(sr, v)
110}
111
112/// Sum two buffers (shorter one zero-padded).
113pub fn add(a: &AudioBuffer, b: &AudioBuffer) -> AudioBuffer {
114    let n = a.frames().max(b.frames());
115    let ch = a.channels().max(b.channels());
116    let a2 = a.with_channels(ch);
117    let b2 = b.with_channels(ch);
118    let data = (0..ch)
119        .map(|c| {
120            (0..n)
121                .map(|i| a2.data[c].get(i).copied().unwrap_or(0.0) + b2.data[c].get(i).copied().unwrap_or(0.0))
122                .collect()
123        })
124        .collect();
125    AudioBuffer { sample_rate: a.sample_rate, data }
126}
127
128/// RMS of the difference (over the common length).
129pub fn rms_diff(a: &AudioBuffer, b: &AudioBuffer) -> f64 {
130    let n = a.frames().min(b.frames());
131    let ch = a.channels().min(b.channels());
132    let mut s = 0.0;
133    for c in 0..ch {
134        for i in 0..n {
135            let d = (a.data[c][i] - b.data[c][i]) as f64;
136            s += d * d;
137        }
138    }
139    (s / (n * ch).max(1) as f64).sqrt()
140}
141
142/// RMS of a slice in dBFS.
143pub fn rms_db(x: &[f32]) -> f64 {
144    let s: f64 = x.iter().map(|v| (*v as f64).powi(2)).sum::<f64>() / x.len().max(1) as f64;
145    crate::buffer::lin_to_db(s.sqrt())
146}
147
148/// Magnitude (linear) of frequency `f` in `x` using a Goertzel filter, normalized so a
149/// full-scale sine reads 1.0.
150pub fn tone_level(x: &[f32], sr: u32, f: f64) -> f64 {
151    let n = x.len();
152    if n == 0 {
153        return 0.0;
154    }
155    // Hann window to reduce leakage
156    let w = 2.0 * PI * f / sr as f64;
157    let coeff = 2.0 * w.cos();
158    let (mut s1, mut s2) = (0.0f64, 0.0f64);
159    let mut wsum = 0.0;
160    for (i, v) in x.iter().enumerate() {
161        let win = 0.5 - 0.5 * (2.0 * PI * i as f64 / (n - 1).max(1) as f64).cos();
162        wsum += win;
163        let s0 = *v as f64 * win + coeff * s1 - s2;
164        s2 = s1;
165        s1 = s0;
166    }
167    let power = s1 * s1 + s2 * s2 - coeff * s1 * s2;
168    2.0 * power.max(0.0).sqrt() / wsum
169}
170
171/// Scale-invariant SDR of `estimate` against `reference`, in dB.
172pub fn si_sdr(reference: &[f32], estimate: &[f32]) -> f64 {
173    let n = reference.len().min(estimate.len());
174    let r = &reference[..n];
175    let e = &estimate[..n];
176    let dot: f64 = r.iter().zip(e).map(|(a, b)| *a as f64 * *b as f64).sum();
177    let rr: f64 = r.iter().map(|a| (*a as f64).powi(2)).sum::<f64>().max(1e-20);
178    let alpha = dot / rr;
179    let mut st = 0.0;
180    let mut en = 0.0;
181    for i in 0..n {
182        let t = alpha * r[i] as f64;
183        st += t * t;
184        en += (e[i] as f64 - t).powi(2);
185    }
186    10.0 * (st.max(1e-20) / en.max(1e-20)).log10()
187}
188
189/// A temp directory unique to the test, removed on drop.
190pub struct TempDir(pub std::path::PathBuf);
191
192impl TempDir {
193    pub fn new(name: &str) -> TempDir {
194        let p = std::env::temp_dir().join(format!(
195            "ferraus-test-{}-{}-{}",
196            name,
197            std::process::id(),
198            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
199        ));
200        std::fs::create_dir_all(&p).unwrap();
201        TempDir(p)
202    }
203    pub fn path(&self, f: &str) -> std::path::PathBuf {
204        self.0.join(f)
205    }
206}
207
208impl Drop for TempDir {
209    fn drop(&mut self) {
210        let _ = std::fs::remove_dir_all(&self.0);
211    }
212}
213
214/// Write a float WAV for tests.
215pub fn write_wav(path: &std::path::Path, b: &AudioBuffer) {
216    std::fs::write(path, crate::wav::encode(b, 32, true).unwrap()).unwrap();
217}
218
219pub fn read_wav(path: &std::path::Path) -> AudioBuffer {
220    crate::wav::decode(&std::fs::read(path).unwrap()).unwrap().0
221}