ferraus-core 0.1.0

Core types for ferraus: audio buffers, unit parsing, operation specs, pipelines, provenance
Documentation
//! Test helpers shared by every crate: synthetic signals generated by code (never binary
//! fixtures in git), a ready-made context, and comparison metrics.

use crate::buffer::AudioBuffer;
use crate::ctx::{Globals, NoProgress, OpCtx};
use crate::op::{Args, OpSpec};
use crate::registry::Registry;
use crate::wav::SimpleIo;
use std::f64::consts::PI;

pub static SIMPLE_IO: SimpleIo = SimpleIo;
pub static NO_PROGRESS: NoProgress = NoProgress;

/// A context with no inputs, SimpleIo, and no progress output.
pub fn ctx() -> OpCtx<'static> {
    OpCtx::new("test", vec![], Globals::default(), &SIMPLE_IO, &NO_PROGRESS)
}

/// A context with a registry attached (for composite ops).
pub fn ctx_with(registry: &'static Registry) -> OpCtx<'static> {
    let mut c = ctx();
    c.registry = Some(registry);
    c
}

/// Leak a registry for tests that need `'static`.
pub fn leak_registry(ops: Vec<OpSpec>) -> &'static Registry {
    let mut r = Registry::new();
    r.extend(ops);
    Box::leak(Box::new(r))
}

/// Build args for `spec` from `key=value` pairs (defaults applied).
pub fn args(spec: &OpSpec, pairs: &[(&str, &str)]) -> Args {
    Args::build(spec, pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), &|_, _| None)
        .expect("valid test args")
}

/// Run a transform spec directly.
pub fn run(spec: &OpSpec, pairs: &[(&str, &str)], input: AudioBuffer) -> crate::error::Result<AudioBuffer> {
    let a = args(spec, pairs);
    let mut c = ctx();
    crate::exec::apply_spec(spec, &a, input, &mut c)
}

/// Run a transform spec and also return the context (to assert on metrics, notes, warnings).
pub fn run_ctx(
    spec: &OpSpec,
    pairs: &[(&str, &str)],
    input: AudioBuffer,
) -> crate::error::Result<(AudioBuffer, OpCtx<'static>)> {
    let a = args(spec, pairs);
    let mut c = ctx();
    let out = crate::exec::apply_spec(spec, &a, input, &mut c)?;
    Ok((out, c))
}

pub fn sine(sr: u32, freq: f64, secs: f64, amp: f32) -> AudioBuffer {
    let n = (sr as f64 * secs).round() as usize;
    AudioBuffer::from_mono(sr, (0..n).map(|i| amp * (2.0 * PI * freq * i as f64 / sr as f64).sin() as f32).collect())
}

pub fn stereo(b: AudioBuffer) -> AudioBuffer {
    b.with_channels(2)
}

/// Deterministic white noise in [-amp, amp].
pub fn noise(sr: u32, secs: f64, amp: f32, seed: u64) -> AudioBuffer {
    let n = (sr as f64 * secs).round() as usize;
    let mut x = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407) | 1;
    AudioBuffer::from_mono(
        sr,
        (0..n)
            .map(|_| {
                x ^= x << 13;
                x ^= x >> 7;
                x ^= x << 17;
                amp * ((x >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0) as f32
            })
            .collect(),
    )
}

/// Linear chirp from f0 to f1.
pub fn chirp(sr: u32, f0: f64, f1: f64, secs: f64, amp: f32) -> AudioBuffer {
    let n = (sr as f64 * secs).round() as usize;
    let k = (f1 - f0) / secs;
    AudioBuffer::from_mono(
        sr,
        (0..n)
            .map(|i| {
                let t = i as f64 / sr as f64;
                amp * (2.0 * PI * (f0 * t + 0.5 * k * t * t)).sin() as f32
            })
            .collect(),
    )
}

/// Click train: single-sample impulses every `period` seconds.
pub fn clicks(sr: u32, period: f64, secs: f64, amp: f32) -> AudioBuffer {
    let n = (sr as f64 * secs).round() as usize;
    let step = (period * sr as f64).round() as usize;
    let mut v = vec![0.0; n];
    let mut i = 0;
    while i < n {
        v[i] = amp;
        i += step.max(1);
    }
    AudioBuffer::from_mono(sr, v)
}

/// Sum two buffers (shorter one zero-padded).
pub fn add(a: &AudioBuffer, b: &AudioBuffer) -> AudioBuffer {
    let n = a.frames().max(b.frames());
    let ch = a.channels().max(b.channels());
    let a2 = a.with_channels(ch);
    let b2 = b.with_channels(ch);
    let data = (0..ch)
        .map(|c| {
            (0..n)
                .map(|i| a2.data[c].get(i).copied().unwrap_or(0.0) + b2.data[c].get(i).copied().unwrap_or(0.0))
                .collect()
        })
        .collect();
    AudioBuffer { sample_rate: a.sample_rate, data }
}

/// RMS of the difference (over the common length).
pub fn rms_diff(a: &AudioBuffer, b: &AudioBuffer) -> f64 {
    let n = a.frames().min(b.frames());
    let ch = a.channels().min(b.channels());
    let mut s = 0.0;
    for c in 0..ch {
        for i in 0..n {
            let d = (a.data[c][i] - b.data[c][i]) as f64;
            s += d * d;
        }
    }
    (s / (n * ch).max(1) as f64).sqrt()
}

/// RMS of a slice in dBFS.
pub fn rms_db(x: &[f32]) -> f64 {
    let s: f64 = x.iter().map(|v| (*v as f64).powi(2)).sum::<f64>() / x.len().max(1) as f64;
    crate::buffer::lin_to_db(s.sqrt())
}

/// Magnitude (linear) of frequency `f` in `x` using a Goertzel filter, normalized so a
/// full-scale sine reads 1.0.
pub fn tone_level(x: &[f32], sr: u32, f: f64) -> f64 {
    let n = x.len();
    if n == 0 {
        return 0.0;
    }
    // Hann window to reduce leakage
    let w = 2.0 * PI * f / sr as f64;
    let coeff = 2.0 * w.cos();
    let (mut s1, mut s2) = (0.0f64, 0.0f64);
    let mut wsum = 0.0;
    for (i, v) in x.iter().enumerate() {
        let win = 0.5 - 0.5 * (2.0 * PI * i as f64 / (n - 1).max(1) as f64).cos();
        wsum += win;
        let s0 = *v as f64 * win + coeff * s1 - s2;
        s2 = s1;
        s1 = s0;
    }
    let power = s1 * s1 + s2 * s2 - coeff * s1 * s2;
    2.0 * power.max(0.0).sqrt() / wsum
}

/// Scale-invariant SDR of `estimate` against `reference`, in dB.
pub fn si_sdr(reference: &[f32], estimate: &[f32]) -> f64 {
    let n = reference.len().min(estimate.len());
    let r = &reference[..n];
    let e = &estimate[..n];
    let dot: f64 = r.iter().zip(e).map(|(a, b)| *a as f64 * *b as f64).sum();
    let rr: f64 = r.iter().map(|a| (*a as f64).powi(2)).sum::<f64>().max(1e-20);
    let alpha = dot / rr;
    let mut st = 0.0;
    let mut en = 0.0;
    for i in 0..n {
        let t = alpha * r[i] as f64;
        st += t * t;
        en += (e[i] as f64 - t).powi(2);
    }
    10.0 * (st.max(1e-20) / en.max(1e-20)).log10()
}

/// A temp directory unique to the test, removed on drop.
pub struct TempDir(pub std::path::PathBuf);

impl TempDir {
    pub fn new(name: &str) -> TempDir {
        let p = std::env::temp_dir().join(format!(
            "ferraus-test-{}-{}-{}",
            name,
            std::process::id(),
            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
        ));
        std::fs::create_dir_all(&p).unwrap();
        TempDir(p)
    }
    pub fn path(&self, f: &str) -> std::path::PathBuf {
        self.0.join(f)
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

/// Write a float WAV for tests.
pub fn write_wav(path: &std::path::Path, b: &AudioBuffer) {
    std::fs::write(path, crate::wav::encode(b, 32, true).unwrap()).unwrap();
}

pub fn read_wav(path: &std::path::Path) -> AudioBuffer {
    crate::wav::decode(&std::fs::read(path).unwrap()).unwrap().0
}