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 written with
StateStore::set_value_rt() and pushed into the per-block
ParameterEventQueue. For a processor whose
wants_sample_accurate_parameters() returns false, the queue stays empty and
each parameter's last value is written once.
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), reserved only when the plug-in declares MIDI input; 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 parameter's last point is denormalized and
written to StateStore once. Every point is also pushed into the per-block
ParameterEventQueue, in time order, unless the processor's
wants_sample_accurate_parameters() returns false. 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 each
parameter's last 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.
Factory Presets (Program List)¶
A plug-in that ships factory presets shows them in the host's own preset menu.
The adapter reads the preset files from the bundle's Contents/Resources/Presets
folder (the same folder the Audio Unit adapters use), sorted by name, and
publishes them as one program list called "Factory Presets" on the root unit.
- Choosing a preset: the host writes the "Program" parameter, whose steps map one-to-one onto the presets. The adapter then sets every parameter the preset file names, tells the host to re-read parameter values, and leaves any parameter the preset does not name alone.
- Automation and the audio thread: the preset values are read once when the plug-in is created, so a program change that arrives during playback never touches the disk. The block that carries the change already renders with the new values.
- Saved with the session: the current program is stored with the rest of the plug-in state. After a session is restored, a host that sends the stored program value again changes nothing, so edits saved with the session are not overwritten by the preset. Only choosing a different program applies a preset. On a new instance with nothing restored, choosing any program, including the first, applies it.
- Read-only:
setUnitProgramDatacan select a preset but refuses to replace one with host-supplied data. - No presets, no list: a plug-in without a
Presetsfolder publishes no program list and no "Program" parameter, so its parameter list is unchanged. - Pitch names: if the processor names notes (
note_names()), each program reports them as program pitch names.
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)
An instrument has one output element per declared output bus and, when its
descriptor declares input buses, one input element per declared input bus
(main input, then a "Side Chain" element): MusicDeviceBase is constructed
with the declared input count and Render() pulls each connected element
itself. Declare an instrument's inputs optional so the unit also validates
with nothing connected; SupportedNumChannels then lists the no-input rows
ahead of the connected pairs. 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: Immediate SetParameter() writes land in the
store. Automation a host schedules with AudioUnitScheduleParameters() reaches
the effect adapter as a buffer's event list, and what the processor sees
depends on two Processor hooks, both default true:
- By default the adapter renders one
process()call per slice between scheduled events. The store holds each parameter's value at the slice's start, and the slice's points (immediates as steps, ramps as ramp segments) are inparam_events()relative to the slice. This is the AU v2 default because it is what every processor received before the hooks existed, and rendering once per buffer would change the output of a processor that reads the store inside each slice. - A sample-accurate processor that reads automation only through
param_events()(or aParamCursorover it) can return false fromwants_au_scheduled_parameter_slices(). The buffer then renders in one call, as VST3 and CLAP render it: every point at its buffer offset, a ramp as a step to its starting value plus a ramp to its value at the buffer's end, and the store at the end-of-buffer value. - A block-rate processor returns false from
wants_sample_accurate_parameters(). The buffer renders in one call with an empty queue, and each automated parameter is written to the store once, at its end-of-buffer value.
The instrument adapter reads host parameters through the StateStore and does
not expose a separate AU v2 parameter-event sidecar.
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, other channel messages via HandleNonNoteEvent()
and SysEx via HandleSysEx(). Each is pushed onto a bounded lock-free queue
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.
Multi-Bus / Sidechain¶
An AU v2 effect (aufx or aumf) whose descriptor declares a second input bus
with a positive channel count exposes a second AU input element, element 1,
named "Side Chain". kAudioUnitProperty_ElementCount on the input scope reports
2, and element 1 starts at the main bus's sample rate and sample layout with the
side-chain bus's declared width (up to 8 channels, non-interleaved Float32). A
host that only changes the main bus rate carries the side-chain element along;
an explicit side-chain rate that differs from the main bus fails Initialize()
with kAudioUnitErr_FormatNotSupported. Hosts discover a side-chain input from
that element count, which is what makes Logic offer its Side Chain pop-up.
Each render pulls element 1 (through the host's connection or render callback)
before the stock effect render pulls the main input, then delivers it as the
Sidechain bus in ProcessBuffers and through Processor::sidechain_input().
While the host leaves element 1 unconnected, or its pull fails, the side chain
is reported as not connected (sidechain_input() returns nullptr), the same
contract the VST3, CLAP, and AU v3 adapters use. Scheduled-parameter slices
advance the side-chain pointers in step with the main buffers, and the pull uses
the element's preallocated buffer, so the audio thread does not allocate.
kAudioUnitProperty_SupportedNumChannels continues to describe only the main
input/output pair.
An instrument (aumu) that declares input buses gets the same two elements
(main input, then "Side Chain"), pulled by the instrument adapter's Render()
since MusicDeviceBase pulls nothing itself, alongside one output element per
declared output bus. A side chain needs a main input bus in front of it:
elements are positional, so a descriptor whose first input bus has 0 channels
exposes no input element at all. A classic synth (no input buses) keeps zero
input elements. A software-instrument track carries no audio of its own, so
in Logic the instrument receives audio only through the Side Chain pop-up the
second element makes Logic offer; which element that pop-up feeds has not yet
been verified in Logic itself (the headless tests prove delivery through host
render callbacks on both elements).
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.
MIDI Output¶
A processor whose descriptor sets produces_midi reports one MIDI output named
"MIDI Out" through AUAudioUnit.MIDIOutputNames. Hosts install their MIDI
output block only for a unit that reports an output there, so a MIDI-producing
plug-in needs this to have its MIDI delivered. Events the processor writes to
midi_out during process() are sent to that block on cable 0 with their
sample offsets. A processor that does not set produces_midi reports no MIDI
outputs, and a bypassed effect emits no MIDI.
Multi-Bus / Sidechain¶
AU v3 builds its input and output bus arrays from the descriptor's bus lists:
- Inputs: descriptor input bus 0 is the main input, bus 1 is the optional
sidechain, and every further input bus with a positive channel count is an
auxiliary input. The render block pulls each one by its host bus number. The
main input and sidechain still reach
Processor::set_sidechain(), and every bus appears inProcessBuffersat its descriptor index. - Outputs: descriptor output bus 0 is the main output. Every further output
bus with a positive channel count is an auxiliary output the host can route
on its own. A host renders each output bus in a separate call; the adapter
runs
process()once for the first call at a time stamp and copies the other buses out of that one render. A processor that overridesprocess(ProcessBuffers&)writes every output bus; one that only implements the simple callback leaves the auxiliary outputs silent. - Limit: at most eight buses per direction are exposed. A bus with no channels is not exposed, and a request for an output bus the processor does not declare gets silence.
Format negotiation. Hosts change a bus format through the bus object. The
adapter accepts a float, non-interleaved format at a positive sample rate when
the processor supports some bus layout in which that bus carries the proposed
channel count. By default a bus accepts mono or stereo and the buses keep the
declared bus counts. A descriptor with supported_bus_layouts, or a processor
that overrides is_bus_layout_supported(), narrows or widens that. Anything
else is refused, including interleaved, double-precision, and more than eight
channels. When render resources are allocated the adapter adopts the channel
counts the host settled on, so a processor sees prepare() and process() at
the negotiated widths.
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¶
Ports are numbered in this order, which lv2_adapter::Lv2PortLayout defines
once for both connect_port() and the manifest generator:
- 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) - Latency output control port (
lv2:designation lv2:latency), always last
A host stores a session's port connections by index, so inserting a port
renumbers every port after it and reconnects a saved session to the wrong
slots. The instance carries fixed 8-channel audio port arrays, and
instantiate() refuses a descriptor whose buses sum past that ceiling in
either direction rather than leaving declared ports unwired.
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¶
extension_data() serves LV2_STATE__interface, and the adapter saves one
atom:Chunk property holding the same versioned envelope
plugin_state_io::serialize() produces for every other format. The host owns
the control ports and rewrites them on the next run(), so a restore is
authoritative only for the plugin-owned half of that envelope.
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/. generate_plugin_ttl() is the specification of that
bundle's Turtle and numbers its ports from the same Lv2PortLayout
connect_port() reads, but no build step writes the file into the bundle
yet, so a bundle produced here is not yet discoverable by a host.
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.
What your standalone app saves¶
A standalone app remembers two things between launches, both in one settings
file named settings.json inside a folder named after your plug-in:
| Platform | Settings folder |
|---|---|
| macOS | ~/Library/Preferences/<Plug-in name>/ |
| Windows | %APPDATA%\<Plug-in name>\ |
| Linux | ~/.config/<Plug-in name>/ |
- Audio and MIDI choices: the audio device, MIDI input, sample rate, buffer size, channel counts, and the built-in transport (tempo, time signature, playing and recording). They are saved when you change them in the Settings tab.
- The plug-in's own state: every parameter value, plus anything the
plug-in saves itself through
serialize_plugin_state()(a loaded sample, a wavetable, a preset name). This is the same data a DAW stores when it saves a project.
When the state is saved. The app saves it when you quit, and also a moment
after you stop changing things: 1.5 seconds of quiet by default, set with
state_save_debounce_ms. While you keep turning a knob the save waits, but a
change that has been going on for ten seconds is saved anyway. Output meters and
other read-only values do not count as changes, so an idle app never rewrites
the file. If you quit without changing anything, the file is left alone.
When the state is restored. The app puts the saved state back when it starts, before the editor opens, so the first thing you see is how you left it.
If the saved state cannot be used. A first launch has nothing saved and starts with the plug-in's defaults. A saved state that is damaged, was written by a newer version of the app, or that the plug-in refuses to load is ignored: the app logs a warning and starts with the defaults. The old text stays in the file until the next save replaces it.
Limits and switches.
- States larger than 8 MiB are not saved, and the app logs a warning.
- Set
persist_plugin_state = falseto keep remembering the audio and MIDI choices but start the plug-in fresh every time. - Set
persist_settings = falseto remember nothing. - A headless or screenshot launch neither reads nor writes the saved state, so automated captures always start from the defaults and never change what you saved.
- If your app runs its own event loop instead of a built-in editor, call
StandaloneApp::service_state_persistence()from it a few times a second to get the quiet-period save. Saving on quit does not depend on it.
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. Every adapter maps its host's
offline-render signal to ProcessMode::Offline with a FasterThanRealtime
render hint, and to realtime otherwise:
| Format | Host signal |
|---|---|
| VST3 | ProcessSetup::processMode == kOffline |
| AU v3 | AUAudioUnit.renderingOffline |
| AU v2 (effect, instrument, MIDI processor) | kAudioUnitProperty_OfflineRender (global, read/write UInt32; AUBase does not implement it, so the adapter does) |
| CLAP | clap.render extension, set(CLAP_RENDER_OFFLINE) |
| AAX | an AudioSuite instance (AAX_IController::GetIsAudioSuite), carried to the algorithm in the last parameter-packet slot. A Pro Tools insert bounce is not distinguishable from playback through AAX. |
A processor that runs work on a worker thread and adopts the result at a later
block should, on an offline block, wait for that work instead: an offline render
outruns the wall clock, so a result that arrives "in time" during playback lands
late and coarser in a bounce. Waiting is legal only when is_offline() is true.
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 | Effects: main input + side-chain input element; instruments: main + side-chain input elements and aux output elements | 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.