Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 between patches-clap and 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):

IDNameFirst featureWhere it appears
com.vulpus-labs.patchesPatchesinstrumentDAW instrument browser slot.
com.vulpus-labs.patches.fxPatches FXaudio-effectDAW 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:

  1. The user gesture is a button or text input in the webview (patches-clap/assets/src/).
  2. JS posts an Intent via window.ipc.postMessage (patches-plugin-common/src/gui.rs).
  3. The plugin lowers it into an Action and passes to Controller::apply (patches-plugin-common/src/controller.rs).
  4. The controller mutates state and returns a StateDelta.
  5. 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.