The patchwerk object
Every method and property on the patchwerk object your plugin UI imports — parameters, live telemetry streams, MIDI output, and the bundled controls built on top of them.
Plugin makers writing custom code in ui/src/Plugin.svelte.
One import, two backends
Every scaffolded plugin UI ships a small API object that is the only bridge between your Svelte code and the audio engine:
import { patchwerk } from '$lib/patchwerk'
It lives in ui/src/lib/patchwerk/ (infrastructure Patchwerk maintains — don't edit it) and has two interchangeable backends: in the Design Canvas preview it talks to the editor, and in the exported plugin it talks to the native engine through the plugin's WebView bridge. The swap happens at build time; your code never changes.
Everything it returns is backed by Svelte 5 $state. Read value or data in your template or inside $derived and your UI re-renders when the engine pushes new values — never poll with requestAnimationFrame or timers.
| Member | What it gives you |
|---|---|
patchwerk.param(nodeId, paramId) | Read/write binding to one DSP parameter |
patchwerk.telemetry(nodeId, kind) | Read-only live data stream from the engine (~60 Hz) |
patchwerk.sendMidi(event) | Push a MIDI event into the graph's MIDI input |
patchwerk.graph | { nodes, edges } — reserved, currently always empty |
patchwerk.midi | { activeNotes, pitchBend, modWheel } — reserved, currently static |
patchwerk.param() — read and write a parameter
patchwerk.param(nodeId: string, paramId: string) returns a live binding object:
| Field | Type | Behavior |
|---|---|---|
value | number | Current value. Reactive — tracks engine-side changes (automation, the graph editor, presets) |
min / max | number | Parameter range. Live in the canvas preview; in the exported plugin they fall back to 0 / 1, so always pass explicit min/max props to controls |
label | string | Display name; falls back to the paramId |
set(v) | (v: number) => void | Writes v to the engine and updates value immediately. Not clamped — keep it inside the parameter's range yourself |
Unknown IDs never throw: you get value 0 and a set() that goes nowhere. That is also what a stale ID looks like after a graph rebuild — a knob that renders fine but does nothing.
The bundled controls call param() for you via the dsp prop, so reach for the imperative form only when you build something custom, like a readout:
<script lang="ts">
import { Knob } from '@audio-ui/svelte'
import { patchwerk } from '$lib/patchwerk'
const gain = patchwerk.param('master', 'gain')
</script>
<div id="gain-knob" style="position: absolute; left: 24px; top: 24px; width: 64px; height: 80px;">
<Knob class="h-full w-full" dsp={{ node: 'master', param: 'gain' }} min={-60} max={6} label="Gain" unit="dB" />
</div>
<div id="gain-readout" class="font-mono text-xs text-zinc-400" style="position: absolute; left: 24px; top: 110px;">
{gain.value.toFixed(1)} dB
</div>
patchwerk.telemetry() — live data from the engine
patchwerk.telemetry(nodeId: string, kind: string) subscribes to a read-only stream. Frames arrive at roughly 60 Hz while audio runs; an unknown stream yields value 0 and an empty data array.
| Field | Type | Behavior |
|---|---|---|
value | number | Latest sample — the first element of the current frame. Reactive |
data | readonly number[] | The full frame. Scalar streams have length 1, stereo streams length 2, visualizer streams a full buffer (typically 128 samples) |
Which kind exists depends on which blocks are in your graph:
| Emitting block | kind | Shape | Data |
|---|---|---|---|
vu-meter | envelopeFollowerLevel | scalar (1) | linear level 0–1 |
output | envelopeFollowerLevel | vector (2) | per-channel envelope, linear 0–1 |
fft-analyzer | fftSpectrum | vector | bin magnitudes 0–1 |
scope | scope | vector | signed samples −1…1 |
signal-probe | signalProbe | vector | raw tap of whatever it's wired to |
master bus (nodeId = __master__) | masterOutputLevel | vector (2) | stereo master level — always present, no block needed |
Custom blocks add their own kinds: any output port with "type": "telemetry" in the block's block.json becomes a stream, and the kind is exactly that port's id (frame length = the port's size).
A custom level readout off the master bus:
<script lang="ts">
import { patchwerk } from '$lib/patchwerk'
const level = patchwerk.telemetry('__master__', 'masterOutputLevel')
const db = $derived(20 * Math.log10(Math.max(level.value, 1e-6)))
</script>
<div id="master-level" class="font-mono text-xs text-emerald-400">{db.toFixed(1)} dB</div>
One Svelte 5 gotcha: for multi-statement computations over data, use $derived.by(() => { ... }) — plain $derived(() => ...) stores the function itself, not its result, and your visualizer renders nothing.
patchwerk.graph and patchwerk.midi — reserved surfaces
Both properties exist on the API but are not fed by the engine yet. patchwerk.graph.nodes and patchwerk.graph.edges are always empty arrays; patchwerk.midi always reports an empty activeNotes set, pitchBend 0, and modWheel 0. Don't build features on them — to react to MIDI in your DSP today, route the graph's midi-in block outputs (gate, pitch, mod wheel) into your patch instead.
patchwerk.sendMidi() — play notes from the UI
patchwerk.sendMidi(event: MIDIEvent) pushes one event into the graph's MIDI input — the same path the bundled Keyboard component uses. In the canvas preview the editor forwards it to the live engine; in the exported plugin it rides the plugin host's MIDI queue. channel is 0–15; note, velocity, and CC values are truncated to 0–127.
type | Fields used | Notes |
|---|---|---|
noteOn | channel, note, velocity | note number 0–127 |
noteOff | channel, note, velocity | release the matching note |
cc | channel, cc, value | controller number + value, both 0–127 |
pitchBend | channel, value | value is −1…1 (clamped), 0 = center |
aftertouch | channel, value | channel pressure, 0–127 |
<script lang="ts">
import { patchwerk } from '$lib/patchwerk'
function blip() {
patchwerk.sendMidi({ type: 'noteOn', channel: 0, note: 60, velocity: 100 })
setTimeout(() => patchwerk.sendMidi({ type: 'noteOff', channel: 0, note: 60, velocity: 0 }), 200)
}
</script>
<button id="test-note-button" class="rounded bg-zinc-800 px-3 py-1 text-xs text-zinc-200" onclick={blip}>Play C4</button>
The components already wired to this API
Everything in @audio-ui/svelte (ejectable into ui/src/components/base/ — see the Design Canvas page) binds through patchwerk under the hood, via resolvers registered in ui/src/App.svelte:
| Components | Bind via | Backed by |
|---|---|---|
Knob, Fader, XYPad, Toggle, ADSREditor | dsp={{ node, param }} | patchwerk.param() |
VUMeter, Scope, FFTAnalyzer | telemetry={{ node, kind }} | patchwerk.telemetry() |
Keyboard | plays notes directly | patchwerk.sendMidi() |
Display, Transport, Image, FilterResponse | static props | — |
Tip
Wondering what to pass as nodeId and paramId? Every node's id lives in your project's dsp/graph.json, the canvas inspector's binding picker lists each exposed parameter as node + param, and the AI assistant reads the live graph — asking it "what params does my filter expose?" is usually fastest.