Chapter 3
Plugin anatomy
How the pieces of a truce plugin fit together: the PluginLogic
trait, the truce::plugin! macro, bus layouts, and state
persistence.
If you've walked through first-plugin this chapter explains why the code you just wrote is shaped the way it is.
#The moving parts
Four things you write:
- A params struct with
#[derive(Params)]. - A plugin struct with an inherent
new(params: Arc<P>). - A single
impl PluginLogic for ...block — DSP and GUI in one trait (reset,process,save_state,load_state,state_changed,latency,tail,bus_layouts,layout,render,custom_editor, …). Onlyresetandprocessare required; every other method has a default. - A single
truce::plugin!macro call that wires those into every plugin format.
Everything else — parameter hosting, GUI event dispatch, state envelope, format-specific lifecycle, hot-reload shell — is generated.
PluginLogic lives in truce_gui (re-exported as
truce::prelude::PluginLogic). The trait covers both the
audio-thread surface (process, reset, …) and the main-thread
surface (layout, render, custom_editor, …); the framework
guarantees the threading split — process() only runs on the
audio thread, layout() only on the main thread.
#Precision (preludes)
DSP precision is a per-file choice. f32 is the host wire format
and the cheaper option; f64 buys mantissa bits for long delay-line
accumulators, biquad cascades, and modulation math that drifts at
single precision. You pick by choosing a prelude at the top of the
file:
use truce::prelude::*; // f32 — the default
// use truce::prelude64::*; // f64 end-to-end
The four variants follow fundsp's naming:
| Prelude | Buffer | param.read() |
Notes |
|---|---|---|---|
prelude |
f32 |
f32 |
Default. Alias for prelude32. |
prelude32 |
f32 |
f32 |
Explicit form. |
prelude64 |
f64 |
f64 |
End-to-end f64. The wrapper widens the host's audio buffer to f64 at the block boundary and narrows on the way out. |
prelude64m |
f32 |
f64 |
Mixed precision: the buffer stays at host f32 (no boundary widening) while reads and intermediate math run in f64. Write .to_f32() at the buffer-write site. |
Each prelude swaps three things in lockstep: a Sample type alias,
the FloatParamReadF32 / FloatParamReadF64 extension trait that
param.read() resolves through, and the PluginLogic /
PluginLogic64 leaf trait (re-exported as PluginLogic either way,
so impl PluginLogic for X { ... } is the same line regardless of
precision).
Reach for prelude64m when you want to handle the f32 buffer
yourself; reach for prelude64 when you want the framework to widen
and narrow at the block boundary for you.
Don't import two of these in the same file. The read / value /
current extension traits collide on method dispatch — that's the
right error if the file hasn't committed to a precision. If a helper
genuinely needs both (e.g., it processes both AudioBuffer<f32> and
AudioBuffer<f64>), name truce_core::buffer::AudioBuffer<S>
explicitly there and keep the prelude out of that scope.
#The PluginLogic trait
Only reset and process are required. Everything else has a
default. Override what you need.
pub trait PluginLogic: Send + 'static {
// --- DSP (audio thread) ---
fn reset(&mut self, sample_rate: f64, max_block_size: usize);
fn process(
&mut self,
buffer: &mut AudioBuffer,
events: &EventList,
context: &mut ProcessContext,
) -> ProcessStatus;
fn bus_layouts() -> Vec<BusLayout> { vec![BusLayout::stereo()] }
fn save_state(&self) -> Vec<u8> { Vec::new() }
fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> { Ok(()) }
fn state_changed(&mut self) {}
fn latency(&self) -> u32 { 0 }
fn tail(&self) -> u32 { 0 }
// --- GUI (main thread) ---
fn layout(&self) -> truce_gui_types::layout::GridLayout { ... }
fn render(&self, backend: &mut dyn RenderBackend) {}
fn uses_custom_render(&self) -> bool { false }
fn hit_test(&self, widgets: &[WidgetRegion], x: f32, y: f32) -> Option<usize> { ... }
fn custom_editor(&self) -> Option<Box<dyn Editor>> { None }
}
#DSP methods
| Method | When called | Real-time? | Notes |
|---|---|---|---|
reset |
Sample rate or block size changes; before the first process |
no | Clear delay lines, reset filter state, call params.set_sample_rate + snap_smoothers. |
process |
Every audio block | yes — no alloc / lock / I/O | The audio thread. See processing. |
bus_layouts |
Plugin discovery / port enumeration | no | Supported audio bus configurations. Default is stereo in/out; instruments / sidechain / MIDI plugins override. See Bus layouts below. |
save_state / load_state |
Host saves/loads a session, recalls a preset, or copies the plugin | no | Extra state only — params are serialized automatically. load_state returns Result<(), StateLoadError> so wrappers can surface a malformed blob to the host. |
state_changed |
After load_state returns |
yes (audio thread, between blocks) | Plugin-side cache invalidation — re-decode an IR, re-build a sample-pad map, anything derived from extra state that the next process() block reads. The companion Editor::state_changed (on truce_core::Editor) handles the GUI-thread repaint. |
latency |
Host bus reconfiguration | no | Samples of processing delay, for PDC. |
tail |
Host transport stop | no | Samples of audio produced after input stops (reverb, delay). |
#GUI methods
| Method | When called | Real-time? | Notes |
|---|---|---|---|
layout |
Built-in GUI rebuild | no | Returns a GridLayout description of widgets. See gui. |
render, uses_custom_render, hit_test |
Built-in GUI, when overridden | no | Escape hatches for custom visuals. See gui. |
custom_editor |
Editor open | no | Return Some(...) to use egui / iced / Slint / raw window handle instead of the built-in widget set. See gui. |
Headless plugins (no editor) just leave the GUI methods at their
defaults — the framework draws nothing. Post-load-state cache
invalidation lives on state_changed (audio thread) and, for
custom editors, on the Editor you return from custom_editor
(GUI thread). See State persistence below.
#Construction is not on the trait
new() is a plain inherent method on your plugin struct:
pub struct MyPlugin {
params: Arc<MyParams>,
extra_dsp_state: SomeFilter,
}
impl MyPlugin {
pub fn new(params: Arc<MyParams>) -> Self {
Self {
params,
extra_dsp_state: SomeFilter::default(),
}
}
}
The truce::plugin! macro calls YourPlugin::new(arc_clone_of_params)
once per plugin instance. It's plain Rust construction — not a
trait method — because the shell needs to hand you the shared
Arc<Params> at construction time, and trait methods can't do
that.
The same Arc<Params> lives on the shell too, and can be cloned
into GUI closures. One source of truth, no synchronization.
#Lifecycle
- Host loads the plugin binary. By this point
truce::plugin!has already readtruce.tomlviaplugin_info!(), emitted the format entry points, and wrappedMyPluginin a format-specific shell. - Shell creates
Arc<MyParams>and clones it into both the host-visible parameter tree andMyPlugin::new(arc_clone). PluginLogic::reset(sr, max_block)runs once the sample rate and block size are known.- Playback loop. The shell drives
process(buffer, events, ctx)on the audio thread,layout()/render()on the main thread, and the host writes automation through atomics. If the sample rate changes,resetis called again. Saving a session triggers automatic parameter serialization plussave_state; loading one callsload_state, thenreset, then resumesprocess. MyPluginis dropped when the host unloads the plugin.
#Per-format display names
By default every format surfaces the same name from truce.toml.
A few situations call for different names per format — most often
running AU v2 and v3 side by side (Logic shows them in the same
list and the user has no way to tell which is which), or shipping
a beta in parallel with a release without colliding bundle IDs.
Set any of clap_name, vst3_name, vst2_name, au_name,
au3_name, aax_name, lv2_name on the [[plugin]] table:
[[plugin]]
name = "Truce Gain"
bundle_id = "gain"
crate = "truce-example-gain"
category = "effect"
fourcc = "TGan"
au3_name = "Truce Gain (AUv3)" # disambiguate from the AU v2
Overrides only change the display name the host shows. Bundle
filenames, IDs, and install paths still derive from name —
except au3_name, which doubles as the
/Applications/{au3_name}.app install path so two AU v3 builds
can coexist. See the truce.toml reference for the full list of [[plugin]] keys.
#Bus layouts
Supported audio bus configurations live on
PluginLogic::bus_layouts(). The host picks one; the others are
rejected at bus-config time before process is ever called.
#Default (stereo in, stereo out)
The trait method's default is stereo effect routing — leave it alone for a stereo effect:
impl PluginLogic for MyGain {
// bus_layouts omitted → [BusLayout::stereo()]
fn reset(/* … */) { /* … */ }
fn process(/* … */) -> ProcessStatus { /* … */ }
}
#Instrument (no audio input)
impl PluginLogic for MySynth {
fn bus_layouts() -> Vec<BusLayout> {
vec![BusLayout::new().with_output("Main", ChannelConfig::Stereo)]
}
/* reset, process … */
}
#Multiple layouts (host picks)
impl PluginLogic for Widener {
fn bus_layouts() -> Vec<BusLayout> {
vec![
BusLayout::new()
.with_input("Main", ChannelConfig::Mono)
.with_output("Main", ChannelConfig::Stereo),
BusLayout::stereo(),
]
}
/* reset, process … */
}
#Sidechain
impl PluginLogic for SidechainComp {
fn bus_layouts() -> Vec<BusLayout> {
vec![
BusLayout::new()
.with_input("Main", ChannelConfig::Stereo)
.with_input("Sidechain", ChannelConfig::Stereo)
.with_output("Main", ChannelConfig::Stereo),
BusLayout::stereo(), // fallback when no sidechain
]
}
/* reset, process … */
}
Inside process, channels are flat-indexed across buses: with the
above layout, buffer.input(0) / (1) is main L/R and (2) /
(3) is sidechain L/R. Use buffer.num_input_channels() to detect
which layout the host selected.
#State persistence
Parameter values are saved and restored automatically by the
format wrappers. The only time you override save_state /
load_state is when you have state that isn't a parameter —
loaded sample paths, custom curves, view mode, selection,
anything else the user can change.
#Option A: #[derive(State)] — recommended
Define a state struct, derive binary serialization, and wire it
into PluginLogic:
#[derive(State, Default)]
pub struct MyExtraState {
pub ir_file_path: String,
pub view_mode: u8,
pub selected_ids: Vec<u32>,
}
pub struct MyPlugin {
params: Arc<MyParams>,
extra: MyExtraState,
}
impl PluginLogic for MyPlugin {
fn save_state(&self) -> Vec<u8> { self.extra.serialize() }
fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> {
match MyExtraState::deserialize(data) {
Some(s) => { self.extra = s; Ok(()) }
None => Err(StateLoadError::Malformed("MyExtraState")),
}
}
// Re-derive caches that depend on extra state (decoded IR,
// sample thumbnails, computed pad layouts). Runs on the audio
// thread under the same `&mut self` borrow as `load_state`, so
// the next `process()` block sees the refreshed caches.
fn state_changed(&mut self) {
self.extra_decoded_ir = decode_ir(&self.extra.ir_file_path);
}
// ... reset, process, layout, custom_editor ...
}
Supported field types: u8..u64, i8..i64, f32, f64,
bool, String, Vec<T>, Option<T>, and nested State
structs. Forward-compatible: adding fields later means old
state blobs still deserialize, with defaults for new fields.
#Option B: bring your own serializer
If you need a specific format — JSON for human-readable presets,
bincode for structs with third-party types — you can do the
bytes yourself:
fn save_state(&self) -> Vec<u8> {
bincode::serialize(&self.extra).unwrap()
}
fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> {
let s = bincode::deserialize::<MyExtraState>(data)
.map_err(|e| StateLoadError::Other(e.to_string()))?;
self.extra = s;
Ok(())
}
#How it works
The framework wraps whatever you return from save_state() in a
binary envelope with a plugin-ID hash, a version field, and the
list of (param_id, f64) parameter values. On load, the envelope
is validated (rejects state saved by a different plugin) and
params are restored before load_state() is called. You only
ever see your extra blob.
If your plugin has no extra state — only #[param] fields and
meters — don't override save_state / load_state at all. The
defaults (Vec::new() / no-op) are fine.
#Editor state
If your editor reads extra state (e.g. a loaded IR path to draw a
waveform), it needs to know when state changes — preset recall,
undo, session load. Use StateBinding<T>:
struct MyEditor {
state: StateBinding<MyExtraState>,
}
impl Editor for MyEditor {
fn open(&mut self, parent: RawWindowHandle, context: PluginContext) {
self.state = StateBinding::new(&context);
}
fn state_changed(&mut self) {
self.state.sync(); // re-read from plugin
}
}
// Reading:
let path = &self.state.get().ir_file_path;
// Writing (user renamed the instance):
self.state.update(|s| s.ir_file_path = new_path);
If your plugin is parameter-only (no custom editor, no extra
state), skip StateBinding — the built-in GUI polls parameters
every frame for free.
#What's next
- Chapter 4 → parameters — every attribute the derive macro accepts, plus meters and parameter groups.
- Chapter 5 → processing — the shapes
process()takes for effects, MIDI processors, and synths. - Chapter 6 → midi — reading and emitting MIDI events; per-format support; testing MIDI plugins.
- Chapter 7 → gui — the built-in widget set and when to reach for a framework backend.