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