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:

  1. A params struct with #[derive(Params)].
  2. A plugin struct with an inherent new(params: Arc<P>).
  3. 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, …). Only reset and process are required; every other method has a default.
  4. 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

  1. Host loads the plugin binary. By this point truce::plugin! has already read truce.toml via plugin_info!(), emitted the format entry points, and wrapped MyPlugin in a format-specific shell.
  2. Shell creates Arc<MyParams> and clones it into both the host-visible parameter tree and MyPlugin::new(arc_clone).
  3. PluginLogic::reset(sr, max_block) runs once the sample rate and block size are known.
  4. 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, reset is called again. Saving a session triggers automatic parameter serialization plus save_state; loading one calls load_state, then reset, then resumes process.
  5. MyPlugin is 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.

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