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