Skip to main content

truce_test/
assertions.rs

1//! Assertion helpers built on top of [`crate::DriverResult`].
2//!
3//! Run a [`crate::PluginDriver`] (typically via the [`crate::driver!`]
4//! macro), then pass the captured result into these helpers for
5//! standard claims:
6//!
7//! - **Whole-run audio shape**: nonzero / silence / no-NaNs / peak
8//!   below threshold.
9//! - **Time-windowed audio shape**: silence-after, nonzero-after,
10//!   silence-between, nonzero-between (for tail-decay /
11//!   gate-between-notes assertions).
12//! - **Meter readings** at end-of-run.
13//! - **Output events** emitted by the plugin.
14//!
15//! ```ignore
16//! use std::time::Duration;
17//! use truce_test::{assertions, driver, InputSource};
18//!
19//! #[test]
20//! fn long_tail_goes_silent() {
21//!     let result = driver!(MyReverb)
22//!         .duration(Duration::from_secs(3))
23//!         .input(InputSource::Constant(0.5))
24//!         .run();
25//!     assertions::assert_nonzero(&result);
26//!     assertions::assert_silence_after(&result, Duration::from_millis(2_500));
27//! }
28//! ```
29
30use std::time::Duration;
31
32use truce_core::cast::sample_count_usize;
33use truce_core::export::PluginExport;
34use truce_driver::{DriverResult, MeterReadings};
35
36const AUDIBLE_THRESHOLD: f32 = 0.001;
37
38fn duration_to_frames<P: PluginExport>(result: &DriverResult<P>, d: Duration) -> usize {
39    let frames = sample_count_usize(d.as_secs_f64() * result.sample_rate);
40    frames.min(result.total_frames)
41}
42
43fn peak_in_range<P: PluginExport>(result: &DriverResult<P>, start: usize, end: usize) -> f32 {
44    if start >= end {
45        return 0.0;
46    }
47    result
48        .output
49        .iter()
50        .flat_map(|ch| {
51            // Bound `start` against the channel too - a channel shorter
52            // than `start` (mismatch between `result.total_frames` and
53            // an individual channel) would otherwise panic on
54            // `ch[start..]`.
55            let s = start.min(ch.len());
56            let e = end.min(ch.len()).max(s);
57            ch[s..e].iter()
58        })
59        .map(|s| s.abs())
60        .fold(0.0f32, f32::max)
61}
62
63// ---------------------------------------------------------------------------
64// Whole-run assertions
65// ---------------------------------------------------------------------------
66
67/// Assert that at least one sample anywhere in the output is above
68/// the audible threshold.
69///
70/// # Panics
71///
72/// Panics if every sample is at or below `AUDIBLE_THRESHOLD` (1e-3).
73//
74// `usize as f64` for sample-count → seconds in the panic message;
75// total_frames is bounded by test duration, well below 2^52.
76#[allow(clippy::cast_precision_loss)]
77pub fn assert_nonzero<P: PluginExport>(result: &DriverResult<P>) {
78    let peak = peak_in_range(result, 0, result.total_frames);
79    assert!(
80        peak > AUDIBLE_THRESHOLD,
81        "Expected non-zero output over the full {:.3} s run, but peak sample was {peak}",
82        result.total_frames as f64 / result.sample_rate
83    );
84}
85
86/// Assert every sample in the output is below the audible threshold.
87///
88/// # Panics
89///
90/// Panics if any sample's absolute value is at or above
91/// `AUDIBLE_THRESHOLD` (1e-3).
92pub fn assert_silence<P: PluginExport>(result: &DriverResult<P>) {
93    let peak = peak_in_range(result, 0, result.total_frames);
94    assert!(
95        peak < AUDIBLE_THRESHOLD,
96        "Expected silence over the full run, but peak sample was {peak}"
97    );
98}
99
100/// Assert no sample is NaN or infinite. If this fails, the DSP went
101/// divergent.
102///
103/// # Panics
104///
105/// Panics on the first non-finite sample, naming the channel,
106/// frame index, and time offset.
107//
108// `usize as f64` for sample-index → milliseconds in the panic
109// message; sample indices are bounded by test duration.
110#[allow(clippy::cast_precision_loss)]
111pub fn assert_no_nans<P: PluginExport>(result: &DriverResult<P>) {
112    let bad = result
113        .output
114        .iter()
115        .enumerate()
116        .flat_map(|(ch, data)| data.iter().enumerate().map(move |(i, &s)| (ch, i, s)))
117        .find(|&(_, _, s)| !s.is_finite());
118    if let Some((ch, i, s)) = bad {
119        panic!(
120            "NaN/Inf at channel {ch} sample {i} (t = {:.3} ms): {s}",
121            (i as f64 / result.sample_rate) * 1000.0
122        );
123    }
124}
125
126/// Assert no sample exceeds `threshold` in absolute value. Typical
127/// use: `assert_peak_below(&result, 1.0)` to catch clipping.
128///
129/// # Panics
130///
131/// Panics if any sample's absolute value exceeds `threshold`.
132pub fn assert_peak_below<P: PluginExport>(result: &DriverResult<P>, threshold: f32) {
133    let peak = peak_in_range(result, 0, result.total_frames);
134    assert!(
135        peak <= threshold,
136        "Peak sample {peak} exceeded threshold {threshold}"
137    );
138}
139
140// ---------------------------------------------------------------------------
141// Time-windowed assertions
142// ---------------------------------------------------------------------------
143
144/// Assert every sample after `t` is below the audible threshold.
145/// Use for reverb / delay tail decay tests.
146///
147/// # Panics
148///
149/// Panics if any sample after `t` has absolute value at or above
150/// `AUDIBLE_THRESHOLD`.
151pub fn assert_silence_after<P: PluginExport>(result: &DriverResult<P>, t: Duration) {
152    let start = duration_to_frames(result, t);
153    let peak = peak_in_range(result, start, result.total_frames);
154    assert!(
155        peak < AUDIBLE_THRESHOLD,
156        "Expected silence after {:.3} ms but peak was {peak} \
157         (tail starts at sample {start}, run ends at sample {})",
158        t.as_secs_f64() * 1000.0,
159        result.total_frames
160    );
161}
162
163/// Assert at least one sample after `t` is above the audible
164/// threshold.
165///
166/// # Panics
167///
168/// Panics if every sample after `t` is at or below
169/// `AUDIBLE_THRESHOLD`.
170pub fn assert_nonzero_after<P: PluginExport>(result: &DriverResult<P>, t: Duration) {
171    let start = duration_to_frames(result, t);
172    let peak = peak_in_range(result, start, result.total_frames);
173    assert!(
174        peak > AUDIBLE_THRESHOLD,
175        "Expected non-zero audio after {:.3} ms but peak was {peak}",
176        t.as_secs_f64() * 1000.0
177    );
178}
179
180/// Assert silence across `[start, end)`. More precise than
181/// `assert_silence_after` when both endpoints matter.
182///
183/// # Panics
184///
185/// Panics if `start >= end`, or if any sample in the half-open
186/// range has absolute value at or above `AUDIBLE_THRESHOLD`.
187pub fn assert_silence_between<P: PluginExport>(
188    result: &DriverResult<P>,
189    start: Duration,
190    end: Duration,
191) {
192    let s = duration_to_frames(result, start);
193    let e = duration_to_frames(result, end);
194    assert!(s < e, "assert_silence_between: start >= end");
195    let peak = peak_in_range(result, s, e);
196    assert!(
197        peak < AUDIBLE_THRESHOLD,
198        "Expected silence in [{:.3} ms, {:.3} ms) but peak was {peak}",
199        start.as_secs_f64() * 1000.0,
200        end.as_secs_f64() * 1000.0
201    );
202}
203
204/// Assert non-zero audio somewhere in `[start, end)`.
205///
206/// # Panics
207///
208/// Panics if `start >= end`, or if every sample in the half-open
209/// range is at or below `AUDIBLE_THRESHOLD`.
210pub fn assert_nonzero_between<P: PluginExport>(
211    result: &DriverResult<P>,
212    start: Duration,
213    end: Duration,
214) {
215    let s = duration_to_frames(result, start);
216    let e = duration_to_frames(result, end);
217    assert!(s < e, "assert_nonzero_between: start >= end");
218    let peak = peak_in_range(result, s, e);
219    assert!(
220        peak > AUDIBLE_THRESHOLD,
221        "Expected non-zero audio in [{:.3} ms, {:.3} ms) but peak was {peak}",
222        start.as_secs_f64() * 1000.0,
223        end.as_secs_f64() * 1000.0
224    );
225}
226
227// ---------------------------------------------------------------------------
228// Meter assertions
229// ---------------------------------------------------------------------------
230
231fn final_meters<P: PluginExport>(result: &DriverResult<P>) -> &[(u32, f32)] {
232    match &result.meters {
233        MeterReadings::Final(v) => v.as_slice(),
234        MeterReadings::PerBlock(blocks) => blocks.last().map_or(&[], std::vec::Vec::as_slice),
235        MeterReadings::None => panic!(
236            "meter assertion called but CaptureSpec::meters was MeterCapture::None - \
237             call .capture_meters(MeterCapture::Final) on the driver"
238        ),
239    }
240}
241
242/// Assert the meter identified by `id` read above `threshold` at
243/// the end of the run.
244///
245/// # Panics
246///
247/// Panics if no meter with `id` is in the result, the meter's
248/// final value is at or below `threshold`, or
249/// `CaptureSpec::meters` was `MeterCapture::None` (call
250/// `.capture_meters(MeterCapture::Final)` on the driver).
251pub fn assert_meter_above<P: PluginExport>(result: &DriverResult<P>, id: u32, threshold: f32) {
252    let meters = final_meters(result);
253    let Some((_, value)) = meters.iter().find(|(mid, _)| *mid == id) else {
254        panic!(
255            "Meter id {id} not found in DriverResult. Available ids: {:?}",
256            meters.iter().map(|(i, _)| i).collect::<Vec<_>>()
257        );
258    };
259    assert!(
260        *value > threshold,
261        "Meter {id} read {value} at end-of-run, expected > {threshold}"
262    );
263}
264
265/// Assert the meter identified by `id` read below `threshold` at
266/// the end of the run.
267///
268/// # Panics
269///
270/// Panics if no meter with `id` is in the result, the meter's
271/// final value is at or above `threshold`, or
272/// `CaptureSpec::meters` was `MeterCapture::None`.
273pub fn assert_meter_below<P: PluginExport>(result: &DriverResult<P>, id: u32, threshold: f32) {
274    let meters = final_meters(result);
275    let Some((_, value)) = meters.iter().find(|(mid, _)| *mid == id) else {
276        panic!(
277            "Meter id {id} not found in DriverResult. Available ids: {:?}",
278            meters.iter().map(|(i, _)| i).collect::<Vec<_>>()
279        );
280    };
281    assert!(
282        *value < threshold,
283        "Meter {id} read {value} at end-of-run, expected < {threshold}"
284    );
285}
286
287// ---------------------------------------------------------------------------
288// Output-event assertions
289// ---------------------------------------------------------------------------
290
291/// Assert exactly `n` output events were emitted by the plugin
292/// across the run. Requires `.capture_output_events(true)` on the
293/// driver.
294///
295/// # Panics
296///
297/// Panics if `result.output_events.len() != n`.
298pub fn assert_output_event_count<P: PluginExport>(result: &DriverResult<P>, n: usize) {
299    assert_eq!(
300        result.output_events.len(),
301        n,
302        "Expected {n} output events, got {} ({:?})",
303        result.output_events.len(),
304        result
305            .output_events
306            .iter()
307            .map(|e| (e.sample_offset, std::mem::discriminant(&e.body)))
308            .collect::<Vec<_>>()
309    );
310}