Plugin Format Adapters¶
Pulp supports six native plugin formats (CLAP, VST3, AU v2, AU v3, LV2, and optional AAX)
plus standalone and headless hosts. You write one Processor subclass; format
adapters handle the rest.
Each format is activated by including a single entry-point header and calling a macro in one .cpp file per target. The macro generates all boilerplate: factory functions, extension dispatch, parameter registration, and lifecycle management.
CLAP¶
Entry point header: <pulp/format/clap_entry.hpp>
Macro¶
#include "my_processor.hpp"
#include <pulp/format/clap_entry.hpp>
PULP_CLAP_PLUGIN(my_namespace::create_my_processor)
PULP_CLAP_PLUGIN(factory_fn) generates:
- A
clap_plugin_entry_texported asclap_entry(the CLAP shared library entry point) - A
clap_plugin_factory_tthat creates one plugin - Extension dispatch for audio-ports, note-ports, params, state, latency, and tail
- Static-init registration via
pulp::format::register_plugin()
The plugin descriptor (id, name, vendor, version, features) is derived automatically from Processor::descriptor(). Category mapping:
PluginCategory |
CLAP feature |
|---|---|
Effect |
CLAP_PLUGIN_FEATURE_AUDIO_EFFECT |
Instrument |
CLAP_PLUGIN_FEATURE_INSTRUMENT |
MidiEffect |
CLAP_PLUGIN_FEATURE_NOTE_EFFECT |
Parameter Sync¶
Host to plugin: During clap_process(), the adapter iterates in_events,
looking for CLAP_EVENT_PARAM_VALUE events. Each one is pushed into the
per-block ParameterEventQueue and written with StateStore::set_value_rt().
Gesture events (CLAP_EVENT_PARAM_GESTURE_BEGIN / END) are forwarded to
StateStore::begin_gesture() / end_gesture().
The per-block queue is fixed-capacity and real-time safe. If a host sends more
than 1024 parameter value events in one block, the adapter keeps the first 1024
sample-accurate points, records the overflow/drop count on the queue, and still
writes every incoming value to StateStore so block-end reads observe the
latest host value.
The params_flush() extension callback handles the same events outside of process() (e.g., when the plugin is bypassed).
Plugin to host: Before calling Processor::process(), the adapter snapshots all parameter values. After processing, it compares each value against the snapshot. Any changed parameters are emitted as CLAP_EVENT_PARAM_VALUE events via out_events->try_push(). This allows hosts to record automation from plugin-side changes.
CLAP Modulation¶
The adapter handles CLAP_EVENT_PARAM_MOD events. At the start of each process call, store.reset_all_mod() clears per-buffer modulation offsets. Incoming mod events are validated as global, control-rate state::ModulationLane routes before writing to StateStore::set_mod_offset(). Processors can call store.get_modulated(id) to read base + mod_offset.
MIDI Routing¶
CLAP note and MIDI events are converted into Pulp's block event surfaces:
CLAP_EVENT_NOTE_ONbecomesMidiEvent::note_on(channel, key, velocity * 127)CLAP_EVENT_NOTE_OFFbecomesMidiEvent::note_off(channel, key, velocity * 127)CLAP_EVENT_NOTE_CHOKEbecomes a zero-velocity note-offCLAP_EVENT_MIDIcarries raw MIDI 1.0 channel messages such as CC, pitch bend, channel pressure, poly pressure, and program changeCLAP_EVENT_MIDI_SYSEXis copied into a preallocated inbound payload arena (128 events per block, 4096 bytes per event); overflow or larger payloads are dropped and counted. Outbound processor-owned SysEx emits asCLAP_EVENT_MIDI_SYSEXCLAP_EVENT_MIDI2is routed throughump_input()when the plugin opts into UMPCLAP_EVENT_NOTE_EXPRESSIONfeeds the MPE sidecar when the plugin opts into MPEsample_offsetis set fromhdr->timefor sample-accurate timing
Note port declaration is driven by descriptor().accepts_midi and descriptor().produces_midi. Ports support both CLAP_NOTE_DIALECT_CLAP and CLAP_NOTE_DIALECT_MIDI, preferring CLAP dialect.
State Save/Load¶
- Save: The adapter writes a host-facing blob containing
StateStoreplus any non-emptyProcessor::serialize_plugin_state()payload. When the processor-owned payload is empty, the adapter preserves the legacy rawStateStoreblob for backward compatibility. - Load: The adapter reads all bytes from
clap_istream_tinto a buffer (4 KB chunks), restoresStateStore, then callsdeserialize_plugin_state(). Older blobs without a processor-owned payload calldeserialize_plugin_state()with empty bytes.
Multi-Bus / Sidechain¶
Audio port count and info come from descriptor().input_buses and descriptor().output_buses. Each bus is reported with:
- Port ID: inputs start at 0, outputs at 100
CLAP_AUDIO_PORT_IS_MAINflag set on bus index 0- Port type:
CLAP_PORT_MONOfor 1 channel,CLAP_PORT_STEREOotherwise in_place_pairset toCLAP_INVALID_ID
The process callback routes bus 0 as the main input/output and routes input bus
1 to Processor::sidechain_input() when present. Descriptor-declared secondary
output buses are routed through the richer ProcessBuffers surface for
processors that override it; processors that only implement the simple
Processor::process() signature write the main output and leave aux outputs
silent. Additional input buses beyond bus 1 are not exposed today.
format::ProcessBuffers and format::ProcessBusBufferSet are the additive
shared vocabulary for that richer surface. They are non-owning views over
host-owned bus buffers and let adapters validate active buses, declared channel
counts, and null channel pointers before projecting the current
main-in/main-out/sidechain view into Processor::process().
Processors that override the richer surface can inspect no-input instrument
layouts, surround main outputs, and named auxiliary or stem outputs directly
through ProcessBuffers::inputs and ProcessBuffers::outputs. Use
BusBufferSet::find(), find_by_index(), or find_by_name() for secondary
buses; main_input(), main_output(), and sidechain_input() remain the
ergonomic compatibility helpers.
Inactive buses are treated as disconnected and should carry empty buffer views.
An active bus with any null channel pointer fails
active_buses_have_storage(), even when its declared channel count matches, so
adapters can fail closed or omit the bus instead of handing processors a
half-valid buffer.
Latency and Tail¶
- Latency:
clap_plugin_latency_t::getreturnsprocessor->latency_samples()after host-quirk clamping, so negative latency reports as 0 on the normal path. - Tail:
clap_plugin_tail_t::getreturnsdescriptor().tail_samples. A value of-1(infinite tail) maps toUINT32_MAX.
Known Limitations¶
- Bus 0 and one sidechain input are routed. Descriptor-declared secondary
output buses are writable through
ProcessBuffers; additional input buses beyond the sidechain are not exposed through the simpleProcessorprocess surface. - Desktop CLAP plugin targets expose
CLAP_EXT_GUIwhen built withPULP_CLAP_GUI=1; headless/CI/test environments and WCLAP builds do not expose a live desktop editor. - Per-note modulation (
note_id,port_index,channel,keyfields in param mod events) is accepted but not per-note routed.
VST3¶
Entry point header: <pulp/format/vst3_entry.hpp>
Macro¶
#include "my_processor.hpp"
#include <pulp/format/vst3_entry.hpp>
static const Steinberg::FUID kMyPluginUID(0x12345678, 0x9ABCDEF0, 0x00000001, 0x00000001);
PULP_VST3_PLUGIN(kMyPluginUID, "My Plugin", "Fx", "My Company",
"1.0.0", "https://example.com",
my_namespace::create_my_processor)
PULP_VST3_PLUGIN(uid, name, category, vendor, version, url, factory_fn) generates:
- A
GetPluginFactory()export using the VST3 SDK'sBEGIN_FACTORY_DEF/END_FACTORYmacros - A single class registration via
DEF_CLASS2 - The factory function that creates a
PulpVst3Processor(which subclassesSingleComponentEffect)
The FUID must be generated once and never changed across versions. It is the stable identity of your plugin in every VST3 host.
Parameter Sync¶
Host to plugin: During process(), the adapter reads
data.inputParameterChanges. Each point is denormalized, pushed into the
per-block ParameterEventQueue, and written with
StateStore::set_normalized_rt(). If a host sends more than 1024 points in one
block, the adapter keeps the first 1024 sample-accurate points, records the
overflow/drop count on the queue, and still writes every incoming point to
StateStore so block-end reads observe the latest host value.
Plugin to host: The adapter snapshots all parameter values before calling Processor::process(). After processing, any value that changed is:
1. Written to data.outputParameterChanges as a normalized value (so the host can record automation)
2. Synced to the VST3 parameter system via setParamNormalized()
Gesture callbacks: During initialize(), the adapter wires StateStore gesture callbacks to beginEdit() / endEdit(). This supports undo grouping in DAWs.
VST3 Parameter Groups¶
Parameters are assigned to VST3 units via ParamInfo::group_id. When you set group_id on a parameter in define_parameters(), the adapter maps it to ParameterInfo::unitId. This enables parameter grouping in DAW interfaces.
Boolean parameters (step >= 1, range 0-1) get stepCount = 1. Parameters named "Bypass" additionally get the kIsBypass flag.
MIDI Routing¶
The adapter adds event buses based on descriptor():
accepts_midi = trueadds an event input bus ("MIDI In")produces_midi = trueadds an event output bus ("MIDI Out")
VST3 note events (Event::kNoteOnEvent, Event::kNoteOffEvent) are converted to/from MidiEvent. Velocity is scaled between float 0-1 (VST3) and integer 0-127 (MIDI). Sample offsets are preserved.
MIDI controllers are host-mediated in VST3. For MIDI-accepting plug-ins, the
adapter implements IMidiMapping so hosts can map CC, pitch bend, and channel
aftertouch to hidden ParamIDs; process() decodes those parameter changes back
into sample-accurate MIDI messages. MPE-enabled plug-ins also expose
INoteExpressionController, routing VST3 tuning, volume, and brightness
note-expression events into Pulp's MPE sidecar.
State Save/Load¶
- Save (
getState): Writes a combined host-facing blob containing theStateStorepayload plus any non-emptyProcessor::serialize_plugin_state()bytes. - Load (
setState): Reads the stream in 4 KB chunks, restores both layers, then syncs restored parameter values back to the VST3 parameter system viasetParamNormalized(). Older blobs without a processor-owned payload still load and calldeserialize_plugin_state()with empty bytes.
Multi-Bus / Sidechain¶
Audio buses from descriptor().input_buses and descriptor().output_buses are added during initialize():
- Bus type:
kMainfor required buses,kAuxfor optional (sidechain) buses - Activity:
kDefaultActivefor required, 0 (inactive by default) for optional - Speaker arrangement: an empty arrangement for zero channels, or a canonical
topology for 1–8 channels (
kMono,kStereo, 3.0, quad, 5.0, 5.1, 7.0, or ITU 7.1) based ondefault_channels - Bus names are converted from
std::stringto VST3String128
When supported_bus_layouts is explicit, its first entry supplies the initial
advertised bus configuration; subsequent entries are host-negotiable options.
setBusArrangements() translates the same canonical VST3 speaker arrangements to Pulp's
count-only BusesLayout model. A descriptor's supported_bus_layouts can
therefore opt into host-negotiated surround layouts such as 5.1 and 7.1; the
Processor still gets the final accept/reject decision before bus state changes.
Latency and Tail¶
getLatencySamples()returnsprocessor->latency_samples()after host-quirk clamping, so negative latency reports as 0 on the normal path.getTailSamples()returnsdescriptor().tail_samples, mapping-1tokInfiniteTail.
Editor / GUI¶
createView("editor") returns a PulpPlugView when the VST3 target is built
with PULP_VST3_GUI and the processor reports an editor. Automation, headless,
CI, and test environments intentionally return nullptr so validators and
non-interactive runs do not launch editor windows.
Known Limitations¶
- Bus 0, one sidechain input, and descriptor-declared secondary output buses
are routed through
ProcessBuffers. A multi-out processor that overridesprocess(ProcessBuffers&)writes each aux output bus; processors that only implement the simple callback leave aux buses silent. - Dynamic bus arrangements are limited to descriptor-declared bus counts and mono/stereo layouts; unsupported layouts require host-quirk silence accommodation.
AU v2¶
Entry point headers:
- Effects: <pulp/format/au_v2_entry.hpp>
- Instruments: <pulp/format/au_v2_instrument_entry.hpp>
Macro (Effect)¶
#include "my_processor.hpp"
#include <pulp/format/au_v2_entry.hpp>
PULP_AU_PLUGIN(MyPluginAU, my_namespace::create_my_processor)
PULP_AU_PLUGIN(ClassName, factory_fn) generates:
- A class
ClassNamesubclassingPulpAUEffect(which subclassesAUEffectBase) - A factory function
ClassNameFactoryfor theInfo.plistfactoryFunctionentry - Plugin registration via
PULP_REGISTER_PLUGIN
Use this macro for audio-only aufx effects. The factory function name must
match the factoryFunction in your AU's Info.plist.
Macro (MIDI-receiving Effect)¶
#include "my_processor.hpp"
#include <pulp/format/au_v2_entry.hpp>
PULP_AU_MIDI_PLUGIN(MyMidiEffectAU, my_namespace::create_my_processor)
PULP_AU_MIDI_PLUGIN(ClassName, factory_fn) generates the same effect adapter
class shape as PULP_AU_PLUGIN, but registers it through the AudioUnitSDK MIDI
effect factory so MusicDeviceMIDIEvent and SysEx selectors reach the adapter.
Pair it with an aumf component type: either let CMake emit aumf by setting
the processor descriptor's accepts_midi = true / ACCEPTS_MIDI, or keep a
custom Info.plist.au in sync manually.
Macro (Instrument)¶
#include "my_synth.hpp"
#include <pulp/format/au_v2_instrument_entry.hpp>
PULP_AU_INSTRUMENT(MySynthAU, my_namespace::create_my_synth)
PULP_AU_INSTRUMENT(ClassName, factory_fn) generates:
- A class
ClassNamesubclassingPulpAUInstrument(which subclassesMusicDeviceBase) - A factory function via
AUSDK_COMPONENT_ENTRY(ausdk::AUMusicDeviceFactory, ClassName)
Instruments have zero audio inputs and one audio output. MIDI is received via HandleNoteOn() / HandleNoteOff().
Parameter Sync¶
Host to plugin (effects and instruments): GetParameter() and
SetParameter() are backed directly by the plugin StateStore, so AU host
automation playback, generic AU host UI edits, and preset recall all land in
the same store that Processor::process() reads. There is no per-buffer
Globals()-to-StateStore reconcile path. Initial AU parameter defaults are
seeded from the StateStore during construction so hosts can inspect them
before initialization.
Editor/UI to host: Editor/UI edits write the StateStore and the adapter's
inline store listener notifies the AU host with
kAudioUnitEvent_ParameterValueChange, guarded so host-originated
SetParameter() calls do not echo back into the host. This lets AU hosts
re-read via GetParameter() and record UI automation without calling
AudioUnitSetParameter() on the render path.
Parameter event model: AU v2 exposes current parameter values through the
AU parameter store rather than a render-event list. The effect adapter attaches
an empty ParameterEventQueue before Processor::process() so AU v2 effects
see the same non-null queue contract as other adapters. The instrument adapter
reads host parameters through the StateStore and does not expose a separate
AU v2 parameter-event sidecar. AU v2 parameter changes are block-rate
StateStore values today.
Render-thread plugin output to host: Parameter output changes made during
Processor::process() are not emitted back to the AU host. The render thread
neither pulls, pushes, nor notifies AU host parameters.
Gesture callbacks: The effect and instrument adapters wire StateStore
gesture callbacks to AUEventListenerNotify() with
kAudioUnitEvent_BeginParameterChangeGesture and
kAudioUnitEvent_EndParameterChangeGesture event types.
AU Parameter Units¶
The adapter maps Pulp unit strings to AU parameter units:
ParamInfo::unit |
AU unit |
|---|---|
"dB" |
kAudioUnitParameterUnit_Decibels |
"Hz" |
kAudioUnitParameterUnit_Hertz |
"%" |
kAudioUnitParameterUnit_Percent |
| Boolean (step >= 1, range 0-1) | kAudioUnitParameterUnit_Boolean |
| Everything else | kAudioUnitParameterUnit_Generic |
Stepped parameters with a to_string function get value string arrays via GetParameterValueStrings().
MIDI Routing¶
Audio-only effects (aufx): AU hosts do not route MIDI to plain effect
components. Use PULP_AU_PLUGIN only when the processor does not accept MIDI.
MIDI-receiving effects (aumf): Use PULP_AU_MIDI_PLUGIN and package the
component as aumf. Host MIDI and SysEx arrive via HandleMIDIEvent() /
HandleSysEx(), are queued on bounded lock-free queues, and are drained into
midi_in at the start of each ProcessBufferLists() call. Sample offsets from
inStartFrame are preserved for short MIDI messages.
Instruments (aumu): MIDI notes arrive via HandleNoteOn() and
HandleNoteOff() callbacks. These are buffered in pending_midi_ (protected by
a mutex) and drained into midi_in at the start of each Render() call. The
sample offset (inStartFrame) is preserved.
State Save/Load¶
The AU adapter stores Pulp state alongside the standard AU state dictionary:
- Save: Calls
AUEffectBase::SaveState()(orMusicDeviceBase::SaveState()), then appends aCFDatablob under the key"pulp-state". That blob contains the parameter-onlyStateStorepayload plus any non-emptyProcessor::serialize_plugin_state()bytes. - Load: Calls the base
RestoreState(), then looks for the"pulp-state"key. If found, restores both layers and syncs all parameter values back to the AU parameter system viaGlobals()->SetParameter(). Older blobs with only rawStateStoredata still load and calldeserialize_plugin_state()with empty bytes.
Tail and Latency¶
Effects: GetTailTime() converts descriptor().tail_samples to seconds by dividing by sample rate. GetLatency() does the same for latency_samples(). A tail of -1 maps to infinity.
Instruments: GetTailTime() and GetLatency() report the same processor
runtime contract as effects, using the output stream sample rate. A tail of -1
maps to infinity.
auval Validation¶
Run auval -a to list registered Audio Units, then validate:
The type codes (aufx, aumu) and four-character codes are set in your AU's Info.plist, not in the Pulp code.
Known Limitations¶
- Effects do not emit parameter output changes back to the host.
- AU v2 effects can receive MIDI as
aumf, but outgoing MIDI frommidi_outis not emitted back to the host yet. - AU v2 effects use
ProcessBufferListswhich receives interleaved audio. The adapter de-interleaves per buffer. - Instruments use a
std::mutexto buffer MIDI between the host's note callbacks and the render call. This is safe because Apple guarantees these calls occur on the same thread or with proper synchronization, but it adds a small overhead.
AU v3 (AUAudioUnit — app extension)¶
Entry point: pulp_add_plugin(... FORMATS AUv3 ...) generates a
.appex bundle via the CMake helper _pulp_add_auv3() in
tools/cmake/PulpUtils.cmake. On iOS, use the wrapper
pulp_add_ios_auv3() — see ios-auv3-guidance.md
for the iOS-specific entitlement, bundle-id, and signing story.
Unlike AU v2, AU v3 plugins are always app extensions and always run
sandboxed. This is Apple's deployment model for AUv3; there is no non-
sandboxed AUv3 form. In a compatible host (Logic Pro, GarageBand, Cubase,
AUM, AUAudioUnit-aware DAWs), the host loads the .appex as an out-of-
process extension with its own audio-unit view controller.
Status: experimental on macOS and iOS per
docs/status/support-matrix.yaml. AUv3 ships
today but hasn't yet passed the host-compatibility bar (auval v2 validation,
Logic Pro + GarageBand + AUM cross-check) that would justify promotion to
usable; see the status manifest for the current promotion criteria.
State Save/Load¶
AU v3 mirrors the AU v2 state contract through AUAudioUnit.fullState:
- Save:
fullStatewrites aNSDatapayload under the key"pulpState". That payload contains the parameter-onlyStateStorebytes plus any non-emptyProcessor::serialize_plugin_state()blob. - Load:
setFullState:reads"pulpState"and restores both layers. Older blobs that contain only rawStateStoredata still load and calldeserialize_plugin_state()with empty bytes.
Parameter Events¶
AU v3 receives sample-accurate AURenderEventParameter and
AURenderEventParameterRamp events in the render event list. The adapter writes
each event into the realtime ParameterEventQueue and also updates
StateStore with the latest value. The queue is fixed-capacity; events beyond
capacity are dropped from the sparse queue and counted as overflow, while the
latest value still reaches StateStore for block-rate reads.
Multi-Bus / Sidechain¶
AU v3 exposes descriptor input bus 0 as the main input and descriptor input bus
1 as an optional sidechain AUAudioUnitBus when it has a positive channel
count. The render block pulls input bus 1 separately, publishes it through
Processor::set_sidechain(), and includes it as the sidechain bus in
ProcessBuffers.
Additional input buses and secondary output buses are not exposed through the AUv3 adapter surface yet; the adapter creates one main output bus.
LV2¶
Entry point header: <pulp/format/lv2_entry.hpp>
Macro¶
#include "my_processor.hpp"
#include <pulp/format/lv2_entry.hpp>
PULP_LV2_PLUGIN(my_namespace::create_my_processor,
"http://example.com/plugins/my-plugin")
PULP_LV2_PLUGIN(factory_fn, plugin_uri) generates:
- An exported
lv2_descriptor(uint32_t index)entry point - Static-init registration that attaches the plugin URI to the descriptor
instantiate,connect_port,activate,run,deactivate, andcleanupcallbacks
The plugin URI must be stable across versions. It is the plugin's identity in every LV2 host and is the key used by the bundle's TTL files.
Status¶
experimental on Linux per
docs/status/support-matrix.yaml. The
adapter loads in real LV2 hosts, routes audio + control ports, and handles
atom MIDI input and output for 1–3-byte short messages. Sysex through atom
sequences and broader control/atom coverage are still in progress.
Parameter and Audio Ports¶
connect_port maps indices to roles in this order:
- Audio input ports (count from
descriptor().input_buses[...].default_channels) - Audio output ports
- Control input ports (one per parameter)
- Atom input port (present when
descriptor().accepts_midi == true) - Atom output port (present when
descriptor().produces_midi == true)
run() reads control-input port values into StateStore at the top of each buffer.
LV2 control ports are block-rate current values, not scheduled sparse
parameter events. The adapter still attaches an empty
ParameterEventQueue before Processor::process() so
Processor::param_events() is non-null, but control-port ingress does not
consume sparse queue capacity; large host-block capacity for LV2 is ordinary
control-port count/state update behavior.
MIDI Routing¶
During instantiate() the adapter resolves the host-supplied
LV2_URID__map feature and caches URIDs for atom:Sequence, atom:Chunk,
and midi:MidiEvent. Hosts that do not provide LV2_URID__map are
refused loudly (instantiate() returns nullptr).
run() walks the connected LV2_Atom_Sequence input and promotes each
short (1–3 byte) MIDI event into a pulp::midi::MidiEvent with
sample_offset = ev->time.frames. Outgoing MIDI written into the
Processor::process() midi_out buffer is serialized back into the atom
output port via lv2_atom_sequence_append_event(); events that don't fit
are dropped rather than truncated.
State Save/Load¶
The current adapter does not implement the LV2 state:interface extension.
Presets authored via the host work, but Pulp-defined StateStore
serialization is not yet exposed over the LV2 state path.
Bundle Layout¶
LV2 plug-ins ship as a .lv2/ directory containing the compiled shared
object plus one or more .ttl files that describe ports and metadata.
pulp_add_plugin(... FORMATS LV2 ...) writes the shared object into
build/LV2/<name>.lv2/; the matching TTL is generated by
generate_plugin_ttl() so port indices always agree with the adapter.
Known Limitations¶
- Atom sysex events are ignored — only 1–3-byte short MIDI messages are routed through the LV2 atom input sequence.
- No
state:interfaceimplementation yet; host-side preset save/restore works, but Pulp's ownStateStorebinary state is not exposed through the LV2 state extension. - Worker extension, UI extension (
ui:UI/ui:Qt5UI/ui:X11UI), andlv2:CVPortsupport are not wired. - Bundle TTL discovery in
core/host/src/plugin_slot_lv2.cppuses a tiny regex scanner intentionally; complex multi-plugin bundles that rely on blank-node syntax beyond the common form may not be discovered correctly.
AAX (optional)¶
Entry point header: <pulp/format/aax_entry.hpp>
Macro¶
#include "my_processor.hpp"
#include <pulp/format/aax_entry.hpp>
PULP_AAX_PLUGIN(my_namespace::create_my_processor)
PULP_AAX_PLUGIN(factory_fn) generates:
- A
GetEffectDescriptions()export for the AAX host - metadata generation from
Processor::descriptor() - Parameter, state, latency, transport, and MIDI registration through the AAX runtime
It registers no editor, so Pro Tools draws its auto-generated parameter strip.
PULP_AAX_PLUGIN_WITH_GUI(factory_fn) is identical except that it also
registers Pulp's custom editor, which has not been validated in Pro Tools
itself — see the AAX guide before opting in.
Status¶
experimental on macOS and Windows, unsupported on Linux per
docs/status/support-matrix.yaml. AAX is
intentionally opt-in: the adapter compiles and loads in Pro Tools, but
custom editor surface, AudioSuite role exercise, and public-CI coverage
remain incomplete. The SDK is developer-supplied and never bundled or
shipped by Pulp.
Build Requirements¶
AAX is intentionally opt-in:
- Supported only on macOS and Windows
- Requires
PULP_ENABLE_AAX=ON - Requires
PULP_AAX_SDK_DIRto point to a developer-supplied out-of-tree AAX SDK - Requires
aax_entry.cppin the plugin source directory
Typical CMake usage:
pulp_add_plugin(MyPlugin
FORMATS VST3 AU CLAP AAX Standalone
PLUGIN_NAME "MyPlugin"
BUNDLE_ID "com.example.myplugin"
MANUFACTURER "Example Audio"
MANUFACTURER_CODE "Exmp"
AAX_PRODUCT_CODE "ExPl"
AAX_NATIVE_CODE "ExPn"
)
Linux and Ubuntu do not support AAX. If FORMATS AAX is requested there,
configuration fails with an explicit error.
Format Model¶
The current AAX adapter supports:
- Effect, instrument, and MIDI-effect categories
- One main output bus
- One main input bus plus one optional mono sidechain
- State save/load
- Host automation and latency reporting
- Transport access
- Local MIDI input/output nodes when declared by the processor
Validation¶
When DigiShell + AAX Validator are installed locally:
pulp validateruns a fast describe-validation probe for each.aaxpluginpulp validate --allruns the fuller AAX validator suite
If the validator is missing, the CLI reports a guided skip and points to the Avid download page instead of guessing.
Known Limitations¶
- Native AAX only. DSP is out of scope. The
InsertOrAudioSuiterole is declared, but Pulp registers noAAX_IHostProcessor, so the dedicated AudioSuite offline-render path is not implemented and is out of scope. - The custom editor requires a Skia-enabled build. Without Skia the Windows
PluginViewHostfalls back to the no-op factory and the plugin loads with no editor. - The editor has not been validated in Pro Tools itself — no Avid SDK is present in Pulp's CI, so it is covered by unit tests only.
- Public CI does not build or validate AAX because the SDK and validator are not bundled by Pulp.
- Component layouts are intentionally constrained to keep the surface small.
For setup, download, and rules, see AAX Setup.
Standalone Host¶
Header: <pulp/format/standalone.hpp>
StandaloneApp runs a Processor as a native desktop application with real audio I/O:
pulp::format::StandaloneApp app(my_namespace::create_my_processor);
pulp::format::StandaloneConfig config;
config.sample_rate = 48000.0;
config.buffer_size = 256;
config.output_channels = 2;
config.input_channels = 2; // 0 for instrument mode
app.set_config(config);
app.start(); // Blocks until stop() is called
Configuration options:
| Field | Default | Description |
|---|---|---|
audio_device_id |
"" (system default) |
Audio device identifier |
midi_input_id |
"" (first available) |
MIDI input device identifier |
sample_rate |
48000.0 | Sample rate in Hz |
buffer_size |
256 | Audio buffer size in samples |
output_channels |
2 | Number of output channels |
input_channels |
0 | Number of input channels (0 = no input) |
The standalone host manages AudioSystem, AudioDevice, MidiSystem, and MidiInput instances. MIDI is buffered with a mutex between the MIDI callback and the audio callback.
HeadlessHost¶
Header: <pulp/format/headless.hpp>
HeadlessHost drives a Processor programmatically with no audio device, no UI, and no DAW. Use cases: CI tests, batch rendering, golden-file comparisons, benchmarks.
pulp::format::HeadlessHost host(MyPlugin::create);
host.prepare(48000, 512);
host.state().set_value(kGainID, -6.0f);
pulp::audio::Buffer<float> in(2, 512), out(2, 512);
auto in_view = in.view();
auto out_view = out.view();
host.process(out_view, in_view);
Key methods:
| Method | Description |
|---|---|
prepare(sample_rate, max_buffer_size, in_ch, out_ch) |
Initialize the processor |
try_prepare(sample_rate, max_buffer_size, in_ch, out_ch, limits) |
Initialize only if the processor's prepare-resource estimate fits the supplied non-zero limits |
process(output, input) |
Process audio (no MIDI) |
process(output, input, midi_in, midi_out) |
Process audio with MIDI |
render_offline(input, options) |
Render effect-shaped AudioFileData through deterministic offline blocks |
release() |
Release processing resources |
state() |
Access the StateStore for parameter reads/writes |
save_state() |
Serialize current plugin state to bytes |
load_state(data) |
Restore parameter and plugin-owned state from bytes |
descriptor() |
Read the plugin's PluginDescriptor |
Use try_prepare() when a test, batch render, or benchmark needs to prove a
plugin fails closed before allocating oversized prepare-time resources:
pulp::format::PrepareResourceLimits limits;
limits.max_total_bytes = 8 * 1024 * 1024;
limits.max_voices = 64;
if (!host.try_prepare(48000, 512, 2, 2, limits)) {
auto reason = host.last_prepare_limit_failure();
// Report or assert the first exceeded budget.
}
When try_prepare() fails a non-zero limit, it returns before
Processor::prepare() and leaves the previous successful prepared render
context intact. Use last_prepare_limit_failure() for diagnostics, then either
continue rendering with the prior prepare or retry with adjusted limits.
If the host also reports memory pressure, a processor may shed rebuildable
owner-thread caches before the retry, but it must keep prepared core state and
fixed per-block scratch accounting valid.
Input and output views may alias for in-place processing.
Use render_offline() when a batch/golden path already has an
AudioFileData artifact and needs OfflineRenderOptions metadata forwarded to
ProcessContext: scheduled block size, sample position, tempo, beat position,
and render-speed hint. It is effect-shaped today, so the rendered artifact has
the same channel count as the input. Parity fixtures should compare both the
rendered samples and the per-block ProcessContext metadata against direct
stepped processing for the same schedule.
Format adapter runtime-mode tests should assert the adapter-owned source of
truth rather than inferring mode from transport. VST3 maps
ProcessSetup::processMode == kOffline to ProcessMode::Offline with a
faster-than-realtime render hint. CLAP has no equivalent process-mode field, so
its adapter reports realtime mode and realtime render speed unless a future CLAP
extension exposes stronger host intent. AU v2 effect and instrument render
callbacks also report realtime mode and realtime render speed because the v2 SDK
does not surface offline-bounce intent to ProcessBufferLists() / Render();
AU v3 mirrors that explicit realtime render-path contract. Bypass, tail-drain,
reset, and transport-jump flags stay explicit ProcessContext metadata and
should be covered where the host API can actually deliver them. VST3 process
context sample-position discontinuities, AU v2 host-callback sample-position
discontinuities, AU v3 transport-state sample-position discontinuities, and
CLAP beat-timeline discontinuities are diffed against the previous block and set
transport_jump, so processors that use should_reset_dsp_state() can reset
delay, lookahead, or oscillator state on host seeks.
Audio precision (f32 / f64)¶
Pulp processes audio in 32-bit float by default, and supports 64-bit float end-to-end as an opt-in. The contract:
Processor::process(...)is the f32 surface every plugin implements.Processor::process_f64(...)(both the simpleBufferView<double>form and the richerProcessBuffers64form) is additive. The default implementation is an allocation-free fallback that converts f64→f32 at the boundary, runs your f32process(), and converts back — so every Pulp plugin works in a 64-bit host with no extra code.- To process natively in double precision, set
caps.supports_f64_audio = truein the descriptor's capabilities and overrideprocess_f64. Thecore/signalDSP classes are sample-type templated (Gain64,Biquad64, …) and the SIMD kernels have f64 variants, so a genuinely double-precision chain is available throughout.examples/pulp-gainis the reference native-f64 plugin.
Which surfaces can actually carry 64-bit audio is fixed by each host API:
| Surface | Host can deliver f64? | Behavior |
|---|---|---|
| VST3 | Yes (kSample64) |
Accepted unconditionally (canProcessSampleSize). Native dispatch when supports_f64_audio, otherwise boundary conversion. |
| CLAP / WebCLAP | Yes (data64) |
All ports advertise CLAP_AUDIO_PORT_SUPPORTS_64BITS; native-f64 plugins also advertise PREFERS_64BITS. Native dispatch requires every active routed bus to be 64-bit; mixed 32/64 blocks demote to the f32 boundary path. |
| AU v2 / AU v3 | No — the AU render ABI is Float32 |
f32 only, by format. |
| AAX | No — AAX audio buffers are 32-bit float | f32 only, by format. |
| LV2 | No — lv2:AudioPort is float |
f32 only, by format. |
| Standalone | No — the device layer runs 32-bit float streams | f32 only. |
| WAM (web) | No — Web Audio is f32 | f32 only. |
| HeadlessHost / ValidationHarness / Python / Node bindings | Yes (API) | process_f64 passes straight through; non-native plugins hit the single boundary conversion in the core fallback. |
Additional precision notes:
- Parameters are 32-bit floats (normalized at the store); host automation
values arriving as doubles narrow at the store boundary. Transport and
timing fields on
ProcessContextare doubles. - GPU audio, SignalGraph, PluginSlot, and the experimental live-kernel are f32 surfaces by policy. A native-f64 processor that routes through them is narrowed to f32 at that node boundary.
- Latency-compensated bypass runs its dry-delay line in f32 even under a 64-bit host, so bypassed passthrough with nonzero reported latency carries an f32-precision floor; non-delayed bypass is a bit-exact copy.
- Denormal flushing (
ScopedFlushDenormals) wraps the audio callback on both the f32 and f64 paths — FTZ/DAZ on x86 and FPCR.FZ on arm64 apply to both sample widths.
Choosing Which Formats to Build¶
Each format is a separate CMake target. A typical CMakeLists.txt creates one target per format, all sharing the same processor source:
# CLAP target
add_library(MyPlugin_CLAP MODULE
src/my_processor.cpp
src/clap_entry.cpp # Contains PULP_CLAP_PLUGIN(...)
)
target_link_libraries(MyPlugin_CLAP PRIVATE pulp::format clap)
# VST3 target
add_library(MyPlugin_VST3 MODULE
src/my_processor.cpp
src/vst3_entry.cpp # Contains PULP_VST3_PLUGIN(...)
)
target_link_libraries(MyPlugin_VST3 PRIVATE pulp::format vst3sdk)
# AU v2 target (macOS only)
if(APPLE)
add_library(MyPlugin_AU MODULE
src/my_processor.cpp
src/au_entry.cpp # Contains PULP_AU_PLUGIN(...)
)
target_link_libraries(MyPlugin_AU PRIVATE pulp::format AudioUnitSDK)
endif()
Each entry-point .cpp file includes the processor header and calls the format-specific macro. The processor code is identical across all targets.
Comparison Table¶
| Feature | CLAP | VST3 | AU v2 | AU v3 |
|---|---|---|---|---|
| Entry macro | PULP_CLAP_PLUGIN |
PULP_VST3_PLUGIN |
PULP_AU_PLUGIN / PULP_AU_INSTRUMENT |
pulp_add_plugin(... FORMATS AUv3 ...) |
| Param values | Raw float | Normalized 0-1 | Raw float | Raw float |
| Param modulation | Yes (PARAM_MOD events) |
No | No | No |
| Param gestures | Yes (event-based) | Yes (beginEdit/endEdit) |
Yes (AUEventListenerNotify) |
Yes (AUParameterTree) |
| MIDI in events | Yes (note events) | Yes (VST3 events) | Effects: aumf yes / aufx no, Instruments: yes |
Yes (raw bytes) |
| State format | Binary via stream | Binary via IBStream |
Binary in CFDictionary |
Binary in fullState |
| Multi-bus declared | Yes | Yes | No | Main input + sidechain input |
| Editor/UI param write-back | Yes | Yes | Yes (AUEventListenerNotify) |
Yes (AUParameterTree) |
| Render-thread param output | Yes | Yes | Not yet | Yes |
| Latency reporting | Yes | Yes | Yes (seconds) | Yes (seconds) |
| Tail reporting | Yes | Yes | Yes (seconds) | Yes (seconds) |
| Stable ID | bundle_id string |
FUID (128-bit) |
Four-char codes in Info.plist | Bundle identifier |
Optional AAX uses its own runtime and follows the constraints listed in the AAX section above.