#[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: implies modulatable, and the event carries a note_id so you can apply a distinct offset per voice (the MIDI 2.0 registered per-note controller path). Read note_id off the ParamMod event 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 CC N (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 Params struct.

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