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 load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> {
207        let result = L::load_state(&mut self.state, data);
208        // Plugin-side cache invalidation runs in the same `&mut`
209        // borrow window so the next `process()` block sees the
210        // refreshed caches - fire it whether or not load_state
211        // succeeded so partial state still triggers a refresh.
212        L::state_changed(&mut self.state, &self.params);
213        result
214    }
215
216    fn migrate_state(foreign: &ForeignState) -> Option<MigratedState>
217    where
218        Self: Sized,
219    {
220        <L as PluginLogicCore<S>>::migrate_state(foreign)
221    }
222
223    fn latency(&self) -> u32 {
224        L::latency(&self.state)
225    }
226    fn tail(&self) -> u32 {
227        L::tail(&self.state)
228    }
229
230    fn get_meter(&self, meter_id: u32) -> f32 {
231        self.meters.read(meter_id)
232    }
233}
234
235/// Publish the plugin's `snapshot_into` bytes into `slot` on the audio
236/// thread. Shared by both shells.
237///
238/// Opting into snapshots is a static capability: `try_snapshot` latches
239/// off only when the logic reports "no snapshot" *before it has ever
240/// published one* (the default `snapshot_into` returning false), so a
241/// non-opt-in plugin stops paying after one block. Once a plugin has
242/// published, it stays subscribed for its lifetime - a plugin that
243/// returns true then later false is violating the contract, and we keep
244/// calling it rather than silently latching off and serving stale bytes.
245/// Never blocks: `SnapshotSlot::publish` skips on reader contention, in
246/// which case the closure doesn't run and the latch is left alone.
247pub(crate) fn publish_snapshot<S, L>(
248    state: &L::DspState,
249    slot: &SnapshotSlot,
250    try_snapshot: &mut bool,
251) where
252    S: Sample,
253    L: PluginLogicCore<S>,
254{
255    publish_snapshot_with(slot, try_snapshot, |buf| L::snapshot_into(state, buf));
256}
257
258/// Latch logic behind [`publish_snapshot`], parameterized over the raw
259/// `snapshot_into` closure so it can be unit-tested without a full
260/// `PluginLogicCore` mock. `pub(crate)` so `HotShell` can drive it with
261/// a closure over the reloadable dylib's `truce_snapshot_into` symbol.
262pub(crate) fn publish_snapshot_with(
263    slot: &SnapshotSlot,
264    try_snapshot: &mut bool,
265    snapshot_into: impl FnOnce(&mut Vec<u8>) -> bool,
266) {
267    if !*try_snapshot {
268        return;
269    }
270    let ran_unsupported = std::cell::Cell::new(false);
271    slot.publish(|buf| {
272        let wrote = snapshot_into(buf);
273        ran_unsupported.set(!wrote);
274        wrote
275    });
276    // First-block opt-out only: a plugin that has already published is
277    // committed for its lifetime, so a later false never latches us off.
278    if ran_unsupported.get() && !slot.is_supported() {
279        *try_snapshot = false;
280    }
281}
282
283// ---------------------------------------------------------------------------
284// export_static! macro
285// ---------------------------------------------------------------------------
286
287/// Compile-time static embedding of a `PluginLogic` impl into the binary.
288///
289/// Produces a `__HotShellWrapper` struct that implements `Plugin + PluginExport`,
290/// so format export macros (`export_clap!`, `export_vst3!`, etc.) work unchanged.
291/// No dlopen, no file watcher, zero runtime overhead. Bus layouts come from
292/// `<$logic as PluginLogic>::bus_layouts()` - override the trait method to
293/// pick something other than the stereo default.
294///
295/// ```ignore
296/// export_static! {
297///     params: GainParams,
298///     info: plugin_info!(...),
299///     logic: Gain,
300/// }
301///
302/// #[cfg(feature = "clap")]
303/// truce_clap::export_clap!(__HotShellWrapper);
304/// ```
305#[macro_export]
306macro_rules! export_static {
307    (
308        params: $params:ty,
309        info: $info:expr,
310        logic: $logic:ty,
311        $(tasks: [$($task:ty),+],)?
312    ) => {
313        pub struct __HotShellWrapper {
314            // `Sample` here resolves to the type alias the user
315            // imported from a prelude (`prelude` / `prelude32` →
316            // `f32`; `prelude64` → `f64`; `prelude64m` → `f32`). The
317            // `PluginLogic<Sample>` bound on the user's impl must
318            // match this, so the prelude is what picks the audio
319            // buffer precision end-to-end.
320            inner: $crate::static_shell::StaticShell<$params, $logic, Sample>,
321        }
322
323        impl $crate::__macro_deps::truce_core::plugin::PluginRuntime for __HotShellWrapper {
324            type Sample = Sample;
325
326            fn supports_in_place() -> bool
327            where
328                Self: Sized,
329            {
330                // `PluginLogicCore<Sample>` is the wrapper-facing
331                // trait; the user impl'd one of the leaf traits
332                // (`PluginLogic` / `PluginLogic64`), and the blanket
333                // bridge defined alongside those traits in
334                // `truce-plugin` makes them also satisfy
335                // `PluginLogicCore<Sample>` automatically. Sample
336                // resolves through the prelude alias in scope at the
337                // macro call site.
338                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::supports_in_place()
339            }
340
341            fn info() -> $crate::__macro_deps::truce_core::info::PluginInfo
342            where
343                Self: Sized,
344            {
345                $info
346            }
347
348            fn bus_layouts() -> Vec<$crate::__macro_deps::truce_core::bus::BusLayout>
349            where
350                Self: Sized,
351            {
352                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::bus_layouts()
353            }
354
355            fn init(&mut self) {
356                self.inner.init();
357            }
358
359            fn reset(&mut self, config: &$crate::__macro_deps::truce_core::config::AudioConfig) {
360                self.inner.reset(config);
361            }
362
363            fn process(
364                &mut self,
365                buffer: &mut $crate::__macro_deps::truce_core::buffer::AudioBuffer<Sample>,
366                events: &$crate::__macro_deps::truce_core::events::EventList,
367                context: &mut $crate::__macro_deps::truce_core::process::ProcessContext,
368            ) -> $crate::__macro_deps::truce_core::process::ProcessStatus {
369                self.inner.process(buffer, events, context)
370            }
371
372            fn save_state(&self) -> Vec<u8> {
373                self.inner.save_state()
374            }
375
376            fn load_state(
377                &mut self,
378                data: &[u8],
379            ) -> Result<(), $crate::__macro_deps::truce_core::state::StateLoadError> {
380                self.inner.load_state(data)
381            }
382
383            fn migrate_state(
384                foreign: &$crate::__macro_deps::truce_core::state::ForeignState,
385            ) -> Option<$crate::__macro_deps::truce_core::state::MigratedState>
386            where
387                Self: Sized,
388            {
389                <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::migrate_state(foreign)
390            }
391
392            fn latency(&self) -> u32 {
393                self.inner.latency()
394            }
395            fn tail(&self) -> u32 {
396                self.inner.tail()
397            }
398            fn get_meter(&self, meter_id: u32) -> f32 {
399                self.inner.get_meter(meter_id)
400            }
401        }
402
403        impl $crate::__macro_deps::truce_core::export::PluginExport for __HotShellWrapper {
404            type Params = $params;
405
406            fn create() -> Self {
407                let params = std::sync::Arc::new(<$params>::new());
408                // Each `tasks: [..]` type gets its own lane (queue + mode).
409                // The bundle collapses to `None` when no types were listed,
410                // so a plugin with no tasks runs with no pool.
411                #[allow(unused_mut)]
412                let mut __task_bundle =
413                    $crate::__macro_deps::truce_core::tasks::TaskSpawnerBundle::new();
414                $(
415                    $({
416                        let __task_run = {
417                            let params = std::sync::Arc::clone(&params);
418                            move |task| {
419                                <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::run(
420                                    task, &params,
421                                )
422                            }
423                        };
424                        // `SERIALIZED` picks one-slot vs concurrent draining
425                        // for this lane; the const folds the branch at
426                        // compile time.
427                        let __spawner = if <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::SERIALIZED {
428                            $crate::__macro_deps::truce_core::tasks::TaskSpawner::<$task>::new_serialized(__task_run)
429                        } else {
430                            $crate::__macro_deps::truce_core::tasks::TaskSpawner::<$task>::new(__task_run)
431                        };
432                        __task_bundle.push(__spawner);
433                    })+
434                )?
435                let tasks = __task_bundle.into_any();
436                // The descriptor `$logic` is stateless; `from_parts`
437                // builds the DSP state via `<$logic>::init(&params, &cx)`.
438                Self {
439                    inner: $crate::static_shell::StaticShell::from_parts(params, tasks),
440                }
441            }
442
443            fn params(&self) -> &$params {
444                &self.inner.params
445            }
446
447            fn params_arc(&self) -> std::sync::Arc<$params> {
448                std::sync::Arc::clone(&self.inner.params)
449            }
450
451            fn meter_store(
452                &self,
453            ) -> std::sync::Arc<$crate::__macro_deps::truce_core::meters::MeterStore> {
454                self.inner.meter_store()
455            }
456
457            fn snapshot_slot(
458                &self,
459            ) -> std::sync::Arc<$crate::__macro_deps::truce_core::snapshot::SnapshotSlot> {
460                self.inner.snapshot_slot()
461            }
462
463            fn task_spawner(
464                &self,
465            ) -> ::core::option::Option<
466                $crate::__macro_deps::truce_core::tasks::AnyTaskSpawner,
467            > {
468                self.inner.task_spawner()
469            }
470
471            fn editor_builder(
472                &self,
473            ) -> $crate::__macro_deps::truce_core::editor::EditorBuilder<$params> {
474                // Builds from the lock-free param store, never the
475                // embedded logic - the audio thread's `&mut logic` is
476                // irrelevant here, so opening the editor takes no lock.
477                Box::new(|params| {
478                    Some(
479                        <$logic as $crate::__macro_deps::truce_plugin::PluginEditor<Sample>>::editor(
480                            params,
481                        ),
482                    )
483                })
484            }
485        }
486    };
487}
488
489#[cfg(test)]
490mod tests {
491    use super::publish_snapshot_with;
492    use truce_core::snapshot::SnapshotSlot;
493
494    #[test]
495    fn non_opt_in_latches_off_on_first_block() {
496        let slot = SnapshotSlot::new();
497        let mut try_snapshot = true;
498
499        // Default `snapshot_into` (returns false) before any publish:
500        // latch off so we stop paying every block.
501        publish_snapshot_with(&slot, &mut try_snapshot, |_| false);
502        assert!(!try_snapshot, "first false must latch off");
503        assert!(!slot.is_supported());
504
505        // Subsequent blocks short-circuit and never call the closure.
506        let mut called = false;
507        publish_snapshot_with(&slot, &mut try_snapshot, |_| {
508            called = true;
509            false
510        });
511        assert!(!called, "latched-off slot must not call snapshot_into");
512    }
513
514    #[test]
515    fn opt_in_then_contract_violation_stays_subscribed() {
516        let slot = SnapshotSlot::new();
517        let mut try_snapshot = true;
518
519        // Block 1: plugin publishes - it has opted in for its lifetime.
520        publish_snapshot_with(&slot, &mut try_snapshot, |buf| {
521            buf.clear();
522            buf.extend_from_slice(&[1, 2, 3]);
523            true
524        });
525        assert!(try_snapshot);
526        assert!(slot.is_supported());
527        assert_eq!(slot.read(), Some(vec![1, 2, 3]));
528
529        // Block 2: a contract-violating false must NOT latch us off - we
530        // keep calling the plugin rather than silently going dark.
531        publish_snapshot_with(&slot, &mut try_snapshot, |_| false);
532        assert!(try_snapshot, "a post-opt-in false must not latch off");
533
534        // Block 3: still subscribed, so a fresh publish still lands.
535        publish_snapshot_with(&slot, &mut try_snapshot, |buf| {
536            buf.clear();
537            buf.extend_from_slice(&[4]);
538            true
539        });
540        assert_eq!(slot.read(), Some(vec![4]));
541    }
542}