CLAP plugin internals
This chapter is for contributors. It describes how patches-clap
hosts the Patches engine inside a CLAP plugin: the webview GUI, the
controller / action / state-delta cycle, persistence, and the
single-dylib two-descriptor packaging.
If you just want to use the plugin in a DAW, Running a patch in a DAW covers that.
Source crates:
patches-clap/— the CLAP entrypoints, webview hosting, plugin lifecycle.patches-plugin-common/— GUI-toolkit-agnostic state and controller logic shared betweenpatches-clapand the player’s TUI.
Single dylib, two descriptors
The bundle is one cdylib. The CLAP entrypoint exports a factory
that advertises two descriptors, backed by the same
PatchesClapPlugin struct (patches-clap/src/descriptor.rs):
| ID | Name | First feature | Where it appears |
|---|---|---|---|
com.vulpus-labs.patches | Patches | instrument | DAW instrument browser slot. |
com.vulpus-labs.patches.fx | Patches FX | audio-effect | DAW effect browser slot. |
Both descriptors carry the same set of feature tags
(instrument, synthesizer, audio-effect, stereo); only the
order differs. Hosts that bucket plugins by the first feature tag
(Bitwig, Live, Logic) put the two descriptors in different slots
from the same dylib. Hosts that don’t care about ordering see both
descriptors with identical capability and surface them wherever they
prefer.
Per-host project state keys by descriptor ID. A project saved
against Patches will not silently re-bind to Patches FX on
reload — they are distinct identities even though they share an
implementation.
Plugin lifetime
clap_entry::init()
└── factory::create(descriptor_id) → PatchesClapPlugin
├── init() (host gives us the audio thread environment)
├── activate() (start engine, open audio I/O)
├── process() (called per audio block by the host)
├── deactivate() (stop engine, close audio I/O)
└── destroy() (drop)
process() runs the engine for the host-supplied block. Parameter
changes from the host arrive as a CLAP parameter event stream and
are translated into the controller’s Action::ParamSet (or
equivalent) before reaching the audio thread.
GUI: wry webview
The plugin GUI is an embedded webview, parented to the host’s window
via wry. Source: patches-clap/src/gui.rs.
The webview loads a single bundled HTML/CSS/JS document
(assets/index.html, assets/app.css, assets/app.bundle.js —
the last is generated by npm run build and checked in so cargo
builds without a Node toolchain).
IPC
JS → Rust: the webview calls window.ipc.postMessage(JSON). The
JSON is parsed into an Intent enum
(patches-plugin-common::Intent) and lowered into an Action for
the controller.
Rust → JS: the GUI builds a GuiSnapshot (a serialisable
projection of the current state) and pushes it via
evaluate_script("window.__patches.applyState(...)") at up to
~30 Hz, skipping the push when the serialised snapshot hasn’t
changed.
Live tap data (scope frames, spectrum bins, meter levels) flows through a separate path with its own dedupe and cadence, so a high-rate scope update doesn’t interfere with snapshot dedupe (and vice versa).
Why a webview
The trade-off chosen for the GUI was: maximise authoring agility
for an unsettled UI vocabulary (lots of small experiments per
feature, easy live-reload while developing), accept the binary-size
and ABI-surface cost of bundling a webview. The webview model also
gives us a clean serialisation boundary — anything in the GUI is
defined by what crosses the GuiSnapshot / Intent types — which
made it natural to factor the underlying logic into
patches-plugin-common.
patches-plugin-common: controller / action / state delta
The plugin’s state mutation logic lives in
patches-plugin-common::Controller. The shape:
Action (from GUI intent, host event, or file watcher)
→ Controller::apply(action)
→ mutates internal state
→ returns StateDelta describing what changed
→ caller reacts (push snapshot to GUI, save sidecar, etc.)
Action is a closed enum:
- UI gestures:
Browse,Reload,LoadPath,AddModulePath,RemoveModulePath,Rescan,AddBundleDir,SetTapOpt,SetWindowSize,SavePreset,LoadPreset. - Host events:
Activate,Deactivate,StateLoad,HaltObserved,DiagnosticsDrained.
StateDelta flags which downstream effects need to fire — typically
snapshot_changed (push a new snapshot to the webview),
persistable_changed (host should re-save the project),
global_config_changed (write settings.toml to disk),
plan_recompile (rebuild and hand off a new ExecutionPlan),
requires_restart (the change can only take effect after the host
reactivates the plugin).
The webview is a thin shell over this model: every button is an
intent JSON, every state visible in the UI is a field on
GuiSnapshot. The same controller backs the player’s ratatui TUI
(via the Env trait abstracting filesystem and host interaction).
Persistence
Two scopes, deliberately separated:
Per-patch state (host-owned)
The plugin returns CLAP state on save and accepts it on load. State captures the loaded patch’s identity (file path + verbatim DSL source as fallback), host-control values, per-tap display options, GUI window size, and the per-patch module-paths list. The DAW serialises this into its project file; reloading the project restores all of it.
If the original .patches file no longer resolves at the original
path, the plugin falls back to the embedded DSL source so the
project still plays. Browse in the GUI re-points it at a live
file.
Global config (plugin-owned)
The bundle_dirs setting and any other host-scoped settings live
in a cross-platform settings.toml under the user’s config
directory. Resolved through patches-plugin-common::GlobalConfig,
written when the user changes the value via the GUI (immediate
save, not deferred). The CLAP path’s bundle-dir resolution adds to
the env-var / constructor-supplied / default-data-dir tiers used
elsewhere.
A sandboxed host that denies filesystem writes will get a graceful no-op on save; reads tolerate a missing or unreadable config.
Real-time boundaries
The plugin maintains the same real-time boundaries as the player.
The CLAP-supplied audio thread is treated as the engine’s audio
thread; no allocation, blocking, or syscalls in process. The
control thread runs the controller, the file watcher, the LSP
client (none), webview IPC, and persistence; it communicates with
the audio thread through the engine’s lock-free rings (plan handoff,
cleanup queue).
The webview ticker runs on its own thread and produces snapshots at its cadence. The host’s main thread shows the webview.
Halt handling
PatchProcessor::tick is wrapped in catch_unwind. On
a module panic, the engine halts cleanly and sends a
HaltObserved action to the controller, which surfaces the
diagnostic in the GUI log pane and stops further process
invocations from producing audio until the patch is reloaded.
This is why the workspace must keep panic = "unwind". With
panic = "abort", the catch can’t intercept and the host process
crashes instead.
Reading the source
If you’re tracing a feature end to end:
- The user gesture is a button or text input in the webview
(
patches-clap/assets/src/). - JS posts an Intent via
window.ipc.postMessage(patches-plugin-common/src/gui.rs). - The plugin lowers it into an Action and passes to
Controller::apply(patches-plugin-common/src/controller.rs). - The controller mutates state and returns a StateDelta.
- The plugin reacts: pushes a new snapshot to the webview,
asks the host to re-save, writes
settings.toml, or whatever the delta indicates.
Each step is small. The reducer-style split keeps the actual logic testable without a CLAP host or a webview attached.