Skip to content

feat(format): AU v2 MIDI-effect adapter — aumi, MIDI in, MIDI out#6438

Merged
shipyard-local[bot] merged 4 commits into
mainfrom
feature/au-midi-effect-adapter
Jul 23, 2026
Merged

feat(format): AU v2 MIDI-effect adapter — aumi, MIDI in, MIDI out#6438
shipyard-local[bot] merged 4 commits into
mainfrom
feature/au-midi-effect-adapter

Conversation

@shipyard-local

@shipyard-local shipyard-local Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What

Adds the core AU v2 MIDI-effect adapterPulpAUMidiProcessor, component type aumi (kAudioUnitType_MIDIProcessor) — so a Pulp Processor whose descriptor declares PluginCategory::MidiEffect can appear in a host's MIDI-FX slot.

CMake already resolved CATEGORY MidiEffectaumi, 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 auv2 skill.

  • Base is MusicDeviceBase (AUBase + AUMIDIBase), not AUMIDIEffectBase. The latter derives AUEffectBase, which is AUBase(ci, 1, 1) and pulls input element 0 every render — a MIDI processor has no audio input to pull.
  • Zero audio input elements, one silent audio output element. The output element carries no signal (zeroed each block, kAudioUnitRenderAction_OutputIsSilence set), 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 fires the MIDI-output callback. With zero output elements there is no bus to pull and the plugin never runs.
  • The zero-channel main output bus handed to process(ProcessBuffers&) is marked ACTIVE. Load-bearing: the default projection does if (!main_output()) return;, so an inactive bus means a MIDI effect written against the classic process(out, in, midi_in, midi_out, ctx) signature never runs at all — silently.
  • PULP_AU_MIDI_EFFECT registers through AUMIDIEffectFactory. A factory only dispatches the selectors its lookup carries; the plain base factory makes every host MIDI delivery return -4 while the plugin otherwise looks healthy (the same trap that previously hit aumf and aumu).
  • Override HandleMIDIEvent, not the per-message hooks. MusicDeviceBase::HandleNoteOn/Off route notes into StartNote/StopNote, which an aumi does not implement — every note would be dropped.

Two deliberate differences from the effect adapter:

  • MIDI output is advertised unconditionally, not gated on descriptor().produces_midi. An aumi that can't emit MIDI has no reason to exist, and gating turns a forgotten produces_midi = true into silently discarded output. (The effect adapter still gates, so a plain aufx never advertises one.)
  • Bypass is a wire, not a mute. A declared Bypass parameter makes the adapter copy midi_inmidi_out untouched and skip process(). 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_BypassEffect lives on AUEffectBase, so on an aumi a 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 render ProcessContext builders, and MidiOutputPacketBuilder.

The effect and instrument adapters shed ~480 lines of duplication, and the effect stops copying its descriptor (a std::string allocation) on every MIDI-output-name query. fill_parameter_info takes an explicit advertise_value_strings flag so the instrument — which serves neither GetParameterValueStrings nor the string-conversion properties — keeps its exact current behavior instead of advertising a door nobody answers.

Validation

auval -v aumi PTrn Pulp on examples/pulp-transpose, stable PASS across three consecutive runs after an AudioComponentRegistrar kill + settle:

VALIDATING AUDIO UNIT: 'aumi' - 'PTrn' - 'Pulp'
Input Scope Bus Configuration:  Default Bus Count:0
Output Scope Bus Configuration: Default Bus Count:1
VERIFYING REQUIRED PROPERTIES:     * * PASS
VERIFYING RECOMMENDED PROPERTIES:  * * PASS
VERIFYING OPTIONAL PROPERTIES:     Latency PASS
VERIFYING CUSTOM UI: Cocoa Views Available: 1  PASS
PUBLISHED PARAMETER INFO: 2 Global Scope Parameters  PASS
AU VALIDATION SUCCEEDED.

Registered as the ctest auval-PulpTranspose.

auval -v aumi is 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 for aufx/aumu). Corollary: an aumi built on the effect adapter also passes auval -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's MIDIEvent/SysEx entry points, a host-installed kAudioUnitProperty_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.yamlau_v2 notes + a format_limitations entry stating what the adapter does and what auval -v aumi does 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 — the aumi section, the shared-surface section, and a warning against introducing a pulp::format::au::detail namespace (it shadows pulp::format::detail and produces confusing errors far from the cause — hit during this work).

🔎 Provenance

Agent claude
Machine m3
Tab Forge v2
Directory /Volumes/Workshop/Code/pulp-aumi
Session c5642b40-0090-4a66-9223-3bcc4f4de820

Resume

claude --resume c5642b40-0090-4a66-9223-3bcc4f4de820

Jump to this tab

cmux surface focus 4D22CE8D-A143-4A13-95CA-A51E871F8D9C

Relaunch (any agent)

cmux surface resume get --surface 4D22CE8D-A143-4A13-95CA-A51E871F8D9C

Restore URLhttps://claude.ai/code/session_01QG7KYpNcdprcaDFrtmzUZC

stamped 2026-07-21 20:29 UTC

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).
@shipyard-local
shipyard-local Bot added this pull request to the merge queue Jul 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 23, 2026
@shipyard-local
shipyard-local Bot added this pull request to the merge queue Jul 23, 2026
Merged via the queue into main with commit 1560dec Jul 23, 2026
21 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant