From pulp
Build an MPE-aware Pulp synth — opt into MPE via PluginDescriptor, consume per-note pitch bend / pressure / timbre from MpeBuffer, and route voices through MpeVoiceAllocator without reinventing channel tracking.
npx claudepluginhub danielraffel/pulp --plugin pulpThis skill uses the workspace's default tool permissions.
Use this skill when adding per-note expression (pitch bend, pressure,
Monitors deployed URLs for regressions after deploys, merges, or upgrades by checking HTTP status, console errors, network failures, performance (LCP/CLS/INP), content, and API health.
Share bugs, ideas, or general feedback.
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 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 — Phase 4 is deferred, see planning/next-features-plan.md |
--mpe./build/pulp create MySynth --type instrument --mpe
The CLI post-processes the generated descriptor to add
.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.
As of the MPE Phase 1–3 merge (PR #135, #138), the CLAP adapter
populates MpeBuffer from inbound MIDI. VST3 and AU adapters still
forward plain MIDI only — they'll be wired in a later iteration. Until
then, 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.
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 detectorplanning/next-features-plan.md § Feature 2MpeBuffer will have a lossless UMP round-trip and hosts with UMP
transport (CLAP draft, future VST3) will skip the 1.0 decode step.MpeBuffer are tracked in the hosting
plan, not here.