crates/veilvoice-core/tests/hostile_audio.rs

what this file is for · veilvoice-core · 389 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! The engine against input that is not well-behaved audio.
//!
//! A realistic use of VeilVoice is "someone sent me a recording and I want to
//! veil it before passing it on", so the input file is not necessarily
//! friendly. A 32-bit-float WAV can legally contain NaN and infinity, and
//! `symphonia` decodes those faithfully rather than sanitising them.
//!
//! That matters more than it might sound, because the engine keeps *persistent*
//! state: the accent neutraliser's long-term spectrum is an exponential moving
//! average, so a single non-finite sample folded into it never washes out. The
//! audit found exactly that: one NaN, and every output sample for the rest of
//! the session was NaN, silently.
//!
//! # In plain words
//!
//! Feeds the engine audio designed to break it: silence, deafening noise, values
//! that are not numbers, files that lie about their own length.
//!
//! The question is not whether it sounds good. It is whether anything can make the
//! engine stop, hang, or quietly produce silence while reporting success. A
//! de-identifier that fails by outputting nothing is one somebody might not notice
//! had failed.

use veilvoice_core::{AccentConfig, DeidConfig, Deidentifier};


fn speech(sample_rate: u32, secs: f32) -> Vec<f32> {
    let n = (sample_rate as f32 * secs) as usize;
    (0..n)
        .map(|i| {
            let t = i as f32 / sample_rate as f32;
            (1..=12)
                .map(|h| (std::f32::consts::TAU * 150.0 * h as f32 * t).sin() / h as f32)
                .sum::<f32>()
                * 0.1
        })
        .collect()
}



fn engine() -> Deidentifier {
    Deidentifier::new(DeidConfig::default()).unwrap()
}



/// The regression. One bad sample must not end the session.
#[test]
fn a_single_nan_does_not_poison_the_engine_for_ever() {
    let mut deid = engine();
    let clean = speech(48_000, 0.5);

    let mut poisoned = clean.clone();
    poisoned[500] = f32::NAN;
    let during = deid.process_vec(&poisoned);
    assert!(
        during.iter().all(|v| v.is_finite()),
        "the frame containing the NaN produced non-finite output"
    );

    let after = deid.process_vec(&clean);
    assert!(
        after.iter().all(|v| v.is_finite()),
        "the engine did not recover: {} of {} samples are non-finite",
        after.iter().filter(|v| !v.is_finite()).count(),
        after.len()
    );
    let energy: f32 = after.iter().map(|v| v * v).sum();
    assert!(
        energy > 1e-6,
        "the engine recovered into silence, energy {energy:e}"
    );
}



#[test]
fn every_flavour_of_non_finite_is_survived() {
    for (name, poison) in [
        ("NaN", f32::NAN),
        ("+inf", f32::INFINITY),
        ("-inf", f32::NEG_INFINITY),
        ("huge", 1e38),
        ("tiny", -1e38),
    ] {
        let mut deid = engine();
        let clean = speech(48_000, 0.3);
        let mut bad = clean.clone();
        // Scatter it, rather than trusting one position to be reached.
        for i in (100..bad.len()).step_by(997) {
            bad[i] = poison;
        }
        let out = deid.process_vec(&bad);
        assert!(
            out.iter().all(|v| v.is_finite()),
            "{name}: output went non-finite"
        );
        let recovered = deid.process_vec(&clean);
        assert!(
            recovered.iter().all(|v| v.is_finite()),
            "{name}: engine did not recover"
        );
    }
}



/// An input made entirely of poison must not panic, hang, or emit garbage.
#[test]
fn an_entirely_non_finite_buffer_is_handled() {
    let mut deid = engine();
    let out = deid.process_vec(&vec![f32::NAN; 24_000]);
    assert!(out.iter().all(|v| v.is_finite()));

    let recovered = deid.process_vec(&speech(48_000, 0.5));
    assert!(recovered.iter().all(|v| v.is_finite()));
}



/// Silence must not drive the long-term averages anywhere strange, and must not
/// come out as anything but silence.
#[test]
fn digital_silence_stays_silent_and_leaves_the_state_usable() {
    let mut deid = engine();
    let out = deid.process_vec(&vec![0.0f32; 48_000]);
    assert!(out.iter().all(|v| v.is_finite()));
    assert!(
        out.iter().all(|v| v.abs() < 1e-3),
        "silence in, noise out: peak {}",
        out.iter().fold(0.0f32, |m, v| m.max(v.abs()))
    );

    let after = deid.process_vec(&speech(48_000, 0.5));
    assert!(after.iter().all(|v| v.is_finite()));
    assert!(after.iter().map(|v| v * v).sum::<f32>() > 1e-6);
}



/// Full-scale square waves and DC are legal audio and unlike anything the
/// engine was tuned on.
///
/// # The bound, and why it is not 4.0
///
/// This asserts the output does not *run away*. It is not a claim about gain:
/// a Nyquist-rate square wave is the worst case for any resampler, and the
/// engine legitimately comes out above unity on one. That is contained where
/// it matters -- `veilvoice_audio::io` clamps to full scale rather than
/// letting a sample wrap, and has its own test for it.
///
/// The bound was 4.0, which is a round number rather than a measured one, and
/// it was about ten percent above what this machine produces. Measured over
/// twenty runs each, on x86-64 Linux:
///
///     DC           1.38
///     square       2.13
///     impulses     0.09
///     alternating  3.58
///
/// Windows produced 4.0456 for `alternating` and failed. Nothing was wrong
/// with the engine: a different libm and different floating-point contraction
/// move the last few percent, and the round number sat inside that margin.
///
/// So the bound is 8.0, which is more than twice the worst measured and still
/// an order of magnitude below anything that has actually diverged. Runaway in
/// this engine has always meant thousands or a non-finite value, and the
/// non-finite case is asserted separately just above.
#[test]
fn pathological_but_legal_audio_is_handled() {
    for (name, signal) in [
        ("DC", vec![1.0f32; 24_000]),
        (
            "square",
            (0..24_000)
                .map(|i| if (i / 24) % 2 == 0 { 1.0 } else { -1.0 })
                .collect(),
        ),
        (
            "impulses",
            (0..24_000)
                .map(|i| if i % 1000 == 0 { 1.0 } else { 0.0 })
                .collect(),
        ),
        (
            "alternating",
            (0..24_000)
                .map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
                .collect(),
        ),
    ] {
        let mut deid = engine();
        let out = deid.process_vec(&signal);
        assert!(
            out.iter().all(|v| v.is_finite()),
            "{name}: non-finite output"
        );
        assert!(
            out.iter().all(|v| v.abs() <= 8.0),
            "{name}: output ran away to {}",
            out.iter().fold(0.0f32, |m, v| m.max(v.abs()))
        );
    }
}



/// The same, with accent neutralisation off, since that changes which paths in
/// `spectral.rs` run.
#[test]
fn hostile_input_is_survived_with_accent_neutralisation_off() {
    let cfg = DeidConfig {
        accent: AccentConfig {
            enabled: false,
            ..AccentConfig::default()
        },
        ..DeidConfig::default()
    };
    let mut deid = Deidentifier::new(cfg).unwrap();
    let clean = speech(48_000, 0.3);
    let mut bad = clean.clone();
    bad[77] = f32::NAN;
    bad[1_500] = f32::INFINITY;

    assert!(deid.process_vec(&bad).iter().all(|v| v.is_finite()));
    assert!(deid.process_vec(&clean).iter().all(|v| v.is_finite()));
}


// ---------------------------------------------------------------------------
// Hostile *configuration*, as opposed to hostile samples.
//
// The section above covers a bad sample reaching the engine. These cover the
// second door onto the same failure, which the audit found still open: the
// engine was built from a configuration nobody had validated, and a `NaN`
// sample rate produced `NaN` output for every sample without a sample ever
// having been bad. The value is reachable from a file, because a WAV's `fmt `
// chunk carries a `u32` sample rate that `symphonia` passes straight through, and
// from any project using these crates as libraries, which the README invites.
// ---------------------------------------------------------------------------


/// A non-finite sample rate must be refused, not built.
///
/// Before the fix, `NaN` passed validation (because `NaN < 8_000.0` is false),
/// the engine built happily, and **every output sample was `NaN`**, silently
/// and for ever. `INFINITY` was worse in a different way: it reached an
/// `as usize` saturation followed by an addition, and panicked with an
/// arithmetic overflow in every build with overflow checks on.
#[test]
fn a_non_finite_sample_rate_is_refused_rather_than_built() {
    for rate in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
        let config = DeidConfig {
            sample_rate: rate,
            ..Default::default()
        };
        let err = config
            .checked()
            .expect_err(&format!("sample_rate {rate} was accepted"));
        assert!(err.contains("real number"), "{err}");
        assert!(
            Deidentifier::new(config).is_err(),
            "an engine was built at sample_rate {rate}"
        );
    }
}



/// A sample rate a file can legally declare, but no hardware produces, sizes
/// the reverb and chorus delay lines. `u32::MAX` asked for about two gigabytes
/// of buffers from a four-kilobyte file, and a failed allocation aborts.
#[test]
fn an_absurd_sample_rate_is_refused_rather_than_allocated() {
    for rate in [
        u32::MAX as f32,
        2e9,
        DeidConfig::MAX_SAMPLE_RATE * 2.0,
        DeidConfig::MAX_SAMPLE_RATE + 1.0,
    ] {
        let config = DeidConfig {
            sample_rate: rate,
            ..Default::default()
        };
        assert!(
            config.checked().is_err(),
            "sample_rate {rate} Hz was accepted"
        );
    }
    // The ceiling itself works, and sits above every real converter.
    assert!(DeidConfig {
        sample_rate: DeidConfig::MAX_SAMPLE_RATE,
        ..Default::default()
    }
    .checked()
    .is_ok());
    for real in [8_000.0, 16_000.0, 44_100.0, 48_000.0, 96_000.0, 384_000.0] {
        assert!(
            DeidConfig {
                sample_rate: real,
                ..Default::default()
            }
            .checked()
            .is_ok(),
            "{real} Hz is a real rate and must still build"
        );
    }
}



/// `frame_size` had no upper bound at all, and it sizes every internal buffer
/// and the FFT plan.
#[test]
fn an_absurd_frame_size_is_refused() {
    for frame_size in [
        DeidConfig::MAX_FRAME_SIZE + 2,
        1 << 26,
        (usize::MAX / 2) & !1,
    ] {
        assert!(
            DeidConfig {
                frame_size,
                overlap: 2,
                ..Default::default()
            }
            .checked()
            .is_err(),
            "frame_size {frame_size} was accepted"
        );
    }
    assert!(DeidConfig {
        frame_size: DeidConfig::MAX_FRAME_SIZE,
        ..Default::default()
    }
    .checked()
    .is_ok());
}



/// Every other float is either clamped to something meaningful or refused for
/// being `NaN`. None of them may reach the engine unexamined.
#[test]
fn non_finite_parameters_are_refused_and_wild_ones_are_clamped() {
    // A named function-pointer type rather than a boxed closure: no allocation,
    // no trait object, and a signature short enough to read.
    type Poison = (&'static str, fn(&mut DeidConfig));

    let named: [Poison; 8] = [
        ("intensity", |c| c.intensity = f32::NAN),
        ("mod_smooth", |c| c.mod_smooth = f32::NAN),
        ("distortion_drive", |c| c.distortion_drive = f32::NAN),
        ("distortion_mix", |c| c.distortion_mix = f32::NAN),
        ("chorus_mix", |c| c.chorus_mix = f32::NAN),
        ("reverb_mix", |c| c.reverb_mix = f32::NAN),
        ("pitch_bounds.0", |c| c.pitch_bounds.0 = f32::NAN),
        ("formant_bounds.1", |c| c.formant_bounds.1 = f32::INFINITY),
    ];
    for (name, poison) in named {
        let mut config = DeidConfig::default();
        poison(&mut config);
        let err = config
            .checked()
            .expect_err(&format!("{name} accepted a non-finite value"));
        assert!(
            err.contains(name),
            "error for {name} does not name it: {err}"
        );
    }

    // Finite but wild values are brought back to something the DSP can act on
    // rather than refused, because each has a sensible nearest legal value.
    let tamed = DeidConfig {
        intensity: 99.0,
        distortion_drive: 1e9,
        chorus_mix: -5.0,
        pitch_bounds: (500.0, 0.0001),
        ..Default::default()
    }
    .checked()
    .expect("finite-but-wild values should be clamped, not refused");
    assert_eq!(tamed.intensity, 1.0);
    assert_eq!(tamed.chorus_mix, 0.0);
    assert!(tamed.distortion_drive <= 64.0);
    assert!(
        tamed.pitch_bounds.0 <= tamed.pitch_bounds.1,
        "bounds came back inverted: {:?}",
        tamed.pitch_bounds
    );
}



/// The whole point, checked end to end: a configuration that survives
/// validation must produce finite audio from finite audio.
#[test]
fn every_configuration_that_builds_produces_finite_audio() {
    for rate in [8_000.0f32, 44_100.0, 48_000.0, 192_000.0] {
        let config = DeidConfig {
            sample_rate: rate,
            accent: AccentConfig::default(),
            ..Default::default()
        };
        let mut deid = Deidentifier::new(config).unwrap();
        let out = deid.process_vec(&speech(rate as u32, 0.1));
        assert!(
            out.iter().all(|v| v.is_finite()),
            "{rate} Hz produced non-finite output"
        );
    }
}