Skip to main content

truce_loader/
static_shell.rs

1//! `StaticShell` - embeds the plugin directly into the binary.
2//!
3//! No dlopen, no file watcher, no Mutex. Same types as `HotShell`
4//! but zero runtime overhead. Use via `export_static!`.
5
6use std::sync::Arc;
7
8use truce_core::buffer::AudioBuffer;
9use truce_core::bus::BusLayout;
10use truce_core::config::AudioConfig;
11use truce_core::events::{EventBody, EventList};
12use truce_core::info::PluginInfo;
13use truce_core::meters::MeterStore;
14use truce_core::plugin::PluginRuntime;
15use truce_core::process::{ProcessContext, ProcessStatus};
16use truce_core::snapshot::SnapshotSlot;
17use truce_core::state::{ForeignState, MigratedState, StateLoadError};
18use truce_core::tasks::{AnyTaskSpawner, InitContext};
19use truce_params::Params;
20use truce_params::sample::Sample;
21use truce_plugin::PluginLogicCore;
22
23// ---------------------------------------------------------------------------
24// StaticShell
25// ---------------------------------------------------------------------------
26
27/// A static plugin shell that embeds the user's `PluginLogic` impl
28/// directly into the format-wrapper binary.
29///
30/// Same bridging as `HotShell` but without `NativeLoader`, `Mutex`,
31/// file watching, or any dynamic loading overhead. Use via `export_static!`.
32pub struct StaticShell<P: Params, L: PluginLogicCore<S, Params = P>, S: Sample = f32> {
33    pub params: Arc<P>,
34    /// The user's mutable DSP state, owned by the shell (not the
35    /// descriptor `L`). Built once via `L::init(&params)`.
36    state: L::DspState,
37    meters: Arc<MeterStore>,
38    /// Lock-free publish slot for `snapshot_into`-based state save.
39    snapshots: Arc<SnapshotSlot>,
40    /// Stays `true` until the logic reports (via `snapshot_into`), on a
41    /// block before it ever publishes, that it has no custom snapshot -
42    /// after which per-block publishing is skipped so non-opt-in plugins
43    /// pay nothing. A plugin that has published once stays subscribed.
44    try_snapshot: bool,
45    sample_rate: f64,
46    /// Background-task spawner for the plugin's `BackgroundTasks::Task`,
47    /// when the plugin wired `tasks:` on `plugin!`. Type-erased; stamped
48    /// into each block's `ProcessContext` so `ctx.tasks::<T>()` works.
49    /// `None` for a plugin with no background tasks.
50    tasks: Option<AnyTaskSpawner>,
51    _sample: std::marker::PhantomData<fn() -> S>,
52}
53
54// SAFETY: `StaticShell` owns `Arc<P>` (params, `Sync` by the
55// `Params` trait contract), `L::DspState` (`Send + 'static` per the
56// `PluginLogicCore` bound), an atomic-slot `MeterStore`, and a
57// `PhantomData<fn() -> S>`. No raw pointers, no `!Send` fields, no
58// interior mutability that escapes the shell's own `&mut` borrows. The
59// host contract that format wrappers invoke methods on a single thread
60// at a time per instance is what keeps the embedded state safe to
61// access without an inner mutex - same model `HotShell` uses through
62// `parking_lot::Mutex`.
63unsafe impl<P: Params, L: PluginLogicCore<S, Params = P>, S: Sample> Send for StaticShell<P, L, S> {}
64
65impl<P: Params + Default + 'static, L: PluginLogicCore<S, Params = P> + 'static, S: Sample>
66    StaticShell<P, L, S>
67{
68    /// Build the shell from shared params, constructing the initial DSP
69    /// state via `L::init(&params, &cx)`. The descriptor `L` is a
70    /// type-only marker; the shell owns the state it produces. `tasks` is
71    /// the plugin's background-task spawner (`Some` only when the plugin
72    /// wired `tasks:` on `plugin!`); it reaches `init` through the
73    /// `InitContext` and each block through the `ProcessContext`.
74    pub fn from_parts(params: Arc<P>, tasks: Option<AnyTaskSpawner>) -> Self {
75        let init_ctx = InitContext::new(tasks.clone());
76        let state = L::init(&params, &init_ctx);
77        Self {
78            params,
79            state,
80            meters: MeterStore::new(),
81            snapshots: SnapshotSlot::new(),
82            try_snapshot: true,
83            sample_rate: 44100.0,
84            tasks,
85            _sample: std::marker::PhantomData,
86        }
87    }
88
89    /// Shared meter storage handle - the GUI-thread-safe channel
90    /// for meter reads (see `PluginExport::meter_store`).
91    pub fn meter_store(&self) -> Arc<MeterStore> {
92        Arc::clone(&self.meters)
93    }
94
95    /// Shared snapshot slot for lock-free state save (see
96    /// `PluginExport::snapshot_slot`).
97    pub fn snapshot_slot(&self) -> Arc<SnapshotSlot> {
98        Arc::clone(&self.snapshots)
99    }
100
101    /// The plugin's background-task spawner (see
102    /// `PluginExport::task_spawner`). `None` unless the plugin wired
103    /// `tasks:` on `plugin!`.
104    pub fn task_spawner(&self) -> Option<AnyTaskSpawner> {
105        self.tasks.clone()
106    }
107
108    /// Access the plugin's DSP state (for testing).
109    pub fn state_ref(&self) -> &L::DspState {
110        &self.state
111    }
112
113    /// Mutable access to the plugin's DSP state (for testing).
114    pub fn state_ref_mut(&mut self) -> &mut L::DspState {
115        &mut self.state
116    }
117}
118
119impl<P: Params + Default + 'static, L: PluginLogicCore<S, Params = P> + 'static, S: Sample>
120    PluginRuntime for StaticShell<P, L, S>
121{
122    type Sample = S;
123
124    fn info() -> PluginInfo
125    where
126        Self: Sized,
127    {
128        unreachable!("StaticShell::info() should not be called statically")
129    }
130
131    fn bus_layouts() -> Vec<BusLayout>
132    where
133        Self: Sized,
134    {
135        unreachable!("StaticShell::bus_layouts() should not be called statically")
136    }
137
138    fn init(&mut self) {}
139
140    fn reset(&mut self, config: &AudioConfig) {
141        self.sample_rate = config.sample_rate;
142        // Params plumbing is the shell's job, not the plugin's: settle
143        // smoother coefficients and state before the user's `reset` so
144        // its body reads post-snap values.
145        self.params.set_sample_rate(config.sample_rate);
146        self.params.snap_smoothers();
147        L::reset(&mut self.state, &self.params, config);
148    }
149
150    fn process(
151        &mut self,
152        buffer: &mut AudioBuffer<S>,
153        events: &EventList,
154        context: &mut ProcessContext,
155    ) -> ProcessStatus {
156        // Apply parameter change events to the shell's params.
157        // ParamChange values from format wrappers are PLAIN (already
158        // denormalized). `set_normalized` here would double-denormalize.
159        for e in events.iter() {
160            if let EventBody::ParamChange { id, value } = &e.body {
161                self.params.set_plain(*id, *value);
162            }
163        }
164
165        // No sync needed - plugin reads from the same Arc<Params>.
166
167        // Build a ProcessContext with param/meter callbacks for the logic.
168        let params = &self.params;
169        let meters = &self.meters;
170        let param_fn = |id: u32| -> f64 { params.get_plain(id).unwrap_or(0.0) };
171        let meter_fn = |id: u32, v: f32| meters.write(id, v);
172        let ctx = ProcessContext::new(
173            context.transport,
174            context.sample_rate,
175            buffer.num_samples(),
176            &mut *context.output_events,
177        )
178        .with_process_mode(context.process_mode)
179        .with_params(&param_fn)
180        .with_meters(&meter_fn);
181        // Stamp the background-task spawner so `ctx.tasks::<T>()` works.
182        let mut ctx = match &self.tasks {
183            Some(t) => ctx.with_tasks(t),
184            None => ctx,
185        };
186
187        let status = L::process(&mut self.state, &self.params, buffer, events, &mut ctx);
188        publish_snapshot::<S, L>(&self.state, &self.snapshots, &mut self.try_snapshot);
189        status
190    }
191
192    fn save_state(&self) -> Vec<u8> {
193        L::save_state(&self.state)
194    }
195
196    fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> {
197        let result = L::load_state(&mut self.state, data);
198        // Plugin-side cache invalidation runs in the same `&mut`
199        // borrow window so the next `process()` block sees the
200        // refreshed caches - fire it whether or not load_state
201        // succeeded so partial state still triggers a refresh.
202        L::state_changed(&mut self.state, &self.params);
203        result
204    }
205
206    fn migrate_state(foreign: &ForeignState) -> Option<MigratedState>
207    where
208        Self: Sized,
209    {
210        <L as PluginLogicCore<S>>::migrate_state(foreign)
211    }
212
213    fn latency(&self) -> u32 {
214        L::latency(&self.state)
215    }
216    fn tail(&self) -> u32 {
217        L::tail(&self.state)
218    }
219
220    fn get_meter(&self, meter_id: u32) -> f32 {
221        self.meters.read(meter_id)
222    }
223}
224
225/// Publish the plugin's `snapshot_into` bytes into `slot` on the audio
226/// thread. Shared by both shells.
227///
228/// Opting into snapshots is a static capability: `try_snapshot` latches
229/// off only when the logic reports "no snapshot" *before it has ever
230/// published one* (the default `snapshot_into` returning false), so a
231/// non-opt-in plugin stops paying after one block. Once a plugin has
232/// published, it stays subscribed for its lifetime - a plugin that
233/// returns true then later false is violating the contract, and we keep
234/// calling it rather than silently latching off and serving stale bytes.
235/// Never blocks: `SnapshotSlot::publish` skips on reader contention, in
236/// which case the closure doesn't run and the latch is left alone.
237pub(crate) fn publish_snapshot<S, L>(
238    state: &L::DspState,
239    slot: &SnapshotSlot,
240    try_snapshot: &mut bool,
241) where
242    S: Sample,
243    L: PluginLogicCore<S>,
244{
245    publish_snapshot_with(slot, try_snapshot, |buf| L::snapshot_into(state, buf));
246}
247
248/// Latch logic behind [`publish_snapshot`], parameterized over the raw
249/// `snapshot_into` closure so it can be unit-tested without a full
250/// `PluginLogicCore` mock. `pub(crate)` so `HotShell` can drive it with
251/// a closure over the reloadable dylib's `truce_snapshot_into` symbol.
252pub(crate) fn publish_snapshot_with(
253    slot: &SnapshotSlot,
254    try_snapshot: &mut bool,
255    snapshot_into: impl FnOnce(&mut Vec<u8>) -> bool,
256) {
257    if !*try_snapshot {
258        return;
259    }
260    let ran_unsupported = std::cell::Cell::new(false);
261    slot.publish(|buf| {
262        let wrote = snapshot_into(buf);
263        ran_unsupported.set(!wrote);
264        wrote
265    });
266    // First-block opt-out only: a plugin that has already published is
267    // committed for its lifetime, so a later false never latches us off.
268    if ran_unsupported.get() && !slot.is_supported() {
269        *try_snapshot = false;
270    }
271}
272
273// ---------------------------------------------------------------------------
274// export_static! macro
275// ---------------------------------------------------------------------------
276
277/// Compile-time static embedding of a `PluginLogic` impl into the binary.
278///
279/// Produces a `__HotShellWrapper` struct that implements `Plugin + PluginExport`,
280/// so format export macros (`export_clap!`, `export_vst3!`, etc.) work unchanged.
281/// No dlopen, no file watcher, zero runtime overhead. Bus layouts come from
282/// `<$logic as PluginLogic>::bus_layouts()` - override the trait method to
283/// pick something other than the stereo default.
284///
285/// ```ignore
286/// export_static! {
287///     params: GainParams,
288///     info: plugin_info!(...),
289///     logic: Gain,
290/// }
291///
292/// #[cfg(feature = "clap")]
293/// truce_clap::export_clap!(__HotShellWrapper);
294/// ```
295#[macro_export]
296macro_rules! export_static {
297    (
298        params: $params:ty,
299        info: $info:expr,
300        logic: $logic:ty,
301        $(tasks: $tasks:ty,)?
302    ) => {
303        pub struct __HotShellWrapper {
304            // `Sample` here resolves to the type alias the user
305            // imported from a prelude (`prelude` / `prelude32` →
306            // `f32`; `prelude64` → `f64`; `prelude64m` → `f32`). The
307            // `PluginLogic<Sample>` bound on the user's impl must
308            // match this, so the prelude is what picks the audio
309            // buffer precision end-to-end.
310            inner: $crate::static_shell::StaticShell<$params, $logic, Sample>,
311        }
312
313        impl $crate::__macro_deps::truce_core::plugin::PluginRuntime for __HotShellWrapper {
314            type Sample = Sample;
315
316            fn supports_in_place() -> bool
317            where
318                Self: Sized,
319            {
320                // `PluginLogicCore<Sample>` is the wrapper-facing
321                // trait; the user impl'd one of the leaf traits
322                // (`PluginLogic` / `PluginLogic64`), and the blanket
323                // bridge defined alongside those traits in
324                // `truce-plugin` makes them also satisfy
325                // `PluginLogicCore<Sample>` automatically. Sample
326                // resolves through the prelude alias in scope at the
327                // macro call site.
328                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::supports_in_place()
329            }
330
331            fn info() -> $crate::__macro_deps::truce_core::info::PluginInfo
332            where
333                Self: Sized,
334            {
335                $info
336            }
337
338            fn bus_layouts() -> Vec<$crate::__macro_deps::truce_core::bus::BusLayout>
339            where
340                Self: Sized,
341            {
342                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::bus_layouts()
343            }
344
345            fn init(&mut self) {
346                self.inner.init();
347            }
348
349            fn reset(&mut self, config: &$crate::__macro_deps::truce_core::config::AudioConfig) {
350                self.inner.reset(config);
351            }
352
353            fn process(
354                &mut self,
355                buffer: &mut $crate::__macro_deps::truce_core::buffer::AudioBuffer<Sample>,
356                events: &$crate::__macro_deps::truce_core::events::EventList,
357                context: &mut $crate::__macro_deps::truce_core::process::ProcessContext,
358            ) -> $crate::__macro_deps::truce_core::process::ProcessStatus {
359                self.inner.process(buffer, events, context)
360            }
361
362            fn save_state(&self) -> Vec<u8> {
363                self.inner.save_state()
364            }
365
366            fn load_state(
367                &mut self,
368                data: &[u8],
369            ) -> Result<(), $crate::__macro_deps::truce_core::state::StateLoadError> {
370                self.inner.load_state(data)
371            }
372
373            fn migrate_state(
374                foreign: &$crate::__macro_deps::truce_core::state::ForeignState,
375            ) -> Option<$crate::__macro_deps::truce_core::state::MigratedState>
376            where
377                Self: Sized,
378            {
379                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::migrate_state(foreign)
380            }
381
382            fn latency(&self) -> u32 {
383                self.inner.latency()
384            }
385            fn tail(&self) -> u32 {
386                self.inner.tail()
387            }
388            fn get_meter(&self, meter_id: u32) -> f32 {
389                self.inner.get_meter(meter_id)
390            }
391        }
392
393        impl $crate::__macro_deps::truce_core::export::PluginExport for __HotShellWrapper {
394            type Params = $params;
395
396            fn create() -> Self {
397                let params = std::sync::Arc::new(<$params>::new());
398                // `tasks:` on `plugin!` wires the background-task spawner;
399                // absent, the shell runs with no pool.
400                #[allow(unused_mut)]
401                let mut tasks: ::core::option::Option<
402                    $crate::__macro_deps::truce_core::tasks::AnyTaskSpawner,
403                > = ::core::option::Option::None;
404                $(
405                    let spawner = $crate::__macro_deps::truce_core::tasks::TaskSpawner::<
406                        <$tasks as $crate::__macro_deps::truce_plugin::BackgroundTasks>::Task,
407                    >::new({
408                        let params = std::sync::Arc::clone(&params);
409                        move |task| {
410                            <$tasks as $crate::__macro_deps::truce_plugin::BackgroundTasks>::run_task(
411                                task, &params,
412                            )
413                        }
414                    });
415                    tasks = ::core::option::Option::Some(
416                        $crate::__macro_deps::truce_core::tasks::AnyTaskSpawner::new(&spawner),
417                    );
418                )?
419                // The descriptor `$logic` is stateless; `from_parts`
420                // builds the DSP state via `<$logic>::init(&params, &cx)`.
421                Self {
422                    inner: $crate::static_shell::StaticShell::from_parts(params, tasks),
423                }
424            }
425
426            fn params(&self) -> &$params {
427                &self.inner.params
428            }
429
430            fn params_arc(&self) -> std::sync::Arc<$params> {
431                std::sync::Arc::clone(&self.inner.params)
432            }
433
434            fn meter_store(
435                &self,
436            ) -> std::sync::Arc<$crate::__macro_deps::truce_core::meters::MeterStore> {
437                self.inner.meter_store()
438            }
439
440            fn snapshot_slot(
441                &self,
442            ) -> std::sync::Arc<$crate::__macro_deps::truce_core::snapshot::SnapshotSlot> {
443                self.inner.snapshot_slot()
444            }
445
446            fn task_spawner(
447                &self,
448            ) -> ::core::option::Option<
449                $crate::__macro_deps::truce_core::tasks::AnyTaskSpawner,
450            > {
451                self.inner.task_spawner()
452            }
453
454            fn editor_builder(
455                &self,
456            ) -> $crate::__macro_deps::truce_core::editor::EditorBuilder<$params> {
457                // Builds from the lock-free param store, never the
458                // embedded logic - the audio thread's `&mut logic` is
459                // irrelevant here, so opening the editor takes no lock.
460                Box::new(|params| {
461                    Some(
462                        <$logic as $crate::__macro_deps::truce_plugin::PluginEditor<Sample>>::editor(
463                            params,
464                        ),
465                    )
466                })
467            }
468        }
469    };
470}
471
472#[cfg(test)]
473mod tests {
474    use super::publish_snapshot_with;
475    use truce_core::snapshot::SnapshotSlot;
476
477    #[test]
478    fn non_opt_in_latches_off_on_first_block() {
479        let slot = SnapshotSlot::new();
480        let mut try_snapshot = true;
481
482        // Default `snapshot_into` (returns false) before any publish:
483        // latch off so we stop paying every block.
484        publish_snapshot_with(&slot, &mut try_snapshot, |_| false);
485        assert!(!try_snapshot, "first false must latch off");
486        assert!(!slot.is_supported());
487
488        // Subsequent blocks short-circuit and never call the closure.
489        let mut called = false;
490        publish_snapshot_with(&slot, &mut try_snapshot, |_| {
491            called = true;
492            false
493        });
494        assert!(!called, "latched-off slot must not call snapshot_into");
495    }
496
497    #[test]
498    fn opt_in_then_contract_violation_stays_subscribed() {
499        let slot = SnapshotSlot::new();
500        let mut try_snapshot = true;
501
502        // Block 1: plugin publishes - it has opted in for its lifetime.
503        publish_snapshot_with(&slot, &mut try_snapshot, |buf| {
504            buf.clear();
505            buf.extend_from_slice(&[1, 2, 3]);
506            true
507        });
508        assert!(try_snapshot);
509        assert!(slot.is_supported());
510        assert_eq!(slot.read(), Some(vec![1, 2, 3]));
511
512        // Block 2: a contract-violating false must NOT latch us off - we
513        // keep calling the plugin rather than silently going dark.
514        publish_snapshot_with(&slot, &mut try_snapshot, |_| false);
515        assert!(try_snapshot, "a post-opt-in false must not latch off");
516
517        // Block 3: still subscribed, so a fresh publish still lands.
518        publish_snapshot_with(&slot, &mut try_snapshot, |buf| {
519            buf.clear();
520            buf.extend_from_slice(&[4]);
521            true
522        });
523        assert_eq!(slot.read(), Some(vec![4]));
524    }
525}