From pulp
Adds per-note expression (pitch bend, pressure, timbre) to a Pulp synth via MpeBuffer, MpeSynthVoice, and MpeVoiceAllocator. Use for Roli/LinnStrument-style controllers requiring polyphonic MPE.
How this skill is triggered — by the user, by Claude, or both
Slash command
/pulp:mpeThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when adding per-note expression (pitch bend, pressure,
Use this skill when adding per-note expression (pitch bend, pressure,
CC 74 timbre) to a Pulp synth, or when writing a host that needs to
dispatch MPE data into a plugin. Pulp keeps MPE as an opt-in sidecar
to the normal MIDI path — plugins that don't set supports_mpe never
see the extra buffer.
If you only need monophonic aftertouch or a global mod wheel, plain
MidiBuffer in process() is simpler — do not reach for MPE.
| You're writing... | Use |
|---|---|
| An MPE synth voice | Subclass midi::MpeSynthVoice, render your oscillator using state().pitch_bend_semitones, state().pressure, state().timbre |
| An MPE synth plugin | MpeVoiceAllocator<YourVoice> inside the processor, dispatch MpeBuffer in process(), set supports_mpe = true or node_capabilities.supports_mpe = true in the descriptor |
| A host that loads MPE plugins | Build an MpeBuffer from inbound MIDI (zone-aware) and hand it to Processor::mpe_input() — the CLAP adapter already does this |
| Pure MIDI 2.0 UMP work | Out of scope — direct UMP-native MPE transport is deferred |
--mpe./build/pulp create MySynth --type instrument --mpe
The CLI post-processes the generated descriptor to add
.supports_mpe = true or .node_capabilities.supports_mpe = true and includes <pulp/midi/mpe_buffer.hpp>. No
manual wiring required.
class Voice : public pulp::midi::MpeSynthVoice {
public:
void on_note_on(const pulp::midi::MpeNoteState& n) override {
pulp::midi::MpeSynthVoice::on_note_on(n); // keep base bookkeeping
// your per-voice init
}
void render(float* out, int n) override {
const auto& s = state(); // read the tracked expressions
// s.pitch_bend_semitones, s.pressure (0..1), s.timbre (0..1)
}
};
Always call the base on_note_on / on_note_off — the base class
maintains the smoothing state and glide refcount. Forgetting it leaves
last_was_glide / timbre smoothing in an inconsistent state and voice
stealing will mis-decrement the glide counter.
process()pulp::midi::MpeVoiceAllocator<Voice> allocator_{8}; // 8-voice polyphony
void process(pulp::audio::BufferView<float>& out,
const pulp::audio::BufferView<const float>& /*in*/,
pulp::midi::MidiBuffer& /*midi_in*/,
pulp::midi::MidiBuffer& /*midi_out*/,
const pulp::format::ProcessContext& ctx) override {
if (auto* mpe = mpe_input()) { // nullptr unless
// supports_mpe=true
for (const auto& e : mpe->events()) {
allocator_.dispatch(e); // one event at a time
}
}
for (std::size_t i = 0; i < allocator_.polyphony(); ++i) {
auto& v = allocator_.voice(i);
if (v.active()) v.render(out.channel(0), ctx.num_samples);
}
}
MpeVoiceAllocator::dispatch(const MpeExpressionEvent&) takes a single
event at a time — iterate over mpe_input()->events() (the per-note
MpeBuffer the host/format adapter populates when the processor sets
PluginDescriptor::supports_mpe = true). The allocator handles note-on
allocation (oldest-steal when full), routes per-note expression updates
to the right voice, and runs note-off logic including the glide
refcount. Do not call on_note_on / on_note_off directly.
Voices are accessed by index via allocator_.voice(i) with
allocator_.polyphony() giving the count — there's no voices()
iterator.
MpeVoiceTracker::process() handles note on/off, pitch bend, channel
pressure, and CC 74 — it does not parse RPN 6 / 7 (MPE Configuration
Messages). Which channels belong to the lower zone (master ch 1,
members 2–N) vs the upper zone (master ch 16, members N–15) is decided
by the MpeConfig you pass to the tracker at construction; you're
responsible for supplying it (usually from the plugin's own
configuration / saved state), not for trusting the controller to
negotiate it.
If you need live RPN 6/7 negotiation, parse it separately (see
core/midi/include/pulp/midi/rpn_parser.hpp) and reconfigure the
tracker off the audio thread.
Pressure is continuous and per-note; velocity is the note-on value and
does not change. Use state().pressure (smoothed, 0..1) for amplitude
modulation, not velocity().
That's the MPE spec default. If your controller sends a different range
via RPN 0, MpeVoiceTracker honors it — but a lot of older controllers
don't send the RPN. When testing, either send the RPN or document the
assumption.
MpeGlideDetector tracks overlapping note-ons on the same channel
(the MPE signal for glide/legato). MpeVoiceAllocator::last_was_glide()
reflects that state. If you hand-roll voice allocation, you are
responsible for incrementing on note-on and decrementing on note-off,
including the steal path — see the test "MpeVoiceAllocator steal
path decrements glide refcount" for the invariant.
MpeVoiceTracker consumes the full MIDI 2.0 per-note expression
surface:
kPerNoteResetControllers
bit returns per-note expression (pitch bend / pressure / timbre) to
spec defaults (0); kPerNoteDetachControllers bit sets
MpeNoteState::detached, after which channel-level controllers
(status 0xE0 / 0xD0 / 0xB0) skip that note. Per-note targeted
messages (0x60 per-note pitch bend, 0x00 registered PNC, 0x10
assignable PNC) still apply to detached notes.set_assignable_timbre_index(uint8_t). Unbound by default —
unbound assignable PNC is silently ignored. Registered PNC 74
(status 0x00) still routes to timbre regardless.detached (re-attaches the slot to channel-level controllers).If you're routing UMP into the tracker, use the factories on
UmpPacket: per_note_management(group, channel, note, flags),
assignable_per_note_cc(...), registered_per_note_cc(...),
per_note_pitch_bend(...). Channel-level cache stays updated even
for detached notes so freshly-added notes on the same channel still
inherit running state via add_note.
The CLAP adapter populates MpeBuffer from inbound MIDI. VST3 and AU
adapters still forward plain MIDI only. Until those adapters gain direct
MpeBuffer wiring, an MPE synth loaded as VST3/AU sees MIDI events but the
MpeBuffer will be empty; the voice tracker inside the processor still works
if you extract per-note data from MidiBuffer yourself.
MpeBuffer and UmpBuffer support the same adapter-owned realtime
capacity policy as MidiBuffer: reserve storage before the audio
thread, call set_realtime_capacity_limit(true), and treat add()
returning false plus dropped_event_count() as the overflow signal.
This matters for CLAP because one short MIDI event can fan out to many
MPE sidecar callbacks, and native CLAP_EVENT_MIDI2 packets append
directly to the UMP sidecar before Processor::process(). The CLAP
adapter reserves both sidecars in clap_activate() and drops rather
than growing vectors during clap_process(). If you add a new adapter
or widen the sidecar contract, test the overflow path without copying
large event vectors inside the processor no-allocation guard.
test/test_mpe_voice_tracker.cpp, test/test_mpe_buffer.cpp,
test/test_mpe_synth_voice.cpp — invariants worth reading before
touching the allocator or glide detectorUMP type-0x3 sysex7 reassembly is not part of MpeVoiceTracker —
it's a separate per-stream state machine shared across every Pulp
UMP backend, exposed as pulp::midi::UmpSysex7Reassembler in
core/midi/include/pulp/midi/ump_sysex7_reassembler.hpp. Each
input port / source owns one instance (the reassembler is not
thread-safe; that's by design, since CoreMIDI / AUv3 callbacks are
already single-threaded per port).
Touching anything in core/midi/include/**/*ump* triggers this
skill via tools/scripts/skill_path_map.json. When you add a new
UMP-aware backend (WinRT MIDI 2.0, ALSA UMP, iOS CoreMIDI 2.0),
delegate sysex7 reassembly to UmpSysex7Reassembler rather than
re-implementing the start / continue / end state machine inline —
the AUv3 and macOS CoreMIDI backends do exactly that, and any drift
between the two backends corrupts multi-packet sysex streams.
The reassembler's feed_packet is a function-pointer-callback
API so it stays RT-safe in the audio render block; the
feed_collect convenience wrapper allocates and is meant for
tests / cold paths only.
Reassembly state is per-stream → per-UMP-group, not per-port. UMP
SysEx7 streams from one endpoint can interleave across the 16 UMP
groups, so a backend that owns a single UmpSysex7Reassembler per
port will let a Start on group 1 reset/corrupt an in-flight stream on
group 0. Keep one reassembler per group (std::array<…,16> indexed by
packet.group()) — the WinRT MIDI 2.0 backend does this; mirror it in
any new UMP backend.
ump_to_midi1_event / midi1_event_to_ump2 in
core/midi/include/pulp/midi/ump_conversion.hpp handle System Real
Time and System Common (UMP Type 0x1: clock 0xF8, start/stop,
song-position 0xF2, …) in addition to channel voice — system
messages encode as Type 0x1 (NOT Type 0x2 MIDI 1.0 Channel Voice,
which is malformed for them) and decode back to MIDI 1.0 short
messages. So ump_to_midi1 flattening a UMP buffer yields
clock/transport events, not channel-voice-only — don't assume a
flattened buffer is note data. (SysEx Type 0x3 still routes through
UmpSysex7Reassembler, above; per-note expression still goes through
the MpeBuffer sidecar, not these converters.)
Pulp exposes a Pulp-native UMP transport surface in
core/midi/include/pulp/midi/:
UmpEndpoint (abstract) — id + direction (can_receive /
can_send) + send(UmpPacket) + set_receive_callback(...).
Concrete subclasses are platform-specific (CoreMIDI 2.0 on
macOS) or in-process (VirtualUmpEndpoint).UmpSession — one per app/plugin; owns the OS MIDI client and a
registry of virtual endpoints. enumerate_endpoints() merges
OS-discovered and virtual entries; open_endpoint(id, &status)
returns a borrowed pointer (session owns lifetime).VirtualUmpEndpoint — purely in-process, loopback-optional,
send() and deliver() counters. The only safe surface for
headless tests because CoreMIDI 2.0 connections require a real
MIDI Studio. UmpSession::wire_virtual_loopback("from", "to")
threads two virtual endpoints together for round-trip fixtures.When you add a new OS backend (WinRT MIDI 2.0, ALSA UMP), do NOT
write a parallel session abstraction — implement the
OsBackendVTable declared in core/midi/src/ump_session_backend.hpp
and register it from a static initialiser in the platform
TU. The cross-platform ump_session.cpp patches the vtable at
load time; if no platform backend is linked, the session reports
os_backend_active() == false and operates virtual-only (this is
exactly what the test target exercises everywhere).
Lifetime invariant: the input-port block on macOS captures the
endpoint's raw pointer. The endpoint is unique_ptr inside
OsState::endpoints — never reseat or move it after the block
is installed, or the block's captured pointer dangles.
MpeVoiceTracker's method bodies live in
core/midi/src/mpe_voice_tracker.cpp, not inline in
core/midi/include/pulp/midi/mpe_voice_tracker.hpp. The header keeps the
class declaration + trivial inline getters; non-trivial methods (process,
set_config, reset, add_note, remove_note, etc.) link from the .cpp.
Practical effect: editing MpeVoiceTracker implementation bodies only
rebuilds the .cpp users. If you're adding a new method, put trivial
getters inline; put anything with branches/loops in the .cpp.
MpeBuffer will have
a lossless UMP round-trip and hosts with UMP transport will skip the 1.0
decode step.MpeBuffer are tracked in the hosting
plan, not here.npx claudepluginhub danielraffel/pulp --plugin pulpProvides techniques and best practices for Max for Live development, including Live Object Model access with live.path/object/observer, device namespaces, pattr persistence, and Push2 mapping.
Generates and plays live electronic music from a text description (genre, mood, activity) using Strudel. Invoke with /Your Task to get DJ Claude to produce dynamic, layered tracks.