Chapter 8
MIDI
MIDI events ride the same EventList as parameter automation —
they're just variants of EventBody. Reading them is a match
on the body; emitting them is context.output_events.push(...).
The framework hands you wire-native integers (7-bit u8, 14-bit
u16, MIDI 2.0 16/32-bit) so values round-trip exactly with the
host. Float helpers are one function call away when you want
them.
#Declaring a MIDI plugin
Every plugin sees MIDI input that the host sends to it. What varies is whether the plugin produces audio, MIDI, or both.
| Plugin shape | truce.toml category |
Example |
|---|---|---|
| Audio effect (may also accept MIDI) | "effect" |
EQ, compressor, synth-style filter |
| Instrument (MIDI in, audio out) | "instrument" |
Synth, sampler |
| Note effect (MIDI in, MIDI out) | "midi" |
Arpeggiator, transpose, chord generator |
| Analyzer (no audio out) | "analyzer" |
Spectrum, level meter |
Set category = "midi" for note effects so each format wrapper
opens its MIDI I/O path:
- VST3 / CLAP: registers a MIDI input and output bus.
- AU: builds as
aumi(MIDI FX), routed to Logic's MIDI FX slot. - AAX: registers
LocalInput+LocalOutputMIDI nodes. - LV2: emits an
atom:Sequenceoutput port in addition to the input.
Set category = "instrument" for synths so wrappers register
MIDI input + audio output (and AU builds as aumu).
By default a plugin's MIDI I/O follows its category: instruments
and note effects accept MIDI input, and only note effects emit MIDI
output. Override either direction per plugin with the midi_input
and midi_output keys in truce.toml (see the
truce.toml reference). They apply
consistently across CLAP, VST3, VST2, AU, AAX, and LV2:
- An audio effect that reacts to MIDI (a CC-controlled filter) sets
midi_input = true. On AU this registers it as anaumfMusicEffect so the host routes MIDI to it. - An instrument or effect that also emits MIDI (a chord generator,
an envelope-to-CC follower) sets
midi_output = trueso every format declares its MIDI output port/bus.
#MIDI 2.0
The wire dialect is opt-in per plugin. Without the opt-in
everything is MIDI 1.0: a host speaking MIDI 2.0 gets its channel
voice down-converted before delivery, so a plugin that didn't ask
never sees the 2.0 EventBody variants.
[[plugin]]
category = "instrument"
midi2 = true # both directions
midi2 = true covers both directions; midi2_input /
midi2_output override one at a time (absent, each follows
midi2). The keys require a MIDI port in the direction they
name: midi2_input = true on a plugin with no MIDI input is a
compile error, and the blanket midi2 = true needs at least one
port in either direction.
What the opt-in buys per format:
- CLAP advertises the MIDI2 note dialect and carries the
native 16/32-bit and per-note variants both ways
(
CLAP_EVENT_MIDI2UMP in; CLAP-native notes, expressions, and raw MIDI out). - AU v3 declares
audioUnitMIDIProtocol= 2.0 so the host delivers native UMP; output rides the host'sMIDIEventListblock in the host's protocol, with the byte block as fallback (macOS 12+ / iOS 15+). - VST3 has no UMP transport; the per-note subset crosses as note expression instead - see the coverage table below.
- VST2, AU v2, AAX, LV2 stay MIDI 1.0 regardless.
examples/truce-example-midi-inspector
decodes every event truce can deliver into a live scrolling log -
useful for eyeballing what a host actually sends on each dialect.
#Multiple MIDI ports
Most plugins have one MIDI in and/or out port. A plugin that needs
more - a router, a merger, a per-destination sequencer - declares the
count with midi_input_ports / midi_output_ports in truce.toml:
[[plugin]]
category = "midi"
midi_output_ports = 4 # one input port, four output ports
A port count is authoritative: a non-zero midi_input_ports /
midi_output_ports enables that direction's MIDI capability on every
format by itself, 0 disables it, and a count that contradicts
midi_input / midi_output is a compile error.
Each Event carries a port field. Inbound, filter by event.port;
outbound, stamp the target port with Event::on_port(offset, port, body)
(the plain Event::new(offset, body) uses port 0). CLAP (N note ports),
VST3 (N event buses), and LV2 (N atom ports) carry more than one MIDI
port in both directions. On VST3 this includes the channel controllers
(CC / pitch bend / pressure), which VST3 delivers as parameter changes
rather than events: truce advertises a separate bank of hidden mapping
parameters per event bus, so each port's controllers decode back onto
that port instead of merging. AU v3 carries multiple MIDI output ports
(MIDIOutputNames, one per declared output; host support varies); its
MIDI input, plus VST2, AU v2, and AAX, clamp to a single port and route
everything to port 0, logging a one-line skip so the truncation isn't
silent.
#Getting MIDI onto a port in a host
Declaring the ports is the easy half. Whether the host lets a user route a distinct MIDI source to each port is entirely up to the host, and the story is bumpier than the API suggests:
- REAPER maps its per-track MIDI buses (B1–B16) to plugin ports,
but only for the VST3 build, and only after you enable
Map REAPER MIDI Buses to VST3 MIDI Buses — it's off by default.
Find it in the FX window: click the pin-connector button (the one
showing e.g.
2/64 out), then the small I/O button in the top-right of the grid, and tick the mapping. Then a track send set toBus 2reaches the plugin's port1. REAPER does not map buses to CLAP note ports today, so a CLAP plugin gets everything on port0— bus-2 traffic even arrives wrapped in a proprietary SysEx envelope (F0 FF 52 50 …), which is REAPER's tell that the mapping is off or unsupported. - Bitwig indexes CLAP note ports (it's ahead of REAPER on most CLAP surfaces), so it's the better host for testing multi-port CLAP.
If notes you routed to port 1 come out sounding like port 0's patch,
that's a host not delivering the second port. Confirm the plugin
itself dispatches event.port correctly before suspecting your code:
the driver! harness scripts on port 0 only, so build Events
directly with Event::on_port(offset, port, body), push them into an
EventList, and call process — the same delivery path a real
host's port routing hits. truce-example-multiport does exactly this
in its tests.
#The event model
pub struct Event {
pub sample_offset: u32, // 0..num_samples in this block
pub port: u8, // MIDI port; 0 unless the plugin declares more
pub body: EventBody,
}
pub enum EventBody {
// MIDI 1.0 channel voice
NoteOn { group, channel, note, velocity }, // u8 each
NoteOff { group, channel, note, velocity },
Aftertouch { group, channel, note, pressure }, // poly key pressure
ChannelPressure { group, channel, pressure },
ControlChange { group, channel, cc, value }, // 7-bit
PitchBend { group, channel, value: u16 }, // 14-bit, 8192 = center
ProgramChange { group, channel, program },
// MIDI 2.0 channel voice (wire-native 16/32-bit)
NoteOn2 / NoteOff2 { ..., velocity: u16, attribute_type, attribute },
PolyPressure2 { ..., pressure: u32 },
PerNoteCC { ..., cc, value: u32, registered },
PerNotePitchBend { ..., value: u32 }, // 0x8000_0000 = center, full-scale ±48 st
PerNoteManagement { ..., flags },
ControlChange2 { ..., cc, value: u32 },
ChannelPressure2 { ..., pressure: u32 },
PitchBend2 { ..., value: u32 },
ProgramChange2 { ..., program, bank: Option<(u8, u8)> },
RegisteredController { ..., bank, index, value: u32 }, // RPN
AssignableController { ..., bank, index, value: u32 }, // NRPN
// Plugin/host control (not MIDI)
ParamChange { id, value },
ParamMod { id, note_id, value }, // CLAP per-voice
Transport (TransportInfo),
// System layer
SysEx { pool_offset, len }, // bytes in EventList::sysex_bytes()
}
EventBody is Copy, so the audio path never clones an event.
group is the UMP group (0–15); legacy MIDI 1.0 wrappers fill
0. channel is 0–15.
The list is stable-sorted by sample_offset before your
plugin sees it. Ties stay in the order the host sent them, which
matters when (e.g.) a CC arrives at the same sample as a note-on.
#Reading MIDI input
The plugin sees &EventList in process():
fn process(state: &mut Self::DspState, _params: &Self::Params,
buffer: &mut AudioBuffer, events: &EventList,
context: &mut ProcessContext) -> ProcessStatus {
for event in events.iter() {
match &event.body {
EventBody::NoteOn { note, velocity, .. } => state.note_on(*note, *velocity),
EventBody::NoteOff { note, .. } => state.note_off(*note),
EventBody::ControlChange { cc, value, .. } => state.cc(*cc, *value),
EventBody::PitchBend { value, .. } => state.pb(*value),
_ => {}
}
}
// ... DSP ...
ProcessStatus::Normal
}
The _ => {} arm catches MIDI 2.0 / per-note variants you don't
care about. Drop it and you'll get a non-exhaustive-match error
that lists everything you missed — use it if you want the
compiler to flag a forgotten case.
SysEx payloads aren't stored inline on the event (a worst-case ~64 KiB body per event would blow up the audio thread's memory footprint). Resolve them via the list:
EventBody::SysEx { .. } => {
let bytes = events.sysex_bytes(&event.body);
state.handle_sysex(bytes); // bytes are the inner payload,
// no leading 0xF0 / trailing 0xF7
}
For sample-accurate handling (synths, transient shapers), interleave the event walk with the sample loop instead:
let mut next = 0;
for i in 0..buffer.num_samples() {
while let Some(e) = events.get(next) {
if e.sample_offset as usize > i { break; }
match &e.body {
EventBody::NoteOn { note, velocity, .. } => state.note_on(*note, *velocity),
EventBody::NoteOff { note, .. } => state.note_off(*note),
_ => {}
}
next += 1;
}
// render sample i...
}
#Reading values as floats
MIDI values are integers on the wire. Convert when DSP wants floats:
use truce_core::midi::{norm_7bit, norm_pitch_bend};
EventBody::ControlChange { cc: 1, value, .. } => {
state.mod_depth = norm_7bit(*value); // 0..=127 → [0.0, 1.0]
}
EventBody::PitchBend { value, .. } => {
state.bend_semitones = norm_pitch_bend(*value) * 2.0; // [-1.0, 1.0)
}
Available helpers (truce_core::midi::*, re-exported from
truce_utils::midi):
norm_7bit(u8) -> f32anddenorm_7bit(f32) -> u8— velocity, CC, channel pressure, aftertouch, program change.norm_pitch_bend(u16) -> f32anddenorm_pitch_bend(f32) -> u16— 14-bit pitch bend. Asymmetric:0decodes to-1.0,8192to0.0,16383to~0.99987.pitch_bend_to_bytes(u16) -> (u8, u8)andpitch_bend_from_bytes(u8, u8) -> u16— split / combine LSB + MSB. Format wrappers use these internally; plugins rarely need them.
MIDI 2.0 companions, same module:
upscale_7_to_16(u8) -> u16,upscale_7_to_32(u8) -> u32,upscale_14_to_32(u16) -> u32- the spec's min-center-max up-scaling, not a plain shift: center and max map exactly (64 → 0x8000,127 → 0xFFFF), so a centered 1.0 value stays centered in 2.0.upconvert_to_midi2(&EventBody) -> Option<EventBody>anddownconvert_to_midi1(&EventBody) -> Option<EventBody>- whole-event dialect conversion. Going up, aNoteOnwith velocity 0 becomes aNoteOff2(the spec's translation rule). Going down, per-note controllers collapse onto their note's channel, MPE-style.Nonemeans the body is already in the target dialect or has no equivalent there.per_note_bend_semitones(u32) -> f64andper_note_bend_from_semitones(f64) -> u32- 32-bit per-note pitch bend to and from semitones at the shared ±48 st full-scale (saturating on the way in).
#Emitting MIDI output
Push events onto context.output_events:
context.output_events.push(Event::new(
e.sample_offset,
EventBody::NoteOn {
group: 0, channel: 0,
note: 60, velocity: 100,
},
));
Sample offsets must fall within the current block
(0..num_samples). Each wrapper translates outbound events into
its host transport: a MIDI 1.0 byte stream on VST2 / AU v2 / AAX /
LV2, CLAP-native events on CLAP, the host's MIDIEventList
protocol on AU v3. On a MIDI 1.0 path, 2.0 bodies down-convert
where a 1.0 equivalent exists and are dropped otherwise - emit
the 2.0 variants only from plugins opted into midi2.
The arpeggiator example in examples/truce-example-arpeggio
walks held-note tracking + step scheduling:
EventBody::NoteOn { note, .. } => state.held.push(*note),
EventBody::NoteOff { note, .. } => state.held.retain(|n| n != note),
// ...later, on each step boundary:
context.output_events.push(Event::new(
step_offset,
EventBody::NoteOn {
group: 0, channel: 0,
note: chosen_note, velocity: 96,
},
));
#Format coverage
| Format | MIDI 1.0 in | MIDI 1.0 out | MIDI 2.0 in | MIDI 2.0 out | SysEx in/out | Notes |
|---|---|---|---|---|---|---|
| CLAP | ✅ | ✅ | ✅† | ✅† | ✅ / ✅ | With midi2 = true, advertises the MIDI2 note dialect and demuxes CLAP_EVENT_MIDI2 UMP (channel voice + per-note, with group) both in and out; without it, host MIDI 2.0 arrives downconverted to 1.0 |
| VST3 | ✅ | ✅ | partial† | partial† | ✅ / ✅ | Per-note expression (volume, pan, tuning, vibrato, expression, brightness) maps to/from PerNoteCC / PerNotePitchBend, in and out; the plugin declares INoteExpressionController so hosts offer per-note input; outgoing noteId is keyed (channel << 7) | note, incoming host noteIds resolve back to (channel, note); per-note CCs with no predefined expression type degrade to channel CCs |
| VST2 | ✅ | ✅ | — | — | ✅ / ✅ | MIDI 1.0 only; opt-in per VST2's canDo("receiveVstMidiEvent") |
| AU v2 | ✅ | ✅ | — | — | — | MusicDeviceMIDIEvent is MIDI 1.0 only; no host path exists for MIDI 2.0 |
| AU v3 | ✅ | ✅ | ✅† | ✅† | ✅ / ✅ | With midi2 = true the appex declares audioUnitMIDIProtocol = 2.0 so a host delivers native UMP in; output rides midiOutputEventListBlock for every dialect, protocol-pure in the host's hostMIDIProtocol (all MT 0x4 in 2.0 lists, all MT 0x2 in 1.0 lists; SysEx as MT 0x3 chains), with the byte block as fallback (macOS 12+ / iOS 15+) |
| AAX | ✅ | ✅ | — | — | ✅ / ✅ | Pro Tools' MIDI tracks; see formats/aax |
| LV2 | ✅ | ✅ | — | — | ✅ / ✅ | Hosts deliver atom:Sequence; emits one in turn for note effects |
† Native MIDI 2.0 channel-voice messages (NoteOn2,
ControlChange2, …) reach a plugin on CLAP and AU v3 - both gated on
midi2 = true in truce.toml (CLAP advertises the MIDI2 note
dialect; AU v3 declares audioUnitMIDIProtocol = 2.0). Without the
opt-in the host down-converts to 1.0, so a plugin that didn't ask for
MIDI 2.0 never sees the 2.0 variants. VST3 instead maps the per-note
subset through note expression. For emitting back to the host (also
gated on midi2): CLAP and AU v3 send the full 2.0 variants over UMP
end-to-end (CLAP via CLAP_EVENT_MIDI2, AU v3 via
midiOutputEventListBlock); VST3 sends the per-note subset (PerNoteCC
for the six predefined expression types, PerNotePitchBend for tuning)
as note-expression value events - a lossy, noteId-correlated mapping,
not UMP. The other wrappers stay MIDI 1.0 or rely on the host's own
downconvert.
Per-note conventions are shared across formats so the same event
sounds the same everywhere: PerNotePitchBend full-scale is ±48
semitones (the MPE convention; wider host bends saturate), and
per-note volume crosses in the 0..=4 linear-gain domain both CLAP
and VST3 define (+12 dB at wire full-scale, unity at the quarter
point).
#Host MIDI compatibility
The table above is truce's half of the contract. The other half is the host: which slots accept a MIDI plugin, what survives plugin-to-plugin routing, and how much of MIDI 2.0 is carried all vary enough that the host - not the wrapper - usually decides what your plugin actually sees. Notes from testing real hosts, current as of July 2026; expect the details to shift host by host.
#Bitwig Studio
- The reference host for CLAP MIDI 2.0: it honors the advertised
MIDI2 note dialect and delivers native
CLAP_EVENT_MIDI2UMP to amidi2plugin. - On the output side its note graph consumes CLAP-native events -
clap_event_noteplusCLAP_EVENT_NOTE_EXPRESSION- not UMP. A plugin emitting only raw UMP is silent in Bitwig, which is why truce emits 2.0 output CLAP-natively (notes with full 16-bit velocity through thef64field, per-note control as note expressions, channel voice down-converted to raw MIDI). - Per-note expressions ride
note_idaddressing, and stopping a clip sends wildcard note-offs - Bitwig is the host that exercises the wildcard paths. - Overall the best desktop host for testing per-note and multi-port CLAP.
#Logic Pro
- On AU v3 the protocol negotiation works as Apple documents it:
the appex receives input in its declared
audioUnitMIDIProtocol, and CoreAudio auto-converts its output to the host'shostMIDIProtocol. A 2.0-emitting plugin needs no 1.0 fallback path of its own. - The MIDI FX → instrument hop runs at the MIDI 1.0 byte level: notes survive the down-conversion, but per-note pitch bend and per-note CCs (no 1.0 equivalent) are silently dropped, and the UMP group is flattened. Channel-level pitch bend survives - MPE-style channel rotation is the portable way to push per-note bends through Logic's MIDI FX slot.
- A
.componentloads as AU v2, whose MIDI I/O (MusicDeviceMIDIEvent) is 1.0-only - the v2 build can't carry native 2.0 in either direction regardless of what Logic supports. UMP I/O exists only on the AU v3 appex.
#Cubase
- The home of VST3 note expression: it queries
INoteExpressionControllerand sends nothing to a plugin that doesn't declare it (truce does). - It sends release-phase expression - value events arriving after the note-off - so expression handling can't assume the note is still sounding. truce keeps its note-id mappings alive past note-off for exactly this.
- Third-party VST3 plugins can't be MIDI inserts in Cubase, so a note-effect → synth chain can't be built there at all. Note expression in Cubase is host-originated (its editor, an MPE controller) and delivered straight to the instrument.
#REAPER
- Routes one plugin's MIDI output onward by flattening it to a MIDI 1.0 byte stream - VST3 note expression doesn't survive the plugin-to-plugin hop. Same attrition rule as Logic's MIDI FX path: 1.0-expressible data survives, per-note data doesn't.
- Channel controllers reach a VST3 plugin only as
IMidiMappingparameter changes; truce synthesizes the bend / CC / pressure events back from its hidden proxy parameters, so plugin code never notices. - Its CLAP support trails its VST3 support: note effects don't surface as MIDI effects, and per-track MIDI buses map to VST3 event buses only (see multi-port routing).
#Ableton Live
- No inline path for MIDI-processing plugins at all: the MIDI-effect slot accepts only Live's own devices and Max for Live, audio effects receive audio (never the track's MIDI), and only the instrument slot receives the track's MIDI. A note effect has to pose as an instrument and be routed track-to-track.
- Live also thins high-rate CC streams before they reach a plugin, so don't calibrate CC-driven DSP against what Live delivers.
#AUM (iOS)
- The practical test bench for AU v3 MIDI: a 2.0-capable host that
honors
MIDIOutputNames, so multi-cable MIDI output routing actually works there. - Lists
auminote effects in a dedicated MIDI-effect slot and routes MIDI to them - coverage of the newer AU v3 MIDI APIs is uneven across AU hosts, and AUM is ahead of most desktop ones.
#The pattern
Host → plugin and plugin → host native 2.0 work where the format table says they do. The consistently weak link is plugin → plugin routing, which nearly every host performs at the MIDI 1.0 byte level: per-note data with a 1.0 equivalent survives the hop, everything else is dropped. Emit per-note events for the hosts that carry them, and fall back to MPE-style channel spread plus channel pitch bend when output must survive an inter-plugin hop. The same skepticism applies inbound - hosts don't always honor the advertised dialect, so truce down-converts stray 2.0 packets for 1.0 plugins rather than dropping them.
#Testing MIDI plugins
truce_test::driver! scripts MIDI events sample-accurately —
same delivery path the format wrappers use, no host required.
use std::time::Duration;
use truce_test::{assertions, driver};
#[test]
fn arp_emits_step_per_quarter_at_120bpm() {
let result = driver!(MyArp)
.duration(Duration::from_secs(1))
.capture_output_events(true)
.script(|s| {
s.note_on(60, 0.8); // velocity is normalized [0, 1]
s.note_on(64, 0.8);
s.note_on(67, 0.8);
})
.run();
let notes = result.output_events.iter()
.filter(|e| matches!(e.body, EventBody::NoteOn { .. }))
.count();
assert_eq!(notes, 4); // four quarter-note steps in one second at 120 BPM
}
The Script builder exposes one method per common MIDI 1.0
message — note_on, note_off, cc, pitch_bend,
channel_pressure, plus set_param for automation. Need
something else? Script::raw(EventBody) is the escape hatch and
takes any variant including MIDI 2.0 ones.
The arpeggiator example's tests
(examples/truce-example-arpeggio/src/lib.rs)
cover the full MIDI-in / MIDI-out shape end to end.
#What's next
- Chapter 8 → gui — visualise note state, expose CC mappings as parameters.
- Chapter 14 → hot-reload — iterate on arp logic without restarting the DAW.
examples/truce-example-arpeggioin the repo — full MIDI in → MIDI out plugin with state, transport, and tests.examples/truce-example-synth— MIDI in → audio out with sample-accurate event handling.