Skip to main content

truce_plugin/
lib.rs

1//! User-facing plugin traits + internal bridge.
2//!
3//! This crate is the plugin author's entry point. The single
4//! `impl PluginLogic for MyPlugin { ... }` block covers both
5//! audio-thread DSP and main-thread GUI, with sample precision
6//! routed through the prelude (see `truce::prelude` /
7//! `truce::prelude64`).
8//!
9//! `truce-plugin` depends on `truce-gui-types` (light: layout,
10//! render trait, widget regions) - not the full `truce-gui`.
11//! Plugin authors who supply a custom editor (egui, iced, slint,
12//! raw window handle) end up with `truce-plugin` in their dep
13//! tree but not the built-in editor's tiny-skia + baseview +
14//! truce-font stack.
15//!
16//! ## Three traits, one source of truth
17//!
18//! - [`PluginLogic`]   - what plugin authors implement for `f32`-buffer plugins.
19//! - [`PluginLogic64`] - what plugin authors implement for `f64`-buffer plugins.
20//! - [`PluginLogicCore`] - generic-over-`S` trait the format wrappers consume.
21//!
22//! The two leaf traits are stamped from one
23//! `plugin_logic_leaf_trait!` `macro_rules!` definition (further
24//! down this file) so their method surfaces stay in lock-step. Each leaf
25//! gets a blanket impl that forwards every method to
26//! `PluginLogicCore<S>` with the matching `S`. Wrappers
27//! (`StaticShell`, `HotShell`, the format crates) bind on
28//! `PluginLogicCore<S>` and don't care which leaf the user impl'd.
29//!
30//! ## What this buys
31//!
32//! Plugin authors writing `impl PluginLogic for Synth { ... }`
33//! never name a precision. The `truce::prelude64` re-export aliases
34//! `PluginLogic64` as `PluginLogic` in the user's scope, so the
35//! same impl header reads the same regardless of which prelude is
36//! in use. The `<S>` token that used to live on the impl header is
37//! gone - the prelude carries the precision choice.
38
39use truce_core::buffer::AudioBuffer;
40use truce_core::bus::BusLayout;
41use truce_core::denormal::DenormalGuard;
42use truce_core::editor::Editor;
43use truce_core::events::EventList;
44use truce_core::process::{ProcessContext, ProcessStatus};
45use truce_core::state::{ForeignState, MigratedState, StateLoadError};
46use truce_gui_types::interaction::WidgetRegion;
47use truce_gui_types::widgets::WidgetType;
48use truce_params::sample::Sample;
49
50// ---------------------------------------------------------------------------
51// PluginLogicCore - generic trait, what format wrappers consume
52// ---------------------------------------------------------------------------
53
54/// Wrapper-facing plugin trait, generic over the audio sample type.
55///
56/// Format wrappers (`StaticShell`, `HotShell`, CLAP / VST3 / etc.)
57/// bind on `PluginLogicCore<S>`. Plugin authors don't implement this
58/// directly - they implement [`PluginLogic`] (`f32`) or
59/// [`PluginLogic64`] (`f64`), and the blanket impls below route them
60/// into `PluginLogicCore`.
61///
62/// Method docs live on the leaf traits ([`PluginLogic`] /
63/// [`PluginLogic64`]); the shape mirrors them exactly.
64pub trait PluginLogicCore<S: Sample = f32>: Send + 'static {
65    #[must_use]
66    fn supports_in_place() -> bool
67    where
68        Self: Sized;
69
70    #[must_use]
71    fn bus_layouts() -> Vec<BusLayout>
72    where
73        Self: Sized;
74
75    fn reset(&mut self, sample_rate: f64, max_block_size: usize);
76
77    fn process(
78        &mut self,
79        buffer: &mut AudioBuffer<S>,
80        events: &EventList,
81        context: &mut ProcessContext,
82    ) -> ProcessStatus;
83
84    fn save_state(&self) -> Vec<u8>;
85    /// Lock-free state-save opt-in. See [`PluginLogic::snapshot_into`].
86    /// Called on the audio thread each block when supported; the shell
87    /// publishes the result into a `SnapshotSlot` the host reads without
88    /// the plugin lock. Default `false`; the leaf bridge overrides it.
89    fn snapshot_into(&self, buf: &mut Vec<u8>) -> bool {
90        let _ = buf;
91        false
92    }
93    /// Restore plugin-specific state. See [`PluginLogic::load_state`].
94    ///
95    /// # Errors
96    ///
97    /// Forwards whatever the user impl returns - typically a malformed
98    /// blob error decoded by `bincode` / `serde` / similar.
99    fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError>;
100    /// Translate foreign state into truce shape. See
101    /// [`PluginLogic::migrate_state`].
102    #[must_use]
103    fn migrate_state(foreign: &ForeignState) -> Option<MigratedState>
104    where
105        Self: Sized;
106    fn state_changed(&mut self);
107    fn latency(&self) -> u32;
108    fn tail(&self) -> u32;
109}
110
111/// Precision-keyed editor factory, bridged from the leaf traits.
112///
113/// `plugin!` / `export_static!` / `export_plugin!` build the editor from
114/// the concrete logic type without naming which leaf trait
115/// ([`PluginLogic`] vs [`PluginLogic64`]) it implements. Keyed on `S`
116/// only so the two per-leaf blanket impls don't overlap - the editor and
117/// param store are precision-independent.
118///
119/// This lives off [`PluginLogicCore`] on purpose: it carries an
120/// associated `Params` type and a receiverless `editor`, either of which
121/// would make `PluginLogicCore` non-object-safe and break the
122/// hot-reload loader's type-erased `Box<dyn PluginLogicCore<S>>`. Only
123/// concrete code (the shells' macros) ever names it, never `dyn`.
124pub trait PluginEditor<S: Sample> {
125    /// The plugin's parameter struct; mirrors the leaf's `Params`.
126    type Params: truce_params::Params;
127
128    /// Build the editor from the lock-free param store. Receiverless, so
129    /// the wrapper constructs it while the audio thread runs, without the
130    /// plugin lock.
131    fn editor(params: std::sync::Arc<Self::Params>) -> Box<dyn Editor>;
132}
133
134// ---------------------------------------------------------------------------
135// Leaf traits - what plugin authors implement
136// ---------------------------------------------------------------------------
137
138/// Define a sample-pinned leaf trait. Two invocations:
139/// `PluginLogic` (f32) and [`PluginLogic64`] (f64). The trait
140/// definition has to be a macro because we want the two trait
141/// surfaces to stay in exact lock-step - adding a new method means
142/// updating one place, not three (the macro, plus two trait
143/// declarations).
144///
145/// Doc-hidden because it's a single-purpose internal macro, not an
146/// API users should reach for.
147#[doc(hidden)]
148#[macro_export]
149macro_rules! plugin_logic_leaf_trait {
150    ($(#[$attr:meta])* $vis:vis trait $name:ident<sample = $sample:ty>) => {
151        $(#[$attr])*
152        $vis trait $name: Send + 'static {
153            /// The plugin's parameter struct (`#[derive(Params)]`). Named
154            /// here so the editor can be built from an `Arc<Self::Params>`
155            /// without borrowing the plugin - see [`Self::editor`].
156            type Params: $crate::__plugin_logic_deps::Params;
157
158            /// Opt into zero-copy in-place I/O. When this returns `true`,
159            /// the format wrapper skips its safety memcpy on host-aliased
160            /// buffers and hands the plugin the raw shared memory through
161            /// `AudioBuffer::in_out_mut(ch)`. The plugin must check
162            /// `AudioBuffer::is_in_place(ch)` per channel before reading
163            /// `input(ch)`.
164            ///
165            /// Default `false`: the wrapper copies aliased inputs into
166            /// scratch so `input(ch)` and `output(ch)` are always
167            /// disjoint. Costs one memcpy per aliased channel per block.
168            #[must_use]
169            fn supports_in_place() -> bool
170            where
171                Self: Sized,
172            {
173                false
174            }
175
176            /// Supported audio bus configurations. The host picks one;
177            /// the others are rejected at bus-config time before
178            /// `process` is ever called. Default: stereo in, stereo out.
179            #[must_use]
180            fn bus_layouts() -> Vec<$crate::__plugin_logic_deps::BusLayout>
181            where
182                Self: Sized,
183            {
184                vec![$crate::__plugin_logic_deps::BusLayout::stereo()]
185            }
186
187            /// Reset for a new sample rate / block size. Called before
188            /// the first `process` and any time the host reconfigures.
189            fn reset(&mut self, sample_rate: f64, max_block_size: usize);
190
191            /// Process one block of audio. Real-time - no allocations,
192            /// locks, or I/O.
193            fn process(
194                &mut self,
195                buffer: &mut $crate::__plugin_logic_deps::AudioBuffer<$sample>,
196                events: &$crate::__plugin_logic_deps::EventList,
197                context: &mut $crate::__plugin_logic_deps::ProcessContext,
198            ) -> $crate::__plugin_logic_deps::ProcessStatus;
199
200            /// Serialize plugin-specific state (DSP state, not params -
201            /// those are saved automatically). Default: delegates to
202            /// [`Self::snapshot_into`] (empty when neither is
203            /// overridden).
204            ///
205            /// Runs on a host or GUI thread while the audio thread is
206            /// paused at a block boundary (the wrapper's plugin lock),
207            /// so reading any field is safe - but an audio block that
208            /// arrives mid-save waits for this to return. Keep it
209            /// cheap: copy bytes out, don't compute or compress here.
210            /// To take this off the plugin lock entirely, override
211            /// [`Self::snapshot_into`] instead.
212            fn save_state(&self) -> Vec<u8> {
213                let mut buf = Vec::new();
214                let _ = self.snapshot_into(&mut buf);
215                buf
216            }
217
218            /// Opt into lock-free state save. `buf` arrives **cleared**,
219            /// with its capacity retained across calls so a steady state
220            /// is allocation-free; fill it with the same bytes
221            /// [`Self::save_state`] would produce (append freely - it is
222            /// never carried over from the previous block).
223            ///
224            /// The return value is a *static capability*, not a
225            /// per-block flag: `true` means "this plugin publishes
226            /// snapshots", `false` means "it never does" (the default).
227            /// Once you return `true` you must return `true` for the
228            /// plugin's whole lifetime - if the custom state empties out,
229            /// return `true` with `buf` left empty (an empty blob), don't
230            /// return `false`. The shell latches the opt-in on the first
231            /// published block; a later `false` is a contract violation
232            /// that would otherwise leave the host reading a stale
233            /// snapshot forever.
234            ///
235            /// Called on the **audio thread** after each process block,
236            /// under the same real-time rules as `process` - bounded, no
237            /// unbounded allocation. The wrapper publishes the result
238            /// into a lock-free slot the host reads without ever taking
239            /// the plugin lock, so saving state while audio runs never
240            /// stalls the audio thread. Overriding this is the
241            /// preferred way to serialize custom state; the default
242            /// [`Self::save_state`] delegates here.
243            fn snapshot_into(&self, buf: &mut Vec<u8>) -> bool {
244                let _ = buf;
245                false
246            }
247
248            /// Restore plugin-specific state.
249            ///
250            /// Runs on the audio thread between blocks, with the same
251            /// exclusive access `process()` has - writing any field
252            /// is safe.
253            ///
254            /// # Errors
255            ///
256            /// Return `Err(StateLoadError)` when the blob is malformed
257            /// or otherwise can't be interpreted - the format wrapper
258            /// logs the failure (and on hosts that support it, surfaces
259            /// it to the DAW).
260            fn load_state(
261                &mut self,
262                _data: &[u8],
263            ) -> Result<(), $crate::__plugin_logic_deps::StateLoadError> {
264                Ok(())
265            }
266
267            /// Called on the audio thread immediately after
268            /// [`Self::load_state`] returns. Invalidate or recompute any
269            /// caches the next `process()` reads. Default: no-op.
270            fn state_changed(&mut self) {}
271
272            /// Translate foreign state - a previous framework's blob,
273            /// or a truce envelope saved under a different plugin id -
274            /// into truce params + extra, so a plugin ported to truce
275            /// keeps its users' old sessions and presets. Runs on the
276            /// host thread; receiverless so it can't touch (or alias)
277            /// the live instance. Return `None` for bytes you don't
278            /// recognize - the wrapper then reports load failure to
279            /// the host, exactly as if this hook didn't exist.
280            ///
281            /// One-shot by construction: the next save writes a normal
282            /// truce envelope, so this never becomes a permanent
283            /// dual-format reader. Keyed formats (AU / LV2 / AAX) only
284            /// see foreign bytes when `truce.toml` declares the legacy
285            /// keys to probe (`[plugin.legacy_state]`).
286            #[must_use]
287            fn migrate_state(
288                _foreign: &$crate::__plugin_logic_deps::ForeignState,
289            ) -> Option<$crate::__plugin_logic_deps::MigratedState>
290            where
291                Self: Sized,
292            {
293                None
294            }
295
296            /// Report latency in samples for plugin delay compensation.
297            fn latency(&self) -> u32 {
298                0
299            }
300
301            /// Report tail time in samples (audio produced after input
302            /// stops - reverbs, delays). `u32::MAX` for infinite tail.
303            fn tail(&self) -> u32 {
304                0
305            }
306
307            // ---- GUI ----
308
309            /// Construct the editor for this plugin. Required.
310            ///
311            /// There is no auto-fallback - every plugin explicitly
312            /// names which renderer it wants. For the built-in
313            /// widget layout, call
314            /// `truce_gui::default_editor(params, layout)`; for
315            /// custom renderers, construct an `EguiEditor` /
316            /// `IcedEditor` / `SlintEditor` / hand-rolled `Editor`
317            /// here. The choice of renderer crate the plugin's
318            /// `Cargo.toml` pulls IS the choice of editor.
319            ///
320            /// An associated function, not a method: it receives the
321            /// lock-free `Arc<Self::Params>` store the wrapper already
322            /// holds, so the host can open the editor while audio is
323            /// running without ever taking the plugin lock. Editors bind
324            /// only to the param store (plus meters / transport, all
325            /// lock-free); custom DSP state is read at runtime through
326            /// the editor bridge, not at construction.
327            fn editor(
328                params: ::std::sync::Arc<Self::Params>,
329            ) -> Box<dyn $crate::__plugin_logic_deps::Editor>;
330        }
331    };
332}
333
334// Re-export the dependencies the leaf-trait macro substitutes by path,
335// under one `pub` doc-hidden module so user crates that invoke the
336// macro don't need to import each truce-core type by hand.
337#[doc(hidden)]
338pub mod __plugin_logic_deps {
339    pub use truce_core::buffer::AudioBuffer;
340    pub use truce_core::bus::BusLayout;
341    pub use truce_core::editor::Editor;
342    pub use truce_core::events::EventList;
343    pub use truce_core::process::{ProcessContext, ProcessStatus};
344    pub use truce_core::state::{ForeignState, MigratedState, StateLoadError};
345    pub use truce_params::Params;
346}
347
348plugin_logic_leaf_trait! {
349    /// The `f32`-buffer user-facing plugin trait.
350    ///
351    /// Plugin authors implement this in a single `impl` block when
352    /// their audio path is `f32` end-to-end (the default - matches
353    /// the host wire format for nearly all DAWs and formats).
354    /// `truce::prelude` and `truce::prelude32` re-export this name
355    /// directly; `truce::prelude64m` does too (the `m` mixed-precision
356    /// prelude keeps the audio buffer at `f32` and only switches the
357    /// `param.read()` precision).
358    ///
359    /// Required: [`Self::reset`], [`Self::process`], [`Self::editor`].
360    /// Everything else has a default. The editor is constructed
361    /// explicitly - layout-only plugins typically call
362    /// `truce_gui::default_editor(params, layout())` (where `layout()`
363    /// is a plain inherent method on the plugin struct, not part of the
364    /// trait).
365    ///
366    /// ## Params vs. DSP state
367    ///
368    /// The struct you implement this on holds two different kinds of
369    /// data, and the method receivers reflect the split:
370    ///
371    /// - **Params** - the user-facing values in your `#[derive(Params)]`
372    ///   struct, held as `Arc<Self::Params>`. Atomic-backed and `Sync`,
373    ///   shared lock-free with the host and the editor.
374    /// - **DSP state** - everything else on the struct: filter memory,
375    ///   phase accumulators, voice buffers, delay lines. Plain and
376    ///   non-atomic, mutated every sample, exclusive to the audio thread.
377    ///
378    /// `process` / `reset` / `load_state` take `&mut self` because they
379    /// mutate DSP state; `save_state` / `snapshot_into` take `&self`
380    /// because they read it. `editor` takes neither - it is an
381    /// associated function over the param store, because a GUI is a
382    /// *view* that binds only params (plus lock-free meters / transport)
383    /// and never touches DSP state, so it can be built without the
384    /// plugin lock. DSP state can't move into params: making per-sample
385    /// filter memory atomic-shared would put a synchronized access on
386    /// the hottest path, and it isn't a "parameter" anyway.
387    pub trait PluginLogic<sample = f32>
388}
389
390plugin_logic_leaf_trait! {
391    /// The `f64`-buffer user-facing plugin trait. Same surface as
392    /// [`PluginLogic`] but with the audio buffer pinned to `f64`.
393    ///
394    /// Plugin authors don't usually name this directly - `truce::prelude64`
395    /// re-exports it as `PluginLogic`, so the impl header reads the
396    /// same regardless of which precision the prelude chose. Pick
397    /// `truce::prelude64` (and thus this leaf) when the DSP path runs
398    /// in `f64` end-to-end and the wrapper-boundary widen/narrow
399    /// memcpy is worth the cleaner DSP code.
400    pub trait PluginLogic64<sample = f64>
401}
402
403// ---------------------------------------------------------------------------
404// Bridges - each leaf forwards every method to PluginLogicCore<S>
405// ---------------------------------------------------------------------------
406
407/// Define a blanket `impl<T: $leaf> PluginLogicCore<$sample> for T`
408/// that forwards every trait method to `<T as $leaf>::method(...)`.
409/// One source-of-truth for both `(PluginLogic, f32)` and
410/// `(PluginLogic64, f64)` bridges.
411macro_rules! plugin_logic_bridge {
412    ($leaf:ident, $sample:ty) => {
413        impl<T: $leaf> PluginLogicCore<$sample> for T {
414            fn supports_in_place() -> bool
415            where
416                Self: Sized,
417            {
418                <Self as $leaf>::supports_in_place()
419            }
420
421            fn bus_layouts() -> Vec<BusLayout>
422            where
423                Self: Sized,
424            {
425                <Self as $leaf>::bus_layouts()
426            }
427
428            fn reset(&mut self, sample_rate: f64, max_block_size: usize) {
429                <Self as $leaf>::reset(self, sample_rate, max_block_size);
430            }
431
432            fn process(
433                &mut self,
434                buffer: &mut AudioBuffer<$sample>,
435                events: &EventList,
436                context: &mut ProcessContext,
437            ) -> ProcessStatus {
438                // FTZ/DAZ (or FZ on AArch64) for the duration of
439                // the user's process body. Denormals on filter
440                // feedback paths stall the core; the guard pays
441                // ~two MXCSR writes per block to avoid that.
442                let _denormal_guard = DenormalGuard::new();
443                <Self as $leaf>::process(self, buffer, events, context)
444            }
445
446            fn save_state(&self) -> Vec<u8> {
447                <Self as $leaf>::save_state(self)
448            }
449
450            fn snapshot_into(&self, buf: &mut Vec<u8>) -> bool {
451                <Self as $leaf>::snapshot_into(self, buf)
452            }
453
454            fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> {
455                <Self as $leaf>::load_state(self, data)
456            }
457
458            fn state_changed(&mut self) {
459                <Self as $leaf>::state_changed(self);
460            }
461
462            fn migrate_state(foreign: &ForeignState) -> Option<MigratedState>
463            where
464                Self: Sized,
465            {
466                <Self as $leaf>::migrate_state(foreign)
467            }
468
469            fn latency(&self) -> u32 {
470                <Self as $leaf>::latency(self)
471            }
472
473            fn tail(&self) -> u32 {
474                <Self as $leaf>::tail(self)
475            }
476        }
477
478        impl<T: $leaf> PluginEditor<$sample> for T {
479            type Params = <T as $leaf>::Params;
480
481            fn editor(params: std::sync::Arc<Self::Params>) -> Box<dyn Editor> {
482                <Self as $leaf>::editor(params)
483            }
484        }
485    };
486}
487
488plugin_logic_bridge!(PluginLogic, f32);
489plugin_logic_bridge!(PluginLogic64, f64);
490
491// ---------------------------------------------------------------------------
492// Default hit test - referenced by leaf macro expansions
493// ---------------------------------------------------------------------------
494
495/// Default hit test: circular for knobs, rectangular for everything
496/// else, skip meters. Used by the leaf traits' `hit_test` defaults.
497#[must_use]
498pub fn default_hit_test(widgets: &[WidgetRegion], x: f32, y: f32) -> Option<usize> {
499    for (i, w) in widgets.iter().enumerate() {
500        if w.widget_type == WidgetType::Meter {
501            continue;
502        }
503        if w.widget_type == WidgetType::Knob {
504            let dx = x - w.cx;
505            let dy = y - w.cy;
506            if dx * dx + dy * dy <= w.radius * w.radius {
507                return Some(i);
508            }
509        } else if x >= w.x && x <= w.x + w.w && y >= w.y && y <= w.y + w.h {
510            return Some(i);
511        }
512    }
513    None
514}