Blocks and custom blocks
A block is a Cmajor processor plus a JSON manifest. Drop a folder into your project's blocks/ directory and it appears in the palette — knobs, modulation, and live reload included.
For plugin makers who want to extend the palette with their own DSP.
What a block actually is
Every node you drop on the canvas is a block: a small Cmajor processor that does the audio math, paired with a JSON manifest that tells Patchwerk what the block looks like from the outside — its audio ports, its parameters, their ranges, and which of them can be modulated.
The built-in library ships dozens of blocks across five categories:
| Category | Examples |
|---|---|
sources | oscillator, wavetable, sampler, noise |
processing | filter, ladder-filter, eq3, gain, mixer |
effects | reverb, delay, chorus, phaser, distortion, compressor |
modulation | lfo, adsr, macro, sample-hold, clock |
display | scope, fft-analyzer, vu-meter, signal-probe |
Add a folder, get a node — same format as the built-ins
Custom blocks live inside your project, under blocks/. One folder per block — the folder name becomes the block's type:
blocks/saturator/
├── block.json manifest — ports, params, category
├── dsp.cmajor the Cmajor processor
└── render.js optional custom node visual
Save the two required files and the block shows up in the palette. No build step, no restart.
Note
Blocks are project-scoped: they travel with the project folder, so sharing the project shares the blocks. A custom block's type must not collide with a built-in name — call yours my-filter, not filter.
The block.json manifest
{
"apiVersion": 1,
"type": "saturator",
"label": "Saturator",
"category": "effects",
"voiceMode": "global",
"role": "audio",
"ports": [
{ "id": "in", "label": "In", "type": "audio", "direction": "input" },
{ "id": "out", "label": "Out", "type": "audio", "direction": "output" }
],
"params": [
{ "id": "drive", "label": "Drive", "min": 1, "max": 10, "default": 2,
"control": "knob", "modulatable": true, "taper": "log" }
]
}
| Field | Values | What it controls |
|---|---|---|
apiVersion | 1 | Manifest format version |
type | kebab-case, equals folder name | Stable identity — don't rename once patches use it |
label | free text | Name shown on the node and in the palette |
category | sources processing effects modulation display | Palette grouping |
role | audio or modulator | Canvas node vs. modulator-panel source |
voiceMode | poly global switchable | Runs per voice, once post-mix, or user's choice |
ports | array | Audio/MIDI inputs and outputs, telemetry outputs |
params | array | Everything below |
Each param takes id, label, min, max, default, and a control (knob, fader, toggle, stepper, segmented, scrub, or hidden). Optional extras: taper: "log" for frequency/time ranges, enumLabels to turn a stepped range into named choices, unit for a suffix like Hz, and modulatable — more on that below.
You never write UI for parameters. Every entry in params automatically becomes a control on the node, with drag behavior, value display, right-click options, and modulation arcs included.
The DSP side of the contract
Names in block.json must match endpoint names in dsp.cmajor — that's the entire contract:
| block.json declares | dsp.cmajor must declare |
|---|---|
audio input in | input stream float<2> in; (float for mono) |
audio output out | output stream float<2> out; |
param drive | input value float drive; |
param with "modulatable": true | also driveMod + driveModDepth (see below) |
A minimal dsp.cmajor for the saturator above:
processor Saturator
{
input stream float<2> in;
output stream float<2> out;
input value float drive [[ name: "Drive", min: 1.0, max: 10.0, init: 2.0 ]];
input stream float driveMod;
input value float driveModDepth [[ init: 0.0 ]];
void main()
{
loop
{
let d = clamp (drive + (driveMod - 0.5f) * 2.0f * driveModDepth * 9.0f, 1.0f, 10.0f);
out <- tanh (in * d);
advance();
}
}
}
Tip
Param and port ids must be Cmajor-safe identifiers — cutoff or side_chain, never side-chain. Dashes are only for the block type itself. Validation fails loudly at load: a broken block can't half-work, it tells you exactly what's wrong.
Making params modulatable
Mark a param "modulatable": true and two things happen. In the UI, the param gains four modulation slots — assign an LFO, ADSR, macro, sample & hold, or clock to any of them, each with its own depth and polarity (bipolar, up-only, down-only). On the DSP side, Patchwerk routes the mixed modulation signal into two endpoints your processor declares: <id>Mod (the modulation stream) and <id>ModDepth (the strength). Your code decides what modulation means — sum it into the param like the saturator above, or get creative.
Modulation also works as visible cables: drag from a modulator's output to a param's mod input on the canvas, and the cable's amount and polarity are adjustable per edge.
Edit, save, hear it
Block files hot-reload while the project is open:
dsp.cmajorsaved → the patch recompiles and the new sound is swapped in live.block.jsonsaved → nodes already on the canvas reconcile: matching params keep their values, new params appear with defaults.render.jssaved → the node's custom visual remounts in place.
Let the assistant do the boilerplate
The AI assistant knows this entire contract. Ask it to "make me a comb filter block with feedback and damping knobs" and it scaffolds the folder, writes both files, watches the compile result, and fixes its own errors. It's the fastest way to get a working skeleton you then tune by hand.