audio-resample-bsd 0.2.1

RT-safe audio resampling crate (rubato-based) — the only processing interface callable directly from the real-time audio thread
Documentation
//! RT-safety verification — measures **heap allocations == 0** on the
//! `RubatoResampler::process_into_buffer` path.
//!
//! This binary has its own `#[global_allocator]` ([`CountingAllocator`]) so it
//! is isolated from proptest/criterion/logging noise and measures only the RT
//! path's allocations. Warmup (construction + initial priming) is performed
//! outside the measurement window; the measurement itself covers only the
//! steady-state `process_into_buffer` loop.

use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};

use audio_resample_bsd::{Resampler, RubatoResampler};

/// Global allocator that counts allocations made while the RT path is running.
struct CountingAllocator;

/// Allocation count accumulated during the measurement window.
static ALLOC_COUNT: AtomicUsize = AtomicUsize::new(0);
/// When `1`, `alloc` calls are counted (measurement gate).
static COUNTING_ENABLED: AtomicUsize = AtomicUsize::new(0);

// SAFETY: the delegate `System` implements `GlobalAlloc`, and the counters use
// wait-free atomics.
unsafe impl GlobalAlloc for CountingAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        if COUNTING_ENABLED.load(Ordering::Relaxed) == 1 {
            ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
        }
        System.alloc(layout)
    }
    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
        // dealloc is not counted — only "new allocations" on the RT path matter.
        System.dealloc(ptr, layout);
    }
}

#[global_allocator]
static A: CountingAllocator = CountingAllocator;

/// Returns the number of **allocations** made while `f` runs.
fn count_allocs<F: FnOnce()>(f: F) -> usize {
    ALLOC_COUNT.store(0, Ordering::Relaxed);
    COUNTING_ENABLED.store(1, Ordering::Relaxed);
    f();
    COUNTING_ENABLED.store(0, Ordering::Relaxed);
    ALLOC_COUNT.load(Ordering::Relaxed)
}

#[test]
fn process_into_buffer_allocates_zero_on_rt_path() {
    // Setup (allocations allowed): construct the resampler + pre-allocate buffers.
    let mut rs = RubatoResampler::new(44_100.0, 48_000.0, 2, 1024).expect("construct");
    let chunk = rs.input_chunk_size();
    let ch = rs.channels();
    // Stereo planar sine: [ch0(chunk), ch1(chunk)].
    let input: Vec<f32> = {
        let mut v = vec![0.0_f32; chunk * ch];
        for ch_idx in 0..ch {
            for i in 0..chunk {
                let t = i as f32;
                let freq = if ch_idx == 0 { 1000.0_f32 } else { 2000.0 };
                v[ch_idx * chunk + i] =
                    (2.0_f32 * std::f32::consts::PI * freq * t / 44_100.0).sin();
            }
        }
        v
    };
    let mut output = vec![0.0_f32; rs.output_frames_max() * ch];

    // Warmup (allocations allowed) — rubato Fft may finalize internal state on
    // early calls, so prime enough before measuring. "Alloc == 0" is verified
    // in steady state.
    for _ in 0..16 {
        let _ = rs.process_into_buffer(&input, &mut output);
    }

    // ★ Measurement: allocations on the RT path (process_into_buffer) must be 0.
    let allocs = count_allocs(|| {
        for _ in 0..32 {
            let _ = rs.process_into_buffer(&input, &mut output);
        }
    });
    assert_eq!(
        allocs, 0,
        "{allocs} allocations on the RT path — RT-safety violation \
         (alloc must be 0 on process_into_buffer)"
    );
}

#[test]
fn process_into_buffer_zero_alloc_upsample_96k() {
    // Verify zero RT allocations at a different ratio (48k -> 96k, mono).
    let mut rs = RubatoResampler::new(48_000.0, 96_000.0, 1, 512).expect("construct");
    let chunk = rs.input_chunk_size();
    let input: Vec<f32> = (0..chunk)
        .map(|i| (2.0_f32 * std::f32::consts::PI * 440.0 * i as f32 / 48_000.0).sin())
        .collect();
    let mut output = vec![0.0_f32; rs.output_frames_max()];
    for _ in 0..16 {
        let _ = rs.process_into_buffer(&input, &mut output);
    }
    let allocs = count_allocs(|| {
        for _ in 0..32 {
            let _ = rs.process_into_buffer(&input, &mut output);
        }
    });
    assert_eq!(allocs, 0, "48k->96k RT path allocated {allocs} times");
}

#[test]
fn process_into_buffer_zero_alloc_downsample_441_from_96() {
    // Verify zero RT allocations in the reverse direction (96k -> 44.1k).
    let mut rs = RubatoResampler::new(96_000.0, 44_100.0, 2, 1024).expect("construct");
    let chunk = rs.input_chunk_size();
    let ch = rs.channels();
    let input = vec![0.5_f32; chunk * ch];
    let mut output = vec![0.0_f32; rs.output_frames_max() * ch];
    for _ in 0..16 {
        let _ = rs.process_into_buffer(&input, &mut output);
    }
    let allocs = count_allocs(|| {
        for _ in 0..32 {
            let _ = rs.process_into_buffer(&input, &mut output);
        }
    });
    assert_eq!(allocs, 0, "96k->44.1k RT path allocated {allocs} times");
}

#[test]
fn construction_allocates_but_isolated_from_rt_path() {
    // Construction allocates (it is not RT-safe). This test confirms that
    // construction itself reports alloc > 0, which guarantees that the RT
    // test's warmup separation is meaningful (i.e. the RT test's 0 is not
    // coincidental — the measurement gate works).
    let allocs = count_allocs(|| {
        let _rs = RubatoResampler::new(44_100.0, 48_000.0, 2, 1024).expect("construct");
    });
    assert!(
        allocs > 0,
        "construction must allocate (it is not RT-safe) — measurement gate check"
    );
}