MPE (MIDI Polyphonic Expression)¶
Pulp provides first-class MPE support as an opt-in sidecar on top of the
normal MIDI processing path. Plugins that don't opt in see the standard
MidiBuffer; plugins that opt in additionally receive an MpeBuffer
that carries per-note pitch bend, pressure, and timbre expressions.
Quick start¶
#include <pulp/format/processor.hpp>
#include <pulp/midi/mpe_buffer.hpp>
#include <pulp/midi/mpe_synth_voice.hpp>
class MySynth : public pulp::format::Processor {
pulp::midi::MpeVoiceAllocator<MyVoice> allocator_{8};
public:
PluginDescriptor descriptor() const override {
return {
.name = "MySynth",
// ...
.accepts_midi = true,
.supports_mpe = true, // ← opt in
};
}
void process(audio::BufferView<float>& out, const audio::BufferView<const float>&,
midi::MidiBuffer& midi_in, midi::MidiBuffer&,
const ProcessContext& ctx) override {
if (const auto* mpe = mpe_input()) {
allocator_.dispatch_all(*mpe);
}
// ... render voices into out ...
}
};
When supports_mpe is true, or when
node_capabilities.supports_mpe is true, a Pulp format adapter that
recognises MPE (currently: CLAP) runs the inbound MidiBuffer through
an MpeVoiceTracker each block, populates an MpeBuffer, and attaches
it via Processor::set_mpe_input() before calling process(). Adapters
read PluginDescriptor::effective_capabilities(), which ORs the legacy
flags with the node ABI capability field.
Components¶
| Header | Class | Role |
|---|---|---|
pulp/midi/mpe_voice_tracker.hpp |
MpeVoiceTracker |
Parses MIDI 1.0, tracks note/expression state by channel |
pulp/midi/mpe_voice_tracker.hpp |
MpeNoteState |
Snapshot of one note's channel, pitch bend (semitones), pressure, timbre |
pulp/midi/mpe_buffer.hpp |
MpeBuffer, MpeExpressionEvent |
Sample-accurate per-note expression event stream |
pulp/midi/mpe_synth_voice.hpp |
MpeSynthVoice |
Base class for voices with built-in expression smoothing |
pulp/midi/mpe_synth_voice.hpp |
MpeVoiceAllocator<Voice> |
Routes MpeBuffer events to a voice pool; configurable steal mode |
pulp/midi/mpe_synth_voice.hpp |
MpeGlideDetector |
Flags legato/glide gestures (overlap on same MPE member channel) |
Zone configuration¶
MpeConfig (from pulp/midi/ump.hpp) describes which channels belong to
which zone. The tracker defaults to the standard lower zone
(MpeConfig::standard_lower(15) — manager on channel 0, members on
channels 1–15). Use MpeConfig::dual(lower, upper) for dual-zone
controllers.
Pitch-bend ranges¶
Per-MPE spec: member channels default to ±48 semitones, manager channels
to ±2 semitones. Override with
MpeVoiceTracker::set_member_bend_range() and
set_manager_bend_range() if your plugin accepts a different convention
(e.g. ±12 for backwards-compat MIDI 1.0).
Expression mapping¶
The tracker normalises expressions so voices see consistent units:
| Source | Tracker field | Range |
|---|---|---|
| 14-bit pitch bend on member channel | pitch_bend_semitones |
±member_bend_range |
Channel pressure (status Dx) |
pressure |
0.0–1.0 |
| CC 74 (timbre/slide) | timbre |
0.0–1.0 |
MpeSynthVoice adds per-sample smoothing so subclasses can read
pitch_bend(), pressure(), timbre() directly without fighting
zipper noise.
MpeVoiceAllocator<Voice>::telemetry() returns an owner-thread snapshot with
polyphony, active/releasing voice counts, steal count, steal mode, and the
latest glide flag. If UI or tooling needs that data, call it from the processor
owner and publish the returned value through a lock-free latest-value channel.
For optional per-voice analysis, preview, or diagnostics, pass a
runtime::RuntimeBudgetFrame to
MpeVoiceAllocator<Voice>::evaluate_optional_runtime_budget(). The allocator
uses the same voice telemetry to return a run/defer/shed/bypass decision without
changing the MPE voice render path. Budget degradation does not disable active
notes, change expression smoothing, or alter voice stealing; it is only for
optional analysis, preview, or diagnostic work.
Example¶
examples/mpe-synth/ ships an MPE-aware sine synth that demonstrates
opt-in, per-note pitch bend across the full ±48 semitone range,
pressure-driven amplitude, and CC 74 brightness control via a one-pole
lowpass.
Adapter status¶
The CLAP adapter populates mpe_input() and ump_input() when the descriptor
opts in. Other adapters still deliver the standard MidiBuffer path only, so
MPE-aware processors should continue to handle ordinary MIDI input as their
fallback.
SignalGraph routing¶
SignalGraph does not carry a separate graph-owned MpeBuffer. Graph MIDI
edges preserve the block event stream that MPE is derived from: MIDI 1.0
channel messages, SysEx sidecars, and attached UmpBuffer packets. That means
MIDI 1.0 MPE channel messages and MIDI 2.0 per-note UMP expression packets can
pass through connect_midi() routes without losing sample offsets or per-note
payloads.
At plugin/adapter boundaries, processors that opt into MPE still consume the
derived Processor::mpe_input() sidecar. Hosts or adapters that need an
MpeBuffer after graph routing should run the routed MidiBuffer and attached
UmpBuffer through MpeVoiceTracker, the same way format adapters derive MPE
for processors.
MIDI 2.0 UMP sidecar¶
Plugins that want native MIDI 2.0 resolution — 16-bit velocity, per-note pitch bend, per-note CCs — can opt in separately:
PluginDescriptor descriptor() const override {
return {
// ...
.accepts_midi = true,
.supports_ump = true, // ← MIDI 2.0 UMP sidecar
};
}
void process(...) override {
if (const auto* ump = ump_input()) {
for (const auto& ue : *ump) {
// ue.packet is a UmpPacket, ue.sample_offset is sample-accurate
}
}
}
supports_mpe and supports_ump are independent and can both be set.
New code may also set node_capabilities.supports_mpe and
node_capabilities.supports_ump; effective_capabilities() makes the
two declaration styles equivalent. The CLAP adapter populates the UMP
sidecar by converting the inbound MIDI 1.0 stream with midi1_to_ump()
and by appending native CLAP_EVENT_MIDI2 packets when the host sends
them.
MpeVoiceTracker::process(UmpPacket) accepts UMP input in addition to
MidiEvent, routing MIDI 2.0 per-note pitch bend (status 0x60) and
per-note CC (status 0x00) directly to the matching note rather than
via the member-channel cache, so per-note expression stays truly
per-note even within the same MPE member channel.
Helpers in pulp/midi/ump_conversion.hpp:
| Function | Purpose |
|---|---|
midi1_to_ump(MidiBuffer&, UmpBuffer&) |
Convert a MIDI 1.0 block to MIDI 2.0 UMP packets, preserving sample offsets |
ump_to_midi1(UmpBuffer&, MidiBuffer&) |
Flatten UMP back to MIDI 1.0 (packets with no MIDI 1.0 equivalent are skipped — route those via mpe_input()) |
scale_7_to_16 / scale_16_to_7 |
Velocity scaling, round-trip-preserving for exact values |
scale_14_to_32 / scale_32_to_14 |
Pitch-bend scaling with centre (0x2000 ↔ 0x80000000) preserved exactly |