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

  1. Open your DAW (Reaper is a good first test — free trial, loads CLAP / VST3 / VST2 / LV2).
  2. Rescan plugins (Reaper: Options → Preferences → Plug-ins → VST/CLAP → Re-scan).
  3. Insert My Gain on a track.
  4. Play audio; drag the knob. Volume should change.

Expected:

Scaffolded my-gain plugin: a single Gain knob reading 0.0 dB

(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