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(¶ms)`.
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(¶ms, &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(¶ms, &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(¶m_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(¶ms);
409 move |task| {
410 <$tasks as $crate::__macro_deps::truce_plugin::BackgroundTasks>::run_task(
411 task, ¶ms,
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(¶ms, &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}