feat(format): AU v2 MIDI-effect adapter — aumi, MIDI in, MIDI out#6438
Merged
Conversation
A Pulp `Processor` whose descriptor declares `PluginCategory::MidiEffect` already packaged as an `aumi` component (CMake resolved the type), but there was no adapter behind it: the bundle was built on the audio-effect adapter, so it presented an audio in/out pair it never used and its MIDI output went nowhere. `PulpAUMidiProcessor` is the missing half — it is what lets a Pulp MIDI effect show up in a host's MIDI-FX slot. The adapter derives `MusicDeviceBase` (AUBase + AUMIDIBase) with zero audio input elements and one silent audio output element. The output element carries no signal, but it has to exist: an AU v2 host advances a plugin by rendering it, and rendering is what drains the inbound MIDI queue, runs `process()`, and delivers the Processor's `midi_out` through `kAudioUnitProperty_MIDIOutputCallback`. Inbound MIDI and SysEx arrive through lock-free queues, per-event sample offsets survive the round trip, and a declared Bypass parameter makes the plugin a wire (MIDI passes through untouched) rather than a mute — dropping the stream would silence every instrument downstream of the bypassed slot. `PULP_AU_MIDI_EFFECT` registers the component through `AUMIDIEffectFactory`, whose lookup carries the MusicDevice MIDIEvent + SysEx selectors. Registering through the plain base factory instead is the long-standing trap that makes a host's every MIDI delivery return -4 while the plugin otherwise looks healthy. Three AU v2 adapters now exist over three different SDK bases, so everything that needs no specific base moved into `au_v2_common` and all three call it: the parameter surface, the editor->host parameter bridge, preset state, the MIDI-output callback handoff, the Cocoa-view hook, and the render context builders. The effect and instrument adapters shed 480 lines of duplication in the process, and the effect stops copying its descriptor (a `std::string` allocation) on every MIDI-output-name query. Tests drive the real adapter: MIDI pushed in through the SDK entry points, a host-installed output callback, and `Render()` advancing the block — covering transform, SysEx, merged event ordering, bypass-as-wire, the empty block, the silent output element, the max-frames guard, a 4096-event flood, offset clamping, parameters, and preset round-trip. `examples/pulp-transpose` is the end-to-end vehicle; `auval -v aumi PTrn Pulp` passes.
… surface Closes the gaps the adapter's own suite left: GetLatency and SupportsTail, the element shape (zero input elements, one output element, only the output scope format writable), the global-scope guards on every Pulp-owned property plus the undersized-payload refusal on the MIDI-output callback, and the parameter text conversions in both directions including an unknown ParamID. examples/pulp-transpose gains a headless MIDI-transform test that drives the Processor with no adapter at all — what a transposer has to get right is which messages carry a note number (note on/off, poly aftertouch) and which must survive byte-identical (CC 74 must not become CC 76), plus range clamping and SysEx pass-through.
The silent output element is zeroed in place; nothing consumes the channel pointers afterwards, so collecting them into a member vector bought a per-block resize on the audio thread and nothing else.
The au_v2_common extraction left kMaxOutputEvents (packet builder) and each adapter's kMaxEventsPerBlock as independent 2048 literals with nothing tying them together — if they ever drifted, host-bound MIDI would silently truncate on a dense arp/generator block. Promote one constexpr in au_v2_common and reference it from all three. Arch-review must-fix; tests unchanged (366/18).
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Jul 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds the core AU v2 MIDI-effect adapter —
PulpAUMidiProcessor, component typeaumi(kAudioUnitType_MIDIProcessor) — so a PulpProcessorwhose descriptor declaresPluginCategory::MidiEffectcan appear in a host's MIDI-FX slot.CMake already resolved
CATEGORY MidiEffect→aumi, but there was no adapter behind it: the bundle was built on the audio-effect adapter, so it advertised an audio in/out pair it never used and its MIDI output went nowhere. This is the missing half.The design decisions
Each of these has a silent-failure mode if reversed, and each is documented in the code and in the
auv2skill.MusicDeviceBase(AUBase+AUMIDIBase), notAUMIDIEffectBase. The latter derivesAUEffectBase, which isAUBase(ci, 1, 1)and pulls input element 0 every render — a MIDI processor has no audio input to pull.kAudioUnitRenderAction_OutputIsSilenceset), but it has to exist: an AU v2 host advances a plugin by rendering it, and rendering is what drains the inbound MIDI queue, runsprocess(), and fires the MIDI-output callback. With zero output elements there is no bus to pull and the plugin never runs.process(ProcessBuffers&)is marked ACTIVE. Load-bearing: the default projection doesif (!main_output()) return;, so an inactive bus means a MIDI effect written against the classicprocess(out, in, midi_in, midi_out, ctx)signature never runs at all — silently.PULP_AU_MIDI_EFFECTregisters throughAUMIDIEffectFactory. A factory only dispatches the selectors its lookup carries; the plain base factory makes every host MIDI delivery return-4while the plugin otherwise looks healthy (the same trap that previously hitaumfandaumu).HandleMIDIEvent, not the per-message hooks.MusicDeviceBase::HandleNoteOn/Offroute notes intoStartNote/StopNote, which anaumidoes not implement — every note would be dropped.Two deliberate differences from the effect adapter:
descriptor().produces_midi. Anaumithat can't emit MIDI has no reason to exist, and gating turns a forgottenproduces_midi = trueinto silently discarded output. (The effect adapter still gates, so a plainaufxnever advertises one.)midi_in→midi_outuntouched and skipprocess(). Dropping the stream — right for an arpeggiator on an audio track, where the MIDI is a side product — would here silence every instrument downstream of the bypassed slot. No bypass parameter is synthesized:kAudioUnitProperty_BypassEffectlives onAUEffectBase, so on anaumia synthesized control would carry no host bypass semantics.Shared adapter surface
Three AU v2 adapters now exist over three different SDK bases, so everything that needs no specific base moved into
au_v2_common.{hpp,cpp}and all three call it: the parameter surface, the editor→host parameter bridge (gestures + the echo guard), preset state, the MIDI-output callback handoff, the Cocoa-view hook,decode_midi_event, the renderProcessContextbuilders, andMidiOutputPacketBuilder.The effect and instrument adapters shed ~480 lines of duplication, and the effect stops copying its descriptor (a
std::stringallocation) on every MIDI-output-name query.fill_parameter_infotakes an explicitadvertise_value_stringsflag so the instrument — which serves neitherGetParameterValueStringsnor the string-conversion properties — keeps its exact current behavior instead of advertising a door nobody answers.Validation
auval -v aumi PTrn Pulponexamples/pulp-transpose, stable PASS across three consecutive runs after anAudioComponentRegistrarkill + settle:Registered as the ctest
auval-PulpTranspose.auval -v aumiis a weak gate and the PR says so in the support matrix and the skill: it stops after the parameter surface — no render test, no MIDI test, no channel-config negotiation (all of which it runs foraufx/aumu). Corollary: anaumibuilt on the effect adapter also passesauval -v aumi, so the validator would not have caught the element shape being wrong. That is why the real proof here is the headless suite below; in-DAW MIDI-FX behavior still needs a host.Tests
test/test_au_v2_midi_processor.cpp(18 cases / 366 assertions) drives the real adapter — MIDI pushed in through the SDK'sMIDIEvent/SysExentry points, a host-installedkAudioUnitProperty_MIDIOutputCallback,Render()advancing the block, and the decoded packet list as the assertion:transform with offsets preserved · reaching a classic
process()with no audio buses · SysEx round-trip · merged SysEx+note ascending order · bypass-as-wire · empty block (no callback) · silent output element + silence flag · max-frames rejection · 4096-event flood (bounded, no leak into the next block) · out-of-block offset clamping · parameters · MIDI-output advertisement · preset round-trip · latency/tail · element shape · global-scope property guards · parameter text conversion both ways.examples/pulp-transpose/test_pulp_transpose.cpp(5 cases) covers the example Processor headlessly: which messages carry a note number (note on/off, poly aftertouch) vs. which must survive byte-identical (CC 74 must not become CC 76), range clamping, SysEx pass-through.Existing AU suites re-run green after the shared-surface refactor:
au-v2-effect(2080 assertions),au-v2-param-display,au-v2-busses,au-bundle-entry,midi-out-offset-parity,store-lifetime,au-bundle-lifecycle-PulpEffect,cmake-au-v2-type-selection.Also
docs/status/support-matrix.yaml—au_v2notes + aformat_limitationsentry stating what the adapter does and whatauval -v aumidoes not prove.docs/reference/cmake.md— the component-type ↔ entry-macro table (the pairing whose mismatch is the silent-MIDI trap)..agents/skills/auv2/SKILL.md— theaumisection, the shared-surface section, and a warning against introducing apulp::format::au::detailnamespace (it shadowspulp::format::detailand produces confusing errors far from the cause — hit during this work).🔎 Provenance
claudem3/Volumes/Workshop/Code/pulp-aumic5642b40-0090-4a66-9223-3bcc4f4de820Resume
Jump to this tab
Relaunch (any agent)
Restore URL — https://claude.ai/code/session_01QG7KYpNcdprcaDFrtmzUZC
stamped 2026-07-21 20:29 UTC