aax
無料Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.
日本語の概要は準備中です。原文の説明を表示しています。
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.
インストール方法を見るインストールする前に、エージェントに与えられる指示の中身を確認できます。
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.
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(…)).audio-ports, note-ports,
params, state, gui, preset-load, ARA companion factory, …).clap-validator pass regresses.set_mpe_input / set_ump_input.| 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.
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) // 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:
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.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.registry.hpp,
see the auv2 skill) at init for cross-format enumeration / editor assets.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).
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.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.
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.
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:
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.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:
params.flush() applies them, so
they are never echoed back as plugin automation.header.time = 0 and drains before the
snapshot fallback and sample-offset merge, preserving global ascending time.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.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.
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.
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_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.
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.
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.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.
clap.render)The adapter offers CLAP_EXT_RENDER from clap_get_extension():
has_hard_realtime_requirement() returns false (a Pulp processor can always
render offline) and set() stores the mode in PulpClapPlugin::render_offline
(atomic — hosts may switch while active). clap_phase_build_context() maps it to
ProcessMode::Offline + RenderSpeedHint::FasterThanRealtime. set() refuses
any value other than CLAP_RENDER_REALTIME / CLAP_RENDER_OFFLINE and keeps the
current mode. Test: test_clap_midi_events.cpp [clap][render][offline].
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.
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.
Build the host's PluginViewHost::Options with
editor_host_options(bridge, gpu, size) (gpu_host_select.hpp), never field
by field: it carries the plug-in's declared background
(ViewBridge::editor_background_rgb()), which the host paints on its backing
layer and under the tree whenever there is no document frame. A
hand-built Options silently drops it and this format opens on the framework
navy while the others open on the plug-in's colour (view-bridge, "The first
frame must already look like the plug-in").
gui_set_parent() calls ViewBridge::prepare_first_frame(*editor_host) after
attaching and notify_attached(): the document mounts inside the call and its
first frame is presented with its Core Animation transaction, so a host whose
window is already on screen (most CLAP hosts call set_parent then show on
a visible window) never composites the empty editor or a vsync of the backing
colour (view-bridge, "Editor open"). gui_create() stays cheap: it only
builds the host and its size.
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.
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.
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().
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.
changed()clap/ext/latency.h carries two requirements, and only reading the
first line of the comment gets you one of them:
// 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.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.
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.
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.
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.
data32 can be null — guard before routingA 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.
data32 can be null too — guard the pre-zero loopA 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.
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 cachedThe 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.
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 cleanclap_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.
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.
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).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.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.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_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.
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.
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.
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.
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 dlopenpulp 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 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.
space_id in every dispatch loopEvery 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 loopclap_adapter.cpp process() note/MIDI loopclap_entry.hpp params_flush() pathIf 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_savestate_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 WebCLAPProcessor::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:
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.
Build and smoke a CLAP bundle with the Pulp CLI:
# 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):
# 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.
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.
test/test_clap_host_validation.cppReal-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:
CLAP_EVENT_PARAM_MOD does NOT bleed across blocks — the adapter
calls store.reset_all_mod() at the top of every process().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.
.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.choc::midi helpers over
hand-rolled MIDI decode when touching the adapter.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:
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.
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.
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.
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 contractclap_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.
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.
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_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.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):
gui_can_resize already returns false.aspect_ratio>0: viewport + aspect lock; preserve_aspect_ratio=true;
gui_adjust_size snaps to the design aspect (design-import plugins live here).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).
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 gateProcessor::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:
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.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.
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().
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:
.pftrace is only written by the FINAL detach, so one
unbalanced instance means the capture silently produces nothing.FreeLibrary /
dlclose runs freed code.No-op unless the build is configured PULP_TRACING=ON.
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.
Right after the processor is created and its parameters defined, the adapter
calls request_editor_prewarm(*processor) (clap_init(), on the host main thread). It hands
Processor::editor_prewarm() (the scripts and materialized documents the
editor evaluates on every open) to the background worker the view layer
installs, once per plug-in bundle per process, never under a headless/CI
environment or PULP_EDITOR_PREWARM=0. Keep the call after
define_parameters() and outside any audio-thread path; a processor with no
editor, or nothing to prewarm, costs one virtual call. The editor itself does
not change: an open that races the worker waits for the in-flight result
instead of compiling again. Details: view-bridge, "What the host shows while
it waits".
まだレビューはありません。使ってみた感想をお寄せください。
概要と使いどころ
Optional AAX support for Pulp, including developer-supplied Avid SDK setup, CMake enablement, DigiShell/AAX Validator workflows, and local AAX builds on macOS or Windows.
日本語の概要は準備中です。原文の説明を表示しています。
Configure, implement, and test Pulp's optional desktop Ableton Link tempo-sync adapter while preserving the developer-supplied SDK, licensing, realtime, latency-compensation, and no-install boundaries.
日本語の概要は準備中です。原文の説明を表示しています。
Maintain Pulp's installed design-time agent capability manifest and public-surface ledger. Use when adding, removing, renaming, or materially changing public audio, MIDI, signal, timebase, or sequence APIs; registering a new algorithm for generators; changing capability support or deprecation state; or repairing agent-capabilities freshness, schema, fingerprint, tombstone, or installed-SDK tests.
日本語の概要は準備中です。原文の説明を表示しています。
Android platform development for Pulp — NDK cross-compilation, Oboe audio, Dawn/Skia GPU rendering, JNI bridge, touch interaction, emulator workflows, and end-to-end smoke validation. Covers build, deploy, debug, and the gotchas discovered during bringup.
日本語の概要は準備中です。原文の説明を表示しています。
Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.
日本語の概要は準備中です。原文の説明を表示しています。
The measurement surface for ALL Pulp DSP and audio-pipeline work — read it BEFORE writing or gating DSP, not only when something already sounds wrong. Covers the C++ harness (signal generators, metrics, assertions, RenderScenario, contracts), the offline Audio Doctor (magnitude/frequency response, THD/THD+N, phase/group delay), and their Python sibling the Audio Quality Lab (tools/audio/quality-lab — null residual + alignment, LTAS log-spectral distance, spectral flux/centroid, HNR, Theil-Sen drift slope, Kaiser-sinc resampling, license-guarded corpus, regression-net ratchet). TRIGGER on AUTHORING work — "build/design an oscillator/filter/synth/effect", "add a DSP module", "what should the acceptance gate be", "how do I measure aliasing / anti-aliasing / alias floor", "null against a reference", "is this DSP correct", "choose a tolerance", "golden/regression corpus for audio", "measure drift or jitter", "A/B two renders" — AND on DEBUGGING work — "is there sound / no audio / I hear nothing", "does this filter/compressor/synth/delay produce the right signal", "prove the DSP / prove the contract", "measure the frequency response", "what's the THD / is it distorting", "what's the group delay / phase response / measured latency", "magnitude response curve", "render a test tone and assert", "audio regression", "64-frame works but 128 is silent", "sample-rate change pitch-shifted it", "describe what's in this buffer", "audio doctor", "compare before/after a DSP refactor". Reach for this BEFORE hand-rolling any FFT, null test, alias measurement, pitch tracker, or golden-render script — most of it already exists in one of the two lanes. Test/tool layer over HeadlessHost — deterministic, no audio device, no speakers. Off the realtime thread entirely.
日本語の概要は準備中です。原文の説明を表示しています。