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