本文へ移動
cccskills
無料GitHub で公開

mpe

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.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md21.7 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

MPE

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.

When to reach for MPE

  • The synth is meant for Roli Seaboard / LinnStrument / Sensel Morph / KMI / similar per-note controllers.
  • You need polyphonic per-note pitch bend (not just a global bend).
  • You want pressure or CC 74 to modulate each voice independently.

If you only need monophonic aftertouch or a global mod wheel, plain MidiBuffer in process() is simpler — do not reach for MPE.

Decision tree

You're writing...Use
An MPE synth voiceSubclass midi::MpeSynthVoice, render your oscillator using state().pitch_bend_semitones, state().pressure, state().timbre
An MPE synth pluginMpeVoiceAllocator<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 pluginsBuild an MpeBuffer from inbound MIDI (zone-aware) and hand it to Processor::mpe_input() — the CLAP, VST3, and AUv3 adapters already do this
Pure MIDI 2.0 UMP workOut of scope — direct UMP-native MPE transport is deferred

The three-step pattern for a new MPE synth

1. Scaffold with --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.

2. Declare the voice

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.

3. Dispatch from 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.

Gotchas

Zones are configured, not auto-discovered

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 ≠ velocity

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().

Pitch bend range defaults to ±48 semitones

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.

Glide detection is refcounted

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.

UMP per-note management + assignable PNC

MpeVoiceTracker consumes the full MIDI 2.0 per-note expression surface:

  • Status 0xF0 — Per-Note Management: 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.
  • Status 0x10 — Assignable Per-Note CC: the index is host-defined per the UMP spec, so the tracker only routes when the plugin binds one via 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.
  • Retrigger semantics: a note-on while the slot is still active clears detached (re-attaches the slot to channel-level controllers).
  • D+S flag combination: when detach and reset bits arrive in the same management packet, detach takes effect on the currently sounding note (state preserved for its lifecycle); reset is armed for the next note-on at the same (channel, note) index. Pulp does not yet maintain the armed-reset memory — D+S currently degrades to detach-only on the live note, which matches the spec for the sounding note. The armed-future-reset behavior is deferred follow-up work; if you need the full D+S note-rotation flow, file an issue with a controller reproducer.

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.

Format adapter coverage

The CLAP, VST3, and AUv3 adapters populate MpeBuffer from inbound MIDI and reset their tracker state at lifecycle boundaries. AUv2 and other adapters still forward plain MIDI only; an MPE synth loaded through one of those formats sees MIDI events but the MpeBuffer will be empty unless the processor derives per-note state from MidiBuffer itself.

Populating the buffer is only half of it — the host has to offer the stream first. MPE is negotiated: Logic reads AUv3's supportsMPE / kAudioUnitProperty_SupportsMPE (both now answered from effective_capabilities().supports_mpe) and VST3/CLAP have their own declarations. A unit with a fully wired tracker that advertises nothing is never routed an MPE zone, and the failure looks like "MPE does not work" with nothing wrong in the tracker. When adding an adapter, wire the capability advertisement in the same change as the sidecar.

Realtime sidecar buffers are capacity-limited

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(). An ownership-event drop or MPE expression drop is fail-closed: CLAP, VST3, and AUv3 clear the partial input, reset the tracker, and request a processor reset in that render. 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.

bind_tracker_to_buffer() treats a same-note retrigger as one atomic pair: NoteOff(old generation) then NoteOn(new generation) at the same sample offset. One remaining realtime slot is not enough, so neither half is emitted and the tracker does not rotate the generation. A physical note-off is different because the controller will not replay it: when the buffer is full, the tracker moves that release into its fixed FIFO and blocks fresh starts until it is drained. MpeSidecar drains it automatically at offset zero before the next block. A direct tracker/buffer integration must do the same by calling flush_pending_note_offs() after clearing its output and before ingesting the next block. Retrigger is a reattack, not glide: because the old generation's NoteOff is dispatched first, MpeVoiceAllocator::last_was_glide() remains false. Only genuinely overlapping held notes on a member channel count as glide.

The generic sidecar cannot own a processor-specific allocator. At deactivation, adapters call Processor::release() before resetting the tracker, so an MPE processor must call MpeVoiceAllocator::reset_all() from release(). For an in-place host reset, adapters clear the tracker and raise ProcessContext::reset_requested on the next block; clear the allocator before dispatching that block's MPE input. Do not use should_reset_dsp_state() for voice ownership: it also includes transport jumps, which do not reset the adapter tracker. MpeVoiceTracker::reset() deliberately does not synthesize callbacks, and MpeSidecar::reset() also clears its buffer and pending-release FIFO.

Scale-aware bend and voice-modulation projection

pulp::midi::ScaleAwareMpePitch in utility_kernels.hpp maps the tracked member-channel bend onto pulp::music::Scale degrees and owns only one pitch glide state. Feed it MpeNoteState; do not add another MPE tracker or scale table. Its bend input is the tracker's semitone value, so configure input_bend_range_semitones to the same member range used by the tracker.

pulp::audio::MidiVoiceModulationAdapter<MaximumVoices> is the dependency-safe bridge to VoiceModulationBuffer. The instrument's existing allocator supplies the voice index; the adapter records note/MPE values for that slot and never allocates or steals a voice. Putting this bridge in core/midi would reverse the established audio -> midi dependency and create a cycle. Pass the same nonzero 64-bit MpeNoteGeneration to note-on, expression, and note-off calls; stale identity updates are rejected. The tracker never recycles generations on reset and permanently refuses note-ons after generation exhaustion; surface that state via note_generation_exhausted() / refused_note_on_count(). release_voice() also requires the matching nonzero generation; use flush() or reset() only for an intentional identity-free lifecycle clear. Prepare the destination for at least four lanes before write_voice() so the adapter can publish its block atomically.

Routing utilities are heap-owned: construct them off the audio thread

ChannelRouter, NoteRangeFilter, and KeyboardSplit keep a 1.2 MB ownership ledger. Held inline, one instance overflowed MSVC's 1 MB main-thread stack and macOS's 512 KB std::thread stack, so the ledger now lives behind a unique_ptr allocated by the constructor (contract major 2: construction and release are control). process(), flush(), reset() and replace_spec() never allocate. The types are move-only; a moved-from object reports valid() == false and refuses all four entry points without touching storage. Each header carries static_assert(sizeof(T) <= 4096), and test/test_public_type_sizes.cpp holds stack ceilings for the large public value types that stay inline.

Routing utilities flush through their output buffers

ChannelRouter, NoteRangeFilter, and KeyboardSplit own downstream note lifecycle state even though their routing specifications are otherwise static. Call their output-bearing flush() until its report is complete before a hot swap; it emits every downstream release and retains suppression for the old input note-offs that can still arrive. Their output-bearing replace_spec() does this before adopting the new mapping. At a lifecycle boundary that also resets the input stream, call output-bearing reset() instead; it emits the same releases and then discards the old input ownership. A reset with no output buffer cannot satisfy the no-orphan-note contract and is intentionally not an API.

Reference material

  • Guide: docs/guides/mpe.md
  • Modules: docs/reference/modules.md — MIDI section
  • Example: examples/mpe-synth/ — full working MPE sine synth
  • Tests: 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 detector

Related UMP and implementation surfaces

UMP sysex7 reassembly

UMP 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 ↔ MIDI 1.0 conversion covers System messages (Type 0x1)

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.)

ump_to_midi1_event also covers MIDI 2.0 poly key pressure (0xA0) and channel pressure (0xD0), narrowing each 32-bit value the same way the control-change case does. Both were absent until a host actually spoke MIDI 2.0 to an AU: channel pressure is the MPE pressure axis, so on a Type-0x4 stream it returned false and the axis silently disappeared — with the note-on and CC74 timbre still arriving, which makes the symptom read as a tracker bug rather than a missing conversion case. When adding a MIDI 2.0 status here, check whether an MPE axis rides on it.

MIDI 2.0 program change is not shaped like CC or pressure

In a MIDI 2.0 Channel Voice program change (status 0xC) the program is the top byte of word 1 — not a 32-bit data value scaled down like CC and pitch bend, and not a byte-2 index like a controller number. Word 0's low byte is an option-flag field, not a controller/note slot; bit 0 is Bank Valid. When it is set, word 1 also carries bank MSB (bits 15-8) and bank LSB (bits 7-0), each in the low 7 bits of its byte. Copying the shape of a neighbouring case in ump_conversion.hpp gets every one of those wrong. ump_program_change_fields() is the pure decode — read it rather than re-deriving the offsets.

A bank-valid program change has no single-message MIDI 1.0 equivalent: it renders as three messages, CC 0 (bank MSB), CC 32 (bank LSB), then the program change, in that order because a MIDI 1.0 receiver latches the bank bytes and applies them on the following program change. ump_to_midi1_event has one MidiEvent& out-parameter, so it emits the program change only — the bank is reachable via ump_program_change_fields(), and ump_to_midi1() emits the full sequence. So one UMP packet is not always one MIDI 1.0 event when flattening: a bank-valid program change yields three.

ShortMessage derives length from the status byte (0xC0 → group 4 → 2 bytes), so a converted program change is 2 bytes and its third byte is not part of the message. Assert size(), not just the bytes.

UMP Session / Endpoint / VirtualEndpoint

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/iOS) 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 expose an explicit platform registration anchor. On Apple, ump_session.cpp calls that anchor so static-library linkers retain the CoreMIDI translation unit. If no platform backend is available, the session reports os_backend_active() == false and operates virtual-only (this is exactly what the test target exercises everywhere).

Lifetime invariant: the Apple input-port block captures shared callback state, not the endpoint's raw pointer. Endpoint teardown deactivates that state before disposing the port, so a late callback cannot dereference a destroyed object. set_receive_callback() is a control-thread API. The CoreMIDI delivery path synchronizes and snapshots its std::function; it is thread-safe, but neither lock-free nor guaranteed allocation-free. A callback that feeds audio work must immediately publish the packet into its own bounded lock-free queue.

Implementation note: where MpeVoiceTracker bodies live

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.

What this skill does NOT cover

  • MIDI 2.0 UMP native path — deferred. When it lands, MpeBuffer will have a lossless UMP round-trip and hosts with UMP transport will skip the 1.0 decode step.
  • VST3 / AU MPE routing — see "Format adapter coverage" above; the host-side adapters that emit MpeBuffer are tracked in the hosting plan, not here.
  • Hosting MPE plugins (MPE output, dispatching MPE into a loaded plugin) — covered by the SignalGraph hosting work, not this skill.

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

aax

無料

Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Configure, implement, and test Pulp's optional desktop Ableton Link tempo-sync adapter while preserving the developer-supplied SDK, licensing, realtime, latency-compensation, and no-install boundaries.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

android

無料

Android platform development for Pulp — NDK cross-compilation, Oboe audio, Dawn/Skia GPU rendering, JNI bridge, touch interaction, emulator workflows, and end-to-end smoke validation. Covers build, deploy, debug, and the gotchas discovered during bringup.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

ara

無料

Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

The measurement surface for ALL Pulp DSP and audio-pipeline work — read it BEFORE writing or gating DSP, not only when something already sounds wrong. Covers the C++ harness (signal generators, metrics, assertions, RenderScenario, contracts), the offline Audio Doctor (magnitude/frequency response, THD/THD+N, phase/group delay), and their Python sibling the Audio Quality Lab (tools/audio/quality-lab — null residual + alignment, LTAS log-spectral distance, spectral flux/centroid, HNR, Theil-Sen drift slope, Kaiser-sinc resampling, license-guarded corpus, regression-net ratchet). TRIGGER on AUTHORING work — "build/design an oscillator/filter/synth/effect", "add a DSP module", "what should the acceptance gate be", "how do I measure aliasing / anti-aliasing / alias floor", "null against a reference", "is this DSP correct", "choose a tolerance", "golden/regression corpus for audio", "measure drift or jitter", "A/B two renders" — AND on DEBUGGING work — "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting", "what's the group delay / phase response / measured latency", "magnitude response curve", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "compare before/after a DSP refactor". Reach for this BEFORE hand-rolling any FFT, null test, alias measurement, pitch tracker, or golden-render script — most of it already exists in one of the two lanes. Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Generous-Corp のスキルをすべて見る

このスキルの問題を報告する