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