Chapter 16

Best practices

A distilled checklist for plugins that behave well in every host. Each item links to the chapter that covers it in depth; this page is the short version you can scan before shipping.

#The audio thread is sacred

process() runs under a hard deadline set by the host's buffer size. A single missed deadline is an audible click, so the audio thread must never do anything with unbounded or unpredictable latency:

  • no allocation, no free, no lock, no syscall, no file or network I/O;
  • no unbounded loop (bound every iteration by the block size or a fixed count);
  • no println! / logging, no Mutex::lock, no Instant::now in hot code.

Everything the audio thread touches is sized up front. Allocate scratch buffers in reset (it receives the maximum block size) or in construction, and size them to that maximum. The per-instance EventList is already pre-allocated; clear() and refill it, never build a fresh one per block. See processing.

#Precision: f64 state, f32 wire

The host wire is f32, but single precision drifts over a long session. Keep DSP state - phase accumulators, filter memory, delay-line positions, modulation math - in f64, and cast to f32 only at the sample-write boundary. An f32 phase counter accumulates error audibly over a multi-hour set.

Pick a prelude per file: truce::prelude::* for f32, truce::prelude64::* for f64 end-to-end (the framework advertises 64-bit support to hosts that can use it). Don't import two preludes in the same file. See precision.

#Params vs. DSP state

Two kinds of mutable data, two homes:

  • Parameters are automatable, host-visible, and shared across threads. They live in your #[derive(Params)] struct with atomic storage. Read them each block; don't cache their values in your logic struct.
  • DSP state is per-instance, mutated every sample, and touched only by the audio thread. It lives in your logic struct as plain fields, not in the params struct.

Keeping the two separate is what lets the editor be a pure function of the params (below). See plugin anatomy and state.

#Smooth every audible parameter

A raw parameter jump zippers. Declare smooth = "exp(5)" (or a suitable time) on any parameter that scales audio - gain, cutoff, mix - and read the smoothed value per sample or per block. Snap smoothers to their targets in reset() so the first block after activation isn't a ramp up from zero. See smoothing and sample-accurate automation.

#Respect event timing

Note and parameter events carry a sample_offset into the block. Apply them at that offset, not all at the top of the block, or automation and note timing smear by up to a buffer. The framework can split the block at event boundaries for you. See sample-accurate event splitting and MIDI.

#Set up in reset(), not construction

Construction runs once; reset() runs on every activation and whenever the sample rate or block size changes. Put sample-rate-dependent setup there: set_sample_rate, snap_smoothers, clearing delay lines, sizing scratch to the new maximum block. Construction just wires up fields. See lifecycle.

#Report latency and tail

If your plugin looks ahead - a lookahead limiter, a linear-phase EQ, an FFT frame - report it from latency() so the host delay-compensates and your plugin stays time-aligned in the mix. If it rings out after input stops - reverb, delay - report tail() so the host doesn't cut the tail when the transport stops.

#Keep bundle_id stable

A plugin's identity (its CLAP / VST3 ids and its state-envelope hash) derives from bundle_id, not the display name. Pick it once and never change it: renaming the display name is free, but changing bundle_id orphans every saved session and preset that referenced the old identity. Porting from another framework? Use migrate_state to keep loading the old blobs.

#The editor is a function of the params

editor(params) has no self by design, so opening the GUI provably can't take the plugin lock or read DSP state. If the editor genuinely needs shared, DSP-derived data (an analyzer's spectrum, say), route it through a #[skip] field on the params struct - fill it in construction, read it back in editor. Don't try to smuggle DSP state to the GUI any other way. See GUI and state.

#Save state off the audio thread

Parameters are saved and restored for you. For extra state (file paths, view modes, custom curves), override snapshot_into: the audio thread serializes into a reused buffer each block and the host reads it back without ever locking the plugin, so a host save never stalls audio. Use serialize_into (not serialize) so the per-block serialize is allocation-free. Reach for the simpler save_state only when the state is small and cheap.

#Offload heavy work to a worker

IR loading, FFT planning, file decode, convolution setup - anything that allocates or blocks - belongs on a worker thread, with results handed to the audio thread through a lock-free channel. The audio thread requests and consumes; it never waits. See workers.

#Kill denormals in feedback paths

Filters and reverbs whose feedback decays toward zero can slip into denormal numbers, which some CPUs process an order of magnitude slower - an idle plugin that spikes CPU. Flush very small values to zero in feedback loops (a tiny DC offset or an explicit flush), and sanitize host input before dividing by it.

#Test and validate before shipping

Bugs in an audio plugin surface as glitches in someone's session, so gate on them:

  • Use the audio-testing driver to assert state round-trips, the editor exists, and known input produces known output - no host needed.
  • Screenshot-test the editor so layout regressions fail in CI.
  • Run cargo truce validate (auval, pluginval, clap-validator) before every release, strict in CI.