Chapter 5
Processing audio
process() is called on the audio thread for every block. Same
constraints as any audio plugin — no allocation, no locking, no
I/O, no println!. Rust's type system catches a lot of this; the
rest is up to you.
The signature is always:
fn process(
&mut self,
buffer: &mut AudioBuffer,
events: &EventList,
context: &mut ProcessContext,
) -> ProcessStatus;
Everything in this chapter is a different shape for that function.
#Buffer model
AudioBuffer exposes one slice per input channel and one mutable
slice per output channel, both borrowing host memory. Wrappers do
not copy input into output: read from buffer.input(ch) and write
to buffer.output(ch). For instruments, output starts wherever the
host left it (typically zero, but don't assume — write every sample).
The slice element type is f32 under truce::prelude and f64
under truce::prelude64; the wrapper widens at the block boundary
and narrows on the way back out. See
Precision (preludes). The
signatures below assume the default prelude:
impl<'a> AudioBuffer<'a> {
// Sizes
fn num_samples(&self) -> usize;
fn num_input_channels(&self) -> usize;
fn num_output_channels(&self) -> usize;
fn channels(&self) -> usize; // min(in, out)
// Channel access
fn input(&self, ch: usize) -> &[f32];
fn output(&mut self, ch: usize) -> &mut [f32];
fn io(&mut self, ch: usize) -> (&[f32], &mut [f32]);
fn io_pair(&mut self, in_ch: usize, out_ch: usize)
-> (&[f32], &mut [f32]);
// Sub-block view (for sample-accurate event splitting)
fn slice(&mut self, start: usize, len: usize) -> AudioBuffer<'_>;
// In-place I/O (opt-in; see below)
fn is_in_place(&self, ch: usize) -> bool;
fn in_out_mut(&mut self, ch: usize) -> &mut [f32];
// Diagnostics
fn output_peak(&self, ch: usize) -> f32;
}
input, output, io, and in_out_mut all return slices of length
num_samples() — the current block, or the current sub-block if
you've called slice().
#Per-sample effect
The most common shape — one multiplication per sample per channel:
fn process(&mut self, buffer: &mut AudioBuffer, _: &EventList,
_: &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
}
Pull smoothed param values per sample when they need to glide cleanly (gain, filter cutoff). Pull per block for param reads that are expensive or that don't care about sample-accuracy (mode switches, enums).
#Per-channel loop with input/output pairs
Useful when you need separate read and write pointers (convolution, IIR filters) rather than in-place modification:
for ch in 0..buffer.num_output_channels() {
let (input, output) = buffer.io_pair(ch, ch);
for i in 0..buffer.num_samples() {
output[i] = self.filters[ch].process(input[i]);
}
}
ProcessStatus::Normal
#MIDI and parameter events
events is a sorted list of Event { sample_offset, body }.
Pattern match the body:
for event in events.iter() {
match &event.body {
EventBody::NoteOn { note, velocity, .. } => self.note_on(*note, *velocity),
EventBody::NoteOff { note, .. } => self.note_off(*note),
EventBody::ControlChange { cc: 1, value, .. } => {
self.mod_depth = *value;
}
_ => {}
}
}
EventBody also carries MIDI 2.0 variants (NoteOn2, PerNoteCC,
PerNotePitchBend, …) and CLAP parameter modulation (ParamMod
with a per-voice note_id). The _ => {} arm means the compiler
can still warn if you forgot a variant that mattered.
For MIDI input and output (arpeggiators, transposers, chord generators), see midi.
#Sample-accurate event splitting
If your synth or transient shaper needs events applied at the exact sample they occur, interleave the event loop with the sample loop:
fn process(&mut self, buffer: &mut AudioBuffer, events: &EventList,
_: &mut ProcessContext) -> ProcessStatus {
let mut next = 0;
for i in 0..buffer.num_samples() {
while let Some(event) = events.get(next) {
if event.sample_offset as usize > i { break; }
self.handle_event(&event.body);
next += 1;
}
for ch in 0..buffer.channels() {
buffer.output(ch)[i] = self.render_sample(ch);
}
}
ProcessStatus::Normal
}
For block-rate event handling (effects where param changes don't need sample accuracy), process the event list once at the top and then the whole block — simpler and cheaper.
#Host transport
context.transport surfaces tempo, play state, beat position, loop
bounds. Use it for tempo-synced LFOs, bar-locked envelopes, looping
delays.
let t = &context.transport;
if t.playing {
let beat = t.position_beats;
let tempo = t.tempo;
let bar = t.time_sig_num as f64;
let phase = (beat * self.sync_rate) % 1.0;
let in_bar = beat % bar;
// ...
}
Not every host fills every field every block. The
examples/truce-example-tremolo example shows the pattern:
fall back to a free-running internal clock at 120 BPM when the
host doesn't provide transport.
#Meters (DSP → UI)
Meters push from process() via context.set_meter, indexed by
typed ParamId. The GUI reads the latest value every frame.
context.set_meter(P::MeterL, buffer.output_peak(0));
context.set_meter(P::MeterR, buffer.output_peak(1));
Realtime-safe (atomic). Declaration of the MeterSlot fields is
in chapter 4 → parameters.md § Meters.
#Declaring tail time
Effects with memory — reverbs, delays, self-oscillating filters — keep producing audio after the input stops. Tell the host how many samples are left so it doesn't cut you off:
if self.is_producing_silence() {
ProcessStatus::Tail(self.remaining_tail_samples())
} else {
ProcessStatus::Normal
}
Return ProcessStatus::Tail(0) from a synth when every voice has
released — the host can then elide further process calls until
the next note-on.
#Building a synth
A polyphonic synth is a combination of the patterns above:
- Sample-accurate event loop so note-ons land at the right sample.
- Per-sample param reads for filter cutoff / resonance (they sound bad when block-rate'd).
ProcessStatus::Tail(0)when all voices are done so the host can idle.
The full examples/truce-example-synth plugin (in the repo) is
roughly this shape:
impl PluginLogic for Synth {
fn bus_layouts() -> Vec<BusLayout> {
// Instrument: output only, no audio input.
vec![BusLayout::new().with_output("Main", ChannelConfig::Stereo)]
}
fn reset(&mut self, sample_rate: f64, _: usize) {
self.sample_rate = sample_rate;
self.voices.clear();
self.params.set_sample_rate(sample_rate);
self.params.snap_smoothers();
}
fn process(&mut self, buffer: &mut AudioBuffer, events: &EventList,
_: &mut ProcessContext) -> ProcessStatus {
let mut next = 0;
for i in 0..buffer.num_samples() {
// 1. Dispatch any events landing at this sample.
while let Some(e) = events.get(next) {
if e.sample_offset as usize > i { break; }
match &e.body {
EventBody::NoteOn { note, velocity, .. } => self.note_on(*note, *velocity),
EventBody::NoteOff { note, .. } => self.note_off(*note),
_ => {}
}
next += 1;
}
// 2. Read per-sample smoothed params. This synth uses
// `use truce::prelude64::*`, so `.read()` returns
// `f64` and the audio buffer slices are `&[f64]`.
let wave = self.params.waveform.index();
let cutoff = self.params.cutoff.read();
let reso = self.params.resonance.read();
let volume = db_to_linear(self.params.volume.read());
// 3. Sum the voices and write.
let mut sample = 0.0;
for voice in &mut self.voices {
sample += voice.render(wave, cutoff, reso, self.sample_rate);
}
sample *= volume;
let out = sample.clamp(-1.0, 1.0);
buffer.output(0)[i] = out;
buffer.output(1)[i] = out;
}
// 4. Retire finished voices; signal idle when empty.
self.voices.retain(|v| !v.is_done());
if self.voices.is_empty() { ProcessStatus::Tail(0) } else { ProcessStatus::Normal }
}
fn layout(&self) -> truce_gui_types::layout::GridLayout { /* ... */ }
}
Voice allocation, ADSR, and filter state live in the Voice struct
— plain Rust, no framework involvement. Parameters flow in through
Arc<Params>; nothing else is shared across threads.
The macro is the same for every plugin shape:
truce::plugin! {
logic: Synth,
params: SynthParams,
}
#In-place I/O (advanced; opt-in)
Some hosts (Reaper, pluginval) pass the same buffer for both input
and output of a given channel. By default truce handles this for you
— the wrapper detects the alias and copies the input into per-channel
scratch so buffer.input(ch) and buffer.output(ch) are always
disjoint slices. The cost is one memcpy per aliased channel per block
(a few hundred KB/sec at audio rates) and it never shows up unless
you go looking. Most plugins should ignore this section.
If you profile and the wrapper memcpy is meaningful for your DSP,
override supports_in_place() on your PluginLogic impl to
return true. The wrapper then skips the copy and you
read+write the shared buffer directly:
impl PluginLogic for MyEffect {
fn supports_in_place() -> bool { true }
// ...
fn process(&mut self, buffer: &mut AudioBuffer, _: &EventList,
_: &mut ProcessContext) -> ProcessStatus {
for ch in 0..buffer.num_output_channels() {
if buffer.is_in_place(ch) {
// Host shares one buffer for in+out; read each
// sample, then overwrite it.
let inout = buffer.in_out_mut(ch);
for s in inout.iter_mut() { *s = self.process_sample(*s); }
} else {
let inp = buffer.input(ch);
let out = buffer.output(ch);
for i in 0..inp.len() { out[i] = self.process_sample(inp[i]); }
}
}
ProcessStatus::Normal
}
}
The contract:
- With
supports_in_place() = true,buffer.input(ch)returns an empty slice for in-place channels — the data only exists in the shared buffer. You must checkbuffer.is_in_place(ch)and usebuffer.in_out_mut(ch)for those channels. - With
SUPPORTS_IN_PLACE = false(default),buffer.input(ch)andbuffer.output(ch)are always safe and disjoint, even when the host requested in-place.is_in_placestill reflects the host's choice — but you can ignore it.
#What's next
- Chapter 6 → midi — emitting MIDI, wire-format helpers, MIDI 2.0 surface.
- Chapter 7 → gui — widgets, layout, meters in the UI.
- Chapter 8 → audio-testing — lock this code in with in-process regression tests before it ships.
- Chapter 10 → hot-reload — keep your DAW open while you iterate on this code.
examples/truce-example-tremoloin the repo — host transport- egui UI in a small, real plugin.