本文へ移動
cccskills
無料GitHub で公開

clap

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.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md66.5 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

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

RolePath
Core adapter (C++)core/format/src/clap_adapter.cpp
Adapter header / PulpClapPlugincore/format/include/pulp/format/clap_adapter.hpp
Entry-point generator macrocore/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 buildercore/format/src/clap_remote_controls.cpp
CLAP+ARA surfacecore/format/src/ara*, see the ara skill
Teststest/test_clap_entry.cpp (dlopen + descriptor), test/test_clap_ara_extension.cpp (ARA companion factory), test/test_clap_webview.cpp (WebView bridge)
CLI validator invocationtools/cli/cmd_validate.cpp (clap-validator validate … with dlopen-only fallback)

The PULP_CLAP_PLUGIN(factory_fn) macro (bottom of clap_entry.hpp) is the single-plugin developer surface. It registers one plugin record, keeps the legacy global factory slot (register_plugin(factory_fn)), and defines the clap_entry exported symbol. There is no separate "factory" TU — the macro is the factory.

Multi-plugin bundle — one CLAP binary, many plugins

A CLAP module has exactly ONE clap_entry symbol but its factory may list many plugins. clap_entry.hpp supports this via a per-plugin record array (g_clap_records) instead of the old single g_factory/g_desc/g_clap_desc globals:

PULP_CLAP_BUNDLE_PLUGIN(Foo, create_foo)   // 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(&param_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 idSynthesised MIDI 1.0
PRESSUREchannel aftertouch 0xDn
TUNING14-bit pitch bend (normalised to ±48st member range)
BRIGHTNESSCC 74
VOLUMECC 7 (0..4 → 0..127 log-domain scale)
PANCC 10
VIBRATO, EXPRESSIONdropped — 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.

Render mode (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].

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.

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.

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:

// 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:

StateWhat happens
Activeclap_host->request_restart(), then publish changed() from inside the clap_activate() that results
InactivePublish 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.

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 =deleted on some toolchains (notably the github-hosted macos-15 sanitizer image), so std::from_chars(first, last, a_double) hard-fails the Sanitizer Tests build with "call to deleted function 'from_chars'" while the Mac Studio macos gate (which has the overload) stays green — the break hides on the advisory lane. Integer from_chars is fine everywhere. For the float value, parse through pulp::format::detail::parse_double_c_locale (core/format/include/pulp/format/detail/locale_independent_float.hpp), a C-locale strtod wrapper shared with the .pulpset parser.

GUI layout must match across CLAP TUs

Do not use #ifdef PULP_CLAP_GUI for CLAP GUI fields or extension dispatch; use #if defined(PULP_CLAP_GUI) && PULP_CLAP_GUI. The WCLAP path may define PULP_CLAP_GUI=0, and #ifdef treats that as enabled. The desktop shared adapter and the per-plugin entry must agree on whether GUI fields exist in PulpClapPlugin, or later lifecycle fields shift and hosts crash when opening or activating the editor.

ARA CLAP lives outside CLAP_EXT_*

The ARA companion factory is keyed on kClapAraFactoryExtension (Pulp-private identifier), not one of CLAP's reserved CLAP_EXT_* strings. Don't rename it; other Pulp + ARA hosts already search for that exact key. Defined in pulp/format/ara.hpp.

clap-validator is optional — fallback is dlopen

pulp validate (tools/cli/cmd_validate.cpp) runs clap-validator validate … when installed, otherwise falls back to a plain dlopen check. CI lanes without clap-validator still exercise the "plugin loads" path; full spec conformance requires the validator binary.

AAX-parity sweep

AAX and CLAP share the same sysex-sidecar pattern. When you change the CLAP sysex accumulator, the AAX adapter (core/format/src/aax_runtime.cpp) and the VST3 / AU halves need to stay in sync — see the memory note on AAX-parity.

Filter in-events by space_id in every dispatch loop

Every clap_input_events dispatch loop in the adapter MUST check hdr->space_id == CLAP_CORE_EVENT_SPACE_ID at the top and continue on mismatch. Non-zero namespaces belong to third-party extensions Pulp doesn't implement, and their type IDs may alias core type IDs (e.g. a fictional extension's event type 5 could be mistaken for CLAP_EVENT_PARAM_VALUE and mutate the param store). clap-validator param-set-wrong-namespace exercises this with space_id = 0xb33f.

Covered sites today:

  • clap_adapter.cpp process() param/gesture loop
  • clap_adapter.cpp process() note/MIDI loop
  • clap_entry.hpp params_flush() path

If you add a third in-events dispatch (e.g. a transport-event loop, or a new extension's callback), add the same guard. Test pattern: test_clap_entry.cpp → "CLAP params_flush ignores events outside the core namespace [issue-743]" (the bracketed token is the Catch2 test tag).

clap_ostream::write may short-write — loop state_save

state_save (in clap_entry.hpp) MUST loop on stream->write() until the full payload is delivered. Per CLAP spec, a single write call may return fewer bytes than requested even on success; only negative or zero returns are errors. clap-validator's state-reproducibility-flush exercises this by capping every write at 23 bytes.

Symmetric note: state_load's stream->read loop was already correct; the bug was only on the write side.

on_non_realtime_tick() fires in native CLAP, not just WebCLAP

Processor::on_non_realtime_tick() / non_realtime_tick_pending() exist so a processor with no worker thread of its own can get expensive control-driven work (decode, resample, FFT-plan, allocate) off the audio thread. The motivating host is the browser — a WAM lives entirely inside an AudioWorklet and a WebCLAP module has no std::thread — but the CLAP adapter calls the hook unconditionally, so a native CLAP plugin gets it too:

  • clap_on_main_thread() calls on_non_realtime_tick() every time.
  • state_load() calls it after deserialize() — a restored state can name a different derived source than the live one, and a worker-less processor has no thread to notice; without this the audio thread renders the OLD derived state for the rest of the session.
  • clap_process() peeks non_realtime_tick_pending() each block and calls host->request_callback() when set — the same mechanism already used for latency/tail changes. This is the only way out of the audio thread in CLAP, because CLAP delivers parameter changes as events inside process().

Two things follow that are easy to get wrong:

  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:

# 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.

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:

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.

The adapter asks for an editor prewarm when the host instantiates the plug-in

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".

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

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.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

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.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

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.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

android

無料

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.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

ara

無料

Optional ARA support for Pulp, including developer-supplied ARA SDK setup, CMake enablement, adapter companion APIs, validation, and ARA-aware plugin implementation guidance.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

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.

日本語の概要は準備中です。原文の説明を表示しています。

Generous-Corp/pulp222026年10月10日 更新

Generous-Corp のスキルをすべて見る

このスキルの問題を報告する