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.
日本語の概要は準備中です。原文の説明を表示しています。
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.
インストール方法を見るインストールする前に、エージェントに与えられる指示の中身を確認できます。
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, VST3, and AUv3 adapters already do 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, 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.
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.
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.
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.
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.
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.)
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.
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.
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.
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.まだレビューはありません。使ってみた感想をお寄せください。
概要と使いどころ
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.
日本語の概要は準備中です。原文の説明を表示しています。
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.
日本語の概要は準備中です。原文の説明を表示しています。
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.
日本語の概要は準備中です。原文の説明を表示しています。
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.
日本語の概要は準備中です。原文の説明を表示しています。
Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.
日本語の概要は準備中です。原文の説明を表示しています。
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.
日本語の概要は準備中です。原文の説明を表示しています。