ff-filter 0.13.1

Video and audio filter graph operations - the Rust way
Documentation
//! Integration tests for audio filter effects on a reference sine wave.

#![allow(clippy::unwrap_used)]

use ff_filter::{EqBand, FilterGraph};
use ff_format::{AudioFrame, SampleFormat, Timestamp};

/// Stereo packed F32 sine wave frame at the given frequency.
///
/// Amplitude is 0.1 to leave headroom for volume boosts (avoids clipping).
fn make_sine_frame(freq_hz: f64, sample_rate: u32, num_samples: usize) -> AudioFrame {
    let channels = 2usize;
    let bytes_per_sample = 4usize; // f32
    let mut buf = vec![0u8; num_samples * channels * bytes_per_sample];
    for i in 0..num_samples {
        let t = i as f64 / sample_rate as f64;
        let v = (0.1_f32 * (2.0 * std::f64::consts::PI * freq_hz * t).sin() as f32).to_le_bytes();
        let offset = i * channels * bytes_per_sample;
        buf[offset..offset + 4].copy_from_slice(&v); // L
        buf[offset + 4..offset + 8].copy_from_slice(&v); // R
    }
    AudioFrame::new(
        vec![buf],
        num_samples,
        2,
        sample_rate,
        SampleFormat::F32,
        Timestamp::default(),
    )
    .unwrap()
}

/// RMS of an f32 sample slice (packed, interleaved channels).
fn rms(samples: &[f32]) -> f64 {
    if samples.is_empty() {
        return 0.0;
    }
    let sum_sq: f64 = samples.iter().map(|&s| (s as f64).powi(2)).sum();
    (sum_sq / samples.len() as f64).sqrt()
}

/// Push `frame` through `graph` and pull the first available output frame.
/// Returns `None` and prints a skip message if the push or pull fails.
fn push_pull_audio(graph: &mut FilterGraph, frame: &AudioFrame) -> Option<AudioFrame> {
    match graph.push_audio(0, frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping push_audio: {e}");
            return None;
        }
    }
    match graph.pull_audio() {
        Ok(Some(f)) => Some(f),
        Ok(None) => {
            println!("Skipping: no audio output frame produced");
            None
        }
        Err(e) => {
            println!("Skipping pull_audio: {e}");
            None
        }
    }
}

#[test]
fn volume_6db_should_double_amplitude() {
    let mut graph = match FilterGraph::builder().volume(6.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let frame = make_sine_frame(440.0, 48000, 4800);
    let out = match push_pull_audio(&mut graph, &frame) {
        Some(f) => f,
        None => return,
    };

    let in_samples = frame.as_f32().unwrap();
    let in_rms = rms(in_samples);

    // Extract output samples — handle both packed F32 and planar F32p.
    let out_rms = if let Some(s) = out.as_f32() {
        rms(s)
    } else if let Some(s) = out.channel_as_f32(0) {
        rms(s)
    } else {
        println!("Skipping: unrecognised output format {:?}", out.format());
        return;
    };

    let ratio = out_rms / in_rms;
    // +6 dB ≈ 2× amplitude; allow ±15% tolerance for FFmpeg quantisation/resampling.
    assert!(
        (ratio - 2.0).abs() < 0.30,
        "+6 dB should double amplitude: expected ratio≈2.0, got {ratio:.3}"
    );
}

#[test]
fn volume_minus6db_should_halve_amplitude() {
    let mut graph = match FilterGraph::builder().volume(-6.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let frame = make_sine_frame(440.0, 48000, 4800);
    let out = match push_pull_audio(&mut graph, &frame) {
        Some(f) => f,
        None => return,
    };

    let in_rms = rms(frame.as_f32().unwrap());
    let out_rms = if let Some(s) = out.as_f32() {
        rms(s)
    } else if let Some(s) = out.channel_as_f32(0) {
        rms(s)
    } else {
        println!("Skipping: unrecognised output format {:?}", out.format());
        return;
    };

    let ratio = out_rms / in_rms;
    // −6 dB ≈ 0.5× amplitude; allow ±15% tolerance.
    assert!(
        (ratio - 0.5).abs() < 0.10,
        "-6 dB should halve amplitude: expected ratio≈0.5, got {ratio:.3}"
    );
}

#[test]
fn afade_in_should_start_at_silence_and_reach_full_volume() {
    // Fade in over the entire 0.1 s frame so first samples are near silence.
    let mut graph = match FilterGraph::builder().afade_in(0.0, 0.1).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    // 4800 samples @ 48 kHz = 0.1 s
    let frame = make_sine_frame(440.0, 48000, 4800);
    let out = match push_pull_audio(&mut graph, &frame) {
        Some(f) => f,
        None => return,
    };

    // First samples must be near silence (absolute value < 0.02).
    let first_sample = if let Some(s) = out.as_f32() {
        s[0].abs()
    } else if let Some(s) = out.channel_as_f32(0) {
        s[0].abs()
    } else {
        println!("Skipping: unrecognised output format {:?}", out.format());
        return;
    };

    assert!(
        first_sample < 0.02,
        "afade_in: first sample should be near silence, got {first_sample:.4}"
    );
}

#[test]
fn afade_out_should_reach_silence_at_end() {
    // Fade out starting at 0.0 s over 0.1 s — entire frame fades to silence.
    let mut graph = match FilterGraph::builder().afade_out(0.0, 0.1).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let frame = make_sine_frame(440.0, 48000, 4800);
    let out = match push_pull_audio(&mut graph, &frame) {
        Some(f) => f,
        None => return,
    };

    // Last sample must be near silence (absolute value < 0.02).
    let last_sample = if let Some(s) = out.as_f32() {
        s[s.len() - 1].abs()
    } else if let Some(s) = out.channel_as_f32(0) {
        s[s.len() - 1].abs()
    } else {
        println!("Skipping: unrecognised output format {:?}", out.format());
        return;
    };

    assert!(
        last_sample < 0.02,
        "afade_out: last sample should be near silence, got {last_sample:.4}"
    );
}

#[test]
fn equalizer_peak_should_boost_target_frequency() {
    // Apply a +6 dB peak at 1 kHz; verify the filter applies without panic and
    // produces output. Full FFT-based frequency verification is outside the scope
    // of a unit integration test.
    let bands = vec![EqBand::Peak {
        freq_hz: 1000.0,
        gain_db: 6.0,
        q: 1.0,
    }];
    let mut graph = match FilterGraph::builder().equalizer(bands).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let frame = make_sine_frame(1000.0, 48000, 4800);
    match push_pull_audio(&mut graph, &frame) {
        Some(out) => {
            assert_eq!(out.sample_rate(), 48000, "sample rate must be unchanged");
            // With +6 dB boost at 1 kHz input sine, output RMS should be ≥ input RMS.
            let in_rms = rms(frame.as_f32().unwrap());
            let out_rms = if let Some(s) = out.as_f32() {
                rms(s)
            } else if let Some(s) = out.channel_as_f32(0) {
                rms(s)
            } else {
                return;
            };
            assert!(
                out_rms >= in_rms * 0.9,
                "EQ peak at 1 kHz: output RMS ({out_rms:.4}) should not be less than input ({in_rms:.4})"
            );
        }
        None => {}
    }
}

#[test]
fn stereo_to_mono_should_average_both_channels() {
    let mut graph = match FilterGraph::builder().stereo_to_mono().build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let frame = make_sine_frame(440.0, 48000, 4800);
    let out = match push_pull_audio(&mut graph, &frame) {
        Some(f) => f,
        None => return,
    };

    assert_eq!(
        out.channels(),
        1,
        "stereo_to_mono: output must have exactly 1 channel, got {}",
        out.channels()
    );
    assert_eq!(
        out.sample_rate(),
        48000,
        "sample rate must be unchanged after stereo_to_mono"
    );
}

#[test]
fn audio_delay_100ms_should_shift_audio_later() {
    // A 100 ms adelay inserts 4800 samples of silence at the beginning for a
    // 48 kHz stream. We push one frame and verify the filter applies without
    // panic and that the output sample rate is preserved.
    let mut graph = match FilterGraph::builder().audio_delay(100.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };

    let frame = make_sine_frame(440.0, 48000, 9600);

    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping push_audio: {e}");
            return;
        }
    }

    // adelay may need a flush to emit output; try pulling once.
    match graph.pull_audio() {
        Ok(Some(out)) => {
            assert_eq!(
                out.sample_rate(),
                48000,
                "sample rate must be preserved after delay"
            );
            // The first sample of delayed audio should be silence (near 0.0).
            if let Some(s) = out.as_f32() {
                if !s.is_empty() {
                    assert!(
                        s[0].abs() < 0.01,
                        "audio_delay: first output sample should be silence, got {:.4}",
                        s[0]
                    );
                }
            }
        }
        Ok(None) => {
            // adelay may buffer internally; this is acceptable.
            println!("Note: audio_delay produced no immediate output (buffering expected).");
        }
        Err(e) => {
            println!("Skipping pull_audio: {e}");
        }
    }
}