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(¶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 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(¶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 // 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(¶ms, &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(¶m_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(¶ms);
418 move |task| {
419 <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::run(
420 task, ¶ms,
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(¶ms, &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}