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::chunked_process::{ChunkedProcess, process_chunked};
53use truce_core::events::{Event, EventBody, EventList, TransportInfo};
54use truce_core::export::PluginExport;
55use truce_core::info::PluginCategory;
56use truce_core::plugin::PluginRuntime;
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        // Per-sub-block scratch + cached static info so the offline
775        // render routes through the same `chunked_process` helper the
776        // format wrappers use. Tests scripting `set_param` at known
777        // offsets get the same deferred-apply behavior live hosts see.
778        let mut sub_event_scratch = EventList::default();
779        let param_infos = plugin.params().param_infos();
780        let params_arc = plugin.params_arc();
781        let min_subblock_samples = P::info().automation.min_subblock_samples;
782
783        // Routes the offline-render loop through the same
784        // `RawBufferScratch::build` helper every format wrapper uses,
785        // so `Plugin::Sample = f64` plugins (prelude64) get widening
786        // scratch transparently. For `Sample = f32` it's still
787        // zero-copy through the host f32 slices.
788        let mut scratch: RawBufferScratch<<P as PluginRuntime>::Sample> =
789            RawBufferScratch::default();
790        scratch.ensure_capacity(in_bufs.len(), out_bufs.len(), self.block_size);
791        let mut in_ptrs: Vec<*const f32> = Vec::with_capacity(in_bufs.len());
792        let mut out_ptrs: Vec<*mut f32> = Vec::with_capacity(out_bufs.len());
793        while cursor < total_frames {
794            let block_len = self.block_size.min(total_frames - cursor);
795
796            // Resize scratch to `block_len` (cheap: identical size on
797            // every iteration except the final tail block).
798            for b in &mut out_bufs {
799                b.clear();
800                b.resize(block_len, 0.0);
801            }
802
803            // Pull events that fall inside [cursor, cursor+block_len).
804            // Reuses the same `EventList` across the offline-render
805            // loop instead of constructing a fresh one each block.
806            event_list.clear();
807            for (off, body) in &script_events {
808                if *off >= cursor && *off < cursor + block_len {
809                    event_list.push(Event {
810                        sample_offset: len_u32(*off - cursor),
811                        body: *body,
812                    });
813                }
814            }
815
816            if is_effect {
817                fill_input_block(
818                    &mut in_bufs,
819                    &mut self.input,
820                    constant_value,
821                    cursor,
822                    block_len,
823                    self.sample_rate,
824                );
825            }
826
827            // Mirror `in_bufs` / `out_bufs` into raw pointer arrays for
828            // `RawBufferScratch::build`. Cheap (a handful of pointers
829            // per block) and keeps the conversion-aware path single-sourced.
830            in_ptrs.clear();
831            out_ptrs.clear();
832            for b in &in_bufs {
833                in_ptrs.push(b.as_ptr());
834            }
835            for b in &mut out_bufs {
836                out_ptrs.push(b.as_mut_ptr());
837            }
838            let block_u32 = len_u32(block_len);
839            let num_in_u32 = len_u32(in_ptrs.len());
840            let num_out_u32 = len_u32(out_ptrs.len());
841            // SAFETY: pointers borrowed from `in_bufs` / `out_bufs`
842            // which outlive `audio`; each `Vec<f32>` was resized to
843            // `block_len` above.
844            let mut audio = unsafe {
845                scratch.build(
846                    in_ptrs.as_ptr(),
847                    out_ptrs.as_mut_ptr(),
848                    num_in_u32,
849                    num_out_u32,
850                    block_u32,
851                    P::supports_in_place(),
852                )
853            };
854
855            // Transport snapshot for this block.
856            let transport_info = TransportInfo {
857                playing: self.transport.playing,
858                tempo: self.transport.bpm,
859                time_sig_num: self.transport.time_signature.0,
860                time_sig_den: self.transport.time_signature.1,
861                position_seconds: cursor as f64 / self.sample_rate,
862                position_beats: transport_pos_beats,
863                bar_start_beats: 0.0,
864                ..Default::default()
865            };
866            output_events_block.clear();
867
868            let mut transport_snap = transport_info;
869            let chunk_args = ChunkedProcess {
870                events: &event_list,
871                sub_event_scratch: &mut sub_event_scratch,
872                transport: &mut transport_snap,
873                sample_rate: self.sample_rate,
874                output_events: &mut output_events_block,
875                params_fn: None,
876                meters_fn: None,
877                param_infos: &param_infos,
878                min_subblock_samples,
879            };
880            process_chunked(
881                &mut plugin,
882                params_arc.as_ref() as &dyn Params,
883                &mut audio,
884                chunk_args,
885            );
886            let _ = audio;
887            // Narrow rendered f64 output back into the f32 `out_bufs`
888            // when the plugin's `Sample = f64`. No-op otherwise.
889            // SAFETY: same pointers + counts as the `build` call above.
890            unsafe {
891                scratch.finish_widening_f32(out_ptrs.as_mut_ptr(), num_out_u32, block_u32);
892            }
893
894            // Capture audio. `out_bufs` is reused across iterations,
895            // so we copy out rather than consuming.
896            if self.capture.audio {
897                for (ch, buf) in out_bufs.iter().enumerate() {
898                    output[ch].extend_from_slice(buf);
899                }
900            }
901
902            // Capture output events with absolute offsets. Use
903            // `saturating_add` so a long run (~24h at 48 kHz puts
904            // `cursor` past `u32::MAX`) clamps the offset rather than
905            // wrapping. The captured offsets are still informative
906            // up to that point and clamped beyond rather than
907            // silently mis-attributed to early frames.
908            if self.capture.output_events {
909                let cursor_u32 = u32::try_from(cursor).unwrap_or(u32::MAX);
910                for ev in output_events_block.iter() {
911                    let mut e = *ev;
912                    e.sample_offset = e.sample_offset.saturating_add(cursor_u32);
913                    output_events_capture.push(e);
914                }
915            }
916
917            // Capture per-block meters / param snapshots.
918            if matches!(self.capture.meters, MeterCapture::PerBlock) {
919                per_block_meters.push(
920                    meter_ids
921                        .iter()
922                        .map(|id| (*id, plugin.get_meter(*id)))
923                        .collect(),
924                );
925            }
926            if self.capture.block_snapshots {
927                let infos = plugin.params().param_infos();
928                block_snapshots.push(
929                    infos
930                        .iter()
931                        .map(|pi| (pi.id, plugin.params().get_plain(pi.id).unwrap_or(0.0)))
932                        .collect(),
933                );
934            }
935
936            // Advance transport.
937            if self.transport.playing {
938                let block_seconds = block_len as f64 / self.sample_rate;
939                transport_pos_beats += block_seconds * beats_per_second;
940            }
941
942            cursor += block_len;
943        }
944
945        let meters = match self.capture.meters {
946            MeterCapture::None => MeterReadings::None,
947            MeterCapture::Final => MeterReadings::Final(
948                meter_ids
949                    .iter()
950                    .map(|id| (*id, plugin.get_meter(*id)))
951                    .collect(),
952            ),
953            MeterCapture::PerBlock => MeterReadings::PerBlock(per_block_meters),
954        };
955
956        DriverResult {
957            output,
958            sample_rate: self.sample_rate,
959            block_size: self.block_size,
960            total_frames,
961            meters,
962            output_events: output_events_capture,
963            block_snapshots,
964            plugin,
965        }
966    }
967}
968
969/// Sort the script's events by sample offset, rescale them if the
970/// driver's sample rate differs from the script's build-time rate, and
971/// warn loudly about events scheduled past `total_frames`.
972///
973/// A builder order like `.script(...).sample_rate(48000).run()` would
974/// otherwise emit events at offsets computed against the old SR -
975/// `wait_ms(100)` produced `4410` at 44100 Hz but the run uses 48000,
976/// putting "100ms" at 91.875ms instead.
977// usize → f64 widening on sample offsets - driver test runs are
978// bounded well below 2^52 frames.
979#[allow(clippy::cast_precision_loss)]
980fn prepare_script_events(
981    script: &mut Script,
982    sample_rate: f64,
983    total_frames: usize,
984) -> Vec<(usize, EventBody)> {
985    let build_sr = script.sample_rate;
986    if build_sr > 0.0 && (build_sr - sample_rate).abs() > f64::EPSILON {
987        let scale = sample_rate / build_sr;
988        for (off, _) in &mut script.events {
989            *off = sample_count_usize(((*off as f64) * scale).round());
990        }
991    }
992    script.sample_rate = sample_rate;
993    script.events.sort_by_key(|(off, _)| *off);
994
995    let dropped = script
996        .events
997        .iter()
998        .filter(|(off, _)| *off >= total_frames)
999        .count();
1000    if dropped > 0 {
1001        eprintln!(
1002            "[truce-driver] warning: {dropped} script event(s) scheduled past \
1003             total_frames ({total_frames}) - they will not be delivered. Check \
1004             `.duration(...)` vs `wait_ms`/`wait_samples` calls in your script."
1005        );
1006    }
1007    std::mem::take(&mut script.events)
1008}
1009
1010/// Refill effect-input scratch for one block. Constant / Silence
1011/// collapse to a per-channel memset; Buffer slice-copies; Generator
1012/// computes into the first channel then broadcasts to the rest, which
1013/// saves `(N-1) × block_len` closure calls per block.
1014fn fill_input_block(
1015    in_bufs: &mut [Vec<f32>],
1016    input: &mut InputSource,
1017    constant_value: Option<f32>,
1018    cursor: usize,
1019    block_len: usize,
1020    sample_rate: f64,
1021) {
1022    for b in in_bufs.iter_mut() {
1023        b.resize(block_len, 0.0);
1024    }
1025    if let Some(v) = constant_value {
1026        for b in in_bufs {
1027            b.fill(v);
1028        }
1029        return;
1030    }
1031    match input {
1032        InputSource::Buffer(bufs) => {
1033            for (dst, src) in in_bufs.iter_mut().zip(bufs.iter()) {
1034                let start = cursor.min(src.len());
1035                let end = (cursor + block_len).min(src.len());
1036                let copied = end - start;
1037                dst[..copied].copy_from_slice(&src[start..end]);
1038                // Pad the tail past `src` with zeros if the
1039                // user-supplied buffer ran short.
1040                for s in &mut dst[copied..] {
1041                    *s = 0.0;
1042                }
1043            }
1044        }
1045        InputSource::Generator(g) => {
1046            if let Some((first, rest)) = in_bufs.split_first_mut() {
1047                for (i, slot) in first.iter_mut().enumerate() {
1048                    *slot = g(cursor + i, sample_rate);
1049                }
1050                for ch in rest {
1051                    ch.copy_from_slice(first);
1052                }
1053            }
1054        }
1055        // Silence / Constant always come paired with a `Some` in
1056        // `constant_value`, handled by the early-return above.
1057        InputSource::Silence | InputSource::Constant(_) => {
1058            for b in in_bufs {
1059                b.fill(0.0);
1060            }
1061        }
1062    }
1063}