Chapter 2
Your first plugin
Scaffold, build, install, load, iterate. End state: a stereo gain plugin with a GUI loaded in your DAW.
Prerequisites: chapter 1 → install.
#Scaffold
cargo truce new my-gain
cd my-gain
cargo truce new writes a minimal, self-contained project:
my-gain/
├── Cargo.toml crate with cdylib crate-type + default clap/vst3/standalone features
├── truce.toml vendor identity + this plugin's metadata (name, IDs, AU codes)
└── src/
├── lib.rs the whole plugin — params, DSP, GUI, export macro
└── main.rs standalone host entry point (gated behind the `standalone` feature)
No build.rs. No separate GUI crate. The src/main.rs host is a
4-line truce_standalone::run::<Plugin>() call so cargo truce run
works out of the box; pass --no-standalone to skip it (drops the
file, the bin entry, and the standalone feature/dep).
Shipping a suite? Pass --workspace to create one Cargo workspace
with a shared truce.toml and one sub-crate per plugin:
cargo truce new studio --workspace gain reverb delay
cargo truce new studio --workspace gain synth arp \
--vendor "Studio Audio" --vendor-id com.studio \
--type:synth=instrument --type:arp=midi
cargo truce new studio --workspace gain reverb \
--no-standalone # skip the standalone host in every plugin
You get studio/plugins/{gain,synth,arp}/, each with its own
lib.rs (and main.rs unless you passed --no-standalone), plus
one truce.toml with three [[plugin]] entries. Every cargo truce
command below works workspace-wide; add -p <crate> (the cargo
crate name, e.g. -p gain) to target one plugin.
#Tour the generated code
src/lib.rs has three parts. Open it alongside this section.
#1. Parameters — what the user controls
#[derive(Params)]
pub struct MyGainParams {
#[param(name = "Gain", range = "linear(-60, 6)",
unit = "dB", smooth = "exp(5)")]
pub gain: FloatParam,
}
One line of attributes per parameter. #[derive(Params)] generates
the new() constructor, a MyGainParamsParamId enum with typed
variants, and the Params trait impl. Parameter IDs auto-assign by
field order (Gain = 0, then 1, 2, ...). See
chapter 4 → parameters for the full attribute
reference.
#2. Plugin logic — what happens to the audio
use MyGainParamsParamId as P;
pub struct MyGain { params: Arc<MyGainParams> }
impl MyGain {
pub fn new(params: Arc<MyGainParams>) -> Self { Self { params } }
}
impl PluginLogic for MyGain {
fn reset(&mut self, sample_rate: f64, _max_block_size: usize) {
self.params.set_sample_rate(sample_rate);
self.params.snap_smoothers();
}
fn process(&mut self, buffer: &mut AudioBuffer, _events: &EventList,
_ctx: &mut ProcessContext) -> ProcessStatus {
for i in 0..buffer.num_samples() {
let gain = db_to_linear(self.params.gain.read());
for ch in 0..buffer.channels() {
let (inp, out) = buffer.io(ch);
out[i] = inp[i] * gain;
}
}
ProcessStatus::Normal
}
fn layout(&self) -> truce_gui_types::layout::GridLayout {
use truce_gui_types::layout::{GridLayout, knob, widgets};
GridLayout::build(vec![widgets(vec![knob(P::Gain, "Gain")])])
}
}
PluginLogic is a single trait covering both the audio thread
(reset() runs when the host knows the sample rate and block
size; process() runs every block) and the main thread
(layout() returns a declarative description of the GUI). Only
reset and process are required; everything else has a
default — headless plugins just leave layout() at the default.
See chapter 3 → plugin-anatomy.
#3. The export macro — makes it a plugin
truce::plugin! {
logic: MyGain,
params: MyGainParams,
}
Generates all format entry points (CLAP, VST3, VST2, LV2, AU v2/v3,
AAX via Cargo features), state serialization, parameter hosting,
and the hot-reload shell. One macro. Default bus layout is stereo;
override PluginLogic::bus_layouts() for instruments, sidechains,
or mono/mono.
#Tour the generated config
#truce.toml
[vendor]
name = "My Company"
id = "com.mycompany"
au_manufacturer = "MyCo"
[[plugin]]
name = "My Gain"
bundle_id = "my-gain"
crate = "my-gain"
category = "effect"
fourcc = "MyG1"
truce.toml is the single source of truth for plugin identity
across every format. The truce::plugin_info!() macro reads it at
compile time so truce::plugin! doesn't need any of this in code.
Full schema in
reference/truce-toml.
Per-developer secrets (signing identity, AAX SDK path, notary
credentials) go in .cargo/config.toml (gitignored), not here.
#Cargo.toml features
[features]
default = ["clap", "vst3", "standalone"]
clap = ["truce/clap"]
vst3 = ["truce/vst3"]
vst2 = ["truce/vst2"]
lv2 = ["truce/lv2"]
au = ["truce/au"]
aax = ["truce/aax"]
standalone = ["truce/standalone"]
shell = ["truce/shell"]
Scaffolded plugins enable CLAP + VST3 + standalone by default. Add more formats
to default, or opt in per-command (cargo truce install --vst2).
Per-format detail (SDKs, env vars, install paths, signing) is in
docs/formats/.
#Build and install
cargo truce install
This builds the crate, bundles each enabled format, codesigns on macOS, and drops bundles into your per-user plug-in directories. You should see something like:
CLAP: ~/Library/Audio/Plug-Ins/CLAP/My Gain.clap
VST3: ~/Library/Audio/Plug-Ins/VST3/My Gain.vst3
No sudo / Administrator prompt — user-scope is the default on
every platform. Pass --system to install into the system-wide
plug-in directories (/Library/Audio/Plug-Ins/... on macOS,
%COMMONPROGRAMFILES%\... on Windows), which prompts for sudo /
admin once per run.
Defaults to the cargo release profile — installing usually means
audio-testing in a DAW, where debug-build DSP can CPU-spike under
load. Pass --debug for fast-compile iteration when DSP correctness
isn't what you're checking; never ship a --debug bundle.
Explicit format selection works too:
cargo truce install --clap
cargo truce install --vst3 --lv2
cargo truce install --system # system-scope install (sudo / admin)
Install destinations per platform live in docs/formats/README.
To stage bundles into target/bundles/ without writing to the
system plug-in directories — useful for CI, packaging dry-runs, or
just inspecting the produced artifact — use cargo truce build:
cargo truce build # all default-feature formats
cargo truce build --clap # one format
cargo truce build --debug # cargo dev profile (faster compile)
Same defaults as install, same --debug opt-in, but never touches
host plug-in paths.
#Load in a DAW
- Open your DAW (Reaper is a good first test — free trial, loads CLAP / VST3 / VST2 / LV2).
- Rescan plugins (Reaper:
Options → Preferences → Plug-ins → VST/CLAP → Re-scan). - Insert My Gain on a track.
- Play audio; drag the knob. Volume should change.
Expected:

(Rendered headlessly with cargo truce screenshot --out screenshots/default.png
— the same path the screenshot regression tests in
gui/screenshot-testing use.)
#Edit and rebuild
Add a pan parameter. In src/lib.rs:
#[derive(Params)]
pub struct MyGainParams {
#[param(name = "Gain", range = "linear(-60, 6)",
unit = "dB", smooth = "exp(5)")]
pub gain: FloatParam,
#[param(name = "Pan", range = "linear(-1, 1)",
unit = "pan", smooth = "exp(5)")]
pub pan: FloatParam,
}
Use it in process():
for i in 0..buffer.num_samples() {
let gain = db_to_linear(self.params.gain.read());
let pan = self.params.pan.read();
let l = gain * (1.0 - pan.max(0.0));
let r = gain * (1.0 + pan.min(0.0));
buffer.output(0)[i] *= l;
if buffer.num_output_channels() >= 2 {
buffer.output(1)[i] *= r;
}
}
Show it in the GUI:
fn layout(&self) -> GridLayout {
GridLayout::build(vec![widgets(vec![
knob(P::Gain, "Gain"),
knob(P::Pan, "Pan"),
])])
}
Rebuild:
cargo truce install
Close and reopen the plugin in your DAW. You now have two knobs.
#What's next
- Other parameter kinds — boolean, int, enum, groups, meters, custom formatting → chapter 4 → parameters.
- Non-trivial processing — transport, sample-accurate events, instruments → chapter 5 → processing.
- MIDI — reading and emitting MIDI events, note effects → chapter 6 → midi.
- A richer UI — more widgets,
section(), switching to egui/iced/Slint → chapter 7 → gui. - Shipping to users — signed
.pkg/.exeinstallers → chapter 9 → shipping. - Real examples —
examples/truce-example-gain,examples/truce-example-eq,examples/truce-example-synth,examples/truce-example-transpose,examples/truce-example-arpeggio,examples/truce-example-tremoloin the repo.