truce_driver/lib.rs
1//! Headless driver for truce plugins.
2//!
3//! Instantiate a plugin, feed it scripted audio + events for a fixed
4//! duration, capture the output. Used by:
5//!
6//! - **Tests** via [`truce-test`](../truce_test) - adds assertion
7//! helpers on top of the captured [`DriverResult`].
8//! - **The standalone host's offline-render path** -
9//! `cargo truce run --no-playback` parses CLI flags into an
10//! [`InputSource::Buffer`] + [`Script`], runs [`PluginDriver`],
11//! writes the captured audio out as WAV.
12//! - **Plugin authors writing custom `main.rs` bins** - batch CI
13//! renders, demo audio generation, preset rendering pipelines.
14//!
15//! No cpal, no midir, no live-audio plumbing. The driver does:
16//!
17//! 1. `P::create()` → `init()` → `reset()` → param `set_sample_rate`
18//! + `snap_smoothers`.
19//! 2. Apply `state_file` bytes via `plugin.load_state(...)`.
20//! 3. Run the `setup` closure (`&mut Plugin`).
21//! 4. Loop blocks: pull script events into the block window, run
22//! `plugin.process(...)`, append the output.
23//! 5. Capture meters / output events / per-block snapshots
24//! according to [`CaptureSpec`].
25//!
26//! See [`PluginDriver`] for the builder surface.
27//!
28//! ```ignore
29//! use std::time::Duration;
30//! use truce_driver::{InputSource, PluginDriver};
31//!
32//! let result = PluginDriver::<MyPlugin>::new()
33//! .sample_rate(48_000.0)
34//! .duration(Duration::from_secs(2))
35//! .input(InputSource::Constant(0.5))
36//! .set_param(MyParamId::Gain, 0.7)
37//! .script(|s| {
38//! s.note_on(60, 0.8);
39//! s.wait_ms(500);
40//! s.note_off(60);
41//! })
42//! .run();
43//! ```
44
45use std::path::PathBuf;
46use std::time::Duration;
47
48use truce_core::buffer::RawBufferScratch;
49#[cfg(feature = "wav")]
50use truce_core::cast::sample_rate_u32;
51use truce_core::cast::{len_u32, sample_count_usize};
52use truce_core::events::{Event, EventBody, EventList, TransportInfo};
53use truce_core::export::PluginExport;
54use truce_core::info::PluginCategory;
55use truce_core::plugin::Plugin;
56use truce_core::process::ProcessContext;
57use truce_params::Params;
58
59// ---------------------------------------------------------------------------
60// InputSource
61// ---------------------------------------------------------------------------
62
63/// What audio gets fed into the plugin's input bus each block.
64///
65/// `Silence` is the default. Effects with smoothers / lookahead /
66/// modulators usually want one of the non-silent variants to reach
67/// steady state during the run.
68#[derive(Default)]
69pub enum InputSource {
70 /// Zero on every channel for the whole run.
71 #[default]
72 Silence,
73 /// Constant DC: every sample is `value` on every channel.
74 Constant(f32),
75 /// Channel-major buffer (`bufs[ch][frame]`). Length must be
76 /// `>= total_frames`; shorter buffers panic at run-time. The
77 /// channel count must match the driver's `channels`.
78 Buffer(Vec<Vec<f32>>),
79 /// `(frame_idx, sample_rate) -> sample`. Same value goes into
80 /// every channel. Useful for sweeps / noise / generators.
81 Generator(Box<dyn FnMut(usize, f64) -> f32>),
82}
83
84// ---------------------------------------------------------------------------
85// TransportSpec
86// ---------------------------------------------------------------------------
87
88/// Transport state visible to the plugin's `ProcessContext`.
89#[derive(Clone)]
90pub struct TransportSpec {
91 pub bpm: f64,
92 pub playing: bool,
93 pub position_beats: f64,
94 pub time_signature: (u8, u8),
95}
96
97impl Default for TransportSpec {
98 fn default() -> Self {
99 Self {
100 bpm: 120.0,
101 playing: false,
102 position_beats: 0.0,
103 time_signature: (4, 4),
104 }
105 }
106}
107
108// ---------------------------------------------------------------------------
109// MeterCapture / CaptureSpec
110// ---------------------------------------------------------------------------
111
112#[derive(Clone, Copy, Default)]
113pub enum MeterCapture {
114 None,
115 /// One snapshot at end-of-run.
116 #[default]
117 Final,
118 /// One snapshot per process block (post-process).
119 PerBlock,
120}
121
122#[derive(Clone, Copy)]
123pub struct CaptureSpec {
124 /// Capture the rendered audio. Default true - turning it off
125 /// means `DriverResult::output` is empty (use case: a meter-only
126 /// run that doesn't care about audio).
127 pub audio: bool,
128 pub meters: MeterCapture,
129 /// Capture events the plugin emits via `ProcessContext::output_events`.
130 pub output_events: bool,
131 /// Capture each block's `(param_id, plain_value)` map. Off by
132 /// default; tests that need it opt in.
133 pub block_snapshots: bool,
134}
135
136impl Default for CaptureSpec {
137 /// Audio + final meters captured; output events + block snapshots
138 /// off. Anything else is rarely the right starting point for a
139 /// driver test.
140 fn default() -> Self {
141 Self {
142 audio: true,
143 meters: MeterCapture::Final,
144 output_events: false,
145 block_snapshots: false,
146 }
147 }
148}
149
150// ---------------------------------------------------------------------------
151// Script
152// ---------------------------------------------------------------------------
153
154/// Sample-accurate sequence of events fed to the plugin during a
155/// run. Cursor advances via `wait_ms` / `wait_samples`; events
156/// land at the current cursor position.
157#[derive(Default, Clone)]
158pub struct Script {
159 /// `(sample_offset, body)` - sorted by offset on `run`.
160 events: Vec<(usize, EventBody)>,
161 cursor_samples: usize,
162 sample_rate: f64,
163}
164
165impl Script {
166 pub fn note_on(&mut self, note: u8, velocity: f32) {
167 self.push(EventBody::NoteOn {
168 group: 0,
169 channel: 0,
170 note,
171 velocity: truce_core::midi::denorm_7bit(velocity),
172 });
173 }
174
175 pub fn note_off(&mut self, note: u8) {
176 self.push(EventBody::NoteOff {
177 group: 0,
178 channel: 0,
179 note,
180 velocity: 0,
181 });
182 }
183
184 pub fn cc(&mut self, cc: u8, value: f32) {
185 self.push(EventBody::ControlChange {
186 group: 0,
187 channel: 0,
188 cc,
189 value: truce_core::midi::denorm_7bit(value),
190 });
191 }
192
193 pub fn pitch_bend(&mut self, normalized: f32) {
194 self.push(EventBody::PitchBend {
195 group: 0,
196 channel: 0,
197 value: truce_core::midi::denorm_pitch_bend(normalized),
198 });
199 }
200
201 pub fn channel_pressure(&mut self, value: f32) {
202 self.push(EventBody::ChannelPressure {
203 group: 0,
204 channel: 0,
205 pressure: truce_core::midi::denorm_7bit(value),
206 });
207 }
208
209 /// Set a parameter to a normalized [0.0, 1.0] value, sample-
210 /// accurate at the cursor's offset. The plugin sees a
211 /// `ParamChange` event in its event list - same delivery path
212 /// CLAP / VST3 / AU automation lanes use.
213 pub fn set_param(&mut self, id: impl Into<u32>, normalized: f64) {
214 self.push(EventBody::ParamChange {
215 id: id.into(),
216 value: normalized,
217 });
218 }
219
220 /// Push an arbitrary `EventBody` at the current cursor - escape
221 /// hatch for events `Script` doesn't have a typed helper for.
222 pub fn raw(&mut self, body: EventBody) {
223 self.push(body);
224 }
225
226 /// Advance the cursor by `ms` milliseconds at the run's sample
227 /// rate. Resolves correctly only after `Script::sample_rate` is
228 /// filled in by `PluginDriver::run` - call sites can rely on the
229 /// driver wiring it before scanning the script.
230 ///
231 /// `wait_ms(0)` is *almost always* a copy-paste artifact and
232 /// trips a `debug_assert` in dev builds. If you genuinely want
233 /// "schedule the next event at the current cursor", that's the
234 /// implicit default - drop the call. If you want a typed no-op
235 /// for clarity (e.g. mirroring a user-supplied delay variable
236 /// that *can* be zero), use `wait_samples(0)` which doesn't
237 /// trip the assertion.
238 //
239 // `ms as f64` for sample-rate math; ms in script wait calls is
240 // bounded by test runtime, far below 2^52.
241 #[allow(clippy::cast_precision_loss)]
242 pub fn wait_ms(&mut self, ms: u64) {
243 debug_assert!(
244 ms != 0,
245 "wait_ms(0) is a no-op - drop the call, or use wait_samples(0) if you mean it"
246 );
247 let sr = if self.sample_rate > 0.0 {
248 self.sample_rate
249 } else {
250 44_100.0
251 };
252 let samples_f = (sr * ms as f64) / 1000.0;
253 let samples = sample_count_usize(samples_f);
254 self.cursor_samples = self.cursor_samples.saturating_add(samples);
255 }
256
257 /// Advance the cursor by `n` samples.
258 pub fn wait_samples(&mut self, n: usize) {
259 self.cursor_samples += n;
260 }
261
262 fn push(&mut self, body: EventBody) {
263 self.events.push((self.cursor_samples, body));
264 }
265}
266
267// ---------------------------------------------------------------------------
268// DriverResult
269// ---------------------------------------------------------------------------
270
271/// Captured audio + metadata + plugin instance from a
272/// [`PluginDriver`] run.
273///
274/// Holds the post-run plugin instance (`plugin: P`) so post-run
275/// assertions can read params or custom state directly. As a side
276/// effect, `DriverResult: !Send` whenever `P: !Send` - which is
277/// true for plugins built via `truce::plugin!` (the generated
278/// `Plugin` alias is `unsafe impl Send` only conditionally on its
279/// inner `Params` type). Test code rarely cares; document if you
280/// hit it.
281pub struct DriverResult<P: PluginExport> {
282 /// Channel-major output: `output[ch][frame]`. Empty when
283 /// `CaptureSpec::audio == false`.
284 pub output: Vec<Vec<f32>>,
285 pub sample_rate: f64,
286 pub block_size: usize,
287 pub total_frames: usize,
288
289 /// Final-or-per-block meter readings.
290 pub meters: MeterReadings,
291
292 /// Output events emitted by the plugin. Offsets are absolute
293 /// (cumulative across blocks). Empty unless
294 /// `CaptureSpec::output_events`.
295 pub output_events: Vec<Event>,
296
297 /// Per-block param snapshots (one Vec per block), each entry
298 /// `(param_id, plain_value)`. Empty unless
299 /// `CaptureSpec::block_snapshots`.
300 pub block_snapshots: Vec<Vec<(u32, f64)>>,
301
302 /// Post-run plugin instance. Read params or custom state from
303 /// here when writing assertions over the final state.
304 pub plugin: P,
305}
306
307#[derive(Default)]
308pub enum MeterReadings {
309 #[default]
310 None,
311 Final(Vec<(u32, f32)>),
312 PerBlock(Vec<Vec<(u32, f32)>>),
313}
314
315#[cfg(feature = "wav")]
316impl<P: PluginExport> DriverResult<P> {
317 /// Write the captured audio as a 32-bit float WAV. Available
318 /// when the `wav` feature is enabled. Convenience shim around
319 /// `hound`; if you need a different sample format, drive `hound`
320 /// yourself off `result.output` / `result.sample_rate`.
321 ///
322 /// # Errors
323 ///
324 /// Returns `InvalidData` if no audio was captured (the driver
325 /// was run with `CaptureSpec::audio == false`), or any I/O /
326 /// encoder error from `hound` while creating / writing the file.
327 pub fn write_wav(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
328 if self.output.is_empty() {
329 return Err(std::io::Error::new(
330 std::io::ErrorKind::InvalidData,
331 "no audio captured (CaptureSpec::audio was false)",
332 ));
333 }
334 // Channel counts are < u16::MAX in practice (typical: 1-8);
335 // sample rate goes through `cast::sample_rate_u32` which
336 // debug-asserts the (positive, ≤ u32::MAX) preconditions.
337 #[allow(clippy::cast_possible_truncation)]
338 let spec = hound::WavSpec {
339 channels: self.output.len() as u16,
340 sample_rate: sample_rate_u32(self.sample_rate),
341 bits_per_sample: 32,
342 sample_format: hound::SampleFormat::Float,
343 };
344 let mut wav = hound::WavWriter::create(path, spec).map_err(io_err)?;
345 for frame in 0..self.total_frames {
346 for ch in &self.output {
347 wav.write_sample(ch[frame]).map_err(io_err)?;
348 }
349 }
350 wav.finalize().map_err(io_err)?;
351 Ok(())
352 }
353}
354
355#[cfg(feature = "wav")]
356fn io_err(e: hound::Error) -> std::io::Error {
357 std::io::Error::other(e)
358}
359
360// ---------------------------------------------------------------------------
361// PluginDriver builder
362// ---------------------------------------------------------------------------
363
364type SetupFn<P> = Box<dyn FnOnce(&mut P, &SetupContext)>;
365
366/// Context passed to the [`PluginDriver::setup`] closure. Carries the
367/// driver state that's been *resolved* by the time setup runs - in
368/// particular the auto-detected channel count, which would otherwise
369/// be invisible to the closure (the user's `&mut P` doesn't know).
370///
371/// Test code that needs to size scratch buffers, validate bus layouts,
372/// or branch on stereo-vs-mono before the first process block reads
373/// these fields directly:
374///
375/// ```ignore
376/// PluginDriver::<MyPlugin>::new()
377/// .setup(|plugin, ctx| {
378/// assert_eq!(ctx.channels, 2, "stereo run expected");
379/// plugin.scratch = vec![0.0; ctx.block_size * ctx.channels];
380/// })
381/// .run();
382/// ```
383#[derive(Clone, Copy, Debug)]
384pub struct SetupContext {
385 /// Channels per audio bus that the driver will run with. Either
386 /// the value passed to [`PluginDriver::channels`] or the
387 /// auto-resolved default from `P::bus_layouts()[0]`.
388 pub channels: usize,
389 /// Sample rate the upcoming process loop will use.
390 pub sample_rate: f64,
391 /// Block size the upcoming process loop will use.
392 pub block_size: usize,
393}
394
395enum StateSource {
396 Blob(Vec<u8>),
397 File(PathBuf),
398}
399
400pub struct PluginDriver<P: PluginExport> {
401 sample_rate: f64,
402 channels: Option<usize>,
403 block_size: usize,
404 duration: Duration,
405
406 transport: TransportSpec,
407 input: InputSource,
408 script: Script,
409
410 /// Pending state source. Either an in-memory blob (set directly by
411 /// callers that already have the bytes) or a path to read at
412 /// `run()` time. Reading is deferred so a builder that's
413 /// constructed but never `.run()`-ed doesn't touch the disk, and
414 /// I/O errors surface alongside the rest of the run rather than
415 /// inside an unrelated builder method.
416 state_source: Option<StateSource>,
417 /// Manifest dir for `state_file` path resolution. Set by callers
418 /// that pass a relative path; absolute paths bypass.
419 manifest_dir: PathBuf,
420 /// `.set_param(id, v)` shortcuts - applied after state load,
421 /// before the `setup` closure.
422 param_overrides: Vec<(u32, f64)>,
423 /// `&mut P` closure run after state load + param overrides.
424 setup: Option<SetupFn<P>>,
425
426 capture: CaptureSpec,
427}
428
429impl<P: PluginExport> Default for PluginDriver<P> {
430 fn default() -> Self {
431 Self::new()
432 }
433}
434
435impl<P: PluginExport> PluginDriver<P> {
436 #[must_use]
437 pub fn new() -> Self {
438 Self {
439 sample_rate: 44_100.0,
440 channels: None,
441 block_size: 512,
442 duration: Duration::from_secs(1),
443 transport: TransportSpec::default(),
444 input: InputSource::Silence,
445 script: Script::default(),
446 state_source: None,
447 manifest_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
448 param_overrides: Vec::new(),
449 setup: None,
450 capture: CaptureSpec::default(),
451 }
452 }
453
454 #[must_use]
455 pub fn sample_rate(mut self, sr: f64) -> Self {
456 self.sample_rate = sr;
457 self
458 }
459 #[must_use]
460 pub fn channels(mut self, n: usize) -> Self {
461 self.channels = Some(n);
462 self
463 }
464 #[must_use]
465 pub fn block_size(mut self, n: usize) -> Self {
466 self.block_size = n;
467 self
468 }
469 #[must_use]
470 pub fn duration(mut self, d: Duration) -> Self {
471 self.duration = d;
472 self
473 }
474
475 #[must_use]
476 pub fn transport(mut self, t: TransportSpec) -> Self {
477 self.transport = t;
478 self
479 }
480 #[must_use]
481 pub fn bpm(mut self, bpm: f64) -> Self {
482 self.transport.bpm = bpm;
483 self
484 }
485 #[must_use]
486 pub fn playing(mut self, playing: bool) -> Self {
487 self.transport.playing = playing;
488 self
489 }
490
491 #[must_use]
492 pub fn input(mut self, source: InputSource) -> Self {
493 self.input = source;
494 self
495 }
496
497 /// Build a script via a closure. Each `set_param` / `note_on`
498 /// / etc. lands at the cursor's current sample offset; `wait_ms`
499 /// advances the cursor.
500 //
501 // `usize as f64` for the sample-offset rescale. Test runs hold
502 // counts well below 2^52.
503 #[allow(clippy::cast_precision_loss)]
504 #[must_use]
505 pub fn script(mut self, f: impl FnOnce(&mut Script)) -> Self {
506 // If a previous `.script` call already populated events at a
507 // different SR (because `.sample_rate(...)` was called in
508 // between two `.script` calls), rescale both the cursor and
509 // the existing event offsets to the current SR before
510 // appending. The previous shape just overwrote
511 // `script.sample_rate` and treated the pre-existing offsets
512 // as the new SR's, silently shifting "100 ms at 44.1 kHz"
513 // (4410 samples) to "91.875 ms at 48 kHz" once the new SR
514 // was painted onto the stale cursor.
515 //
516 // The single-`.script` case (the common one) is handled by
517 // the run-time rescale at `run()` - both safety nets are
518 // needed so any builder ordering produces correct offsets.
519 let old_sr = self.script.sample_rate;
520 let new_sr = self.sample_rate;
521 if old_sr > 0.0 && (old_sr - new_sr).abs() > f64::EPSILON {
522 let scale = new_sr / old_sr;
523 self.script.cursor_samples =
524 sample_count_usize(((self.script.cursor_samples as f64) * scale).round());
525 for (off, _) in &mut self.script.events {
526 *off = sample_count_usize(((*off as f64) * scale).round());
527 }
528 }
529 self.script.sample_rate = new_sr;
530 f(&mut self.script);
531 self
532 }
533
534 /// Set a parameter to a normalized [0, 1] value before the run
535 /// starts. Equivalent to a `setup(|p| p.params().set_normalized(id, v))`
536 /// closure but written as one builder call. Multiple `.set_param`
537 /// calls compose; they run in declaration order, before the
538 /// `.setup` closure (if any).
539 ///
540 /// For automation *during* a run, use `.script(|s| s.set_param(...))`,
541 /// which emits a sample-accurate `ParamChange` event the plugin
542 /// processes inline.
543 #[must_use]
544 pub fn set_param(mut self, id: impl Into<u32>, normalized: f64) -> Self {
545 self.param_overrides.push((id.into(), normalized));
546 self
547 }
548
549 /// Anchor for `state_file` relative paths. Defaults to the
550 /// process CWD; callers from `truce-test` override it with the
551 /// test crate's `CARGO_MANIFEST_DIR` via the `screenshot!`-style
552 /// macro pattern (see `truce-test`'s wrapping macro).
553 #[must_use]
554 pub fn manifest_dir(mut self, dir: impl Into<PathBuf>) -> Self {
555 self.manifest_dir = dir.into();
556 self
557 }
558
559 /// Mutate the plugin between init/reset+state-load and the
560 /// first process block. Use when the test needs more than
561 /// param tweaks - load arbitrary fields, drive a warmup
562 /// `process()` call to populate meters / lookahead, etc.
563 ///
564 /// Composes with `state_file` (state loads first) and
565 /// `set_param` (shortcuts apply first); the closure runs last.
566 ///
567 /// The closure receives a [`SetupContext`] with the resolved
568 /// channel count, sample rate, and block size - exactly what the
569 /// upcoming process loop will use. Channel resolution happens
570 /// before setup runs, so a closure that allocates per-channel
571 /// scratch can size correctly without re-querying `P::bus_layouts`.
572 #[must_use]
573 pub fn setup<F: FnOnce(&mut P, &SetupContext) + 'static>(mut self, f: F) -> Self {
574 self.setup = Some(Box::new(f));
575 self
576 }
577
578 /// Apply an in-memory `.pluginstate` blob via
579 /// `plugin.load_state(&bytes)` at the same lifecycle point as
580 /// [`Self::state_file`] (after init/reset, before `set_param`
581 /// shortcuts and `setup`). Use when the test already has the
582 /// bytes in hand and doesn't want a temp file round-trip.
583 #[must_use]
584 pub fn state_blob(mut self, bytes: Vec<u8>) -> Self {
585 self.state_source = Some(StateSource::Blob(bytes));
586 self
587 }
588
589 /// Read a `.pluginstate` file (the standalone host's `Cmd+S`
590 /// save format) and apply it via `plugin.load_state(&bytes)`
591 /// after init/reset and before any `set_param` overrides /
592 /// `setup` closure. Path is resolved relative to
593 /// `manifest_dir`, or used as-is if absolute.
594 ///
595 /// I/O is deferred to `.run()`. The builder records the path; a
596 /// missing or unreadable file panics at run time with the resolved
597 /// path in the message, alongside other run-time failures, rather
598 /// than from inside this method.
599 #[must_use]
600 pub fn state_file(mut self, path: impl Into<PathBuf>) -> Self {
601 let raw = path.into();
602 let resolved = if raw.is_absolute() {
603 raw
604 } else {
605 self.manifest_dir.join(&raw)
606 };
607 self.state_source = Some(StateSource::File(resolved));
608 self
609 }
610
611 #[must_use]
612 pub fn capture_audio(mut self, on: bool) -> Self {
613 self.capture.audio = on;
614 self
615 }
616 #[must_use]
617 pub fn capture_meters(mut self, m: MeterCapture) -> Self {
618 self.capture.meters = m;
619 self
620 }
621 #[must_use]
622 pub fn capture_output_events(mut self, on: bool) -> Self {
623 self.capture.output_events = on;
624 self
625 }
626 #[must_use]
627 pub fn capture_block_snapshots(mut self, on: bool) -> Self {
628 self.capture.block_snapshots = on;
629 self
630 }
631
632 /// Drive the plugin and return the captured result.
633 ///
634 /// # Panics
635 ///
636 /// Panics if a `state_file(...)` path cannot be read. Plugin
637 /// `init` / `reset` / `process` / `restore_values` panics propagate
638 /// unchanged so the underlying failure surfaces with its original
639 /// stack rather than being wrapped.
640 //
641 // The driver loop widens `usize`-counted sample offsets and
642 // `i64` transport positions to `f64`. Driver test runs are
643 // bounded well below 2^52 frames.
644 //
645 // Sequential block-driving pipeline: setup → per-block loop →
646 // result assembly. Extracting `prepare_script_events` and
647 // `fill_input_block` already split out the largest reusable seams;
648 // further extraction would force a 20+ field context struct that
649 // hides exactly the linear control flow that makes this readable.
650 #[allow(clippy::cast_precision_loss, clippy::too_many_lines)]
651 #[must_use]
652 pub fn run(mut self) -> DriverResult<P> {
653 // Build + activate.
654 let mut plugin = P::create();
655 plugin.init();
656 plugin.reset(self.sample_rate, self.block_size);
657 plugin.params().set_sample_rate(self.sample_rate);
658 plugin.params().snap_smoothers();
659
660 // 1. State load (if any). Reads from disk here rather than at
661 // builder time, so the I/O failure (if any) surfaces as a
662 // run-time panic at the same lifecycle stage as smoother /
663 // process panics.
664 let state_bytes =
665 match self.state_source.take() {
666 Some(StateSource::Blob(b)) => Some(b),
667 Some(StateSource::File(path)) => Some(std::fs::read(&path).unwrap_or_else(|e| {
668 panic!("state_file: failed to read {}: {e}", path.display())
669 })),
670 None => None,
671 };
672 if let Some(bytes) = state_bytes.as_deref()
673 && let Err(e) = plugin.load_state(bytes)
674 {
675 eprintln!("truce-driver: load_state failed: {e}");
676 }
677
678 // 2. Param overrides (the `.set_param(...)` shortcuts).
679 for (id, value) in &self.param_overrides {
680 plugin.params().set_normalized(*id, *value);
681 }
682 plugin.params().snap_smoothers();
683
684 // Resolve channel count *before* the setup closure runs so the
685 // closure's `SetupContext` can expose it. The previous order
686 // (setup first, channels after) meant a setup closure that
687 // wanted to size scratch buffers had to re-query `P::bus_layouts`
688 // by hand, which silently disagreed with the driver's auto-pick
689 // when callers later passed `.channels(...)`.
690 let channels = self.channels.unwrap_or_else(|| {
691 let layouts = P::bus_layouts();
692 let layout = &layouts[0];
693 let outs = layout.total_output_channels() as usize;
694 if outs > 0 { outs } else { 2 }
695 });
696
697 // 3. Setup closure (most general). Receives the resolved
698 // `SetupContext` so it can size per-channel state, branch on
699 // mono/stereo, etc.
700 if let Some(f) = self.setup.take() {
701 let ctx = SetupContext {
702 channels,
703 sample_rate: self.sample_rate,
704 block_size: self.block_size,
705 };
706 f(&mut plugin, &ctx);
707 }
708
709 let is_effect = P::info().category == PluginCategory::Effect;
710 let total_frames = sample_count_usize(self.duration.as_secs_f64() * self.sample_rate);
711
712 // Capture buffers.
713 let mut output: Vec<Vec<f32>> = if self.capture.audio {
714 (0..channels)
715 .map(|_| Vec::with_capacity(total_frames))
716 .collect()
717 } else {
718 Vec::new()
719 };
720 let mut output_events_capture: Vec<Event> = Vec::new();
721 let mut per_block_meters: Vec<Vec<(u32, f32)>> = Vec::new();
722 let mut block_snapshots: Vec<Vec<(u32, f64)>> = Vec::new();
723
724 // Pre-resolve input source into per-block chunks. For
725 // Buffer / Generator we lazy-fill per block; for Constant
726 // we just produce a single fill-value to broadcast.
727 let constant_value: Option<f32> = match &self.input {
728 InputSource::Constant(v) => Some(*v),
729 InputSource::Silence => Some(0.0),
730 _ => None,
731 };
732
733 let script_events = prepare_script_events(&mut self.script, self.sample_rate, total_frames);
734
735 // Transport tracker.
736 let mut transport_pos_beats = self.transport.position_beats;
737 let beats_per_second = self.transport.bpm / 60.0;
738
739 let meter_ids: Vec<u32> = plugin.params().meter_ids();
740
741 // Validate `InputSource::Buffer` shape up front so a mismatched
742 // channel count panics before the run starts (rather than
743 // mid-loop after capture buffers have been partially built).
744 if let InputSource::Buffer(bufs) = &self.input {
745 assert_eq!(
746 bufs.len(),
747 channels,
748 "InputSource::Buffer channel count {} doesn't match driver channels {channels}",
749 bufs.len(),
750 );
751 }
752
753 // Pre-allocate per-block scratch outside the loop. Reusing the
754 // buffers keeps the hot loop allocation-free for `Silence` /
755 // `Constant` / `Buffer` and reduces per-block work for
756 // `Generator`.
757 let mut out_bufs: Vec<Vec<f32>> = (0..channels)
758 .map(|_| vec![0.0f32; self.block_size])
759 .collect();
760 let mut in_bufs: Vec<Vec<f32>> = if is_effect {
761 (0..channels)
762 .map(|_| vec![0.0f32; self.block_size])
763 .collect()
764 } else {
765 Vec::new()
766 };
767
768 let mut cursor = 0usize;
769 let mut event_list = EventList::with_capacity(script_events.len().min(256));
770 // Hoisted out of the loop and reused - `EventList::default()`
771 // does the `EVENT_LIST_PREALLOC` reservation, so re-constructing
772 // it per block re-allocates on the first push.
773 let mut output_events_block = EventList::default();
774
775 // Routes the offline-render loop through the same
776 // `RawBufferScratch::build` helper every format wrapper uses,
777 // so `Plugin::Sample = f64` plugins (prelude64) get widening
778 // scratch transparently. For `Sample = f32` it's still
779 // zero-copy through the host f32 slices.
780 let mut scratch: RawBufferScratch<<P as Plugin>::Sample> = RawBufferScratch::default();
781 scratch.ensure_capacity(in_bufs.len(), out_bufs.len(), self.block_size);
782 let mut in_ptrs: Vec<*const f32> = Vec::with_capacity(in_bufs.len());
783 let mut out_ptrs: Vec<*mut f32> = Vec::with_capacity(out_bufs.len());
784 while cursor < total_frames {
785 let block_len = self.block_size.min(total_frames - cursor);
786
787 // Resize scratch to `block_len` (cheap: identical size on
788 // every iteration except the final tail block).
789 for b in &mut out_bufs {
790 b.clear();
791 b.resize(block_len, 0.0);
792 }
793
794 // Pull events that fall inside [cursor, cursor+block_len).
795 // Reuses the same `EventList` across the offline-render
796 // loop instead of constructing a fresh one each block.
797 event_list.clear();
798 for (off, body) in &script_events {
799 if *off >= cursor && *off < cursor + block_len {
800 event_list.push(Event {
801 sample_offset: len_u32(*off - cursor),
802 body: *body,
803 });
804 }
805 }
806
807 if is_effect {
808 fill_input_block(
809 &mut in_bufs,
810 &mut self.input,
811 constant_value,
812 cursor,
813 block_len,
814 self.sample_rate,
815 );
816 }
817
818 // Mirror `in_bufs` / `out_bufs` into raw pointer arrays for
819 // `RawBufferScratch::build`. Cheap (a handful of pointers
820 // per block) and keeps the conversion-aware path single-sourced.
821 in_ptrs.clear();
822 out_ptrs.clear();
823 for b in &in_bufs {
824 in_ptrs.push(b.as_ptr());
825 }
826 for b in &mut out_bufs {
827 out_ptrs.push(b.as_mut_ptr());
828 }
829 let block_u32 = len_u32(block_len);
830 let num_in_u32 = len_u32(in_ptrs.len());
831 let num_out_u32 = len_u32(out_ptrs.len());
832 // SAFETY: pointers borrowed from `in_bufs` / `out_bufs`
833 // which outlive `audio`; each `Vec<f32>` was resized to
834 // `block_len` above.
835 let mut audio = unsafe {
836 scratch.build(
837 in_ptrs.as_ptr(),
838 out_ptrs.as_mut_ptr(),
839 num_in_u32,
840 num_out_u32,
841 block_u32,
842 P::supports_in_place(),
843 )
844 };
845
846 // Transport snapshot for this block.
847 let transport_info = TransportInfo {
848 playing: self.transport.playing,
849 tempo: self.transport.bpm,
850 time_sig_num: self.transport.time_signature.0,
851 time_sig_den: self.transport.time_signature.1,
852 position_seconds: cursor as f64 / self.sample_rate,
853 position_beats: transport_pos_beats,
854 bar_start_beats: 0.0,
855 ..Default::default()
856 };
857 output_events_block.clear();
858 let mut ctx = ProcessContext::new(
859 &transport_info,
860 self.sample_rate,
861 block_len,
862 &mut output_events_block,
863 );
864
865 plugin.process(&mut audio, &event_list, &mut ctx);
866 let _ = audio;
867 // Narrow rendered f64 output back into the f32 `out_bufs`
868 // when the plugin's `Sample = f64`. No-op otherwise.
869 // SAFETY: same pointers + counts as the `build` call above.
870 unsafe {
871 scratch.finish_widening_f32(out_ptrs.as_mut_ptr(), num_out_u32, block_u32);
872 }
873
874 // Capture audio. `out_bufs` is reused across iterations,
875 // so we copy out rather than consuming.
876 if self.capture.audio {
877 for (ch, buf) in out_bufs.iter().enumerate() {
878 output[ch].extend_from_slice(buf);
879 }
880 }
881
882 // Capture output events with absolute offsets. Use
883 // `saturating_add` so a long run (~24h at 48 kHz puts
884 // `cursor` past `u32::MAX`) clamps the offset rather than
885 // wrapping. The captured offsets are still informative
886 // up to that point and clamped beyond rather than
887 // silently mis-attributed to early frames.
888 if self.capture.output_events {
889 let cursor_u32 = u32::try_from(cursor).unwrap_or(u32::MAX);
890 for ev in output_events_block.iter() {
891 let mut e = *ev;
892 e.sample_offset = e.sample_offset.saturating_add(cursor_u32);
893 output_events_capture.push(e);
894 }
895 }
896
897 // Capture per-block meters / param snapshots.
898 if matches!(self.capture.meters, MeterCapture::PerBlock) {
899 per_block_meters.push(
900 meter_ids
901 .iter()
902 .map(|id| (*id, plugin.get_meter(*id)))
903 .collect(),
904 );
905 }
906 if self.capture.block_snapshots {
907 let infos = plugin.params().param_infos();
908 block_snapshots.push(
909 infos
910 .iter()
911 .map(|pi| (pi.id, plugin.params().get_plain(pi.id).unwrap_or(0.0)))
912 .collect(),
913 );
914 }
915
916 // Advance transport.
917 if self.transport.playing {
918 let block_seconds = block_len as f64 / self.sample_rate;
919 transport_pos_beats += block_seconds * beats_per_second;
920 }
921
922 cursor += block_len;
923 }
924
925 let meters = match self.capture.meters {
926 MeterCapture::None => MeterReadings::None,
927 MeterCapture::Final => MeterReadings::Final(
928 meter_ids
929 .iter()
930 .map(|id| (*id, plugin.get_meter(*id)))
931 .collect(),
932 ),
933 MeterCapture::PerBlock => MeterReadings::PerBlock(per_block_meters),
934 };
935
936 DriverResult {
937 output,
938 sample_rate: self.sample_rate,
939 block_size: self.block_size,
940 total_frames,
941 meters,
942 output_events: output_events_capture,
943 block_snapshots,
944 plugin,
945 }
946 }
947}
948
949/// Sort the script's events by sample offset, rescale them if the
950/// driver's sample rate differs from the script's build-time rate, and
951/// warn loudly about events scheduled past `total_frames`.
952///
953/// A builder order like `.script(...).sample_rate(48000).run()` would
954/// otherwise emit events at offsets computed against the old SR -
955/// `wait_ms(100)` produced `4410` at 44100 Hz but the run uses 48000,
956/// putting "100ms" at 91.875ms instead.
957// usize → f64 widening on sample offsets - driver test runs are
958// bounded well below 2^52 frames.
959#[allow(clippy::cast_precision_loss)]
960fn prepare_script_events(
961 script: &mut Script,
962 sample_rate: f64,
963 total_frames: usize,
964) -> Vec<(usize, EventBody)> {
965 let build_sr = script.sample_rate;
966 if build_sr > 0.0 && (build_sr - sample_rate).abs() > f64::EPSILON {
967 let scale = sample_rate / build_sr;
968 for (off, _) in &mut script.events {
969 *off = sample_count_usize(((*off as f64) * scale).round());
970 }
971 }
972 script.sample_rate = sample_rate;
973 script.events.sort_by_key(|(off, _)| *off);
974
975 let dropped = script
976 .events
977 .iter()
978 .filter(|(off, _)| *off >= total_frames)
979 .count();
980 if dropped > 0 {
981 eprintln!(
982 "[truce-driver] warning: {dropped} script event(s) scheduled past \
983 total_frames ({total_frames}) - they will not be delivered. Check \
984 `.duration(...)` vs `wait_ms`/`wait_samples` calls in your script."
985 );
986 }
987 std::mem::take(&mut script.events)
988}
989
990/// Refill effect-input scratch for one block. Constant / Silence
991/// collapse to a per-channel memset; Buffer slice-copies; Generator
992/// computes into the first channel then broadcasts to the rest, which
993/// saves `(N-1) × block_len` closure calls per block.
994fn fill_input_block(
995 in_bufs: &mut [Vec<f32>],
996 input: &mut InputSource,
997 constant_value: Option<f32>,
998 cursor: usize,
999 block_len: usize,
1000 sample_rate: f64,
1001) {
1002 for b in in_bufs.iter_mut() {
1003 b.resize(block_len, 0.0);
1004 }
1005 if let Some(v) = constant_value {
1006 for b in in_bufs {
1007 b.fill(v);
1008 }
1009 return;
1010 }
1011 match input {
1012 InputSource::Buffer(bufs) => {
1013 for (dst, src) in in_bufs.iter_mut().zip(bufs.iter()) {
1014 let start = cursor.min(src.len());
1015 let end = (cursor + block_len).min(src.len());
1016 let copied = end - start;
1017 dst[..copied].copy_from_slice(&src[start..end]);
1018 // Pad the tail past `src` with zeros if the
1019 // user-supplied buffer ran short.
1020 for s in &mut dst[copied..] {
1021 *s = 0.0;
1022 }
1023 }
1024 }
1025 InputSource::Generator(g) => {
1026 if let Some((first, rest)) = in_bufs.split_first_mut() {
1027 for (i, slot) in first.iter_mut().enumerate() {
1028 *slot = g(cursor + i, sample_rate);
1029 }
1030 for ch in rest {
1031 ch.copy_from_slice(first);
1032 }
1033 }
1034 }
1035 // Silence / Constant always come paired with a `Some` in
1036 // `constant_value`, handled by the early-return above.
1037 InputSource::Silence | InputSource::Constant(_) => {
1038 for b in in_bufs {
1039 b.fill(0.0);
1040 }
1041 }
1042 }
1043}