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.
Installs into .claude/skills of the current project.
Are you the author of Clap?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/danielraffel-clap)
---
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:
```cpp
PULP_CLAP_BUNDLE_PLUGIN(Foo, create_foo) // register a plugin (repeat, distinct Ident)
PULP_CLAP_BUNDLE_PLUGIN(Bar, create_bar)
PULP_CLAP_BUNDLE_ENTRY() // the ONE clap_entry symbol
```
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`. A valid `StateStore` group supplies its full root-to-leaf path
in `module`, using `/` as CLAP's hierarchy separator; invalid, unknown, and
ungrouped parameters leave `module` empty. The fixed `CLAP_PATH_SIZE` buffer
is safely truncated and NUL-terminated. `CLAP_PARAM_IS_AUTOMATABLE` is set by
`state::is_automatable_param` — for an ordinary parameter that is always,
because the attribute defaults to automatable.
`CLAP_PARAM_IS_HIDDEN` and `CLAP_PARAM_IS_READONLY` come from
`state::is_hidden_param` / `state::is_read_only_param`.
`CLAP_PARAM_IS_STEPPED` is set for a discrete parameter — `ParamKind` other
than `Continuous` (`state::is_discrete_param`) — or a bypass. It is NOT
derived from `range.step`/range width; a continuous parameter may quantize
its plain value without becoming an indexed control.
`CLAP_PARAM_IS_MODULATABLE` is set by `state::is_modulatable_param`.
- `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.
**Modulation is advertised, not just decoded.** A compliant host (Bitwig) only
sends `CLAP_EVENT_PARAM_MOD` to a parameter whose info carries
`CLAP_PARAM_IS_MODULATABLE`, so the whole PARAM_MOD lane —
`clap_param_modulation_lane` → `ModulationEventQueue` /
`store.set_mod_offset` → `Processor::get_modulated()` — is dead in a real host
without the flag. `state::is_modulatable_param` decides it:
`ParamInfo::modulatable` (author opt-out, defaults **true**), then three rules
an author cannot override — not a bypass, not a discrete parameter, not an
auto-resetting trigger. `params_flags` and `clap_param_modulation_lane` both
call that one predicate, so the adapter can never invite modulation it then
drops.
**Hidden / read-only / non-automatable are a SHARED-MODEL concern, not a CLAP
one.** When CLAP could not advertise `CLAP_PARAM_IS_HIDDEN` or
`CLAP_PARAM_IS_READONLY`, the tempting read was "a CLAP adapter gap". It was
not: VST3 and AU could not express them either, because `ParamInfo` had no
field to express. The same hole appeared in three adapters at once because all
three project one model. The tell was that Pulp's own CLAP *host*
(`plugin_slot_clap.cpp`) had been reading those two flags off hosted plugins
for a long time — Pulp consumed a concept it could not produce. **Before adding
a per-format flag, check whether the neighbouring formats are equally blind; if
they are, the fix belongs in `core/state`, not here.**
`ParamInfo::hidden` / `read_only` / `automatable` now carry it, read through
`is_hidden_param` / `is_read_only_param` / `is_automatable_param` — the same
one-predicate-per-attribute shape as `is_modulatable_param`, for the same
reason. Two consequences that are easy to get wrong:
- **Read-only implies not automatable and not modulatable.** Automation and
modulation are both host writes, so advertising `READONLY | AUTOMATABLE`
would be self-contradictory. `is_automatable_param` and
`is_modulatable_param` both refuse a read-only parameter; do not re-derive
either from the raw field.
- **A bypass control can never be hidden or frozen**, whatever the author
declares — a host surfaces bypass in its own chrome, and hiding it makes the
plugin un-bypassable. `is_hidden_param` / `is_read_only_param` enforce that.
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()`.
Editor-originated automation takes a separate bounded path. `clap_init()`
installs the store's gesture callbacks plus an inline value listener; they push
`BEGIN`, `VALUE`, and `END` records, in call order, into the per-instance
`outbound_param_events` SPSC queue and call the host params extension's
`request_flush()`. Both `clap_process()` and `params.flush()` drain that queue,
so a drag remains visible to the host when transport is either running or
stopped. CLAP guarantees those two consumers do not run concurrently. Keep
these invariants when changing the path:
- The sole producer is the CLAP init/main thread. Audio/background store writes
stay on the existing explicit-event or snapshot-diff path; letting them push
here turns the SPSC queue into an unsafe multi-producer queue.
- Host-originated writes are guarded while `params.flush()` applies them, so
they are never echoed back as plugin automation.
- Every emitted editor record uses `header.time = 0` and drains before the
snapshot fallback and sample-offset merge, preserving global ascending time.
- If `out_events->try_push()` rejects a record, retain that exact head record
and retry it before later records. Dropping a rejected `BEGIN` or `END` can
leave host automation permanently unbalanced.
- The queue is allocated/bounded before processing and must remain
allocation-free at both drains. A queued value marks the snapshot skip-set
only after the host accepts it, preventing duplicate `PARAM_VALUE` output.
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.
### Note-port dialects and voice-info
`note_ports_get` advertises what the adapter can **decode**. Every plugin gets
`CLAP_NOTE_DIALECT_CLAP | CLAP_NOTE_DIALECT_MIDI`; the input port additionally
gets `CLAP_NOTE_DIALECT_MIDI_MPE` when `effective_capabilities().supports_mpe`
and `CLAP_NOTE_DIALECT_MIDI2` when `supports_ump`. Read the capabilities
through `effective_capabilities()`, never the bare legacy fields — the node
capability sidecar carries the same claim and is OR'd there.
The added dialects are input-only on purpose: `push_outbound_short` emits
`CLAP_EVENT_MIDI` exclusively, so claiming them on the output port would
promise packets Pulp never sends.
`CLAP_EXT_VOICE_INFO` is published only when `PluginDescriptor::voice_count`
is non-zero, and reports that count as both `voice_count` and
`voice_capacity`. A host told nothing assumes mono and collapses per-voice
modulation to a global mapping, so a polyphonic instrument that leaves the
descriptor field at its 0 default silently loses per-voice modulation in
Bitwig. `CLAP_VOICE_INFO_SUPPORTS_OVERLAPPING_NOTES` is deliberately NOT set:
that bit promises the plugin separates overlapping notes by `note_id`, and the
adapter lowers CLAP note events to MIDI 1.0 keyed on channel + key, discarding
`note_id` (`ev.note_id = -1` on the outbound side). Set it only if that
lowering changes.
### 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`, constructed from
`ViewBridge::Options::hosted_editor()` (never a hand-assembled `Options` — see
the `view-bridge` skill for why, and for the test that enforces it):
`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 editor idle pump
(`make_editor_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):
1. `create_plugin()` captures the `clap_host_t*` pointer for later
`request_callback()` use.
2. `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.
3. `clap_on_main_thread()` then drains via
`consume_latency_changed_flag()` / `consume_tail_changed_flag()`.
Tail publishes straight away as `clap_host_tail->changed()`.
**Latency does not** — see below.
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.
#### Latency needs a RESTART, not just `changed()`
`clap/ext/latency.h` carries two requirements, and only reading the
first line of the comment gets you one of them:
```c
// Tell the host that the latency changed.
// The latency is only allowed to change during plugin->activate.
// If the plugin is activated, call host->request_restart()
// [main-thread & being-activated]
void(CLAP_ABI *changed)(const clap_host_t *host);
```
So `changed()` is **not** a general-purpose "my latency moved" push.
It is annotated `[main-thread & being-activated]` — legal only from
inside `clap_activate()` — and the reported latency may only move in
that same window. An **active** plugin that merely calls `changed()`
is telling the host a number the host is not sanctioned to act on: a
strict host ignores it and stays on the stale value forever, a lenient
one re-compensates mid-session against a latency the plugin is not yet
allowed to have. Both misalign plugin-delay compensation silently.
The adapter therefore branches on activation state:
| State | What happens |
|---|---|
| **Active** | `clap_host->request_restart()`, then publish `changed()` from inside the `clap_activate()` that results |
| **Inactive** | Publish `changed()` immediately — nothing is rendering, no compensation is in force, and the next activate re-reads `get()` regardless |
Three invariants that are easy to break:
- **`is_active` is adapter state, not processor state.** `PulpClapPlugin::is_active`
is set at the end of `clap_activate()` and cleared first thing in
`clap_deactivate()`. There is no processor-side equivalent.
- **`latency_restart_pending` coalesces AND defers, and must survive
`clap_deactivate()`** — the deactivate it lives across is the one it
asked for. A host may take arbitrarily long to honour a restart or
decline it entirely, so one outstanding request has to cover any
number of further edges; re-asking per edge is a request storm.
- **Publish after `prepare()`, at the END of `clap_activate()`.** The
host answers `changed()` by calling `clap_plugin_latency->get()`;
publish before the processor is prepared and it reads the stale value
back, which is the original bug wearing a restart.
`clap_host->request_restart` is `[thread-safe]` per `clap/host.h`, so a
direct audio-thread call would be legal — but the edge is routed through
the existing `request_callback` → `clap_on_main_thread()` hand-off anyway,
because `changed()` is *not* thread-safe and keeping both halves on one
path keeps the ordering obvious.
**The other three formats have no equivalent gap.** VST3 already calls
`restartComponent(kLatencyChanged)`, which is that API's deactivate/
reactivate request. AU v2 (`PropertyChanged(kAudioUnitProperty_Latency)`)
and AU v3 (KVO on `latency`) expose latency as an observable property
with no restart precondition, so an in-line notification is complete
there. CLAP is the only one of the four whose spec names a restart.
### 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
### Decoding an event is not the same as advertising that you accept it
The adapter's decode paths are written before, and independently of, the
extension metadata that tells a host to use them — so a lane can be complete,
tested, and still dead in every real host. Three of these shipped at once and
none was visible from the decode side: `CLAP_EVENT_PARAM_MOD` was fully
decoded while `params_flags` never set `CLAP_PARAM_IS_MODULATABLE`;
`CLAP_EVENT_MIDI2` was decoded and routed to the UMP sidecar while
`note_ports_get` advertised a hardcoded `CLAP | MIDI`; and a polyphonic
instrument published no `voice-info` at all. In each case a unit test that
drove the adapter helper directly passed, because a test supplies the event
itself and a host does not.
When you add or change a decode path, ask what makes a host send it, and pin
BOTH halves in one test: read the advertisement through the extension the host
reads, then send only the event that advertisement licenses. The reachability
tests in `test_clap_midi_events.cpp` and `test_clap_param_flags.cpp` are
written in that order on purpose.
### 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.
```cpp
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).
1. 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.
2. 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.
3. 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.
### MPE reset ownership spans adapter and Processor
`clap_reset()` clears the adapter-owned MPE tracker immediately and carries
`ProcessContext::reset_requested` to the next block that actually reaches
`Processor::process()`. A bypassed block or a block that loses the
state-restore gate must not prepare the MPE sidecar or consume that request:
doing either advances tracker identity while the Processor's voice allocator
did not run. On deactivation, call `Processor::release()` before resetting the
sidecar so an MPE Processor can clear its allocator while the corresponding
adapter identities are still at the same lifecycle boundary. See the `mpe`
skill for the processor-side `release()` and reset contract.
### 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 `=delete`d 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:
1. **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.
2. **`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:
```bash
# Build everything, then validate
./build/pulp build
./build/pulp validate # runs clap-validator if installed
```
Direct `clap-validator` usage (matches what `cmd_validate.cpp` invokes):
```bash
# macOS / Linux
clap-validator validate "$(pwd)/build/path/to/MyPlugin.clap"
# Install if missing
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:
1. Plugin id + parameter id + range stability across instances.
2. `CLAP_EVENT_PARAM_MOD` does NOT bleed across blocks — the adapter
calls `store.reset_all_mod()` at the top of every `process()`.
3. Non-core event spaces (`hdr->space_id != CLAP_CORE_EVENT_SPACE_ID`)
are ignored, matching `clap-validator`'s
`param-set-wrong-namespace` expectation.
4. `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.
**Updated (WAH-1): subscribe, do not sample.** The one-shot
`attach_gpu_surface(host->gpu_surface())` read this section used to
describe is GONE. It only worked on hosts that build their surface in
the constructor; the Windows host creates its Dawn surface inside
`attach_to_parent()`, so the read returned `nullptr` forever and every
Windows editor fell back to mock WebGPU. Adapters now call the shared
helper once:
```cpp
gpu_surface_binding_ = bind_gpu_surface(*host, bridge->scripted_ui(),
gpu_decision, "CLAP");
```
It follows `PluginViewHost::observe_gpu_surface()`, forwards creation
AND teardown into the session, and owns the CPU-fallback diagnostic
(which no longer fires on a pre-attach `pending` state). Reset the
returned subscription in the editor-close path, before the bridge that
owns the session is destroyed.
## 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).
## Editor-INITIATED resize (`request_editor_resize` → `gui.request_resize`)
The resize contract above is HOST→plugin (the DAW dragged the window). The
opposite direction — the editor asking the DAW to resize the plugin window —
runs through `Processor::request_editor_resize(w, h)`. `gui_create` installs the
handler under the CLAP editor instance's owner key (and `gui_destroy` clears
that same owner BEFORE resetting
`editor_host`/`bridge`, so a late call never touches freed state). The handler:
`bridge->set_preferred_size(w,h)` (so `gui_get_size`/`gui_get_resize_hints`
report the new shape), `editor_host->set_design_viewport(w,h)` +
`set_fixed_aspect_ratio(w/h)`, then
`host->get_extension(host, CLAP_EXT_GUI)->request_resize(host, w, h)`. Returns
that call's bool (a host may refuse). This is what lets a mode switch (e.g. a
compact "player" view) shrink the window and change its aspect at runtime — the
design-viewport re-pin keeps content filling the new size with no letterbox. See
the `view-bridge` skill for the cross-format seam.
## `state.load` runs under the state-restore gate
`Processor::deserialize_plugin_state()` is documented as running "with the
audio thread stopped", but CLAP hosts call `state.load` on the main thread
while the plug-in is active and rendering. `clap_entry.hpp`'s `state_load`
therefore holds `PulpClapPlugin::state_restore_gate` across the deserialize;
acquiring it proves no audio callback is inside `clap_process()`.
Two consequences when working in this adapter:
* The render side takes the gate BEFORE `clap_phase_prepare_sidecars`, not just
around the `processor->process()` call, because the sidecar phase already
reaches into the Processor (`mpe.run(*self->processor, …)`). A gate taken only
around the process call would leave that phase racing a restore.
* On contention the block routes through `clap_phase_bypass_passthrough` — the
same degradation as an engaged Bypass, since both mean "the Processor did not
run this block". If you add a phase that touches the Processor, put it inside
the gated region or it will run against a half-restored plug-in.
The gate is released before `on_non_realtime_tick()` so a processor that
reconciles derived state there cannot stall the audio thread for the length of
that work.
## Note names (`clap.note-name`)
`Processor::note_names()` is published through `clap_note_name.cpp`. The
extension is offered from `clap_get_extension` UNCONDITIONALLY — do not gate it
on the list being non-empty. Hosts query `get_extension` once at init, so a
sampler that names notes only after loading a kit would be locked out for the
whole session if the empty-list case refused the extension.
Both entry points are `[main-thread]` per the extension, and `note_names()` is a
main-thread call, so re-reading the processor there needs no gate.
`flag_note_names_changed()` rides the existing `request_callback` set alongside
the latency / tail pending flags, and the `clap_host_note_name->changed()` push
happens in `clap_on_main_thread()` — never from `process()`.
### Tracing attaches for this format now (WAH-4)
Perfetto tracing used to be wired into **VST3 only**. A capture of a CLAP
session recorded nothing while `Tracing`'s API described itself as
process-global — so an empty `.pftrace` looked like an environment problem
rather than a missing call.
This adapter now holds a `runtime::ScopedTracingAttachment` (`PulpClapPlugin::tracing`). Two
things follow:
- **It is RAII, not a hand-balanced attach/detach pair.** A leaked attachment
is not benign: the `.pftrace` is only written by the FINAL detach, so one
unbalanced instance means the capture silently produces nothing.
- **Declaration order is load-bearing.** It must outlive every span this
instance can emit, so it is declared to destroy LAST. The final detach also
cancels and JOINS the auto-flush timer, which is what makes plug-in module
unload safe — a detached timer thread that wakes after `FreeLibrary` /
`dlclose` runs freed code.
No-op unless the build is configured `PULP_TRACING=ON`.
## The bundle carries its own icon, and the plist key is what makes it work
`pulp_app_icon(<target>_CLAP ...)` brands `.clap`: it copies the
`.icns` into `Contents/Resources/` and sets `MACOSX_BUNDLE_ICON_FILE`, which
CMake substitutes into the bundle's `Info.plist` at generate time.
The load-bearing half is easy to miss. That substitution needs a
`CFBundleIconFile` key in `tools/cmake/PulpInfoPlist.clap.in` to land in.
Without it the copy still happens and the property is still set, so nothing
errors — the bundle just comes out unbranded. If an icon does not appear,
check the template for the key before suspecting the helper.
Prefer `ICNS` over `SOURCE` for a mark with fine detail. `SOURCE` derives
every size from one PNG with `sips`, whose Lanczos kernel overshoots on hard
edges: a feature one or two device pixels wide at 16x16 smears into its
neighbours and the bundle edge picks up a bright halo. Render each size on
its own pixel grid and pass the finished `.icns`.