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::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 /// Restore plugin-specific state. See [`PluginLogic::load_state`].
86 ///
87 /// # Errors
88 ///
89 /// Forwards whatever the user impl returns - typically a malformed
90 /// blob error decoded by `bincode` / `serde` / similar.
91 fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError>;
92 fn state_changed(&mut self);
93 fn latency(&self) -> u32;
94 fn tail(&self) -> u32;
95 /// Construct the editor for this plugin. Required - there is no
96 /// auto-fallback. Layout-only plugins call
97 /// `truce_gui::default_editor(params, layout)` from here; custom-
98 /// renderer plugins construct their `EguiEditor` / `IcedEditor` /
99 /// `SlintEditor` / hand-rolled `Editor` directly.
100 fn editor(&self) -> Box<dyn Editor>;
101}
102
103// ---------------------------------------------------------------------------
104// Leaf traits - what plugin authors implement
105// ---------------------------------------------------------------------------
106
107/// Define a sample-pinned leaf trait. Two invocations:
108/// `PluginLogic` (f32) and [`PluginLogic64`] (f64). The trait
109/// definition has to be a macro because we want the two trait
110/// surfaces to stay in exact lock-step - adding a new method means
111/// updating one place, not three (the macro, plus two trait
112/// declarations).
113///
114/// Doc-hidden because it's a single-purpose internal macro, not an
115/// API users should reach for.
116#[doc(hidden)]
117#[macro_export]
118macro_rules! plugin_logic_leaf_trait {
119 ($(#[$attr:meta])* $vis:vis trait $name:ident<sample = $sample:ty>) => {
120 $(#[$attr])*
121 $vis trait $name: Send + 'static {
122 /// Opt into zero-copy in-place I/O. When this returns `true`,
123 /// the format wrapper skips its safety memcpy on host-aliased
124 /// buffers and hands the plugin the raw shared memory through
125 /// `AudioBuffer::in_out_mut(ch)`. The plugin must check
126 /// `AudioBuffer::is_in_place(ch)` per channel before reading
127 /// `input(ch)`.
128 ///
129 /// Default `false`: the wrapper copies aliased inputs into
130 /// scratch so `input(ch)` and `output(ch)` are always
131 /// disjoint. Costs one memcpy per aliased channel per block.
132 #[must_use]
133 fn supports_in_place() -> bool
134 where
135 Self: Sized,
136 {
137 false
138 }
139
140 /// Supported audio bus configurations. The host picks one;
141 /// the others are rejected at bus-config time before
142 /// `process` is ever called. Default: stereo in, stereo out.
143 #[must_use]
144 fn bus_layouts() -> Vec<$crate::__plugin_logic_deps::BusLayout>
145 where
146 Self: Sized,
147 {
148 vec![$crate::__plugin_logic_deps::BusLayout::stereo()]
149 }
150
151 /// Reset for a new sample rate / block size. Called before
152 /// the first `process` and any time the host reconfigures.
153 fn reset(&mut self, sample_rate: f64, max_block_size: usize);
154
155 /// Process one block of audio. Real-time - no allocations,
156 /// locks, or I/O.
157 fn process(
158 &mut self,
159 buffer: &mut $crate::__plugin_logic_deps::AudioBuffer<$sample>,
160 events: &$crate::__plugin_logic_deps::EventList,
161 context: &mut $crate::__plugin_logic_deps::ProcessContext,
162 ) -> $crate::__plugin_logic_deps::ProcessStatus;
163
164 /// Serialize plugin-specific state (DSP state, not params -
165 /// those are saved automatically). Default: no extra state.
166 fn save_state(&self) -> Vec<u8> {
167 Vec::new()
168 }
169
170 /// Restore plugin-specific state.
171 ///
172 /// # Errors
173 ///
174 /// Return `Err(StateLoadError)` when the blob is malformed
175 /// or otherwise can't be interpreted - the format wrapper
176 /// logs the failure (and on hosts that support it, surfaces
177 /// it to the DAW).
178 fn load_state(
179 &mut self,
180 _data: &[u8],
181 ) -> Result<(), $crate::__plugin_logic_deps::StateLoadError> {
182 Ok(())
183 }
184
185 /// Called on the audio thread immediately after
186 /// [`Self::load_state`] returns. Invalidate or recompute any
187 /// caches the next `process()` reads. Default: no-op.
188 fn state_changed(&mut self) {}
189
190 /// Report latency in samples for plugin delay compensation.
191 fn latency(&self) -> u32 {
192 0
193 }
194
195 /// Report tail time in samples (audio produced after input
196 /// stops - reverbs, delays). `u32::MAX` for infinite tail.
197 fn tail(&self) -> u32 {
198 0
199 }
200
201 // ---- GUI ----
202
203 /// Construct the editor for this plugin. Required.
204 ///
205 /// There is no auto-fallback - every plugin explicitly
206 /// names which renderer it wants. For the built-in
207 /// widget layout, call
208 /// `truce_gui::default_editor(params, layout)`; for
209 /// custom renderers, construct an `EguiEditor` /
210 /// `IcedEditor` / `SlintEditor` / hand-rolled `Editor`
211 /// here. The choice of renderer crate the plugin's
212 /// `Cargo.toml` pulls IS the choice of editor.
213 fn editor(&self) -> Box<dyn $crate::__plugin_logic_deps::Editor>;
214 }
215 };
216}
217
218// Re-export the dependencies the leaf-trait macro substitutes by path,
219// under one `pub` doc-hidden module so user crates that invoke the
220// macro don't need to import each truce-core type by hand.
221#[doc(hidden)]
222pub mod __plugin_logic_deps {
223 pub use truce_core::buffer::AudioBuffer;
224 pub use truce_core::bus::BusLayout;
225 pub use truce_core::editor::Editor;
226 pub use truce_core::events::EventList;
227 pub use truce_core::process::{ProcessContext, ProcessStatus};
228 pub use truce_core::state::StateLoadError;
229}
230
231plugin_logic_leaf_trait! {
232 /// The `f32`-buffer user-facing plugin trait.
233 ///
234 /// Plugin authors implement this in a single `impl` block when
235 /// their audio path is `f32` end-to-end (the default - matches
236 /// the host wire format for nearly all DAWs and formats).
237 /// `truce::prelude` and `truce::prelude32` re-export this name
238 /// directly; `truce::prelude64m` does too (the `m` mixed-precision
239 /// prelude keeps the audio buffer at `f32` and only switches the
240 /// `param.read()` precision).
241 ///
242 /// Required: [`Self::reset`], [`Self::process`], [`Self::editor`].
243 /// Everything else has a default. The editor is constructed
244 /// explicitly - layout-only plugins typically call
245 /// `truce_gui::default_editor(self.params.clone(), self.layout())`
246 /// (where `layout()` is a plain inherent method on the plugin
247 /// struct, not part of the trait).
248 pub trait PluginLogic<sample = f32>
249}
250
251plugin_logic_leaf_trait! {
252 /// The `f64`-buffer user-facing plugin trait. Same surface as
253 /// [`PluginLogic`] but with the audio buffer pinned to `f64`.
254 ///
255 /// Plugin authors don't usually name this directly - `truce::prelude64`
256 /// re-exports it as `PluginLogic`, so the impl header reads the
257 /// same regardless of which precision the prelude chose. Pick
258 /// `truce::prelude64` (and thus this leaf) when the DSP path runs
259 /// in `f64` end-to-end and the wrapper-boundary widen/narrow
260 /// memcpy is worth the cleaner DSP code.
261 pub trait PluginLogic64<sample = f64>
262}
263
264// ---------------------------------------------------------------------------
265// Bridges - each leaf forwards every method to PluginLogicCore<S>
266// ---------------------------------------------------------------------------
267
268/// Define a blanket `impl<T: $leaf> PluginLogicCore<$sample> for T`
269/// that forwards every trait method to `<T as $leaf>::method(...)`.
270/// One source-of-truth for both `(PluginLogic, f32)` and
271/// `(PluginLogic64, f64)` bridges.
272macro_rules! plugin_logic_bridge {
273 ($leaf:ident, $sample:ty) => {
274 impl<T: $leaf> PluginLogicCore<$sample> for T {
275 fn supports_in_place() -> bool
276 where
277 Self: Sized,
278 {
279 <Self as $leaf>::supports_in_place()
280 }
281
282 fn bus_layouts() -> Vec<BusLayout>
283 where
284 Self: Sized,
285 {
286 <Self as $leaf>::bus_layouts()
287 }
288
289 fn reset(&mut self, sample_rate: f64, max_block_size: usize) {
290 <Self as $leaf>::reset(self, sample_rate, max_block_size);
291 }
292
293 fn process(
294 &mut self,
295 buffer: &mut AudioBuffer<$sample>,
296 events: &EventList,
297 context: &mut ProcessContext,
298 ) -> ProcessStatus {
299 // FTZ/DAZ (or FZ on AArch64) for the duration of
300 // the user's process body. Denormals on filter
301 // feedback paths stall the core; the guard pays
302 // ~two MXCSR writes per block to avoid that.
303 let _denormal_guard = DenormalGuard::new();
304 <Self as $leaf>::process(self, buffer, events, context)
305 }
306
307 fn save_state(&self) -> Vec<u8> {
308 <Self as $leaf>::save_state(self)
309 }
310
311 fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> {
312 <Self as $leaf>::load_state(self, data)
313 }
314
315 fn state_changed(&mut self) {
316 <Self as $leaf>::state_changed(self);
317 }
318
319 fn latency(&self) -> u32 {
320 <Self as $leaf>::latency(self)
321 }
322
323 fn tail(&self) -> u32 {
324 <Self as $leaf>::tail(self)
325 }
326
327 fn editor(&self) -> Box<dyn Editor> {
328 <Self as $leaf>::editor(self)
329 }
330 }
331 };
332}
333
334plugin_logic_bridge!(PluginLogic, f32);
335plugin_logic_bridge!(PluginLogic64, f64);
336
337// ---------------------------------------------------------------------------
338// Default hit test - referenced by leaf macro expansions
339// ---------------------------------------------------------------------------
340
341/// Default hit test: circular for knobs, rectangular for everything
342/// else, skip meters. Used by the leaf traits' `hit_test` defaults.
343#[must_use]
344pub fn default_hit_test(widgets: &[WidgetRegion], x: f32, y: f32) -> Option<usize> {
345 for (i, w) in widgets.iter().enumerate() {
346 if w.widget_type == WidgetType::Meter {
347 continue;
348 }
349 if w.widget_type == WidgetType::Knob {
350 let dx = x - w.cx;
351 let dy = y - w.cy;
352 if dx * dx + dy * dy <= w.radius * w.radius {
353 return Some(i);
354 }
355 } else if x >= w.x && x <= w.x + w.w && y >= w.y && y <= w.y + w.h {
356 return Some(i);
357 }
358 }
359 None
360}