Skip to main content

truce_rack_test/
lib.rs

1//! Assertion helpers for truce-rack host integration tests.
2//!
3//! Use these from host-side test suites that want to verify a
4//! [`truce_rack_core::scanner::PluginScanner`] impl can scan a corpus, load each result,
5//! activate it, and render a known input without NaN / clipping.
6//!
7//! The helpers operate on `Plugin<f32>` instances by default;
8//! a parallel `_f64` variant for `Plugin<f64>` can be added when
9//! the first VST3 / AU 64-bit consumer needs it.
10//!
11//! # Example
12//!
13//! ```ignore
14//! use truce_rack_core::scanner::PluginScanner;
15//! use truce_rack_test::{render_silence, assert_no_nans};
16//!
17//! let scanner = MyScanner::new();
18//! for info in scanner.scan()? {
19//!     let mut plugin = scanner.load(&info)?;
20//!     let rendered = render_silence(&mut plugin, 48_000.0, 1024)?;
21//!     assert_no_nans(&rendered);
22//! }
23//! ```
24
25use truce_rack_core::buffer::{AudioBuffer, BusRange};
26use truce_rack_core::bus::BusLayout;
27use truce_rack_core::error::{Error, Result};
28use truce_rack_core::events::EventList;
29use truce_rack_core::plugin::{Plugin, PluginCore, ProcessContext};
30
31/// Rendered audio block — what every helper hands back so callers
32/// can run their own assertions.
33#[derive(Debug, Clone)]
34pub struct Rendered {
35    /// Output channels, planar.
36    pub output: Vec<Vec<f32>>,
37}
38
39impl Rendered {
40    /// Maximum absolute sample across all channels.
41    #[must_use]
42    pub fn peak(&self) -> f32 {
43        self.output
44            .iter()
45            .flat_map(|c| c.iter())
46            .map(|s| s.abs())
47            .fold(0.0f32, f32::max)
48    }
49
50    /// `true` if any sample in any channel is NaN.
51    #[must_use]
52    pub fn any_nan(&self) -> bool {
53        self.output
54            .iter()
55            .flat_map(|c| c.iter())
56            .any(|s| s.is_nan())
57    }
58}
59
60/// Render `num_frames` of silence through `plugin` at
61/// `sample_rate`. Useful for "plugin doesn't crash on empty
62/// input" smoke tests.
63///
64/// # Errors
65/// Propagates `activate` and `process` failures.
66pub fn render_silence<P>(plugin: &mut P, sample_rate: f64, num_frames: usize) -> Result<Rendered>
67where
68    P: PluginCore + Plugin<f32>,
69{
70    render(plugin, sample_rate, num_frames, |_ch, _frame| 0.0)
71}
72
73/// Render `num_frames` of a generated input through `plugin`.
74/// `generator` is called per `(channel, frame)` and returns the
75/// input sample at that position.
76///
77/// # Errors
78/// Propagates `activate` and `process` failures.
79pub fn render<P, F>(
80    plugin: &mut P,
81    sample_rate: f64,
82    num_frames: usize,
83    mut generator: F,
84) -> Result<Rendered>
85where
86    P: PluginCore + Plugin<f32>,
87    F: FnMut(usize, usize) -> f32,
88{
89    let channels = 2usize;
90    if !plugin.is_active() {
91        plugin.activate(BusLayout::stereo(), sample_rate, num_frames)?;
92    }
93    let mut input_buf = vec![vec![0.0f32; num_frames]; channels];
94    for (ch_idx, ch) in input_buf.iter_mut().enumerate() {
95        for (frame, sample) in ch.iter_mut().enumerate() {
96            *sample = generator(ch_idx, frame);
97        }
98    }
99    let mut output_buf = vec![vec![0.0f32; num_frames]; channels];
100    let bus_in = [BusRange::new(0, channels)];
101    let bus_out = [BusRange::new(0, channels)];
102
103    {
104        let inputs: Vec<&[f32]> = input_buf.iter().map(Vec::as_slice).collect();
105        let mut outputs: Vec<&mut [f32]> = output_buf.iter_mut().map(Vec::as_mut_slice).collect();
106        let mut buffer = AudioBuffer::new(&inputs, &mut outputs, num_frames, &bus_in, &bus_out);
107        let events = EventList::default();
108        let mut out_events = EventList::default();
109        let mut ctx = ProcessContext {
110            sample_rate,
111            max_block_size: num_frames,
112            transport: None,
113            output_events: &mut out_events,
114        };
115        plugin.process(&mut buffer, &events, &mut ctx)?;
116    }
117
118    Ok(Rendered { output: output_buf })
119}
120
121/// Assert that no sample in `rendered` is NaN. Panics if any is.
122///
123/// # Panics
124/// If any sample in any output channel is NaN.
125pub fn assert_no_nans(rendered: &Rendered) {
126    assert!(!rendered.any_nan(), "rendered audio contained NaN samples");
127}
128
129/// Assert that the peak sample is at or below `bound`.
130///
131/// # Panics
132/// If the peak exceeds `bound`.
133pub fn assert_peak_below(rendered: &Rendered, bound: f32) {
134    let peak = rendered.peak();
135    assert!(peak <= bound, "rendered peak {peak} exceeds bound {bound}");
136}
137
138/// Round-trip the plugin's saved state: dump → load → dump,
139/// and assert the second dump equals the first. Catches
140/// non-deterministic state encoding.
141///
142/// # Errors
143/// Returns whatever `save_state` / `load_state` produces. Returns
144/// [`Error::Other`] if the round-tripped bytes differ.
145pub fn assert_state_round_trip<P>(plugin: &mut P) -> Result<()>
146where
147    P: PluginCore,
148{
149    let first = plugin.save_state()?;
150    plugin.load_state(&first)?;
151    let second = plugin.save_state()?;
152    if first != second {
153        return Err(Error::Other(format!(
154            "state round-trip mismatch: first {} bytes, second {} bytes",
155            first.len(),
156            second.len(),
157        )));
158    }
159    Ok(())
160}