Skip to main content

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}