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, editor, save_state, load_state, state_changed, latency, tail, bus_layouts, …). reset, process, and editor 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_plugin (re-exported as truce::prelude::PluginLogic). The trait covers both the audio-thread surface (process, reset, …) and the main-thread surface (editor); the framework guarantees the threading split — process() only runs on the audio thread, editor() 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

reset, process, and editor 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 editor(&self) -> Box<dyn Editor>;
}

#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 method

Method When called Real-time? Notes
editor Editor open no Return the editor to display. For the built-in widget set, build a GridLayout and finish with .into_editor(&self.params); for a framework backend, construct an EguiEditor / IcedEditor / SlintEditor / hand-rolled Editor and finish with .into_editor(). See gui.

editor() is required: the renderer is whichever editor you return, and which crate your Cargo.toml pulls in. Post-load-state cache invalidation lives on state_changed (audio thread) and, for the editor, on the Editor's own state_changed (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, editor() 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, 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 (built-in editor, no extra state), skip StateBinding — the built-in GUI polls parameters every frame for free.

#What's next