#[derive(Params)] reference
Every attribute, range type, smoothing mode, and parameter type that the params derive accepts. For a narrative tour and recipes, see the parameters chapter in the guide.
##[param(...)] attribute keys
| Key | Example | Notes |
|---|---|---|
id |
id = 0 |
Optional. When omitted, auto-assigned as a stable hash of the field name, so reordering or inserting params (including in nested structs) never shifts IDs. Set it to pin a specific value; an explicit id always wins. See Parameter ID stability. |
name |
name = "Gain" |
Required. Display name in host and GUI. |
short_name |
short_name = "Gn" |
Abbreviated name for narrow strips. Defaults to name. |
range |
range = "linear(-60, 6)" |
Value mapping. Inferred for BoolParam and EnumParam<T>. |
default |
default = 0.0 |
Default value in plain units. Defaults to range min. |
unit |
unit = "dB" |
Display unit. Shapes the default formatter. Valid: dB, Hz, ms, s, %, pan, st. % is display-only scaling: the plain value is a 0..=1 fraction and the formatter multiplies by 100, so a 0-150% param is range = "linear(0, 1.5)". Every other unit formats the plain value as-is. |
smooth |
smooth = "exp(5)" |
Smoothing style + time in ms. See Smoothing. |
group |
group = "Filter" |
Parameter group surfaced by the host (CLAP module path / VST3 unit / AU group). |
flags |
flags = "automatable | bypass" |
Combination of: automatable, hidden, readonly, bypass. |
format |
format = "format_cutoff" |
Method on the params struct that converts a f64 value to a String. |
parse |
parse = "parse_cutoff" |
Inverse of format. Method that parses a host text-input &str back to f64. |
chunk |
chunk = false |
Opt this parameter out of sample-accurate sub-block chunking. Default true. Set to false for parameters too expensive to re-target mid-block (FFT sizes, lookahead lengths). See parameters § Sample-accurate automation. |
midi_cc |
midi_cc = 74 |
Default host MIDI-learn binding: a CC number 0..=127. Bakes the initial CC→parameter map the host offers before the user learns their own. See MIDI binding. Mutually exclusive with midi_source. |
midi_source |
midi_source = "pitchbend" |
Default MIDI-learn binding to a non-CC source: "pitchbend", "pressure" (channel pressure / aftertouch), or "program" (program change). See MIDI binding. Mutually exclusive with midi_cc. |
midi_channel |
midi_channel = 1 |
Scope a midi_cc / midi_source binding to one MIDI channel 1..=16. Omit to bind on every (omni) channel. |
The derive generates MyParams::new(), a Default impl, the full Params trait impl, and a typed MyParamsParamId enum (#[repr(u32)]) with one variant per parameter.
#Parameter types
| Field type | Default widget | Notes |
|---|---|---|
FloatParam |
knob | Continuous. Supports smoothing and custom formatting. |
BoolParam |
toggle | On / off. Range is implicit 0..1 — don't pass range. |
IntParam |
knob | Integer steps within a range. |
EnumParam<T> |
dropdown | Click-to-open list. T is a #[derive(ParamEnum)] enum; range is inferred from the variant count. |
MeterSlot |
meter | Read-only, written from process(), drawn by the GUI. Declared with #[meter], not #[param(...)]. |
#Enum parameters
#[derive(ParamEnum)]
pub enum Waveform { Sine, Saw, Square, Triangle }
#[param(name = "Waveform")]
pub waveform: EnumParam<Waveform>,
Use #[name = "..."] on a variant to override its display text — the Rust name stays the same:
#[derive(ParamEnum)]
pub enum Direction {
#[name = "Up/Down"]
UpDown,
Down,
}
#Meters
Meters are not parameters — they flow audio-thread → UI-thread instead of host → plugin. Declare them with #[meter]:
#[derive(Params)]
pub struct MyParams {
#[param(name = "Gain", range = "linear(-60, 6)", unit = "dB", smooth = "exp(5)")]
pub gain: FloatParam,
#[meter] pub meter_l: MeterSlot,
#[meter] pub meter_r: MeterSlot,
}
Parameters and meters share the generated ParamId enum. Write from process(), read in editor():
// process():
context.set_meter(P::MeterL, buffer.output_peak(0));
context.set_meter(P::MeterR, buffer.output_peak(1));
// editor():
meter(&[P::MeterL, P::MeterR], "Level").rows(3)
The write is realtime-safe (atomic); the GUI reads the latest value every frame.
#Range types
range = "linear(-60, 6)" # linear between min and max
range = "log(20, 20000)" # logarithmic - frequency, time constants
range = "skewed(0, 1, 0.5)" # power-law taper; factor < 1 packs detail near min
range = "sym_skewed(-1, 1, 0.5, 0)" # center-anchored taper - pan, EQ-gain knobs
range = "reversed(linear(0, 1))" # any range with its knob axis flipped
range = "discrete(1, 16)" # integer steps
range = "enum(4)" # N discrete cases (rarely written by hand;
# EnumParam<T> infers this from the variant count)
skewed(min, max, factor) bends the knob response by a power law: a
factor below 1 gives finer control near min, above 1 near max.
sym_skewed(min, max, factor, center) does the same but anchored at
center and mirrored either side of it, so a pan or EQ-gain knob stays
symmetric around its detent. reversed(<range>) wraps any of the other
shapes and flips the knob axis (max at the bottom).
BoolParam ranges are implicit 0..1. EnumParam<T> ranges are inferred from T's variant count.
#Smoothing
Host automation usually arrives block-rate. Smoothing interpolates between successive target values so there's no zipper noise on continuous parameters.
smooth = "none" # instant jump. Right for toggles, enums, voice counts.
smooth = "linear(20)" # linear ramp over 20 ms. Right for pan and mix.
smooth = "exp(5)" # exponential one-pole, 5 ms. Right for gain and filter cutoff.
smooth = "log(20)" # multiplicative one-pole, 20 ms. Constant perceived rate -
# frequency and linear-gain params.
log smooths in the multiplicative (ratio) domain, so a cutoff sweep or a
linear-gain fade moves at a constant perceived rate rather than a
constant additive one. Both endpoints must be positive.
The shell sets the sample rate and snaps the smoothers for you before
reset() runs, so you don't prime them yourself.
In process(), pull a smoothed value per sample with .read():
let g = params.gain.read();
.read() returns f32 or f64 depending on the prelude in scope —
see Precision (preludes).
The method takes &self (the smoother state is atomic), so it
works through Arc<Params> without &mut.
#Read accessors
FloatParam exposes four read accessors:
| Method | What it returns | When to use |
|---|---|---|
.read() |
Next smoothed sample; advances the smoother. | Per-sample DSP loop. |
.read_into(&mut [f32]) |
Fills the slice with the next out.len() smoothed samples; advances the smoother by out.len(). One atomic load + one atomic store per call, regardless of length. |
Block-rate DSP — pair with vectorized math (ops::*_block, math::*_block) to amortize the atomic over a whole chunk. Pass &mut scratch[..n] to keep the smoother in lockstep with the consumed samples even when chunks vary in length. See the processing chapter for the full slow-path / fast-path pattern. |
.current() |
Current smoothed value without advancing. | Peeking at the smoother without consuming a tick — e.g. a per-block snapshot. |
.value() |
The raw target value (last write from host automation / set_normalized), with no smoothing. |
Threshold checks, structural decisions, anything that shouldn't react to the smoother's crawl. |
All four are trait methods (FloatParamReadF32 / FloatParamReadF64) that the preludes bring into scope; the return type follows the prelude in use. use truce::prelude::*; gives you f32-returning versions, use truce::prelude64::*; gives you f64-returning versions, same call sites either way.
The canonical case for .value() is a parameter that drives a
discrete downstream decision rather than a continuous gain. The
fundsp reverb example reads
Time with .value() and triggers a graph rebuild when the raw
target drifts past a 5% threshold:
let time_s = params.time.value();
if (time_s - state.last_built_time_s).abs() > REBUILD_THRESHOLD {
request_rebuild(time_s);
}
If that code used .read() instead, a single knob move would
crawl across the threshold over the smoother's ~200 ms ramp and
request a rebuild on every block until it settled — audible as an
unstable tail. The same principle applies to anything else gated
on "did the user actually change this?": filter mode switches,
voice-count changes, oversampling toggles wired through a float
param.
#Flags
flags is a |-separated combination of bit flags:
| Flag | Meaning |
|---|---|
automatable |
Surfaces in the host's automation lane (default for non-bypass params). |
hidden |
Excluded from the host parameter list. Use sparingly — most hosts surface every exposed param. |
readonly |
Host can't write; GUI can't write either. For internal state you want serialized. |
bypass |
Marks the bypass parameter. Hosts treat this specially (per-track bypass UI). One per plugin. |
modulatable |
Advertises the parameter as host-modulatable (CLAP CLAP_PARAM_IS_MODULATABLE). The host may send modulation offsets on top of the automation value, delivered to process as EventBody::ParamMod. See Modulation. |
modulatable_per_note |
Per-note (polyphonic) modulation: a MIDI 2.0 registered per-note controller (CLAP CLAP_PARAM_IS_MODULATABLE_PER_NOTE_ID). Implies modulatable; the ParamMod event's note_id scopes the offset to one voice. See Modulation. |
#Modulation
modulatable and modulatable_per_note are opt-in flags for hosts that
send modulation separately from automation: a modulation offset rides
on top of the parameter's current value rather than overwriting it. Set
them only on parameters you actually consume modulation for; other params
stay host-owned.
#[derive(Params)]
pub struct SynthParams {
// Host may modulate cutoff; per-voice registered per-note controller too.
#[param(name = "Cutoff", range = "log(20, 20000)", unit = "Hz",
flags = "automatable | modulatable_per_note")]
pub cutoff: FloatParam,
}
Modulation is currently a CLAP feature (CLAP_PARAM_IS_MODULATABLE /
CLAP_PARAM_IS_MODULATABLE_PER_NOTE_ID). The host emits
CLAP_EVENT_PARAM_MOD, which truce delivers to process as an
EventBody::ParamMod in the EventList:
modulatable: one mono offset for the whole parameter.modulatable_per_note: impliesmodulatable, and the event carries anote_idso you can apply a distinct offset per voice (the MIDI 2.0 registered per-note controller path). Readnote_idoff theParamModevent to route it to the right voice.
Other formats ignore these flags: they have no separate modulation channel, so the parameter behaves as ordinary automation there.
#MIDI binding
midi_cc / midi_source (with an optional midi_channel scope) seed a
default MIDI-learn binding: the CC-or-source-to-parameter map the
host offers out of the box, before the user assigns their own. Set at most
one of midi_cc or midi_source per parameter.
#[derive(Params)]
pub struct SynthParams {
// Mod wheel (CC 1) drives vibrato depth by default, on every channel.
#[param(name = "Vibrato", range = "linear(0, 1)", midi_cc = 1)]
pub vibrato: FloatParam,
// Channel pressure drives filter cutoff, MIDI channel 1 only.
#[param(name = "Cutoff", range = "log(20, 20000)", unit = "Hz",
midi_source = "pressure", midi_channel = 1)]
pub cutoff: FloatParam,
}
midi_cc = N: bind to CCN(0..=127).midi_source = "pitchbend" | "pressure" | "program": bind to a non-CC source (pitch bend, channel pressure / aftertouch, or program change).midi_channel = 1..=16: restrict the binding to one channel; omit for omni (all channels).
#Custom formatting
Most plugins get by with the default formatter chosen from unit. When you need conditional display — Hz vs. kHz, semitones, dotted-note durations — point format at a method on your params struct:
#[derive(Params)]
pub struct SynthParams {
#[param(name = "Cutoff", range = "log(20, 20000)", unit = "Hz",
format = "format_cutoff", parse = "parse_cutoff")]
pub cutoff: FloatParam,
}
impl SynthParams {
fn format_cutoff(&self, value: f64) -> String {
if value >= 1000.0 {
format!("{:.1} kHz", value / 1000.0)
} else {
format!("{:.0} Hz", value)
}
}
fn parse_cutoff(&self, text: &str) -> Option<f64> {
let t = text.trim().to_lowercase();
if let Some(n) = t.strip_suffix("khz") {
n.trim().parse::<f64>().ok().map(|v| v * 1000.0)
} else {
t.trim_end_matches("hz").trim().parse().ok()
}
}
}
parse is the inverse, used when the host accepts text input (Logic's "Type Value", REAPER's modify-value dialog, etc.).
#Nested structs
Split a wide parameter set into self-contained groups with #[nested], each its own #[derive(Params)] struct. The host still sees one flat parameter list.
#[derive(Params)]
pub struct FilterParams {
#[param(name = "Cutoff", group = "Filter", range = "log(20, 20000)", unit = "Hz")]
pub cutoff: FloatParam,
#[param(name = "Resonance", group = "Filter", range = "linear(0, 1)")]
pub resonance: FloatParam,
}
#[derive(Params)]
pub struct PluginParams {
#[nested] pub filter: FilterParams,
#[nested] pub envelope: EnvelopeParams,
}
A nested group's params auto-number locally from 0; you don't write ids inside the group. The parent rebases each group into the plugin's id space by a base: bare #[nested] auto-packs each group right after the preceding params (own params first, then each group in order). Above, filter lands at ids 0-1 and envelope follows.
#Reusing a group
Because the parent assigns the base, the same group type can be nested more than once without an id clash:
#[derive(Params)]
pub struct ChannelStrip {
#[param(name = "Gain", range = "linear(-60, 12)", unit = "dB")]
pub gain: FloatParam,
#[param(name = "Mute")]
pub mute: BoolParam,
}
#[derive(Params)]
pub struct UtilityParams {
#[nested] pub left: ChannelStrip, // ids 0-1
#[nested] pub right: ChannelStrip, // ids 2-3
}
The two ChannelStrips flatten to disjoint id ranges. (Their param names are shared, since they come from one type, so a host's flat list shows Gain twice; label them per-instance in your editor's sections.)
#Pinning a base for a group
Under the default (hash) id scheme, nested ids are already stable - a nested param's id is derived from its field name and its slot name, so reordering groups or inserting a param never shifts them (see Parameter ID stability). Pin a group's base with #[nested(base = N)] only when you want explicit numeric placement, or under the legacy #[params(id_scheme = "ordinal")] scheme where auto bases pack by order and do shift:
#[derive(Params)]
pub struct EqParams {
#[nested(base = 0)] pub low: LowBand, // ids 0-2
#[nested(base = 3)] pub mid: MidBand, // ids 3-5
#[nested(base = 6)] pub high: HighBand, // ids 6-8
#[param(id = 9, name = "Output", range = "linear(-18, 18)", unit = "dB")]
pub output: FloatParam,
}
The derive panics at construction if any two flattened ids collide, so a bad base is a loud failure, not silent state corruption.
#Addressing nested params in the editor
The generated ParamId enum (below) covers a struct's own params, not its nested children - the derive can't see inside another type. Reach a nested param by its runtime id off the field itself:
knob(params.filter.cutoff.id(), "Cutoff")
.id() returns the rebased (flattened) id, so it stays correct under reuse and pinning alike.
See the synth (distinct groups), stereo-utility (one group reused), and eq (pinned bases) examples for the three shapes.
Meters can't live in a nested group that's used more than once - meter ids occupy a separate fixed range and aren't rebased, so two nested groups each declaring a meter collide (the derive rejects it at construction). Keep meters in a single, non-reused
Paramsstruct.
#Parameter ID stability
Every parameter has a numeric ID, and the host persists it as the stable handle for automation lanes and saved presets - it becomes the CLAP clap_id, the VST3 ParamID, and the key in truce's saved-state envelope. So a parameter's ID must not change between releases, or a user's saved session reloads with automation pointing at the wrong parameter.
truce keeps IDs stable for you by default. When you omit #[param(id = N)], the derive assigns a hash of the field name. Reordering parameters, inserting a new one in the middle, or rearranging #[nested] groups all leave existing IDs put; only renaming a field (an explicit, reviewable edit) changes its ID. The same nested group type reused in two slots still gets distinct IDs, because each slot's name hashes differently.
You rarely need to think about IDs. The escape hatches:
#[param(id = N)]pins one parameter to an exact value; an explicit ID always wins over the hash.- If two field names happen to hash to the same value, the derive fails the build with a duplicate-ID error - pin one of them with
id = N.
#Legacy ordinal scheme
Before the hash default, IDs were a counter from 0 in declaration order, so reordering shifted them. A plugin already shipped with those order-based IDs must keep them, or its users' saved sessions break on upgrade. Opt back in at the struct level:
#[derive(Params)]
#[params(id_scheme = "ordinal")]
pub struct MyParams { /* ... */ }
This restores the counter-from-0 assignment and the base-packed nesting exactly. New plugins should leave it off and use the hash default.
#Generated ParamId enum
The derive emits a #[repr(u32)] enum named <StructName>ParamId with one variant per parameter (and per meter). Use it everywhere you'd otherwise pass a raw u32:
use MyParamsParamId as P;
knob(P::Gain, "Gain");
slider(P::Pan, "Pan");
context.set_meter(P::MeterL, peak);
Typos become compile errors; rename-refactor stays safe.
#Shared ownership
The shell owns the Arc<MyParams> and hands &MyParams to process / reset / init and a clone of the Arc to editor. GUI closures can also clone the Arc. Host automation writes atomically; every reader sees the latest value without locking.
fn process(state: &mut Self::DspState, params: &MyParams, buffer: &mut AudioBuffer,
events: &EventList, ctx: &mut ProcessContext) -> ProcessStatus {
let g = params.gain.read();
// ...
}
fn editor(params: Arc<MyParams>) -> Box<dyn Editor> {
// clone the Arc into GUI closures as needed
}
#Skipped fields (#[skip])
A field marked #[skip] is not a parameter — it's plugin-owned state that lives in the params struct so both sides can reach it through the Arc<MyParams> they already share (see Shared ownership). The derive Default-initializes it in new() and excludes it from the parameter IDs, infos, saved state, and count, so it never appears as a host automation lane or in a preset. The field's type must implement Default.
Use it for non-automatable state the editor needs to reach directly: a shared atomic flag, decoded or cached data, or a lock-free queue of audio-thread events for a live visualiser. Because the audio thread (writer) and the editor (reader) both hold the same Arc<MyParams>, no separate channel is needed.
#[derive(Params)]
pub struct MyParams {
#[param(name = "Gain", range = "linear(-60, 6)", unit = "dB")]
pub gain: FloatParam,
// Lock-free audio-thread -> editor channel. Not a parameter:
// excluded from ids / state / count, default-initialized in `new()`.
#[skip]
pub events: Arc<EventRing>,
}
process() pushes into events (real-time safe — no locks or allocations), and the editor drains it each frame through the same Arc — the pattern the MIDI-inspector example uses to stream decoded events to its UI.