ferraus-denoise 0.1.2

Noise reduction for ferraus: noise profiles, spectral gating, adaptive tracking, RNNoise
Documentation
//! PRD acceptance tests for noise reduction.

mod common;

use common::*;
use ferraus_core::testing::{self, si_sdr};
use ferraus_core::{AudioBuffer, OpSpec};
use ferraus_denoise::metrics::{birdie_score, hard_gate, BIRDIE_THRESHOLD};
use ferraus_denoise::profile;

fn spec(name: &str) -> OpSpec {
    ferraus_denoise::ops().into_iter().find(|o| o.name == name).unwrap()
}

const LEAD_S: f64 = 1.0;
const SIG_S: f64 = 2.0;

struct Row {
    signal: &'static str,
    noise: NoiseKind,
    snr: f64,
    before: f64,
    after: f64,
    retained: f64,
}

/// Mix = 1 s noise-only lead-in (used for profiling) + 2 s of program. SNR is set on the
/// program section; SI-SDR is measured over the whole mix, as a person would hear the
/// recording; the clean level is the projection gain in voiced regions.
fn run_case(signal: &'static str, kind: NoiseKind, snr: f64, pairs: &[(&str, &str)]) -> Row {
    let clean = match signal {
        "speech" => speech_like(LEAD_S, SIG_S, 0.5),
        _ => music_like(LEAD_S, SIG_S, 0.5),
    };
    let lead = (LEAD_S * SR as f64) as usize;
    let nz = make_noise(kind, LEAD_S + SIG_S, 7 + snr as u64);
    let (noisy, _) = mix(&clean.buf, &nz, snr, lead);
    let mut p = pairs.to_vec();
    p.push(("noise-range", "0s..1s"));
    let out = testing::run(&spec("denoise"), &p, noisy.clone()).unwrap();
    assert_eq!(out.frames(), noisy.frames());
    let c = &clean.buf.data[0];
    let before = si_sdr(c, &noisy.data[0]);
    let after = si_sdr(c, &out.data[0]);
    let retained = retained_db(c, &out.data[0], &clean.voiced);
    Row { signal, noise: kind, snr, before, after, retained }
}

fn matrix(pairs: &[(&str, &str)], snrs: &[f64], label: &str) -> Vec<Row> {
    let mut rows = Vec::new();
    for signal in ["speech", "music"] {
        for kind in NoiseKind::ALL {
            for &snr in snrs {
                let r = run_case(signal, kind, snr, pairs);
                eprintln!(
                    "[{label}] {:6} {:7} {:4.0} dB SNR: SI-SDR {:6.2} -> {:6.2} ({:+5.2} dB), clean level in voiced regions {:+5.2} dB",
                    r.signal,
                    format!("{:?}", r.noise),
                    r.snr,
                    r.before,
                    r.after,
                    r.after - r.before,
                    r.retained
                );
                rows.push(r);
            }
        }
    }
    rows
}

/// PRD: at 10 dB SNR the profile path improves SI-SDR by >= 8 dB without reducing the
/// clean signal by more than 1 dB in voiced regions. With the default 12 dB reduction the
/// improvement is capped near 12 dB by construction (even an oracle binary mask at -12 dB
/// reaches only about 9-10 dB here), so the criterion is checked at --reduction 24dB; the
/// defaults are checked against a regression floor.
#[test]
fn synthetic_mixes_improve_si_sdr() {
    let mut failures = Vec::new();
    for r in matrix(&[("reduction", "24dB")], &[0.0, 10.0, 20.0], "24dB") {
        let gain = r.after - r.before;
        if r.snr == 10.0 && gain < 8.0 {
            failures.push(format!("{} {:?} 10 dB: SI-SDR gain {gain:.2} < 8 dB", r.signal, r.noise));
        }
        if r.snr == 10.0 && r.retained < -1.0 {
            failures.push(format!(
                "{} {:?} 10 dB: clean level in voiced regions {:.2} dB < -1 dB",
                r.signal, r.noise, r.retained
            ));
        }
        if gain < 1.0 {
            failures.push(format!("{} {:?} {} dB: SI-SDR gain only {gain:.2}", r.signal, r.noise, r.snr));
        }
    }
    for r in matrix(&[], &[10.0], "defaults") {
        let gain = r.after - r.before;
        if r.snr == 10.0 && (gain < 6.0 || r.retained < -1.0) {
            failures.push(format!(
                "defaults {} {:?} 10 dB: SI-SDR gain {gain:.2} (floor 6), voiced level {:.2}",
                r.signal, r.noise, r.retained
            ));
        }
        if gain < 1.0 {
            failures.push(format!("defaults {} {:?} {} dB: SI-SDR gain only {gain:.2}", r.signal, r.noise, r.snr));
        }
    }
    assert!(failures.is_empty(), "{}", failures.join("\n"));
}

#[test]
fn identity_at_zero_reduction() {
    let clean = speech_like(0.5, 1.0, 0.5);
    let nz = make_noise(NoiseKind::Fan, 1.5, 3);
    let (noisy, _) = mix(&clean.buf, &nz, 10.0, 24_000);
    let stereo = testing::stereo(noisy.clone());
    for (buf, mode) in [(noisy, "profile"), (stereo, "adaptive")] {
        let out =
            testing::run(&spec("denoise"), &[("reduction", "0dB"), ("mode", mode), ("auto", "true")], buf.clone())
                .unwrap();
        let err = testing::rms_diff(&buf, &out);
        assert!(err == 0.0 || 20.0 * err.log10() < -120.0, "{mode}: {err}");
    }
}

#[test]
fn mix_zero_is_identity() {
    let x = testing::add(&testing::sine(SR, 440.0, 0.5, 0.3), &testing::noise(SR, 0.5, 0.05, 1));
    let out = testing::run(&spec("denoise"), &[("mix", "0%")], x.clone()).unwrap();
    assert_eq!(out, x);
}

fn noise_only(kind: NoiseKind) -> AudioBuffer {
    let mut x = make_noise(kind, 3.0, 11);
    for v in &mut x {
        *v *= 0.05;
    }
    AudioBuffer::from_mono(SR, x)
}

#[test]
fn birdie_score_soft_gate_beats_hard_gate() {
    for kind in [NoiseKind::Hiss, NoiseKind::Fan] {
        let x = noise_only(kind);
        let lead = SR as usize;
        let prof = profile::learn(&x, 0, lead, Default::default()).unwrap();
        let ours = testing::run(&spec("denoise"), &[("noise-range", "0s..1s")], x.clone()).unwrap();
        let naive = hard_gate(&x, &prof, 12.0, 6.0);
        let seg = |b: &AudioBuffer| b.data[0][(1.2 * SR as f64) as usize..(2.8 * SR as f64) as usize].to_vec();
        let s_raw = birdie_score(&seg(&x), SR);
        let s_ours = birdie_score(&seg(&ours), SR);
        let s_hard = birdie_score(&seg(&naive), SR);
        eprintln!("{kind:?}: birdie raw {s_raw:.5}, ours {s_ours:.5}, hard gate {s_hard:.5}");
        assert!(s_ours < BIRDIE_THRESHOLD, "{kind:?}: ours {s_ours:.3} >= {BIRDIE_THRESHOLD}");
        assert!(s_hard > BIRDIE_THRESHOLD, "{kind:?}: hard gate {s_hard:.3} <= {BIRDIE_THRESHOLD}");
        // and the soft gate still removes the noise
        let red = 20.0 * (rms(&seg(&ours)) / rms(&seg(&x))).log10();
        assert!(red < -8.0, "{kind:?}: noise only reduced by {red:.1} dB");
    }
}

#[test]
fn rnnoise_reduces_noise_on_speech() {
    let clean = speech_like(1.5, 1.5, 0.5);
    let lead = 3 * SR as usize / 2;
    let nz = make_noise(NoiseKind::Fan, 3.0, 5);
    let (noisy, _) = mix(&clean.buf, &nz, 5.0, lead);
    let out = testing::run(&spec("denoise"), &[("mode", "ml"), ("model", "rnnoise")], noisy.clone()).unwrap();
    let c = &clean.buf.data[0];
    let before = si_sdr(c, &noisy.data[0]);
    let after = si_sdr(c, &out.data[0]);
    // after RNNoise has adapted (the first second), the noise-only lead-in level drops
    // (RNNoise is gentle on steady synthetic noise; the SI-SDR gain is the main check)
    let lead_before = rms(&noisy.data[0][SR as usize..lead]);
    let lead_after = rms(&out.data[0][SR as usize..lead]);
    eprintln!(
        "rnnoise: SI-SDR {before:.2} -> {after:.2}, lead-in noise {:.1} dB",
        20.0 * (lead_after / lead_before).log10()
    );
    assert!(after > before + 2.0, "SI-SDR {before:.2} -> {after:.2}");
    assert!(lead_after < lead_before, "noise-only level did not drop");
}