Wiki home
UI API reference

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.

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

FieldTypeBehavior
valuenumberCurrent value. Reactive — tracks engine-side changes (automation, the graph editor, presets)
min / maxnumberParameter 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
labelstringDisplay name; falls back to the paramId
set(v)(v: number) => voidWrites 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.

FieldTypeBehavior
valuenumberLatest sample — the first element of the current frame. Reactive
datareadonly 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 blockkindShapeData
vu-meterenvelopeFollowerLevelscalar (1)linear level 0–1
outputenvelopeFollowerLevelvector (2)per-channel envelope, linear 0–1
fft-analyzerfftSpectrumvectorbin magnitudes 0–1
scopescopevectorsigned samples −1…1
signal-probesignalProbevectorraw tap of whatever it's wired to
master bus (nodeId = __master__)masterOutputLevelvector (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.

typeFields usedNotes
noteOnchannel, note, velocitynote number 0–127
noteOffchannel, note, velocityrelease the matching note
ccchannel, cc, valuecontroller number + value, both 0–127
pitchBendchannel, valuevalue is −1…1 (clamped), 0 = center
aftertouchchannel, valuechannel 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:

ComponentsBind viaBacked by
Knob, Fader, XYPad, Toggle, ADSREditordsp={{ node, param }}patchwerk.param()
VUMeter, Scope, FFTAnalyzertelemetry={{ node, kind }}patchwerk.telemetry()
Keyboardplays notes directlypatchwerk.sendMidi()
Display, Transport, Image, FilterResponsestatic 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.