| name | clap |
| description | CLAP format adapter for Pulp — how Processor bridges to clap_plugin_t, how parameters / modulation / sidechain / MPE / UMP / sysex flow, and the pitfalls discovered while wiring the adapter. |
CLAP Skill
Use this skill when touching Pulp's CLAP adapter, when answering
questions about how a Pulp plugin appears to a CLAP host, or when a
CLAP validator run surfaces something odd. CLAP is Pulp's first-class,
MIT-safe plugin format — every plugin built with Pulp ships a CLAP
binary and CLAP is the fastest iteration lane because the
clap-validator runs without a DAW.
When to use
- Editing
core/format/src/clap_adapter.cpp or the generated entry header
core/format/include/pulp/format/clap_entry.hpp (the boilerplate-
generator macro PULP_CLAP_PLUGIN(…)).
- Adding or changing a CLAP extension (
audio-ports, note-ports,
params, state, gui, preset-load, ARA companion factory, …).
- A CLAP host reports a behaviour issue — sidechain missing, MIDI
events dropped, presets not loading, GUI refusing to attach.
- A
clap-validator pass regresses.
- Working on the MPE or UMP sidecar as it flows through the CLAP path
— the CLAP adapter is currently the canonical consumer of
set_mpe_input / set_ump_input.
- Cross-referencing CLAP behaviour against VST3 / AU when debugging
host-specific regressions — use the three adapters as each other's
"oracle" during parity fixes.
Files and entry points
| Role | Path |
|---|
| Core adapter (C++) | core/format/src/clap_adapter.cpp |
Adapter header / PulpClapPlugin | core/format/include/pulp/format/clap_adapter.hpp |
| Entry-point generator macro | core/format/include/pulp/format/clap_entry.hpp |
| CLAP module (FetchContent) | declared in CMakeLists.txt; CLAP headers are MIT and fetched at configure time — there is no hand-written PulpClap.cmake |
| WebAssembly compact variant (wclap) | tools/cmake/PulpWclap.cmake, core/format/src/wasm/ |
| Remote-control page builder | core/format/src/clap_remote_controls.cpp |
| CLAP+ARA surface | core/format/src/ara*, see the ara skill |
| Tests | test/test_clap_entry.cpp (dlopen + descriptor), test/test_clap_ara_extension.cpp (ARA companion factory), test/test_clap_webview.cpp (WebView bridge) |
| CLI validator invocation | tools/cli/cmd_validate.cpp (clap-validator validate … with dlopen-only fallback) |
The PULP_CLAP_PLUGIN(factory_fn) macro (bottom of clap_entry.hpp) is the
single-plugin developer surface. It registers one plugin record, keeps the
legacy global factory slot (register_plugin(factory_fn)), and defines the
clap_entry exported symbol. There is no separate "factory" TU — the macro is
the factory.
Multi-plugin bundle — one CLAP binary, many plugins
A CLAP module has exactly ONE clap_entry symbol but its factory may list many
plugins. clap_entry.hpp supports this via a per-plugin record array
(g_clap_records) instead of the old single g_factory/g_desc/g_clap_desc
globals:
PULP_CLAP_BUNDLE_PLUGIN(Foo, create_foo)
PULP_CLAP_BUNDLE_PLUGIN(Bar, create_bar)
PULP_CLAP_BUNDLE_ENTRY()
Load-bearing facts, each a trap if forgotten:
- Descriptors are built in
entry_init(), NOT at static init. Building a
descriptor calls the factory → constructs a Processor, which is unsafe during
C++ static initialization (global/std::string order). register_clap_record
stores only the factory at static init; init_all_records() (invoked from the
host's clap_entry.init()) fills each record's descriptor. A test that queries
the factory MUST call clap_entry.init(...) first — the old
init_descriptor() free function is gone.
get_plugin_count() returns the count of uniquely-addressable plugins
(the deduplicated index init_all_records() builds — a record whose
bundle_id collides with an earlier one, or whose factory returned null, is
excluded so the host is never handed an advertised-but-unreachable
descriptor). create_plugin(id) resolves BY ID (via find_clap_record),
not by a single hardcoded descriptor. Every plugin needs a unique bundle_id.
- Extension callbacks read per-instance metadata, never a global. Before
clap_init() constructs processor, audio_ports / note_ports read
PulpClapPlugin::descriptor_snapshot (cached from the record at
create_plugin()); after, they prefer the live processor->descriptor(). When
adding a new extension that needs bus/MIDI metadata, follow this pattern — do
NOT reintroduce a file-scope descriptor global.
- Each record is also published to the shared keyed registry (
registry.hpp,
see the auv2 skill) at init for cross-format enumeration / editor assets.
- Single-plugin
PULP_CLAP_PLUGIN is unchanged behaviourally; a bundle is opt-in.
Tests: test/test_clap_bundle_entry.cpp (two plugins, one entry) and the
migrated test/test_clap_entry.cpp (single-plugin contract + per-instance
descriptor_snapshot fallback).
Core conventions
The shape of a CLAP instance
PulpClapPlugin (in clap_adapter.hpp) is the shared per-instance
struct. It owns:
std::unique_ptr<Processor> processor — the user's DSP.
state::StateStore store — parameter state, wired to the processor
via set_state_store(&store) during clap_init.
- Pre-allocated
input_ptrs / output_ptrs / sidechain_ptrs arrays
sized to kMaxChannels = 8. Process must not allocate — these
pointer fan-outs are static across calls.
param_snapshot for detecting plugin-side parameter edits during
process(). After processor->process(), the adapter compares each
param to its snapshot and emits CLAP_EVENT_PARAM_VALUE out-events
so the host can record automation.
- RT-safety invariant: every scratch vector
process() sizes to
all_params().size() (via resize/assign) runs on the audio thread,
so each MUST be reserve()d to that size in clap_activate() — otherwise
the first block grows-and-allocates. param_snapshot and
output_param_has_event (the sample-accurate-output skip-set) are both
reserved there. Adding a new per-param scratch means adding its reserve;
the ClapSlot::process is allocation-free rt-safety test catches a miss
(it measures the first block, where the missing reserve shows up).
param_events (state::ParameterEventQueue) for the current block's
inbound CLAP_EVENT_PARAM_VALUE events. It preserves every host
automation point with header.time before the StateStore dual-write
lands the last value for ordinary parameter reads.
mpe_tracker + mpe_buffer + mpe_enabled — MPE sidecar populated
only if PluginDescriptor::effective_capabilities().supports_mpe
is true. The effective value ORs the legacy descriptor flag with the
node ABI capability field. clap_activate() reserves and capacity-limits
the sidecar too; one MIDI event can fan out to many MPE callbacks.
ump_buffer + ump_enabled — UMP sidecar. Cleared at the top of
every block, then filled from BOTH sources every block: native
CLAP_EVENT_MIDI2 packets append directly during the event loop,
and after decode midi1_to_ump(midi_in, ump_buffer) always runs
(synthesises UMP from the MIDI 1.0 stream). Both paths run
unconditionally because real hosts mix transports — notes via
CLAP_EVENT_NOTE_* and CCs via CLAP_EVENT_MIDI2 is common, and
skipping the synthesis when MIDI2 is present silently drops the
note half from the UMP buffer. See Gotchas. clap_activate() reserves
and capacity-limits this sidecar.
native_f64_enabled plus the data64 scratch/pointer arrays — CLAP hosts
may provide double-precision audio buffers. If
PluginDescriptor::effective_capabilities().supports_f64_audio is false,
the adapter converts host data64 to the normal f32 process(...) path and
converts f32 outputs back to data64. If the plugin opts in, every active
routed bus for the block must be f64 before the adapter calls
Processor::process_f64(ProcessBuffers64&, ...); mixed f32/f64 blocks stay
on the compatibility path. Every audio port advertises
CLAP_AUDIO_PORT_SUPPORTS_64BITS (in audio_ports_get), and native-f64
descriptors additionally advertise CLAP_AUDIO_PORT_PREFERS_64BITS — a
spec-compliant host only sends data64 to ports carrying the SUPPORTS
flag, so dropping it silently kills the whole f64 path. The boundary
f64→f32 demotion and output-scratch pre-zero are deferred until after the
native-f64 decision inside clap_process (native blocks read the host's
double buffers directly and skip the conversion); keep any new bus wiring
consistent with that ordering.
ara_controller — lazily created on the first host query for the
ARA companion-factory extension.
bridge + editor_host + editor_visible — gated on
PULP_CLAP_GUI. Editor lifecycle flows through ViewBridge; see the
view-bridge skill for the open/attach/close protocol.
PulpClapPlugin is consumed from two translation units: the per-plugin
clap_entry.cpp and the shared clap_adapter.cpp in pulp-format.
They must see the same GUI define. Desktop plugin builds compile both
with PULP_CLAP_GUI=1; WCLAP/non-GUI builds compile both with
PULP_CLAP_GUI=0. Do not add GUI-only fields under a preprocessor
condition unless the shared adapter target receives the same condition.
A layout mismatch presents as random CLAP lifecycle corruption, often a
REAPER crash in gui_create() or clap_activate() touching a bogus
bridge/editor_host pointer.
Parameters
Parameter semantics come from ParamInfo::kind, not heuristics over step/range.
value_labels are the single display and parse table; toggle/enum host text does
not accept arbitrary numeric fallback. Route conversion through
parameter_text.hpp, which also contains author exceptions.
Parameters are defined by the Processor during define_parameters(store)
and enumerated to the host by the params extension in
clap_entry.hpp:
params_count → store.param_count().
params_get_info → builds a clap_param_info_t from the stored
ParamInfo. CLAP_PARAM_IS_AUTOMATABLE is always set.
CLAP_PARAM_IS_STEPPED is set when range.step >= 1 and the range
is narrow (< 10).
params_get_value returns the current base value (without
modulation).
params_value_to_text uses ParamInfo::to_string when provided,
otherwise falls back to "%.2f %s" with the unit.
During clap_process, the adapter routes host events into the store:
CLAP_EVENT_PARAM_VALUE → store.set_value_rt(id, value) ← RT-safe
CLAP_EVENT_PARAM_MOD → store.set_mod_offset(id, amount)
CLAP_EVENT_PARAM_GESTURE_BEGIN / _END → store.begin_gesture / end_gesture
Use set_value_rt, not set_value, on the audio thread. The generic
set_value() path dispatches ListenerThread::Main listeners through
the installed EventLoop, and that dispatch lambda allocates on the
firing thread — fatal for the audio thread. set_value_rt() writes the
atomic + pushes an event on a non-allocating SPSC queue; the editor's
UI tick drains via store.pump_listeners(). Audio listeners still fire
inline (caller asserts RT-safety), so audio-thread listeners must be
trivial, non-allocating, and bounded.
Do not collapse inbound CLAP parameter automation to a single last point.
clap_process appends every CLAP_EVENT_PARAM_VALUE to
PulpClapPlugin::param_events, sorts by sample offset, and still calls
store.set_value_rt(...) for the same events so legacy block-level reads
observe the final value.
Before calling Processor::process(), the adapter attaches that queue via
processor->set_param_events(¶m_events), so sample-accurate processors
read the same sorted events through Processor::param_events().
The modulation offset is per-buffer: store.reset_all_mod() runs
at the top of every process() before applying new PARAM_MOD events.
DSP reads modulated values via store.get_modulated(id) = base +
current mod offset. Plugins that only read store.get_value(id) do
not see host modulation.
Audio buses (incl. sidechain)
When supported_bus_layouts is non-empty, expose
CLAP_EXT_AUDIO_PORTS_CONFIG. audio_ports_get and clap_activate must both
use the selected widths so PrepareContext matches host metadata.
audio_ports enumeration in clap_entry.hpp is descriptor-driven:
desc.input_buses / desc.output_buses. Bus 0 is always the main
bus (flag CLAP_AUDIO_PORT_IS_MAIN); bus 1 (when present) is the
sidechain and is routed via Processor::set_sidechain(&view) before
process(). Additional input buses beyond index 1 are ignored — the
Processor API exposes a single sidechain slot.
Secondary (aux) output buses ARE routed to the richer
Processor::process(ProcessBuffers&) surface (role Aux, index ≥1) for
multi-out instruments (drum machines, multitimbral, stem renderers).
clap_process builds one ProcessBusBufferView<float> per host output
bus from pre-allocated aux_output_ptrs[kMaxOutputBuses-1][kMaxChannels]
storage (row b-1 backs host bus b; the main bus uses output_ptrs),
so the routing path never allocates on the audio thread. Each aux bus is
pre-zeroed before process(), so a single-output processor (whose
default process(ProcessBuffers&) writes only the main bus) leaves aux
buses silent — no uninitialised memory, no behavioural change. A
multi-out processor overrides process(ProcessBuffers&) and writes each
aux bus. The aux view's declared_channels is the descriptor's declared
count (cached in clap_activate — never call descriptor() on the audio
thread; it allocates), while buffer.num_channels() carries the actual
routed count, so matches_declared_layout() can detect a host-vs-declared
mismatch. Host output buses beyond kMaxOutputBuses are zero-filled but
not routed.
MIDI: short messages, sysex, note-expression, UMP
Inbound event decode in clap_process():
CLAP_EVENT_NOTE_ON / _NOTE_OFF → MidiEvent::note_on / note_off
CLAP_EVENT_MIDI → MidiEvent::from_bytes(data[0..2])
— CC, pitch bend, channel AT,
poly AT, program change
CLAP_EVENT_MIDI_SYSEX → midi_in.add_sysex_copy(bytes, time, 0.0)
backed by a preallocated payload pool
CLAP_EVENT_NOTE_EXPRESSION → synthesised MIDI 1.0 (see table)
CLAP_EVENT_NOTE_CHOKE → note_off(channel, key, velocity=0)
CLAP_EVENT_MIDI2 → self->ump_buffer.add(packet)
(guarded by CLAP_VERSION_GE(1,1,0) —
the event is an enumerator, NOT a
preprocessor macro; see Gotchas)
Note-expression → MIDI 1.0 mapping. MpeVoiceTracker only ingests
MIDI 1.0, so per-note expressions are synthesised to channel-wide
equivalents and narrowed back per-voice by the tracker:
| CLAP expression id | Synthesised MIDI 1.0 |
|---|
PRESSURE | channel aftertouch 0xDn |
TUNING | 14-bit pitch bend (normalised to ±48st member range) |
BRIGHTNESS | CC 74 |
VOLUME | CC 7 (0..4 → 0..127 log-domain scale) |
PAN | CC 10 |
VIBRATO, EXPRESSION | dropped — no unambiguous MIDI 1.0 equivalent; UMP-aware plug-ins should consume via the CLAP_EVENT_MIDI2 path |
Non-MPE descriptors drop note-expression events with a one-time
debug log. See the mpe skill for tracker details.
Outbound MIDI: the processor's midi_out emits short messages as
CLAP_EVENT_MIDI and sysex entries as
CLAP_EVENT_MIDI_SYSEX, both via out_events->try_push.
sample_offset carries through to header.time via the shared cross-format
helper detail::clap_output_offset(sample_offset) (clamps a negative offset up
to 0, since header.time is unsigned) — the same "offset N in → offset N out"
contract AU v2 and VST3 share, defined once in
core/format/include/pulp/format/detail/midi_out_offset.hpp and pinned by
test/test_midi_out_offset_parity.cpp. Do NOT re-open-code the offset clamp.
The sysex
clap_event_midi_sysex_t.buffer field is non-owning — the backing
vector is alive for the duration of clap_process(), which is all
CLAP's push contract requires (the host copies before returning).
midi_in, midi_out, mpe_buffer, and ump_buffer are per-instance
buffers on PulpClapPlugin, not fresh locals inside clap_process().
clap_activate() reserves their storage and enables realtime capacity
limits, then clap_process() clears and reuses them every block. Past
the reserved capacity, appends must drop and increment the relevant drop
counters; they must not grow vectors under the process no-allocation
guard.
Inbound CLAP SysEx is the exception to the move-based adapter pattern:
the host gives the adapter a non-owning payload pointer, so accepting it
requires copying bytes into owned storage. clap_activate() reserves a
bounded payload pool on midi_in; MidiBuffer::add_sysex_copy() copies
into that pool on the realtime-limited process path and drops only when
the sidecar count or per-payload capacity is exceeded. Keep the happy
path and overflow case covered in test_clap_midi_events.cpp.
If you add a new inbound/outbound MIDI path, cover the overflow case in
test_clap_midi_events.cpp. If you add a test processor that
captures/forwards sysex while behind the CLAP no-alloc guard, preallocate
destination SysEx payload storage in prepare() and call
MidiBuffer::add_sysex_copy() for explicit captures. Whole-event
forwarding with midi_out.add_sysex(std::move(sx)) is supported because
pool-backed input events copy into the destination's prepared payload
pool; moving only sx.data out of midi_in is intentionally not
supported. Copying a vector payload inside process() will trip the RT
allocation trap under ASan/TSan/debug test builds.
State save / restore
Serialisation goes through the single StateStore::serialize() /
deserialize(bytes) path (in clap_entry.hpp state_ext). Format is
the Pulp binary blob — identical bytes across CLAP / VST3 / AU, so
round-trip parity is trivial to test.
Transport context
clap_process() maps process->transport into ProcessContext when
the host supplies it:
is_playing / is_recording from the CLAP transport flags.
tempo_bpm only when CLAP_TRANSPORT_HAS_TEMPO is set.
position_beats from song_pos_beats / CLAP_BEATTIME_FACTOR when
CLAP_TRANSPORT_HAS_BEATS_TIMELINE is set.
position_samples from song_pos_seconds / CLAP_SECTIME_FACTOR * sample_rate when CLAP_TRANSPORT_HAS_SECONDS_TIMELINE is set.
Leave it at 0 when the host omits the seconds timeline; deriving an
absolute sample position from beats + current tempo is not authoritative
in tempo-mapped projects and can create false transport jumps.
- time signature, loop range, and bar from the matching CLAP fields.
Keep position_samples non-zero when the host provides a seconds timeline;
native-core processors forward it as playhead_frames, so leaving it at the
default 0 makes CLAP-only playhead-sensitive processors think every block
starts at the song origin.
Editor
Gated on PULP_CLAP_GUI; for desktop CLAP, both the shared
pulp-format adapter TU and the per-plugin entry TU must compile with
the same value. Lifecycle flows through
pulp::format::ViewBridge: gui_create → bridge->open(), the host
then calls gui_set_parent(window) → editor_host->attach_to_parent +
bridge->notify_attached(), gui_destroy → bridge->close(). See the
view-bridge skill for the full contract — the CLAP adapter is the
reference implementation for the "open, then notify_attached after
host has attached" protocol.
Window API negotiation is compile-time platform-switched to Cocoa /
Win32 / X11.
Proportional resize with aspect lock
gui_can_resize returns true. gui_get_resize_hints advertises
preserve_aspect_ratio=true with aspect_ratio_{width,height} set to
the editor's preferred design size, so DAWs (Bitwig, Reaper, Live, …)
lock the corner-drag to the design aspect. gui_adjust_size snaps the
requested rectangle to the design aspect (largest box at the design
aspect that fits within the request), then clamps to plugin min/max
constraints.
gui_create calls host->set_design_viewport(design_w, design_h) so
the host scales content to fit the resized window via a paint-time
canvas transform — the JS/Yoga tree still thinks it's at design size,
and the existing gui_set_size → host->set_size(...) path resizes
the surfaces without re-laying out. This is the proportional+locked
behavior the standalone host already had (same design-viewport contract as WindowHost); AU v2
cannot offer it because the DAW resizes the returned NSView directly
with no host-side gui_can_resize analogue. Cross-format design lives
in the view-bridge skill.
gui_create calls
pulp::format::decide_gpu_host(*bridge) so a Skia/Dawn/scripted editor
auto-selects the GPU PluginViewHost, wires the per-vsync scripted idle pump
(make_scripted_idle_pump), and screams via warn_if_unexpected_cpu_fallback
on a silent CPU fallback. CLAP's gui_set_size already resizes the bridge +
host, so no extra resize seam is needed (unlike AU v2). Full contract: the
view-bridge skill's "GPU view host auto-selection" section.
ARA companion factory
clap_get_extension(kClapAraFactoryExtension) lazily creates the
plugin's AraDocumentController on first query, then returns the
companion factory pointer. Only instantiates when the Processor
overrode create_ara_document_controller() — plugins that don't
participate in ARA return nullptr naturally. See the ara skill.
Bypass routing — auto-detected
CLAP doesn't model "bypass" as a first-class extension the way VST3
(kIsBypass) or AU v3 (AUAudioUnitBypass) do — hosts treat a
plugin-declared "Bypass" parameter as the on/off lane. The adapter
auto-detects that parameter at clap_init and short-circuits
clap_process to pass-through (in→out for effects, zero-fill for
instruments) when the cached parameter's current value is >= 0.5,
without invoking Processor::process. MIDI output stays empty so
bypassed MIDI FX don't leak notes — same contract the VST3 and AU v3
adapters honour.
Param designation (declared bypass) + trigger params. The bypass
parameter is found through the shared pulp::state::is_bypass_param
contract, not a re-implemented name/range check. A Processor author can
declare ParamInfo::designation = ParamDesignation::Bypass to mark a
param as the bypass control independently of its name — the legacy
boolean-"Bypass" name/range heuristic remains the fallback when no
designation is declared, so existing plugins are unchanged. The adapter
also calls StateStore::reset_triggers_rt() to auto-reset trigger /
momentary params (is_trigger, or a ParamDesignation::Reset
"reset/panic" control) back to their default. The call sits AFTER the
bypass if/else (not inside the non-bypass branch), so it is a
single-exit invariant: a trigger raised while bypassed still settles
this block instead of firing late. It runs before the out_events scan,
so the host records the settle. Input param events are applied to the
store before the bypass check, so a host-raised trigger is observed-then-
settled within the same block whether or not the plugin is bypassed.
Latency / tail change notifications
A Processor flags a mid-render latency or tail change via
flag_latency_changed() / flag_tail_changed() (RT-safe atomic
store-release). Don't call clap_host_latency->changed() from
process() directly — the spec requires that on the main thread.
CLAP wiring (the most involved of the four adapters):
create_plugin() captures the clap_host_t* pointer for later
request_callback() use.
process() peeks via latency_change_pending() /
tail_change_pending() (non-mutating — does NOT drain the edge)
and, if either is set, calls host->request_callback() to ask
the host for a main-thread callback.
clap_on_main_thread() then drains via
consume_latency_changed_flag() / consume_tail_changed_flag()
and calls clap_host_latency->changed() /
clap_host_tail->changed().
The peek-vs-consume split exists specifically for CLAP — VST3 / AU
v3 / AU v2 drain in-line because their host APIs are safe from the
audio callback path. Don't collapse the two helpers into one if you
add another adapter that needs the same edge.
Preset loading
clap_plugin_preset_load is exposed only when the Processor builds a
PresetManager during clap_init (driven by
desc.manufacturer/desc.name). Today only
CLAP_PRESET_DISCOVERY_LOCATION_FILE is honoured; bundle- and plugin-
internal preset sources are ignored and return false.
Remote controls and dynamic extension routing
clap_entry.hpp::get_extension(plugin, id) handles static extensions first,
then delegates to clap_adapter::clap_get_extension(plugin, id) for
instance-owned extensions such as preset-load, ARA, and
clap.remote-controls/2. Any new adapter-owned extension must be reachable
through the real host callback path, not only through direct unit-test calls
to the adapter helper.
Remote-control page generation lives in clap_remote_controls.cpp. It is a
main-thread, metadata-only extension: build pages from StateStore parameter
and group metadata, do not touch DSP state, and do not add audio-thread work
to clap_process(). The current pages expose ungrouped params as Main,
then grouped params in registered group order, eight params per page, with
unused slots set to CLAP_INVALID_ID. Keep the stable
CLAP_EXT_REMOTE_CONTROLS id and the compatibility id routed together unless
the CLAP headers remove the older spelling.
Gotchas
Sidechain data32 can be null — guard before routing
A host may report audio_inputs_count > 1 but hand the adapter a null
data32 pointer (bus deactivated). A loose translation of "bus exists
→ publish sidechain" hands the Processor a BufferView over garbage.
The guard in clap_process demotes the whole sidechain bus to "not
supplied" if any per-channel pointer is null — do not remove it.
if (sc_bus.data32) {
sc_channels = std::min(static_cast<int>(sc_bus.channel_count), kMaxChannels);
for (int ch = 0; ch < sc_channels; ++ch) {
self->sidechain_ptrs[ch] = sc_bus.data32[ch];
if (!self->sidechain_ptrs[ch]) { sc_channels = 0; break; }
}
}
The VST3 adapter carries the same guard for null bus channel pointers.
Mirror both whenever reshaping the sidechain path.
Aux output bus data32 can be null too — guard the pre-zero loop
A deactivated secondary output bus can report channel_count > 0
while data32 == nullptr, exactly like the sidechain case. The aux
pre-zero loop in clap_process runs before the routing loop's own
data32 guard, so it must if (!bus.data32) continue; before indexing
bus.data32[ch] — otherwise a host that presents an inactive aux output
null-derefs on the audio thread. Both the pre-zero loop and the routing
loop guard the bus pointer; keep both.
Reset modulation offsets every buffer
store.reset_all_mod() is the first line of clap_process(). If you
refactor the process prologue, keep it first — otherwise stale
PARAM_MOD offsets from a previous block leak into the next one and
the plugin's DSP drifts away from the host's expected modulated value.
Found during CLAP modulation bring-up.
param_snapshot is per-buffer, not cached
The snapshot is taken after host events are applied but before
processor->process(). The diff compared against current values at
the end is what the adapter emits as PARAM_VALUE out-events. If you
optimise this into a persisted snapshot you will drop plugin-side
param edits that happen at block boundaries.
Secondary output buses must be zero-filled
Multi-out instruments that don't route to bus ≥ 1 leave those output
buffers whatever the host's last tenant wrote. The adapter zeroes
every secondary output channel every block — do not skip this even for
"only bus 0 used" plugins; some hosts reuse memory across plugin
slots.
constant_mask on an output bus is inherited, not given to you clean
clap_audio_buffer_t::constant_mask is the plugin's promise about the
block it just wrote: bit N set means "every frame of channel N equals
sample 0", and a host may act on it by reading one sample instead of the
block. The host never clears it, and CLAP explicitly permits in-place
buffers — so an output bus can arrive still carrying the mask an upstream
plugin set on the input it aliases. An adapter that never writes the mask
inherits that lie, and a plugin whose output varies gets read back as one
held sample.
clap_process therefore clears constant_mask on every output bus
(not just the routed ones) after the Processor call and after the bypass
short-circuit. Zero means "no channel is known constant", which is always
true and always safe. Never set a bit speculatively: a set bit obliges you
to have actually filled the channel with that constant.
The symptom is loudest on CV-rate outputs — where the variation is the
signal, so the whole plug-in reads as a frozen DC level — and can be
invisible on audio, because hosts are free to ignore the mask entirely.
Never touch the input bus's mask; that one belongs to the host.
ARA companion factory is returned only after Processor exists
clap_get_extension may be called before clap_init populates
self->processor. The current impl returns the static companion
factory pointer early; it only lazily instantiates the
AraDocumentController once self->processor != nullptr. If you
refactor this path, preserve that ordering — eagerly constructing the
controller at extension-query time triggers the
create_ara_document_controller() virtual before the Processor is
alive.
UMP sidecar: native + synthesised, both always run
The adapter handles every host shape: pure MIDI 1.0 (CLAP_EVENT_NOTE_*
CLAP_EVENT_MIDI), pure MIDI 2.0 (CLAP_EVENT_MIDI2), and mixed
(notes via NOTE_*, CCs via MIDI2 — common in real DAWs).
- At the top of every
clap_process() block, ump_buffer.clear()
runs when ump_enabled. This is load-bearing — keep the clear
up-front so the buffer reflects only the current block.
- During event decode,
CLAP_EVENT_MIDI2 packets are appended
directly to self->ump_buffer. host_delivered_ump is retained as
observability only; it must not gate MIDI 1.0 synthesis.
- After the decode loop,
midi1_to_ump(midi_in, self->ump_buffer)
ALWAYS runs when ump_enabled. Skipping synthesis when the host
delivered any MIDI2 silently drops the note half of mixed streams from
the UMP buffer. CLAP guarantees a spec-conformant host won't redundantly
encode the same logical event in two transports, so unconditional
synthesis doesn't double-deliver.
The UMP buffer shape lives in core/midi/include/pulp/midi/ump_buffer.hpp
and the CLAP adapter's ump_buffer sidecar.
CLAP event types are enumerators, not preprocessor macros
When gating on a new CLAP event type, do not write
#ifdef CLAP_EVENT_MIDI2 — CLAP_EVENT_MIDI2 is a C enumerator value,
and #ifdef on an enum always evaluates false. Use
#if defined(CLAP_VERSION_GE) && CLAP_VERSION_GE(1, 1, 0) (or the
release that introduced the event) instead. Same trap applies to any
future CLAP_EVENT_* additions — the CLAP header does not define
them as macros. Use the guard shape in core/format/src/clap_adapter.cpp.
Param text parsing must be locale-independent — but not via std::from_chars<float>
params_text_to_value and params_value_to_text must be immune to a
comma-decimal global host locale (a DAW that called setlocale): a typed-in
"0.5" must never parse as 0.0, and a formatted value must never emit
"0,5". Use std::to_chars for formatting (always C-locale). For parsing,
the obvious choice — std::from_chars — is a trap for floats: libc++
leaves the floating-point from_chars overloads =deleted on some toolchains
(notably the github-hosted macos-15 sanitizer image), so
std::from_chars(first, last, a_double) hard-fails the Sanitizer Tests
build with "call to deleted function 'from_chars'" while the Mac Studio
macos gate (which has the overload) stays green — the break hides on the
advisory lane. Integer from_chars is fine everywhere. For the float value,
parse through pulp::format::detail::parse_double_c_locale
(core/format/include/pulp/format/detail/locale_independent_float.hpp), a
C-locale strtod wrapper shared with the .pulpset parser.
GUI layout must match across CLAP TUs
Do not use #ifdef PULP_CLAP_GUI for CLAP GUI fields or extension
dispatch; use #if defined(PULP_CLAP_GUI) && PULP_CLAP_GUI. The WCLAP
path may define PULP_CLAP_GUI=0, and #ifdef treats that as enabled.
The desktop shared adapter and the per-plugin entry must agree on
whether GUI fields exist in PulpClapPlugin, or later lifecycle fields
shift and hosts crash when opening or activating the editor.
ARA CLAP lives outside CLAP_EXT_*
The ARA companion factory is keyed on
kClapAraFactoryExtension (Pulp-private identifier), not one of CLAP's
reserved CLAP_EXT_* strings. Don't rename it; other Pulp + ARA hosts
already search for that exact key. Defined in pulp/format/ara.hpp.
clap-validator is optional — fallback is dlopen
pulp validate (tools/cli/cmd_validate.cpp) runs
clap-validator validate … when installed, otherwise falls back to a
plain dlopen check. CI lanes without clap-validator still exercise
the "plugin loads" path; full spec conformance requires the validator
binary.
AAX-parity sweep
AAX and CLAP share the same sysex-sidecar pattern. When you change the
CLAP sysex accumulator, the AAX adapter
(core/format/src/aax_runtime.cpp) and the VST3 / AU halves need to
stay in sync — see the memory note on AAX-parity.
Filter in-events by space_id in every dispatch loop
Every clap_input_events dispatch loop in the adapter MUST check
hdr->space_id == CLAP_CORE_EVENT_SPACE_ID at the top and continue
on mismatch. Non-zero namespaces belong to third-party extensions
Pulp doesn't implement, and their type IDs may alias core type IDs
(e.g. a fictional extension's event type 5 could be mistaken for
CLAP_EVENT_PARAM_VALUE and mutate the param store). clap-validator
param-set-wrong-namespace exercises this with space_id = 0xb33f.
Covered sites today:
clap_adapter.cpp process() param/gesture loop
clap_adapter.cpp process() note/MIDI loop
clap_entry.hpp params_flush() path
If you add a third in-events dispatch (e.g. a transport-event loop,
or a new extension's callback), add the same guard. Test pattern:
test_clap_entry.cpp → "CLAP params_flush ignores events outside
the core namespace [issue-743]" (the bracketed token is the Catch2 test tag).
clap_ostream::write may short-write — loop state_save
state_save (in clap_entry.hpp) MUST loop on stream->write()
until the full payload is delivered. Per CLAP spec, a single write
call may return fewer bytes than requested even on success; only
negative or zero returns are errors. clap-validator's
state-reproducibility-flush exercises this by capping every write
at 23 bytes.
Symmetric note: state_load's stream->read loop was already
correct; the bug was only on the write side.
on_non_realtime_tick() fires in native CLAP, not just WebCLAP
Processor::on_non_realtime_tick() / non_realtime_tick_pending() exist so a
processor with no worker thread of its own can get expensive control-driven
work (decode, resample, FFT-plan, allocate) off the audio thread. The motivating
host is the browser — a WAM lives entirely inside an AudioWorklet and a WebCLAP
module has no std::thread — but the CLAP adapter calls the hook
unconditionally, so a native CLAP plugin gets it too:
clap_on_main_thread() calls on_non_realtime_tick() every time.
state_load() calls it after deserialize() — a restored state can name a
different derived source than the live one, and a worker-less processor has no
thread to notice; without this the audio thread renders the OLD derived state
for the rest of the session.
clap_process() peeks non_realtime_tick_pending() each block and calls
host->request_callback() when set — the same mechanism already used for
latency/tail changes. This is the only way out of the audio thread in CLAP,
because CLAP delivers parameter changes as events inside process().
Two things follow that are easy to get wrong:
- Do not write
on_non_realtime_tick() assuming "this only runs in a
browser." It runs in Bitwig and Reaper. A processor that already has a real
worker thread should do nothing here, or the worker and the host race.
non_realtime_tick_pending() must be realtime-safe — it is called from
process() every block. Read atomics and compare. No locks, no allocation.
VST3, AU, and the standalone host do not call the hook at all. A processor
that depends on it for correctness must also have a worker (or do the work in
prepare()), or it will never reconcile in those formats. Both methods are
appended at the end of the Processor vtable to keep vtable ordering
additive-only (node_abi_gate) — keep it that way when adding more.
Validation recipes
Build and smoke a CLAP bundle with the Pulp CLI:
./build/pulp build
./build/pulp validate
Direct clap-validator usage (matches what cmd_validate.cpp invokes):
clap-validator validate "$(pwd)/build/path/to/MyPlugin.clap"
cargo install clap-validator
CI's fallback when clap-validator is not on the path is a dlopen
check — load the bundle's entry symbol (clap_entry) and verify the
factory hands back a valid descriptor. See
test/test_clap_entry.cpp for the in-repo equivalent.
pulp build --test runs validation before allowing
pulp build --install to write into
~/Library/Audio/Plug-Ins/CLAP/. Do not --skip-validation a CLAP
build before a DAW scan — a crashing entry point takes the DAW down
with it.
Validator runs must disable editor creation
clap-validator can query CLAP_EXT_GUI and call GUI callbacks even
when the test's intent is non-visual validation. Run validator automation
with PULP_DISABLE_PLUGIN_EDITOR=1 PULP_HEADLESS=1 PULP_TEST_MODE=1.
Under those guards, Pulp hides CLAP_EXT_GUI from get_extension() and
the GUI callbacks fail closed if a host cached the extension pointer.
Host-API contract pinned by test/test_clap_host_validation.cpp
Real-DAW validation (Bitwig, Reaper, FL Studio, Studio One) requires
a license + manual install, so the CI proxy is
test/test_clap_host_validation.cpp. It pins the four contracts hosts
have historically broken on:
- Plugin id + parameter id + range stability across instances.
CLAP_EVENT_PARAM_MOD does NOT bleed across blocks — the adapter
calls store.reset_all_mod() at the top of every process().
- Non-core event spaces (
hdr->space_id != CLAP_CORE_EVENT_SPACE_ID)
are ignored, matching clap-validator's
param-set-wrong-namespace expectation.
state.save → state.load → state.save produces byte-equivalent
output (Studio One project-recall determinism).
When changing the adapter's event dispatch or param surface, run
pulp-test-clap-host-validation first — it will catch the regression
before a host scan does.
Cross-references
.agents/skills/view-bridge/SKILL.md — editor open / attach /
close protocol; CLAP is the reference wiring for this adapter family.
.agents/skills/mpe/SKILL.md — MPE sidecar contract. CLAP is the
canonical consumer.
.agents/skills/ara/SKILL.md — ARA SDK setup and companion-factory
lifecycle.
.agents/skills/vst3/SKILL.md and .agents/skills/auv3/SKILL.md —
cross-format parity table when triaging host-specific bugs.
docs/guides/formats.md — user-facing format overview.
docs/guides/host-matrix.md — per-host ARA / CLAP compatibility
notes.
- Memory note: CHOC-first policy — prefer
choc::midi helpers over
hand-rolled MIDI decode when touching the adapter.
CLAP editor hands GpuSurface to ScriptedUiSession
clap_entry.hpp::gui_create calls
p->bridge->scripted_ui()->attach_gpu_surface(p->editor_host->gpu_surface())
right after PluginViewHost::create() succeeds. Without this, a
CLAP plugin whose UI uses Three.js or raw WebGPU JS renders black —
the JS shim silently falls back to mocks. See the view-bridge skill's
"GpuSurface plumbing into WidgetBridge" section.
Host-quirks consumption
This adapter consumes the host-quirks ledger at init: it caches
resolved_quirks(detect_host_info().type, version) once (the runtime
policy — PULP_HOST_QUIRKS env / set_host_quirk_policy() API / compile
default — applies via resolved_quirks()), then gates DAW accommodations
on those flags instead of hardcoding them.
First wired flag: clamp_latency_to_nonneg. Latency reporting routes
through the pure helper pulp::format::reported_latency_samples(raw, quirks)
(in host_quirks.hpp): a negative latency_samples() clamps to 0 when the
quirk is enforced, and passes through raw (wrapping the unsigned host field)
when PULP_HOST_QUIRKS=off. See docs/reference/host-quirks-policy.md.
synthesize_bypass_parameter pass-through
At init (clap_init / PulpAUEffect ctor), the adapter calls
pulp::format::maybe_synthesize_bypass(store, host_quirks) then detects the
"Bypass" param (shared boolean-range heuristic: name=="Bypass", step>=1,
0..1) into a cached bypass_param_id. In the audio callback (clap_process /
ProcessBufferLists) it short-circuits to a null-guarded pass-through
(copy main input → output, zero any output channel without a matching input)
and skips the Processor when the param value is >= 0.5 — mirroring the VST3
processBlockBypassed path. PULP_HOST_QUIRKS=off synthesizes nothing
(bypass_param_id stays 0). The pass-through MUST null-check each destination
channel pointer because a bus can report channels with null buffers.
Sample-accurate parameter output
CLAP's out_events requires globally ascending time across ALL event types.
The param-output drain (clap_adapter.cpp) therefore does a three-cursor
merge of output param events + MIDI shorts + sysex by ascending sample offset,
not "params then MIDI". Snapshot-diff fallback params (all at offset 0) emit
first; a per-param skip-set stops them double-reporting a param that pushed
explicit push_output_param_event() events. CLAP param values are plain
domain (double), unlike VST3 which normalizes. test_clap_midi_events.cpp
asserts the param@0, midi@8, param@16 interleave stays non-decreasing in time.
clap_process() is phase-split — the phase ORDER is the contract
clap_process() is a sequence of named phase helpers rather than one
megafunction. The split is presentational: the phases must run in exactly the
order they do, because the CLAP boundary is order-sensitive in ways the function
names do not advertise — input events must be drained before the block is
processed, and the out_events drain must stay last so the three-cursor merge
above sees every event the block produced. Reordering two phases compiles
cleanly and passes any test that only checks a single-block render.
So when adding work, add it inside the phase that owns it; do not add a new
phase between existing ones without re-reading the merge contract above. The
guard is test/test_adapter_audio_parity.cpp, which renders through the real
adapter and nulls it against the direct render — a phase-order regression shows
up there as a block-boundary difference, not as a crash.
find_param_index() in adapter_boundary.hpp is shared by the CLAP and VST3
output-param publication paths so the two cannot drift. Note it is a linear
scan, not an index — param counts are small and the scan is what both adapters
already did. If it ever becomes hot, index it for both adapters at once; a
one-sided optimization is how these two paths drifted before.
The StateStore must outlive the Processor
Processor::state() dereferences a pointer the host installs. A Processor may
follow it for its whole lifetime — from process(), from its destructor, and from
any worker thread that destructor is about to join(). So the host has to keep the
store alive until the Processor is gone.
In practice that is one rule about member order: declare the state::StateStore
before the std::unique_ptr<Processor>. Members are destroyed in reverse
declaration order, so the store then dies last. Every host in core/format had it
backwards until 2026-07; the effect is nothing at all for a Processor with no
threads, and a use-after-free on plug-in close for one with a background thread that
reads state().get_value() while the destructor walks to its join().
It crashes only on close, only sometimes, and the DAW gets the blame. The regression
test is test/test_store_lifetime.cpp; it observes the store's destruction through a
sentinel owned by a parameter's to_string closure rather than reading freed memory
and hoping the result looks wrong.
A Processor should not rely on this either: a worker thread that reads the store on
every tick is one host away from the same crash. Publish what the thread needs to
atomics from process() instead.
Param text entry + gesture threading (PARAMS region)
params_text_to_value must try ParamInfo::from_string BEFORE the generic
numeric parse. from_string is the inverse of the to_string used by
value_to_text, so a custom rendering ("quality=0.75", an enum label)
round-trips; a bare strtod would reject it. CLAP values are plain (min..max),
the same domain from_string returns — no normalization step. Guard the result
with std::isfinite and fall through to the locale-independent strtod path on
a non-finite parse. Keep the locale-independent fallback (parse_double_c_locale)
for plain-numeric params. Test: test/test_clap_entry.cpp
("text_to_value routes through ParamInfo::from_string").
- CLAP delivers
CLAP_EVENT_PARAM_GESTURE_BEGIN/END on the process/flush path
and calls store.begin_gesture/end_gesture directly. Those StateStore entry
points are main-thread-only (they forward to host undo grouping). A background
writer must use StateStore::run_gesture_on_main() — see the state notes in
binding.hpp.
Editor resize contract (aspect-lock vs free reflow)
gui_can_resize returns resizable iff view_size().min_width>0 && min_height>0 —
a plugin on the base-class default (ViewSize{w,h,0,0,0,0}) is fixed-size. Do NOT
naively "honor aspect_ratio==0" everywhere: that default returns aspect_ratio==0,
so a naive read flips every hand-authored plugin to free-reflow. The three cases the
GUI region dispatches (gui_create, gui_get_resize_hints, gui_adjust_size):
- min==0 (not resizable): keep the design-viewport pin at preferred (letterbox
backstop for off-size panes).
gui_can_resize already returns false.
- resizable +
aspect_ratio>0: viewport + aspect lock; preserve_aspect_ratio=true;
gui_adjust_size snaps to the design aspect (design-import plugins live here).
- resizable +
aspect_ratio==0: free drag — no set_design_viewport, no
set_fixed_aspect_ratio; preserve_aspect_ratio=false; gui_adjust_size clamps each
axis to [min,max] independently with no aspect snap; the root reflows via Yoga at
the host size.
The rule is free = (min>0 && aspect_ratio==0). VST3's PulpPlugView mirrors it exactly
(canResize/checkSizeConstraint). Tests: the [resize] cases in
test/test_clap_entry.cpp build a PulpClapPlugin whose bridge is constructed
directly from a controlled view_size() (the ViewBridge ctor copies view_size() into
size_hints_, so no gui_create/attach is needed to exercise the negotiation math).