br41ndmg 0.3.0

A polyphase sinc audio resampler with offline and streaming APIs for Rust.
Documentation
use br41ndmg::{PolyphaseFilterParams, Resampler, StreamingResampler, Window};

const EPSILON: f32 = 1.0e-6;

fn assert_close(actual: &[f32], expected: &[f32]) {
    assert_eq!(actual.len(), expected.len());

    for (index, (&a, &b)) in actual.iter().zip(expected).enumerate() {
        assert!(
            (a - b).abs() <= EPSILON,
            "sample {index} differed: {a} vs {b}"
        );
    }
}

fn collect_stream_output(
    stream: &mut StreamingResampler,
    input: &[f32],
    chunk_frames: &[usize],
) -> Vec<f32> {
    let mut output = Vec::new();
    let channels = stream.channels();
    let mut frame_offset = 0;

    for &chunk_frames in chunk_frames {
        let start = frame_offset * channels;
        let end = start + chunk_frames * channels;
        let chunk = &input[start..end];
        let mut scratch = vec![0.0; stream.output_samples_for(chunk_frames)];
        let written_frames = stream.process_into(chunk, &mut scratch).unwrap();
        output.extend_from_slice(&scratch[..written_frames * channels]);
        frame_offset += chunk_frames;
    }

    assert_eq!(frame_offset * channels, input.len());

    let mut tail = vec![0.0; stream.flush_samples()];
    let written_frames = stream.flush_into(&mut tail).unwrap();
    output.extend_from_slice(&tail[..written_frames * channels]);

    output
}

#[test]
fn streaming_matches_offline_for_chunked_mono() {
    let input: Vec<f32> = (0..32)
        .map(|index| (((index as f32) * 0.37).sin() * 0.75) + ((index % 5) as f32 * 0.05))
        .collect();
    let offline = Resampler::new(44_100.0, 48_000.0)
        .unwrap()
        .resample(&input)
        .unwrap();

    let mut stream = StreamingResampler::new(44_100.0, 48_000.0, 1).unwrap();
    let chunked = collect_stream_output(&mut stream, &input, &[1, 4, 3, 7, 2, 5, 10]);

    assert_close(&chunked, &offline);
}

#[test]
fn streaming_matches_offline_for_chunked_stereo() {
    let mut input = Vec::new();
    for index in 0..24 {
        input.push((index as f32 * 0.2).sin() * 0.5);
        input.push((index as f32 * 0.13).cos() * 0.25);
    }

    let offline = Resampler::new(48_000.0, 44_100.0)
        .unwrap()
        .resample_interleaved(&input, 2)
        .unwrap();

    let mut stream = StreamingResampler::new(48_000.0, 44_100.0, 2).unwrap();
    let chunked = collect_stream_output(&mut stream, &input, &[2, 1, 5, 3, 4, 9]);

    assert_close(&chunked, &offline);
}

#[test]
fn streaming_rejects_processing_after_flush_without_reset() {
    let mut stream = StreamingResampler::new(44_100.0, 48_000.0, 1).unwrap();
    let input = [0.0_f32, 1.0, 0.5, -0.5];
    let mut output = vec![0.0; stream.output_samples_for(input.len())];

    let _ = stream.process_into(&input, &mut output).unwrap();

    let mut tail = vec![0.0; stream.flush_samples()];
    let _ = stream.flush_into(&mut tail).unwrap();

    let mut next_output = vec![0.0; stream.output_samples_for(2)];
    let error = stream
        .process_into(&[0.25, 0.75], &mut next_output)
        .unwrap_err();
    assert!(error.to_string().contains("cannot process after flush"));
}

#[test]
fn streaming_matches_offline_with_custom_filter_params() {
    let params = PolyphaseFilterParams {
        phases: 128,
        taps_per_phase: 31,
        window: Window::Hamming,
    };
    let input: Vec<f32> = (0..48)
        .map(|index| (((index as f32) * 0.29).sin() * 0.5) + ((index as f32) * 0.07).cos() * 0.2)
        .collect();
    let offline = Resampler::with_filter_params(44_100.0, 48_000.0, params)
        .unwrap()
        .resample(&input)
        .unwrap();

    let mut stream = StreamingResampler::with_filter_params(44_100.0, 48_000.0, 1, params).unwrap();
    let chunked = collect_stream_output(&mut stream, &input, &[3, 5, 2, 9, 4, 7, 18]);

    assert_eq!(stream.filter_params(), params);
    assert_close(&chunked, &offline);
}