Introduction
Patches is a modular audio instrument with a text-based patch language. You describe a graph of modules — oscillators, filters, envelopes, sequencers, effects — and how they connect; the engine runs the graph in real time. Save the file and Patches swaps in the new graph without interrupting audio or resetting module state, so editing the patch is itself the development cycle.
Why Patches?
Most software modular systems present a graphical reproduction of a Eurorack-style hardware modular setup. You drag patch cables between ports, turn knobs, move sliders. This is satisfying and creatively rewarding, but it isn’t the only way to work.
Patches sets the visual interface aside and concentrates on two things: a clear language for describing patches, and a fast, real-time-stable engine for running them. The language is supported by a full Language Server Protocol implementation, so any compatible editor can offer autocomplete, inline hints, and navigation between definitions.
Working in text makes some abstractions natural that would be awkward in a visual environment. Three examples:
- Per-cable scaling, offset and clipping. Signals can be amplified, offset, or mapped into and bounded within a constant range with an annotation on the cable connection.
- Templates. Define a sub-patch once with parameters, then instantiate it multiple times with different values.
- Multi-channel modules. A mixing desk or multi-tap delay can be declared with whatever number of channels you need, each with its own parameters, inputs, and outputs.
Three ways to run a patch
Patches has no single intended workflow. The same .patches file works in any of these contexts:
As a CLAP plugin in a DAW. Load the Patches plugin in REAPER, Bitwig, or any CLAP host. Point it at a patch file, drive it from MIDI tracks, automate its parameters, save with the project. Use this when you want a custom synth voice inside a larger production.
As a standalone instrument driven by external MIDI. Run patch_player hello.patches, connect a controller, play. Use this when you want a hardware-style instrument with no DAW in the loop.
As a self-contained piece driven by built-in sequencing. A patch can include the tracker, step sequencer, and clock modules — its own note source. Run it in the player and the piece plays itself. Use this when the patch is the composition.
The DSL, module set, and audio engine are identical across all three. Pick the surface that fits the task.
A first patch
patch {
module osc : Osc { frequency: 440Hz }
module out : AudioOut
osc.sine -> out.in
}
Save as hello.patches and run:
patch_player hello.patches
A 440 Hz sine tone. Change the frequency to 220Hz, save, and the pitch drops — no click, no restart. Change osc.sine to osc.sawtooth for a brighter timbre. Ctrl-C to stop.
What’s next
- Mental model — how patches are built from modules, ports, cables, and signal kinds. Read this before the DSL chapters; it is the conceptual foundation everything else assumes.
- Installation — getting the player, CLAP plugin, and VS Code extension onto your machine.
- Basic operation — running existing patches in each of the three workflows.
- The DSL — the patch language in detail.
- Patch authoring — building real instruments and pieces.
- Module reference — every built-in module, its ports, and its parameters.
- Extending Patches — writing modules in Rust, or as native plugins via the ABI.
- Internals — the audio engine, CLAP plugin, and language server.
Mental model
A patch is a directed graph. Boxes are modules; arrows are cables. A module reads from its inputs, computes, and writes to its outputs once per audio sample. Cables carry the output of one module to one or more inputs of others.
That is the whole model. Everything else — the DSL syntax, the polyphony rules, the live reload behaviour — is a way of building and editing this graph.
Modules
A module is a unit of computation with a fixed set of named inputs and outputs. Examples:
- An oscillator has no inputs (or some control inputs) and produces an audio output.
- A filter has an audio input and one or more control inputs (cutoff, resonance), and produces a filtered audio output.
- An envelope has a gate input and a trigger input, and produces a control signal that rises and falls.
- An output module has an audio input and writes to the soundcard.
The set of ports is defined by the module’s type. An instance of the type carries its own state — an oscillator’s phase, a filter’s history, an envelope’s position. State is private to the instance.
Ports and signal kinds
Each port has a name (in, out, cutoff, gate, voct) and a fixed kind. The kind decides what can connect to it.
- Audio — a stream of audio samples. Centred on zero, typically in the range −1 to +1.
- Control voltage (CV) — a stream of slow-changing modulation values. The convention is V/oct: 1 unit = 1 octave. Pitch and many modulation signals use this.
- Gate — a level held high (≥ 0.5) or low (< 0.5). Tells an envelope a note is being held.
- Trigger — a sub-sample-accurate event encoding.
0.0means “no event on this sample”; a value in(0.0, 1.0]is the fractional position of an event within the sample. Tells an envelope to restart. - Stereo — a pair
(L, R)of audio samples on one cable. Symmetric stereo modules use a single stereo port rather than two mono ports.
Audio, CV, and gate all ride on the same mono Audio cable layout — the engine doesn’t distinguish them, and the difference is only how a module interprets what it reads. You can deliberately abuse this: feed an audio signal into a CV input for aggressive modulation, or use an envelope’s output as audio for a one-shot percussive sample.
Trigger is a different cable layout. It encodes sub-sample event positions, not levels, and the connection validator rejects audio↔trigger wiring in either direction — use the appropriate event source or a conversion module. Poly cables have further distinct layouts for transport frames and packed MIDI events; same rule applies, no implicit coercion across layouts.
Mono and poly
Cables come in two flavours: mono (one sample per audio tick) and poly (one sample per voice per tick, typically 16 voices). A module type is either mono or poly throughout — there is Osc (mono) and PolyOsc (polyphonic), Adsr and PolyAdsr, and so on.
A mono cable cannot connect to a poly input or vice versa. Use a MonoToPoly to broadcast one mono signal to every voice, and PolyToMono to sum voices down to a single mono signal.
Stereo and mono interact more permissively: a mono source feeding a stereo input is silently broadcast (L = R = source). The reverse — stereo into mono — is an error; if you want it, route through a StereoSplitter.
Cables
A cable connects exactly one output to exactly one input. To split a signal to several destinations, draw several cables from the same output (this is free — no mixing node required). To combine signals into one input, route them through a mixer module — the destination only accepts one cable.
A cable can carry a scale factor that multiplies the signal as it passes:
osc.sine -[0.3]-> out.in
This is a per-cable gain; it is useful for trimming levels, scaling modulation depth, or inverting (-1.0).
Cable delay
Cables in cycle-bearing regions of the graph impose a one-sample delay: a module reads what its neighbours wrote on the previous tick. This is what makes feedback loops well-defined — a feedback cable carries the previous tick’s value, and modules in a cycle can be evaluated in any order.
In acyclic (feedback-free) regions, the planner fuses the chain so consumers read producers’ current-tick output. This is invisible to the patch author — the audio result matches the conceptual model — but it removes the one-sample delay that would otherwise accumulate audibly across a long signal chain.
A patch is a file
A patch is described in a .patches file: a single text document that declares modules, sets their parameters, and draws cables between them. There is no project file, no binary patch format. You edit the file; the engine runs the graph the file describes.
When you save, the engine builds the new graph and swaps it in without stopping the audio. Module instances whose name and type are unchanged carry their state across the swap. This is the REPL property — small edits sound like parameter tweaks, not restarts.
Visualising the graph
The graph can be rendered as an SVG diagram with patches-svg (covered in Visualising patches). Reading the diagram is a useful way to understand a patch you didn’t write; writing one rarely needs the picture.
Installing the player
patches-player is the standalone command-line player. Give it a
.patches file and it opens an audio device, runs the patch, watches
the file, and hot-reloads on save. Two install paths: a prebuilt
release archive, or a build from source.
Prebuilt release archive
Each stable GitHub release attaches:
| Archive | Use on |
|---|---|
patches-<version>-macos-aarch64.tar.gz | Apple Silicon Macs |
patches-<version>-macos-x86_64.tar.gz | Intel Macs |
patches-<version>-windows-x86_64.msi | 64-bit Windows (installer, default) |
patches-<version>-windows-x86_64.zip | 64-bit Windows (flat zip) |
Prerelease tags ship only the zip on Windows; the MSI is built on stable tags. Linux has no prebuilt artefact yet — see Build from source below.
Each archive contains:
patch_player(the binary;patch_player.exeon Windows)Patches.clap— the CLAP plugin, covered in the next chapter- a matching
.vsixfor the VS Code extension examples/— the patches underexamples/in the repopatches-manual.pdf— this manual- on macOS:
macos-unquarantine.sh
macOS
tar -xzf patches-<version>-macos-aarch64.tar.gz
cd patches-<version>-macos-aarch64
./macos-unquarantine.sh
./patch_player examples/square_440.patches
The binary is ad-hoc-signed but not notarised, so Gatekeeper marks it
quarantined on first download. macos-unquarantine.sh strips the
quarantine flag from patch_player, Patches.clap, any bundled
native module dylibs, and the bundled .vsix. As a convenience it
also installs Patches.clap into ~/Library/Audio/Plug-Ins/CLAP/
and (if the code CLI is on PATH) installs the bundled VS Code
extension — covered in the next two chapters. Without the quarantine
strip the first run fails with a “cannot be opened because the
developer cannot be verified” dialog. See Unsigned binaries for the
underlying mechanism and manual overrides.
Drop patch_player somewhere on your PATH (e.g. ~/.local/bin/,
/usr/local/bin/) if you want to run it without a path prefix.
Windows
Two install paths. The MSI is the default — use it unless you specifically want a portable, no-install copy.
MSI (recommended). Double-click patches-<version>-windows-x86_64.msi
and accept the elevation prompt. The installer is not signed, so
SmartScreen will warn on first run — click More info → Run anyway. It
places:
patch_player.exe, the manual PDF, and the LICENSE intoC:\Program Files\Patches\Patches.clapintoC:\Program Files\Common Files\CLAP\(the standard CLAP system path)- the bundled
.patchesexamples intoC:\Users\Public\Documents\Patches\examples\ - registers
.patchesas a file type whose default opener ispatch_player.exe, so double-clicking a.patchesfile launches it - adds an
App Pathsentry, sopatch_playerresolves from any cmd / PowerShell prompt without manualPATHediting - adds a Start Menu Patches group with shortcuts to the examples folder and the manual
The VS Code extension is not included in the MSI — install it via
Extensions → Install from VSIX… using the .vsix from the zip archive,
or from the Marketplace if/when it lands there.
Zip (portable). Extract patches-<version>-windows-x86_64.zip with
Explorer or tar -xf from PowerShell. Right-click patch_player.exe →
Properties → tick Unblock before first run, or SmartScreen will
refuse to launch it. Then:
patch_player.exe examples\square_440.patches
The zip is the lightweight option — no installer, no registry changes, no Start Menu entries — and it stays available alongside the MSI on every stable release.
Linux
No prebuilt artefact yet. Build from source.
Build from source
The player builds on macOS, Linux, and Windows with a stable Rust toolchain. From a checkout of the repo:
cargo install --path patches-player
This installs patch_player into ~/.cargo/bin/. If that directory is
on your PATH, the binary is immediately runnable.
To build without installing — useful when iterating on the source —
use cargo build --release -p patches-player; the binary lands at
target/release/patch_player.
Linux dependencies
CPAL needs ALSA development headers. On Debian/Ubuntu:
sudo apt install libasound2-dev
On Fedora:
sudo dnf install alsa-lib-devel
JACK is supported through ALSA’s JACK bridge or PipeWire’s JACK shim — no separate dependency on the JACK library is required.
macOS / Windows
No system packages required beyond a working Rust toolchain
(rustup default stable). On Windows, cargo install uses the MSVC
toolchain by default; the GNU toolchain is untested.
Verification
patch_player --version # not yet implemented; use the next check
patch_player examples/square_440.patches
A short pulse-width-modulated square at ~100 Hz plays through the default output device. Ctrl-C to stop. If you see the TUI, hear the tone, and see “audio: started” in the log pane, the player is working.
If the player exits with “no default output device”, pass
--output-device <name> (run with --list-devices to see what is
available).
Installing the CLAP plugin
The CLAP build is a single dynamic library that ships two descriptors:
Patches (an instrument) and Patches FX (an audio effect). Most
hosts surface them in two slots — instruments and effects — from the
same .clap bundle. No separate effect build, no extra install step.
Prebuilt release archive
The release archives described in the previous chapter contain
Patches.clap alongside patch_player. To install just the CLAP
plugin from those archives, copy the bundle into your CLAP search
directory:
| OS | Install path |
|---|---|
| macOS | ~/Library/Audio/Plug-Ins/CLAP/Patches.clap |
| Windows | %LOCALAPPDATA%\Programs\Common\CLAP\Patches.clap |
| Linux | ~/.clap/Patches.clap |
macOS
tar -xzf patches-<version>-macos-aarch64.tar.gz
cd patches-<version>-macos-aarch64
./macos-unquarantine.sh
macos-unquarantine.sh strips the Gatekeeper quarantine flag from
Patches.clap and copies it into ~/Library/Audio/Plug-Ins/CLAP/.
For a manual install, skip the script and run:
mkdir -p ~/Library/Audio/Plug-Ins/CLAP
cp -R Patches.clap ~/Library/Audio/Plug-Ins/CLAP/
xattr -dr com.apple.quarantine ~/Library/Audio/Plug-Ins/CLAP/Patches.clap
If a host rejects the plugin with “cannot be opened” after install,
repeat the xattr -dr step and rescan.
Windows
Right-click Patches.clap → Properties → Unblock, then copy:
mkdir "%LOCALAPPDATA%\Programs\Common\CLAP"
copy Patches.clap "%LOCALAPPDATA%\Programs\Common\CLAP\"
The system-wide path C:\Program Files\Common Files\CLAP\ works too
but requires elevation.
Linux
No prebuilt artefact yet — build from source.
Build from source
The fastest macOS recipe is the deploy.sh script at the repo root.
It release-builds patches-clap, strips quarantine from any previous
install, and copies the dylib into
~/Library/Audio/Plug-Ins/CLAP/Patches.clap. It also packages and
installs the VS Code extension; read the script first if you only
want the plugin.
./deploy.sh
For the underlying steps on any OS:
cargo build --release -p patches-clap
This produces a platform-specific dylib in target/release/:
| OS | Built artefact | Copy to |
|---|---|---|
| macOS | libpatches_clap.dylib | ~/Library/Audio/Plug-Ins/CLAP/Patches.clap |
| Linux | libpatches_clap.so | ~/.clap/Patches.clap |
| Windows | patches_clap.dll | %LOCALAPPDATA%\Programs\Common\CLAP\Patches.clap |
The destination filename ends in .clap regardless of source
extension — hosts identify the bundle by name and load whatever shared
object is inside.
Linux dependencies
Same as the player: ALSA dev headers
(sudo apt install libasound2-dev or equivalent).
Verification
- Start your DAW (REAPER, Bitwig, Ableton 11.3+, FL Studio 21.2+, Studio One 6.5+, …).
- Trigger a plugin rescan if the host doesn’t scan on startup.
- Patches should appear under the Instruments category.
- Patches FX should appear under the Effects (or Audio Effects) category.
- Load Patches on a MIDI track, open its UI, and load a patch
from
examples/via the file picker.
If only one of the two descriptors shows up, the host is likely filtering by feature tag; check both the instrument and effect sections of the plugin browser. The dylib is the same — no second install needed.
If neither shows up:
- macOS — quarantine.
xattr -l ~/Library/Audio/Plug-Ins/CLAP/Patches.clapwill list it if present; re-runmacos-unquarantine.shor strip manually. - Windows — Unblock the file via Properties; SmartScreen blocks load until you do.
- All OSes — confirm the host is actually scanning the directory you copied to. Some DAWs maintain a per-host CLAP path list that doesn’t include the user-local default.
Installing the VS Code extension
The VS Code extension provides syntax highlighting and language-server
features (diagnostics, hover, go-to-definition, completions) for
.patches files. The language server itself is patches-lsp, a
native binary bundled inside the VSIX for the current platform.
The extension is platform-specific: each VSIX contains the
patches-lsp binary for one OS / architecture. Install the one that
matches the machine VS Code is running on.
| VSIX name | Platform |
|---|---|
patches-vscode-darwin-arm64-<ver>.vsix | Apple Silicon Macs |
patches-vscode-darwin-x64-<ver>.vsix | Intel Macs |
patches-vscode-win32-x64-<ver>.vsix | 64-bit Windows |
patches-vscode-linux-x64-<ver>.vsix | 64-bit Linux (built |
| from source — not yet | |
| shipped in releases) |
The release archives contain the matching VSIX for that OS already; the file is also attached as a separate asset on each release for people who only want the extension.
Install from VSIX
From a terminal:
code --install-extension patches-vscode-<platform>-<ver>.vsix
Or from inside VS Code:
- Extensions (Cmd+Shift+X / Ctrl+Shift+X).
- Three-dot menu → Install from VSIX…
- Pick the VSIX.
Reload the window. Open any .patches file — the Patches DSL
extension activates, the editor gains syntax colouring, and the LSP
starts in the background.
macOS: bundled LSP quarantine
If patches-lsp itself was downloaded as part of the release archive
it will likely be quarantined. The extension auto-starts the bundled
binary on first activation; if it fails (the Patches DSL: Output
panel shows “operation not permitted” or a Gatekeeper dialog appears),
strip quarantine from the binary inside the installed extension:
xattr -dr com.apple.quarantine \
~/.vscode/extensions/vulpus-labs.patches-vscode-*/server/
Then Developer: Reload Window. Running macos-unquarantine.sh on
the release archive avoids this — the script clears the flag on the
.vsix and, if the code CLI is on PATH, installs the extension
for you. code --install-extension preserves the cleared attribute,
so the bundled LSP starts cleanly.
Windows: bundled LSP unblock
If the LSP fails to start on Windows, locate the installed extension
under %USERPROFILE%\.vscode\extensions\vulpus-labs.patches-vscode-*,
open server\patches-lsp.exe → Properties → Unblock, and reload
the window.
Build from source
From a repo checkout, the canonical recipe is the per-platform build script:
./scripts/package-vsix.sh darwin-arm64
# or: darwin-x64 | win32-x64 | linux-x64
# omit the arg to build all four
This release-builds patches-lsp for the requested target, copies
the binary into patches-vscode/server/, compiles the TypeScript
client, and produces a VSIX under dist/. Then:
code --install-extension dist/patches-vscode-darwin-arm64-*.vsix --force
./deploy.sh runs this for the current host architecture and reloads
the extension into VS Code in one step — the recommended path during
local development on macOS.
Linux dependencies for building
Building patches-lsp needs ALSA dev headers
(sudo apt install libasound2-dev); building the VSIX itself needs
Node.js 20+ and npx.
Configuration
The extension contributes two settings of note (both under Patches DSL in the settings UI):
patches.lsp.path— override the bundled binary with an alternativepatches-lsp(e.g. a debug build undertarget/debug/). Leave empty to use the bundled binary, or to fall back to PATH lookup if no bundle is present.patches.modulePaths— directories or specific dylib files to scan for FFI module bundles. Required if you reference modules from external bundles (e.g.patches-vintage) in a.patchesfile — without it the LSP can’t resolve their descriptors and hover/diagnostics will be incomplete.
Verification
- Open a
.patchesfile (any fromexamples/will do). - Syntax should be coloured: keywords, module type names, ports, parameters distinct.
- Hover over a module type name (e.g.
Osc) — a tooltip describes the module and lists its ports. - Introduce a typo (
Osce); a red squiggle appears with a “Unknown module type” diagnostic.
If steps 3–4 don’t work the LSP isn’t running. Open Output → Patches DSL in the bottom panel for the server log; the most common cause on macOS is the quarantine flag covered above.
Unsigned binaries
The Patches release binaries are unsigned. There is currently no Apple Developer ID and no Windows code-signing certificate behind the project. macOS and Windows both treat downloaded unsigned binaries with suspicion, so first-run requires an explicit override.
This is policy on the publisher’s side, not a security flaw on yours — but the override mechanisms are real protections, so it’s worth knowing what each one does.
macOS — Gatekeeper
When macOS downloads a file from the internet it tags it with an
extended attribute, com.apple.quarantine. Gatekeeper checks this
attribute on first launch: if the file is unsigned (or signed by an
unrecognised developer), the OS refuses to run it with a “cannot be
opened because the developer cannot be verified” dialog.
The release binaries are ad-hoc signed — codesign --sign - —
which satisfies the dyld loader (the dylib will resolve and link) but
not Gatekeeper (no trusted certificate). The quarantine flag must be
removed for first launch.
The release archive ships macos-unquarantine.sh for this:
./macos-unquarantine.sh
It runs xattr -dr com.apple.quarantine over patch_player,
Patches.clap, any bundled native module dylibs, and the bundled
.vsix. It also installs Patches.clap into
~/Library/Audio/Plug-Ins/CLAP/ and, if the code CLI is on PATH,
installs the bundled VS Code extension. Steps that have no matching
input are skipped silently; the script is idempotent. Manual
equivalent for clearing a single file:
xattr -dr com.apple.quarantine /path/to/Patches.clap
xattr -l <file> lists extended attributes if you want to check
whether the flag is still set.
Once cleared, the binary launches normally — Gatekeeper only checks on first launch. Re-downloading or re-extracting from the original archive re-applies the flag.
Windows — SmartScreen and Mark of the Web
Windows downloads from the internet get tagged with a Mark of the
Web (an alternate data stream named Zone.Identifier). When you
launch a tagged executable, SmartScreen interposes a “Windows
protected your PC” dialog and refuses to run unrecognised
unsigned binaries by default.
This applies equally to the MSI installer and to individual
binaries in the zip archive — the MSI is not code-signed, so
SmartScreen warns on it just as it does on the loose .exe. From
the warning dialog, click More info → Run anyway to proceed.
For loose files in the zip archive, the override is per-file:
- Right-click the file (
.exe,.dll,.clap,.vsix). - Properties → General tab.
- Tick Unblock at the bottom.
- OK.
This clears the Mark of the Web for that file. SmartScreen won’t prompt on subsequent launches.
PowerShell equivalent for scripting:
Unblock-File -Path .\patch_player.exe
Unblock-File -Path .\Patches.clap
Get-Item .\patch_player.exe -Stream Zone.Identifier lists the
zone-identifier stream if it is still present.
Linux
Linux distributions have no equivalent gating — the binary runs as soon as it has the executable bit set. If you downloaded a build artefact without the executable bit, restore it:
chmod +x patch_player
That’s the whole “override”.
Why no signing?
Both Apple and Microsoft signing programs require ongoing developer identity verification and a non-trivial annual fee. Patches is a small project and signing has not been prioritised. When that changes, these workarounds become unnecessary.
For now: clear the quarantine flag, tick Unblock, run the binary.
Running a patch in the player
patch_player (built from the patches-player crate; binary name
patch_player, sometimes still referred to as patches-player) is
the standalone command-line player. It opens an audio device, loads
a .patches file, runs it, and watches the file for changes.
The smallest run
patch_player examples/square_440.patches
A pulse-width-modulated square plays through the default output device. The terminal switches into the TUI — log pane, CPU pane, status bar. Ctrl-C exits cleanly.
If the patch fails to compile, the player renders diagnostics with source spans to stderr (still in TUI mode) and keeps the previous patch running. Fix the error and save; the new graph swaps in.
MIDI
When the player starts it auto-connects to every MIDI input port
currently visible to the system. There is no UI for choosing a
device — plug the controller in before launching, and it appears.
Events from any connected port feed into the patch’s MIDI source
modules (MidiToCv, PolyMidiToCv, MidiCC, etc.).
If no ports are available, the player logs a warning and keeps running; you can still drive sequenced patches that don’t need external input.
To connect a new device after launch, exit and re-run — MIDI ports are opened once at startup. Re-running is cheap; the patch reloads fresh.
Audio routing
Use --list-devices to see what CPAL exposes:
patch_player --list-devices
Then point at a specific device with --output-device:
patch_player --output-device "External Headphones" examples/square_440.patches
--input-device <name> opens a capture device for modules that
process input audio (AudioIn and friends). Names are matched
literally; if the host system reports a device as
"Built-in Microphone (Builtin)", that whole string is the argument.
--oversampling <1|2|4|8> raises the internal sample rate before
downsampling to the device rate — useful for alias-heavy patches
(unfiltered saw, hard sync). Default is 1 (no oversampling).
Recording
patch_player --record session.wav examples/tracker_three_voices.patches
The output stream is forked into a 16-bit PCM stereo WAV at the
device’s sample rate. The file finalises when the player exits
(Ctrl-C is fine; it runs the recorder’s Drop impl on the way out).
Crash the process and the WAV header will be incomplete.
Hot-reload
The player watches the patch file. Save it, and the engine builds the new graph and swaps it in without stopping the audio. Module instances whose name and type are unchanged keep their internal state across the swap — an oscillator’s phase, a delay’s buffer, an envelope’s position — so small edits sound like parameter tweaks rather than restarts.
This is the development REPL described in the introduction. See The edit-reload cycle for the rules on what survives a reload, what resets, and how to structure patches to make iteration smooth.
Other flags
patch_player with no arguments prints the full flag list. Common
options:
| Flag | Effect |
|---|---|
--no-tui | Legacy stdout frontend (no TUI). |
--no-stdin | With --no-tui, also skip reading from stdin. |
--module-path <path> | Scan a directory or specific dylib for FFI module bundles. Repeatable. |
--output-device <name> | Open a specific audio output device by name. |
--input-device <name> | Open a specific audio input device by name. |
--oversampling <N> | Internal sample-rate multiplier N ∈ {1, 2, 4, 8}; default 1. |
--record <path> | Fork the output stream to a 16-bit PCM stereo WAV at the device rate. |
--monitor | Enable the per-instance CPU monitor tab. |
--list-devices | Enumerate audio devices and exit. |
--no-tui is most useful for headless smoke runs and CI — it just
prints to stdout and reads from stdin, no terminal raw mode.
Exiting
Ctrl-C in the TUI. The player tears down the audio stream, finalises
any WAV recording, and restores the terminal. If the terminal looks
broken after an unclean exit, reset will fix it.
Running a patch in a DAW
The CLAP plugin loads a .patches file inside any CLAP host and runs
it under that host’s transport, audio routing, and MIDI. The same
DSL, modules, and engine — just hosted, with the DAW responsible for
audio I/O and clock instead of the player binary.
The bundle ships two descriptors from a single dylib:
- Patches — an instrument. Appears under the host’s instrument browser. Takes MIDI in, produces stereo audio out.
- Patches FX — an audio effect. Appears under the host’s effect browser. Takes stereo audio in, produces stereo audio out, still accepts MIDI for control.
Pick the descriptor that matches what you want to do; the underlying engine is the same.
Loading a patch
- Add Patches to a MIDI track (or Patches FX to an audio track).
- The plugin window shows a small webview-based GUI with a header strip carrying the current patch path and two buttons — Browse… and Reload — and a tabbed body (Modules, Diagnostics, scope/meter taps if the patch declares any).
- Hit Browse… and pick a
.patchesfile. The plugin compiles it, swaps the graph in, and starts producing audio on the next block.
Diagnostics appear in the Diagnostics tab’s Event log. If a file fails to compile, the plugin reports the error there and keeps the previous patch running.
Host examples
REAPER
- Insert → Virtual Instrument on New Track → pick Patches from Instruments (CLAP).
- Or, on an audio track, FX → Add → Patches FX.
- MIDI routing is automatic from the track’s MIDI items.
- Options → Preferences → Plug-ins → CLAP lists the directories
scanned; the user-local default (
~/Library/Audio/Plug-Ins/CLAP/on macOS) is included by default.
Bitwig Studio
- Drop Patches onto an instrument track from the device browser under Instruments → CLAP.
- For audio processing, drop Patches FX onto an audio track.
- Bitwig rescans on launch; trigger a manual rescan via Settings → Locations → CLAP plug-ins → Refresh if a freshly installed bundle doesn’t appear.
Other CLAP hosts (Ableton Live 11.3+, FL Studio 21.2+, Studio One 6.5+) follow the same pattern: instrument bucket for Patches, effect bucket for Patches FX.
MIDI
The host routes MIDI to the plugin. For Patches that means notes
flow into MidiToCv / PolyMidiToCv modules and CC traffic flows
into MidiCC modules. The plugin doesn’t open MIDI ports of
its own — the host handles all device selection.
Self-contained patches (those driven by a MasterSequencer /
PatternPlayer rather than MidiToCv) ignore inbound MIDI and play
themselves. Sequenced and externally-driven approaches can be mixed
within a single patch.
Automation
Host controls declared in a patch via the knob / slider /
toggle / trigger declarations show up in the host’s parameter
list for automation, modulation, and per-track recall. Manipulate
them from a DAW lane the same way you would any other plugin
parameter.
Parameter values move through the persisted-settings layer, so an automated value at the start of playback is restored from the saved project state.
Project save and recall
When the host saves its project, the plugin’s persisted state goes into the project file. That state captures:
- the absolute path to the loaded
.patchesfile - the DSL source itself (stored verbatim, so the project survives a missing or moved file)
- host-control values
- per-tap display options (scope decimation, spectrum FFT size, etc.)
- the GUI window size
- the user’s persisted bundle-dir list
Reopening the project restores all of it. If the original file path no longer resolves, the plugin falls back to the embedded DSL source — the patch still plays; Browse re-points it at a live file.
The persisted-settings format is local to the plugin, so a project moved to another machine keeps its audio behaviour even if the file path doesn’t transfer.
GUI tour
- Header strip — current patch path, plus Browse… and Reload buttons.
- Modules tab — list of registered modules with an intent bar
carrying Rescan (re-probe the bundle directories for FFI
module bundles, used after dropping in a new
.dylib) and Add bundle dir… (add to the per-project module search path). - Diagnostics tab — Event log with compile diagnostics, hot-reload events, rescan reports, halt notifications, plus the current diagnostics summary.
- Tap views (separate tabs when the patch declares scope / meter / spectrum taps) — per-tap panels render the live signal. Display options (decimation, FFT size, heatmap on/off) persist with the project.
The internals of the plugin — webview IPC, snapshot pushes, halt handling — live in CLAP plugin internals.
Self-contained patches
A self-contained patch generates its own notes. It uses the tracker, step-sequencer, or clock modules as the note source rather than reading MIDI from an external controller or a DAW. Load it in the player, the piece starts, and the patch is the composition.
This is the third of the three peer workflows. The DSL, modules, and engine are identical to the other two — the only difference is what drives the gate, trigger, and pitch ports of the voice modules.
How it works
External-MIDI patches typically begin with something like:
module kbd : PolyMidiToCv
kbd.voct -> osc.voct
kbd.gate -> env.gate
kbd.trigger -> env.trigger
A self-contained patch replaces that source with a sequencer, and wires the sequencer’s clocks and CV outputs into one or more voices:
module seq: MasterSequencer([voice_a]) {
song: my_song,
bpm: 120,
rows_per_beat: 4,
autostart: true
}
module pp: PatternPlayer([note, vel])
seq.clock[voice_a] -> pp.clock
pp.cv1[note] -> voice.voct
pp.gate[note] -> voice.gate
pp.trigger[note] -> voice.trigger
pp.cv1[vel] -> voice.velocity
The notes themselves live in song and pattern blocks at the top
of the file. Their full syntax — pitch names, ties (~), continues
(.), velocity rows, transitions (>), repeats (*N),
play { ... } structure — is covered in the
DSL reference under the tracker section.
Walkthrough — tracker_three_voices.patches
examples/tracker_three_voices.patches is a three-voice piece: bass,
two leads, stereo delay and reverb. Run it:
patch_player examples/tracker_three_voices.patches
It plays itself. No MIDI connection needed. Ctrl-C to stop.
The structure, top to bottom:
song demo(bass, lead1, lead2)— declares three named voices and the patterns they play. Eachpatternblock defines a row of pitches and a row of velocities;~ties into the previous note,.continues the gate,*Nrepeats.play { ... }lists the patterns to chain together, with* Nfor repetition.template voice(...)— a parameterised synth-voice template exposingvoct,gate,trigger,velocityinputs and one audio output. Internally: oscillator, ADSR, VCA, filter, glide.template sq_voice(...)— a square-wave variant for lead 2.patch { ... }— the actual graph. Inside:MasterSequencerconsumes the song and produces a per-voice clock plus pattern data.- One
PatternPlayerper voice unpacks the active pattern’s rows into CV / gate / trigger streams. - Each voice template instantiates with per-voice parameters (attack, decay, filter cutoff, glide).
StereoMixer(3)sums voices, panning leads left and right.- A delay send/return loop and a master reverb + limiter sit on the bus.
The bottom of the file is the same AudioOut the player chapter
showed — the only difference is that audio reaching it was generated
internally rather than triggered from outside.
Autostart vs. external clock
MasterSequencer accepts autostart: true (start playing when the
patch loads) or false (wait for an external trigger). In the
player, with autostart: true, the patch plays as soon as the file
is loaded. In a DAW, you can wire the host’s transport into the
sequencer’s start input for project-synchronised playback — see the
sequencer entry in Sequencers & clocks.
Mixing the modes
Nothing prevents a single patch from doing both. A MasterSequencer
can drive a backing voice while a PolyMidiToCv reads incoming MIDI
into a lead voice — the engine doesn’t care where the gate edges
come from. Self-contained vs. externally-driven is a property of how
the patch is wired, not a mode the player has to be told about.
DSL basics
This chapter introduces the syntax for declaring modules, setting their parameters, and drawing cables between them. It assumes the concepts from Mental model — modules, ports, cables, signal kinds — and shows how to write them down.
For the complete grammar with every form, see the DSL reference. This chapter is the on-ramp; that is the lookup table.
The patch block
Every .patches file ends with exactly one patch { ... } block. It
contains module declarations, connections, and (optionally)
pattern/song/template/host-control blocks. Comments run from # to
end of line; whitespace and blank lines don’t matter.
patch {
module osc : Osc { frequency: 440Hz }
module out : AudioOut
osc.sine -> out.in
}
A 440 Hz sine to the soundcard. Save and run.
Modules
A module declaration gives an instance a name and a type:
module osc : Osc
- Name — your handle for the instance (
osc). Used in connections, and used for identity-matching across hot-reloads: a module whose name and type are unchanged carries its state across a reload. - Type — the module type as registered in the module registry
(
Osc,PolyAdsr,Svf,StereoMixer). Determines what ports the instance has.
The set of available types is the module reference.
Parameters
Most modules take parameters in braces after the type:
module osc : Osc { frequency: 440Hz }
module env : Adsr { attack: 0.005, decay: 0.1, sustain: 0.7, release: 0.3 }
Parameter values support several literal forms:
| Syntax | Meaning |
|---|---|
440.0, -0.5 | Float. |
2, -1 | Integer. |
440Hz, 2.5kHz | Frequency. Converted to V/oct internally. |
-6dB | Decibels. Converted to linear amplitude. |
7s | Semitones. Converted to V/oct (s = semitone). |
100c | Cents. Converted to V/oct. |
C4, Bb2, F#5 | Note name. Converted to V/oct. |
true, false | Boolean. |
plate, linear | Bare identifier (used by enum parameters). |
"some string" | Quoted string. |
There is no seconds suffix. Time parameters take a bare float
(attack: 0.005 is 5 ms). The s after a number means semitones,
not seconds — 7s is “seven semitones above C0”, which is G0. The
Hz/kHz/dB/s/c suffixes are parsed as part of the literal
and converted at compile time, so frequency: 440Hz and
frequency: 440.0 are not equivalent — the first means 440 hertz,
the second means 440 V/oct (which is nonsense). Use the right unit.
Shape arguments
Some modules have a variable number of ports. The shape is set in parentheses before the parameter braces. Prefer a named alias list — it documents what each channel is for and lets you address the indexed ports by name everywhere they appear:
module mix : StereoMixer([drums, bass, lead]) {
@drums: { level: 0.8, pan: 0.0 },
@bass: { level: 0.7, pan: -0.2 },
@lead: { level: 0.6, pan: 0.3 }
}
module dly : StereoDelay([short, long]) { delay_ms: 750, feedback: 0.4 }
The aliases become channel names: mix.in[drums], mix.in[bass],
mix.in[lead]. A bare count (StereoMixer(3)) or the named-count
form (StereoMixer(channels: 3)) work too — they fall back to
numeric indices (mix.in[0], mix.in[1], …) — but the alias form
reads better and survives reordering.
Indexed parameters
Per-channel parameters are written with a bracket index — using the alias declared on the shape:
module mix : StereoMixer([drums, bass, lead]) {
level[drums]: 0.8, pan[drums]: 0.0,
level[bass]: 0.7, pan[bass]: -0.2,
level[lead]: 0.6, pan[lead]: 0.3
}
Or, more concisely, with the at-block form that groups all fields for one channel:
module mix : StereoMixer([drums, bass, lead]) {
@drums: { level: 0.8, pan: 0.0 },
@bass: { level: 0.7, pan: -0.2, send_a: 0.3 },
@lead: { level: 0.6, pan: 0.3, send_a: 0.3 }
}
Numeric at-blocks (@0: { ... }) work when you declared the shape
with a bare count.
The colon after @N is optional. Channels not listed take the
parameter defaults defined by the module.
Connections
A connection (cable) carries a signal from one output port to one input port:
osc.sine -> filt.in
filt.lowpass -> vca.in
env.out -> vca.cv
vca.out -> out.in
module.port on each side; an arrow -> between them.
Fan-out is free. An output can drive many inputs without an explicit “split” module — draw multiple cables from the same output:
lfo.sine -> osc.frequency_cv
lfo.sine -> filt.cutoff_cv
Fan-in is summed automatically. List multiple sources before the
arrow and the expander synthesises a Sum (or PolySum /
StereoSum, depending on cable kind) under the hood:
osc_a.sine, osc_b.sine -> out.in
When you want explicit level control per channel — sends, mute/solo,
panning — declare a Mixer or StereoMixer and route into its
indexed inputs instead.
Scaled cables
A cable can multiply its signal by a constant. Write the scale in square brackets between the dashes:
osc.sine -[0.3]-> out.in # attenuate
lfo.sine -[-1.0]-> osc.voct # invert
mod.out -[0.03]-> osc.phase_mod # light FM
mix.out -[-12dB]-> out.in # attenuate by 12 dB
The scale can be a plain number or a unit literal (dB, Hz, etc.,
where it makes sense for the destination).
Range-mapped cables
For modulation depth that depends on the destination’s natural unit,
use uni(lo, hi) (maps a [0, 1] source into [lo, hi]) or
bi(lo, hi) (maps a [-1, 1] source into [lo, hi]):
lfo.sine -[bi(-1Hz, 1Hz)]-> osc.frequency_cv
env.out -[uni(0Hz, 1kHz)]-> filt.cutoff_cv
See the DSL reference for the full semantics and the cases where range-mapping matters.
Indexed ports
Connect to a specific channel by its alias (declared on the module’s shape) or by numeric index. Aliases are the better default — they document what each cable is for and don’t break when channels are added or reordered.
module mix : StereoMixer([bass, lead1, lead2])
bass_v.audio -> mix.in[bass]
lead1_v.audio -> mix.in[lead1]
lead2_v.audio -> mix.in[lead2]
Numeric indices still work for modules declared with a bare count
(StereoMixer(3) gives you mix.in[0], mix.in[1], mix.in[2])
and are fine for throwaway sketches. Reach for aliases as soon as
the patch has more than two channels or you find yourself counting.
A complete example
Detuned sawtooth pair through a filter to stereo out:
patch {
module osc_a : Osc { frequency: 220Hz }
module osc_b : Osc { frequency: 221Hz }
module filt : Lowpass { cutoff: 1.5kHz }
module out : AudioOut
osc_a.sawtooth, osc_b.sawtooth -> filt.in
filt.out -[0.3]-> out.in
}
Run it, then change 221Hz to 222Hz and save — the beating slows
without restarting the audio.
What’s next
- Signal kinds and cable rules — what connects to what, and why.
- Templates and abstraction — building reusable named subgraphs.
- Poly and voice allocation — polyphony, voice counts, MIDI-to-CV.
- DSL reference — every syntax form, for lookup.
Signal kinds and cable rules
Every port has a kind: what flavour of signal it carries. The kind decides which cables can connect to it. The five kinds — Audio, CV, Gate, Trigger, Stereo — were introduced in Mental model. This chapter lays out the rules the planner enforces and the conversions that happen for you.
The five kinds, quickly recapped
| Kind | Sample stream | Typical use |
|---|---|---|
| Audio | Float samples, roughly [-1, +1]. | Sound. Anything you’d hear. |
| CV | Float samples, slow. V/oct convention. | Pitch, modulation. |
| Gate | 1.0 (held) or 0.0 (released). | Note hold. |
| Trigger | Single-sample 1.0 pulse. | Note start. |
| Stereo | One cable carrying (L, R) pairs. | Stereo audio. |
Underneath, the engine just streams f32 samples — there is no
runtime tag distinguishing audio from gate. The kind is a typing
discipline. Patches modules declare their ports’ kinds and the
planner refuses connections that violate the rules.
The freedom of “samples are samples” is intentional: you can
deliberately feed audio into a CV input for aggressive modulation,
or run an envelope’s output through AudioOut as a one-shot
percussion sample. The type system lets the planner catch the
mistakes worth catching while leaving the creative misuse paths
open.
Mono and poly are orthogonal
A cable is also either mono (one sample per audio tick) or poly (one sample per voice per tick — typically 16 voices). This is independent of the kind:
Osc(mono) has mono audio outputs.PolyOsc(polyphonic) has poly audio outputs.- Similarly
Adsr(mono CV out) andPolyAdsr(poly CV out).
A module instance is wholly mono or wholly poly; there is no
“mostly mono with one poly port” mixed case. The poly counterpart
of a mono module type is a separate type, usually prefixed Poly.
Cable rules
The planner enforces the following rules at compile time:
Identical kind → free
Audio→Audio, CV→CV, Gate→Gate, Trigger→Trigger, Stereo→Stereo all connect without ceremony.
osc.sine -> filt.in # Audio → Audio
lfo.sine -> osc.voct # CV → CV
midi.gate -> env.gate # Gate → Gate
midi.trigger -> env.trigger # Trigger → Trigger
Audio ↔ CV / Gate / Trigger → free
Because they are all f32 streams under the hood, mono Audio /
CV / Gate / Trigger can be cross-connected. The planner does not
flag this — it’s the deliberate-misuse path.
This is a feature: routing an envelope’s output as audio gives a one-shot percussion sample; routing an audio signal into a CV input gives audio-rate modulation.
Mono Audio → Stereo input → silent broadcast
A mono audio output feeding a stereo audio input is silently
broadcast: L = R = source. No synthetic splitter node, no warning.
osc.sine -> dly.in # dly.in is stereo; sine is mono
# → both channels receive sine
This removes the need for explicit broadcasters at every mono-to-stereo boundary.
Stereo → mono input → rejected
The reverse is not permitted. Compiling fails with
CableKindMismatch. If you want to mix down, route through
PolyToMono for poly, or StereoSplitter / StereoJoiner for
stereo when you want both halves explicitly.
dly.out -> mono_filt.in # ERROR: stereo → mono
Use StereoSplitter first if the split is intentional:
module split : StereoSplitter
dly.out -> split.in
split.left -> mono_filt.in
Mono ↔ Poly → bridge module required
Mono and poly are not auto-convertible. Use MonoToPoly to
broadcast a mono signal to every voice:
module mtp : MonoToPoly
lfo.sine -> mtp.in
mtp.out -> poly_osc.voct # poly_osc.voct now sees the LFO on all voices
And PolyToMono to sum poly voices into a single mono stream:
module ptm : PolyToMono
poly_vca.out -> ptm.in
ptm.out -> reverb.in
There is no automatic broadcast or sum; the type mismatch must be handled by an explicit module so it appears in the patch.
Fan-out and fan-in
Two separate rules, distinct from kind rules:
- Fan-out is free. An output port can drive any number of inputs. Draw multiple cables from it; no “split” module needed.
- Fan-in needs a mixer. An input port accepts exactly one
cable. To combine signals into one input, route them through a
Sum,Mixer,StereoMixer, or similar.
# Fan-out: one LFO modulates three destinations
lfo.sine -> osc.frequency_cv
lfo.sine -> filt.cutoff_cv
lfo.sine -> vca.cv
# Fan-in: three sources sum into one filter — auto-summed,
# no explicit Sum module needed
osc_a.sine, osc_b.sine, osc_c.sine -> filt.in
Cable delay
This is a subtle one and matters for understanding when feedback is well-defined.
In a cycle-bearing region of the graph, every cable carries a
one-sample delay: a module’s process call sees what its
neighbours wrote on the previous tick. This is what makes feedback
loops have a well-defined value at every tick — a feedback cable is
simply yesterday’s sample.
In acyclic (feedback-free) regions, the planner fuses the chain so consumers read producers’ current-tick output. This is invisible to you — the audio matches the conceptual model — but it removes the one-sample-per-cable lag that would otherwise accumulate audibly across a long signal chain.
You don’t need to think about this when writing a patch. The cable delay matters only when you’re building feedback loops where the one-sample latency is a feature (a delay line’s feedback path, a self-oscillating filter, a phase-modulation loop).
Summary
| Source kind | Destination kind | Allowed? |
|---|---|---|
| Audio (mono) | Audio (mono) | yes |
| Audio (mono) | CV / Gate / Trigger | yes (intentional misuse) |
| Audio (mono) | Stereo | yes — L = R = source |
| Stereo | Stereo | yes |
| Stereo | Mono Audio | no — splitter needed |
| Mono | Poly | no — MonoToPoly |
| Poly | Mono | no — PolyToMono |
| Poly Audio | Poly Audio | yes |
If a connection is rejected, the diagnostic will name the source kind, the destination kind, and the rule that fails. Read it literally — it tells you which bridge module to insert.
Templates and abstraction
A template is a named, parameterised subgraph that you can stamp out many times in a patch. Templates are expanded at compile time — they are a DSL-level abstraction, not a runtime construct. Every instantiation produces concrete module instances with mangled names, and the result is what the engine actually runs.
Use templates to:
- Define a synth voice once and instantiate it per pitch / role.
- Wrap a stable subgraph (an effect chain, a stereo widener, a drum kit channel) so the top-level patch reads at a higher level of abstraction.
- Parameterise across patches — a
voicetemplate lives in one place and a hundred patches can use it.
Defining a template
A template definition is a top-level block:
template voice(attack: float = 0.01, decay: float = 0.1,
sustain: float = 0.7, release: float = 0.3) {
in: voct, gate, trigger
out: audio
module osc : Osc
module env : Adsr { attack: <attack>, decay: <decay>,
sustain: <sustain>, release: <release> }
module vca : Vca
$.voct -> osc.voct
$.gate -> env.gate
$.trigger -> env.trigger
osc.sawtooth -> vca.in
env.out -> vca.cv
vca.out -> $.audio
}
Three things to notice:
- Parameter list (
attack: float = 0.01, ...). Typed, with optional defaults. Types arefloat,int,bool,str,pattern,song. Parameters without a default are required at the call site. - Boundary ports (
in:andout:lists). These are the template’s external interface. Inside the body,$.namerefers to the boundary portname. - Parameter references (
<attack>). Substituted wherever a value is expected in the body: parameter values, scale factors, shape arguments. The substitution happens during expansion, soattack: <attack>becomesattack: 0.01in the expanded copy.
The body is otherwise just patch syntax — modules, connections,
scaled cables. Templates can also use the <- backward arrow,
which makes destination-first writing cleaner for out: ports:
$.audio <- vca.out
is equivalent to vca.out -> $.audio.
Instantiating
A template instantiates with the same module name : Type syntax as
a built-in module, except the type is the template name and the
parentheses carry the template arguments:
patch {
module bass_v : voice(attack: 0.005, decay: 0.15, sustain: 0.6)
module lead1_v : voice(attack: 0.02, decay: 0.3, sustain: 0.5)
module lead2_v : voice # all defaults
module mix : StereoMixer([bass, lead1, lead2])
bass_v.audio -> mix.in[bass]
lead1_v.audio -> mix.in[lead1]
lead2_v.audio -> mix.in[lead2]
}
Each instantiation expands to its own copy of the template’s
modules; the engine sees bass_v_osc, bass_v_env, bass_v_vca,
lead1_v_osc, and so on as distinct instances. From outside, an
instance presents only its declared boundary ports — bass_v.voct,
bass_v.gate, bass_v.audio. The internals are invisible.
Parameter types
| Type | Accepts |
|---|---|
float | Numeric values; int coerces to float. |
int | Integer values only. |
bool | true / false. |
str | Quoted string or bare identifier. |
pattern | A pattern name (for tracker-driven templates). |
song | A song name. |
Type checking is strict — a float parameter rejects true; a
bool rejects 0. The error message names the parameter and the
expected type.
Arity parameters
A template can take a parameter that controls the number of ports.
Inside the body, [*name] expands per-index:
template summing_voice(size: int, level[size]: float = 1.0) {
in: audio[size]
out: mixed
module sum : Sum(<size>)
$.audio[*size] -> sum.in[*size]
sum.out -> $.mixed
}
Instantiate it with a concrete size, and the connection
$.audio[*size] -> sum.in[*size] expands into one cable per index:
$.audio[0] -> sum.in[0], $.audio[1] -> sum.in[1], and so on.
The level[size] indexed parameter accepts per-index values:
level[0]: 0.8, level[1]: 0.6, ....
Scale composition across boundaries
When a scaled connection crosses a template boundary, the scales
multiply. A patch-level -[0.5]-> into a template that internally
attenuates with -[0.3]-> produces an effective scale of 0.15 at
the underlying cable — there is no intermediate buffer.
# Inside the template:
module mix : Sum(2)
$.in -[0.3]-> mix.in[0]
...
# In the patch:
src.out -[0.5]-> tmpl.in
# Effective scale at sum.in[0]: 0.5 * 0.3 = 0.15
This means template authors don’t need to leave headroom for “unknown caller scaling” — the multiplication composes predictably.
Nesting
Templates can instantiate other templates. The expander flattens the whole tree before the patch is built:
template filtered_voice(...) {
in: voct, gate, trigger
out: audio
module v : voice(attack: 0.005, ...)
module f : Lowpass { cutoff: 1.2kHz }
$.voct -> v.voct
$.gate -> v.gate
$.trigger -> v.trigger
v.audio -> f.in
f.out -> $.audio
}
patch { module fv : filtered_voice; ... } expands to a v_voice
sub-instance, a v_voice_osc, v_voice_env, v_voice_vca, and a
v_filter — six concrete instances from one declaration. Recursion
is bounded by depth; infinite recursion is caught at expansion time
with a clear error.
When to use templates
Use them when:
- The same subgraph appears three or more times.
- A subgraph has a stable interface (a few clearly-named boundary ports) and you want to vary internal parameters.
- You want to name an abstraction (
voice,kit_channel,widener) that makes the top-level patch read at a higher level.
Skip them when:
- A subgraph appears once and won’t be reused. Inline modules read better than a one-shot template.
- You’re tempted to use templates to abstract over module types.
Templates instantiate a fixed set of modules with parameterised
values — they don’t let you swap
OscforPolyOsc. For that, write two templates.
Templates as facades
A useful mental model: a template is a typed facade over the underlying graph. The parameter list declares which knobs are exposed; the port list declares the connection surface. The body — which modules get instantiated and how they’re wired — is hidden implementation. Patches that use the template see only the typed interface; the expander erases the abstraction before the engine ever runs.
This is why scale composition, port-arity expansion, and parameter substitution all happen at expansion time: templates are a compile step, not a runtime layer.
Poly and voice allocation
A poly cable carries one sample per voice per audio tick, where “voice” is one of a fixed-size pool. Patches uses a pool size of 16 voices in every shipping host (player, CLAP plugin, integration tests). A poly cable’s data is laid out as 16 parallel mono streams.
This chapter covers how poly works at the patch level: how to introduce voices, how MIDI gets mapped into them, what happens when the pool fills up, and how to bridge between mono and poly.
Poly module types
Most module types come in mono-only flavour. Poly variants exist for the building blocks of a polyphonic voice:
| Mono | Poly |
|---|---|
Osc | PolyOsc |
Adsr | PolyAdsr |
Vca | PolyVca |
Svf | PolySvf |
Glide | PolyGlide |
MidiToCv | PolyMidiToCv |
A poly module’s ports are poly throughout: PolyOsc.voct is a poly
CV input that takes one V/oct value per voice, and its sawtooth
output is a poly audio stream of 16 sawtooths. An entire voice —
oscillator, envelope, filter, VCA — is wired in poly and produces
16 parallel voice outputs, which are then mixed down to a mono or
stereo signal for the master bus.
Voice allocation: PolyMidiToCv
The standard way to introduce poly is with PolyMidiToCv. It
receives MIDI note-on / note-off events and assigns each note to a
voice slot, exposing per-voice voct, gate, trigger, and
velocity poly outputs.
module kbd : PolyMidiToCv
module osc : PolyOsc
module env : PolyAdsr {
attack: 0.005, decay: 0.1, sustain: 0.7, release: 0.3
}
module vca : PolyVca
module mix : PolyToMono
module out : AudioOut
kbd.voct -> osc.voct
kbd.gate -> env.gate
kbd.trigger -> env.trigger
osc.sine -> vca.in
env.out -> vca.cv
vca.out -> mix.in
mix.out -[0.2]-> out.in
The signal chain is wholly poly from kbd through vca; PolyToMono
sums the 16 voices into one mono stream before output.
Allocation policy
When a new note arrives:
- A free voice exists. The note takes the first free slot.
- All voices are active. The most-recently-allocated voice is stolen (LIFO — last in, first out): its gate goes low briefly, then the new note takes its slot.
This is fast, predictable, and avoids the “oldest voice dropping mid-release” surprise of FIFO stealing. The trade-off: rapidly restruck notes can chain-steal each other if you exceed 16 simultaneous voices.
Note-off releases the matching voice, gating its envelope down. The voice slot becomes free for reuse after the envelope’s release phase finishes (or immediately, if a new note steals it).
Velocity
PolyMidiToCv.velocity carries the per-voice velocity as a CV
signal (0 to 1). Route it into a PolyVca’s CV input to
velocity-shape the envelope:
module vel_vca : PolyVca
env.out -> vca.cv
vca.out -> vel_vca.in
kbd.velocity -> vel_vca.cv
vel_vca.out -> mix.in
Bridges: MonoToPoly / PolyToMono
Mono and poly cables cannot connect directly. Two bridge modules handle the boundary.
MonoToPoly — broadcast
MonoToPoly copies a mono signal to every voice. Useful when a
single LFO or envelope modulates all voices identically:
module lfo : Lfo { rate: 0.5 }
module mtp : MonoToPoly
lfo.sine -> mtp.in
mtp.out -> poly_osc.frequency_cv # All voices receive the same LFO
After MonoToPoly, every poly voice slot carries the source’s
sample value.
PolyToMono — sum
PolyToMono sums the active voices into a single mono stream. This
is the standard way to take a poly voice chain to the master bus:
module ptm : PolyToMono
poly_vca.out -> ptm.in
ptm.out -> reverb.in
PolyToMono only sums the active voices. Inactive slots
contribute zero — there’s no per-voice CPU overhead for unused
voices in the mix-down, though every poly module still runs all 16
slots per tick.
Where poly costs come from
Poly modules run all 16 voice slots every tick. A PolyOsc is, at
runtime, 16 oscillator instances running in parallel — but they
share state allocation, control-rate updates, and DSP kernel calls,
so the per-voice overhead is much lower than 16 separate Osc
instances would be.
In practice:
- Poly voice chains scale linearly with module count in the chain, not with active voice count.
- Mono modules between poly nodes (e.g. a global reverb fed by
PolyToMono) cost the same regardless of how many voices are playing. - Effects you want per-voice (chorus, drive, voice-specific filter) go inside the poly chain; effects on the global sound go after the mix-down.
Mixing mono and poly
There’s no rule against using a mono MidiToCv for a lead voice and
a PolyMidiToCv for a pad in the same patch — the engine doesn’t
care. The same physical MIDI input feeds both modules; each does its
own allocation.
module lead : MidiToCv # mono — monophonic lead
module pad : PolyMidiToCv # 16-voice pad
This is the typical structure for a hybrid synth: a mono lead voice with its own dedicated note source, plus a poly pad voice running in parallel. Both modules see the same MIDI traffic.
What’s next
PolyMidiToCv is the most common source of poly traffic, but other
modules also produce poly streams — sequencers with multiple voice
outputs, drum-rack patterns, modulation matrices. See the
Sequencers & clocks and
MIDI reference pages for the full set.
DSL reference
This is the complete syntax reference for .patches files. It
assumes familiarity with the concepts in
Mental model and the narrative introductions in
DSL basics,
Signal kinds and cable rules,
Templates and abstraction, and
Poly and voice allocation. This page is the lookup;
those are the on-ramp.
File structure
A patch file contains zero or more top-level blocks followed by exactly one patch block. Comments run from # to end of line. Whitespace is insignificant.
# optional: include another .patches file (templates / patterns / sections)
include "shared/voices.patches"
# optional: pattern definitions for the tracker sequencer
pattern kick_basic {
hit: x:1.0 . . . x:1.0 . . .
}
# optional: song definitions referencing patterns
song my_song(kick, bass) {
play {
kick_basic, bass_verse
}
}
# optional: top-level reusable section (visible to all songs)
section intro {
kick_basic, bass_verse
}
# optional: templates
template voice(...) {
...
}
# required: exactly one patch block
patch {
...
}
include directive
include "path" inlines another .patches file at the point of the
directive. Includes are resolved at parse time, relative to the including
file. The included file may contribute templates, patterns, songs, and
top-level sections — but not a patch block (only the root file declares
one).
Module declarations
module <name> : <Type>
module <name> : <Type> { <params> }
module <name> : <Type>(<shape-args>) { <params> }
stereo module <name> : <Type>(<shape-args>) { <params> }
- name — an identifier (
[a-zA-Z_][a-zA-Z0-9_-]*). Used for connections and for identity matching across hot-reloads. - Type — the module type name as registered in the module registry (e.g.
Osc,PolyAdsr,Sum). - shape-args — key-value pairs that control port arity. Written in parentheses before the parameter braces:
Sum(channels: 3). - params — key-value pairs configuring the module’s parameters.
stereo— prefix keyword requesting two parallel instances of the module sharing connections and parameters. Per-side overrides use@l:/@r:at-blocks; per-side ports are addressed viaport[l]/port[r].
Parameter syntax
Parameters are comma-separated key: value pairs:
module env : Adsr { attack: 0.01, decay: 0.1, sustain: 0.8, release: 0.3 }
Indexed parameters use bracket notation:
module mix : StereoMixer(channels: 4) { level[0]: 0.8, pan[0]: -0.5 }
At-block syntax groups parameters by index:
module dly : Delay(channels: 2) {
@0: { delay_ms: 250, feedback: 0.4 },
@1: { delay_ms: 375, feedback: 0.3 }
}
When the module shape defines named aliases, those can be used instead of numeric indices:
module mix : Mixer(channels: [drums, bass, lead]) {
@drums { level: 0.8 },
@bass { level: 0.6 }
}
On a stereo module, the special @l: / @r: blocks override per side:
stereo module dly : Delay(channels: 2) {
@0: { delay_ms: 250 },
@l: { feedback: 0.45 }, # left-side-only override
@r: { feedback: 0.55 } # right-side-only override
}
Parameter value types
| Syntax | Type | Notes |
|---|---|---|
440.0 | float | bare decimal |
2 | int | bare integer |
440Hz | frequency | converted to V/oct; also 2.5kHz |
-6dB | amplitude | converted to linear (0 dB = 1.0, -6 dB ≈ 0.5) |
7s | interval | semitones, converted to V/oct (s = semitone) |
50c | interval | cents, converted to V/oct (c = cent) |
C4, A#3, Bb2 | note name | converted to V/oct |
true / false | boolean | |
linear | string | unquoted; "linear" also works |
"hello world" | string | quotes required if value contains spaces |
file("ir.wav") | file path | resolves to a WAV file path (used by convolution) |
Frequency, note, and dB literals are case-insensitive. There is no
time-unit suffix; durations are bare floats in seconds. s after a number
means semitones, not seconds.
Connections
Forward connection
osc.sine -> out.in
out here is AudioOut, whose in is a stereo port. The mono osc.sine
broadcasts onto both channels automatically (see Cable kinds below).
Scaled forward connection
osc.sine -[0.5]-> out.in
The scale factor is a float multiplied onto the signal at the cable level.
Range-mapped connections
A scalar -[k]-> only multiplies the signal. To map a known source
range affinely onto a destination range, use a range expression
on the arrow:
ctrl.knob -[uni(20Hz, 8kHz)]-> filter.cutoff
lfo.out -[bi(C2, C5)]-> osc.voct
Both forms hard-clip at the destination endpoints; out-of-source-range inputs saturate.
uni(lo, hi) — [0, 1] → [lo, hi]
For knob and host-control sources delivering a normalized value:
patch {
knob cutoff { displayName: "Cutoff" }
module filt : Svf
~cutoff -[uni(20Hz, 8kHz)]-> filt.cutoff
}
bi(lo, hi) — [-1, 1] → [lo, hi]
For bipolar modulation sources (LFOs, audio-rate modulators):
patch {
module lfo : Lfo
module osc : Osc
# vibrato around C4, ±1 octave; note literals lower to v/oct.
lfo.sine -[bi(C3, C5)]-> osc.voct
}
Note literals and Hz / kHz literals mix freely inside a single
range — both belong to the pitch family and lower to v/oct over
C0. Other unit families (s, dB) stay in their native unit.
Cross-family pairs are rejected at expansion time:
# error: cable range endpoints have incompatible unit families:
# pitch vs level
osc.sine -[bi(440Hz, -12dB)]-> dst.in
Endpoints accept the same forms as scalar scales: numeric and
unit-suffixed literals, note literals, and <param> references. A
template can therefore parameterise the destination range:
template knob_to_cutoff(lo: float, hi: float) {
in: knob
out: cutoff
$.knob -[uni(<lo>, <hi>)]-> $.cutoff
}
Range and scalar segments compose freely across template boundaries (see Scale composition below); the result is a single affine plus clip applied once at the destination port.
Backward connection
$.audio <- vca.out
$.audio <-[0.5]- vca.out
<- connects from right to left. Primarily useful inside templates for wiring boundary ports. The scaled form uses <-[scale]-.
Indexed ports
The index in brackets selects which slot of a multi-port to connect. Aliases declared on the module’s shape are the idiomatic form:
module mix : Mixer([drums, bass, lead])
kick.out -> mix.in[drums]
bass.out -> mix.in[bass]
lead.out -> mix.in[lead]
Numeric indices work when the shape is declared as a bare count:
module sum : Sum(2)
osc_a.sine -> sum.in[0]
osc_b.sine -> sum.in[1]
On stereo modules, the channel selectors [l] and [r] address the
per-side instance instead of a numeric index:
stereo module dly : Delay
src.left -> dly.in[l]
src.right -> dly.in[r]
Fan-in and fan-out
A comma-separated list of endpoints on either side of an arrow connects multiple sources or destinations in one statement:
osc.sine -> filter.in, scope.in # fan-out: one source → many sinks
voice_a.out, voice_b.out -> mix.in # fan-in: many sources → one sink
Fan-out drives every listed sink from the same source. Fan-in to a single
input port of uniform cable kind is auto-summed: the expander
synthesises a Sum (or PolySum / StereoSum) module under the hood.
Mixed-kind fan-in is rejected at expansion time.
Arity expansion
$.audio[*size] -> sum.in[*size]
The [*name] wildcard expands into one connection per index from 0 to name - 1. Only valid inside templates where name is an int parameter.
Rules
- An input port may receive multiple connections of uniform cable kind;
the expander synthesises a
Sum/PolySum/StereoSumto combine them (see Fan-in and fan-out). Mixed-kind fan-in is rejected. - An output port can drive any number of inputs.
- Cable kinds must match: mono ↔ mono, poly ↔ poly, stereo ↔ stereo.
Use
MonoToPoly/PolyToMonoto bridge mono and poly.
Cable kinds (mono / poly / stereo)
- Mono carries one
f32per tick. The default for control and audio cables. - Poly carries 16 voices (
[f32; 16]). Used byPoly*modules. - Stereo carries an
(L, R)pair. Stereo modules expose a single port —stereo_delay.in,lim.out— instead of paired*_left/*_rightports.
A mono Audio source feeding a stereo input is silently broadcast
(L = R = source); no extra wiring is needed for the common
“mono-into-effect” case. Stereo→mono is rejected — when the user
genuinely wants the two halves apart they go through StereoSplitter,
and StereoJoiner assembles two monos back into one stereo cable.
Host controls
A host control is a named source surfaced to the plugin host
(CLAP parameter, knob row in the CLI). Four kinds, declared at the
top of patch { ... }:
patch {
knob cutoff { displayName: "Cutoff" }
slider mix { default: 0.5 }
toggle bypass { default: false }
trigger fire { }
}
knob/sliderproduce a smoothed audio-rate signal in[-1, 1].toggleproduces a stepped 0 / 1 signal;defaultis required.triggerproduces one-sample pulses fired by the host; no required fields.
Fields
| Field | Kinds | Optional | Notes |
|---|---|---|---|
displayName | all | yes | Surface label for the host UI. |
unit | knob / slider | yes | Hint for value formatting. |
default | knob / slider / toggle | knob / slider only | Toggle requires default; others optional. |
low / high | knob / slider | yes | Display-only metadata. Runtime mapping is the cable |
| range operator’s job (see Range-mapped connections). | |||
taper | knob / slider | yes | UI taper hint (linear, exp). |
References
A ~name reference on a cable endpoint connects the host control to
a port:
patch {
knob cutoff { }
module filt : Svf
~cutoff -[uni(20Hz, 8kHz)]-> filt.cutoff
}
The cable carries the normalized [-1, 1] value; the range operator
remaps it to the destination’s musical units.
Implicit knob
If a ~name cable endpoint has no matching declaration, the
desugarer synthesises an empty knob name {} block:
patch {
module filt : Svf
~cutoff -[uni(20Hz, 8kHz)]-> filt.cutoff # implicit knob `cutoff`
}
Implicit and explicit empty-knob forms lower to the same patch. To
surface a slider, toggle, or trigger instead, declare it
explicitly — only knob is implicit.
Templates
Templates define reusable sub-graphs that are expanded at compile time with no runtime cost.
Definition
template voice(attack: float = 0.01, decay: float = 0.1, sustain: float = 0.7) {
in: voct, gate
out: audio
module osc : Osc
module env : Adsr { attack: <attack>, decay: <decay>, sustain: <sustain>, release: 0.3 }
module vca : Vca
$.voct -> osc.voct
$.gate -> env.gate
osc.sine -> vca.in
env.out -> vca.cv
$.audio <- vca.out
}
- Parameters are typed:
float,int,str,bool,pattern,song. Parameters with= defaultare optional at the call site; those without are required. - Boundary ports are declared with
in:andout:lists. Inside the template body,$.namerefers to a boundary port. - Parameter references
<name>substitute the parameter’s value anywhere in the template body: in parameter values ({ attack: <attack> }), port names (osc.<wave>), and cable scales (-[<gain>]->).
Instantiation
patch {
module v1 : voice(attack: 0.005, sustain: 0.6)
module v2 : voice # all defaults
v1.audio -> out.in
v2.audio -> out.in
}
Each instantiation expands to a separate copy of the template’s modules with mangled names to avoid collisions.
Parameter types
| Type | Accepts |
|---|---|
float | numeric values (int-to-float coercion allowed) |
int | integer values |
str | string values |
bool | true / false |
pattern | pattern names — passed to a PatternPlayer cell |
song | song names — passed to a MasterSequencer cell |
Type checking is strict. A float parameter rejects true; a bool rejects 0.
Arity parameters
A template parameter can control the number of ports:
template mixer(size: int, level[size]: float = 1.0) {
in: audio[size]
out: mixed
module sum : Sum(channels: <size>)
$.audio[*size] -> sum.in[*size]
$.mixed <- sum.out
}
The [*size] expansion generates one connection per index, scaling automatically with the arity.
Scale composition
When a scaled connection crosses a template boundary, the scales are multiplied. A connection -[0.5]-> into a template that internally has -[0.3]-> produces an effective scale of 0.15 at the underlying cable.
Nesting
Templates can instantiate other templates. A filtered_voice template can contain module v : voice(...) and add further processing. Expansion is recursive — the outermost template is fully flattened before the patch is built.
Patterns
Pattern blocks define step data for the tracker sequencer. Each pattern has one or more named channels, and each channel has a row of step values.
pattern bass_line {
note: C2 . Eb2 . G1 . Ab1 .
vel: 1.0 . 0.8 . 0.9 . 0.7 .
}
- Channel names (
note,vel,hit, etc.) are identifiers. They must match thechannelslist on thePatternPlayerthat reads this pattern. - All channels in a pattern must have the same number of steps.
- Steps are separated by whitespace.
Step events
The step grammar is a unified taxonomy of cell shapes (ADR 0077). Each
cell of a channel row parses into one shape; the row-build pass walks
each channel left-to-right tracking (slide_open, roll_active) channel
state and emits a resolved effect per cell. The pattern player
dispatches on the resolved effect.
Cell taxonomy
A cell produces up to four signals: cv1, cv2, trigger, gate.
| Cell | Trigger? | Lead-in effect on this tick | Outgoing slide / roll state |
|---|---|---|---|
value | yes | snap cv1 (and cv2) at start | closes any open slide at the boundary |
value:cv2 | yes | snap cv1 + cv2 at start | closes any open slide at the boundary |
value*N | yes | snap cv1; fires N sub-triggers | opens a roll |
value>value | yes | snap cv1 at start | one-tick slide that lands at end value |
value> | yes | snap cv1 at start | opens a multi-tick slide |
/value | no | snap cv1 at the tick boundary | closes any open slide at the boundary |
>_ | no | continues / opens a slide | extends or opens slide flow |
>value | no | ramps to value within the tick | closes the slide inside this tick |
_ | no | depends on the channel’s state | absorbed by roll / slide, else sustains |
. | no | rest — gate drops | resets channel state |
value is any of: note name (C4, A#3), float (0.5), int (2),
unit literal (440Hz, -6dB). The optional :cv2 tail attaches to
value, value>, /value, and >value (cv2 endpoints on close
cells ramp through the slide). Cv2 on value>value sugar is
A:0.5>B:0.8.
Unified close rule
A slide opened by value> or >_ closes when the row-build pass
encounters a non-_, non->_, non-value> cell:
valueclose — slide lands atvalueat the tick boundary entering the cell; cell then fires a fresh trigger on its own tick./valueclose — slide lands atvalueat the tick boundary; cell holds without retrigger.>valueclose — slide ramps tovalueinside this tick (no trigger); the close-tick is itself a slide-flow tick.
The lead-in shape (flat hold vs ramp) is determined by prior cells,
not by anything on the close cell. Bare value is therefore always
locally readable as a fresh trigger.
Continuation absorption
A _ cell takes its meaning from whichever modifier was last open on
the channel:
- After a non-roll, non-slide anchor: sustain (gate held, cv unchanged).
- After a
value*Nanchor: absorbed into the roll’s spread schedule (the N sub-triggers spread across the anchor tick + the absorbed-tie run). SpanS = 1 + consecutive_underscores; sub-triggers fall atk * S / Nof the span fork = 0..N-1; gate articulates at ~80% of each interval. Swing within the span is respected per-tick. - After a
value>or>_: absorbed into the slide’s ramp.
>_ is the explicit-flow form for slides that start on a non-value
cell (“hold this note for a bit, then start ramping”).
Worked-example timelines
Each five-tick figure below shows trigger placement (!), gate
articulation (- = high, . = low), and cv shape across ticks.
Pattern: E4 . . . .
Tick: 1 2 3 4 5
Trigger: ! . . . .
Gate: -- . . . .
cv1: E4 E4 E4 E4 E4 (held after gate drops; analog convention)
Pattern: E4 /G4 . . .
Tick: 1 2 3 4 5
Trigger: ! . . . . (only the E4 fires)
Gate: --- -- . . .
cv1: E4 G4 G4 G4 G4 (snap at the 1→2 boundary)
Pattern: E4 _ /G4 . .
Tick: 1 2 3 4 5
Trigger: ! . . . .
Gate: -- -- -- . .
cv1: E4 E4 G4 G4 G4 (snap at the 2→3 boundary)
Pattern: E4> _ G4 . .
Tick: 1 2 3 4 5
Trigger: ! . ! . . (fresh G4 trigger at start of tick 3)
Gate: --- -- -- . .
cv1: E4→ →G4 G4 G4 G4 (ramp through ticks 1+2; lands at boundary)
Pattern: E4 _ >_ /G4 .
Tick: 1 2 3 4 5
Trigger: ! . . . .
Gate: -- -- -- -- .
cv1: E4 E4 E4→ G4 G4 (slide opens at tick 3 via >_; closes at
tick-3→tick-4 boundary)
Pattern: E4> _ >G4 . .
Tick: 1 2 3 4 5
Trigger: ! . . . .
Gate: --- -- --- . .
cv1: E4→ → →G4 G4 G4 (continuous ramp across ticks 1-3; lands
at end of tick 3)
Pattern: x*3 _ . . .
Tick: 1 2 3 4 5
Trigger: !!!!! . . . . (3 sub-triggers spread across ticks 1+2,
`*0/3`, `*2/3`, `*4/3` of T each)
Gate: -..-..-.. . . . (~80% duty per sub-interval)
cv1: anchor cv1 held across the span
The value>value sugar is the common one-tick case: it packs an
open-and-close into a single cell. It is exactly equivalent in effect
to value> /value except that the unsugared form takes two ticks
(slide tick 1, hold tick 2). Use the sugar when the timing fits.
Row-continuation across |
Channel state and slide / roll spans flow through the |
row-continuation marker — the parser merges continuations into one
logical row before the row-build pass, so a span can cross the join:
hit: x*3
| _ _
vox: A3>
| _ /B3
Migration from ~
The ~ tie token was retired in favour of _ (ADR 0077). Old
patches using ~ in step rows do not parse; mechanically replace
~ → _ inside pattern bodies. The cable-endpoint forms (~tap(…),
~host_control) are unaffected.
slide(…) macro retired
The slide(n, A, B) macro generator was removed in ticket 0948.
Authors write slides inline using the cell shapes above. Mechanical
equivalences:
slide(1, A, B)→A>Bslide(2, A, B)→A> >Bslide(n, A, B)(n ≥ 3) →A>+ (n−2)×>_+>B
Known limitations
- Row-end truncation: a slide or roll never extends past the end
of a channel row.
x*5 _ _at the end of a 3-step row still fires 5 sub-triggers across those 3 ticks. Avalue>with no close cell in the row is degraded to a no-op (close target = open value); the row-build pass emits a diagnostic. - Cross-loop / cross-bank: spans don’t wrap past a pattern loop or bank boundary; trailing sub-events are dropped rather than wrapping.
- Mid-span live edit: if a
_cell is edited to a non-tie mid-roll or mid-slide, the new content takes over on its own tick rise and the prior span is replaced (no overrun).
Songs
A song block defines a playback order built from named section blocks and
play statements.
song my_song(bass, lead, drums) {
section verse {
bass_verse, lead_verse, drums_a
bass_verse, lead_verse, drums_b
}
section chorus {
bass_chor, lead_chor, drums_a
}
play verse
@loop
play (verse, chorus) * 2
}
Lanes
The lanes — the identifiers in parentheses after the song name — declare
one column per pattern assignment. Every row must supply exactly this many
cells. Lane names must match the channels shape argument on the
MasterSequencer.
Cells
A row is a comma-separated list of cells. A cell is one of:
- a pattern name — the pattern played in that lane for that row;
_— silence on that lane;<param>— a template-parameter reference, resolved at expansion time.
Rows are separated by newlines (newlines are significant inside row
sequences). A parenthesised sub-sequence can be repeated: (a, b c, d) * 2.
A bare cell * N (without parentheses) is rejected at parse time.
Sections
A section block is a named, reusable row sequence. Sections may be defined
inside a song (song-local scope) or at file top level (visible to all songs).
A section has no lane count of its own; it is validated against the invoking
song’s lanes when play references it.
Play statements
play composes sections into the song’s running row list:
play verse— appendverse.play verse, chorus— appendverse, thenchorus.play chorus * 2— appendchorustwice (*binds tighter than,).play (verse, chorus) * 4— append the group four times.play { row, ... }— an anonymous inline body (no section name).play chorus { row, ... }— defineschorusas a song-local section and plays it once. Laterplay chorus/chorusreferences replay it.
Inline bodies (play { ... } and play name { ... }) appear only as the
entire body of a single play statement. They cannot be mixed into a
composition expression such as play a, { row, ... }, b.
Loop marker
@loop is a standalone song item between play statements. It sets the
loop-back row index in the emitted song. A song may contain at most one
@loop; omitting it loops the song from the beginning.
Inline patterns
pattern blocks may appear as song items alongside section and play.
Inline patterns are song-local: their names are mangled under the enclosing
song so two songs may each define a fill pattern without collision.
Anatomy of a synth voice
This chapter walks through examples/synths/lead.patches, a
sixteen-voice polyphonic lead synth driven by MIDI. It’s the canonical
“build a synth voice from oscillator outward” patch. The architecture
generalises: pad, bass, pluck, brass — every voice in examples/synths/
is a variation on the same skeleton with different envelope and filter
choices.
This is patch authoring, not DSL syntax. If you want a refresher on
how module declarations and connections work, read DSL
basics first.
The full patch
patch {
module kbd : PolyMidiToCv
module osc : PolyOsc { drift: 0.04 }
module mix : PolySum([saw, sq])
module amp_env : PolyAdsr {
attack: 0.003, decay: 0.18, sustain: 0.7, release: 0.18
}
module vca : PolyVca
module filt_env : PolyAdsr {
attack: 0.005, decay: 0.22, sustain: 0.4, release: 0.2
}
module filt : PolyLowpass {
cutoff: 1.2kHz, resonance: 0.55, saturate: true
}
module delay : StereoDelay {
delay_ms: 280, feedback: 0.35, tone: 0.7, dry_wet: 0.25,
pingpong: true
}
module lim : StereoLimiter { threshold: -3db }
module out : AudioOut
kbd.voct -> osc.voct
kbd.trigger -> amp_env.trigger
kbd.gate -> amp_env.gate
kbd.trigger -> filt_env.trigger
kbd.gate -> filt_env.gate
osc.sawtooth -[0.6]-> mix.in[saw]
osc.square -[0.5]-> mix.in[sq]
mix.out -> vca.in
amp_env.out -> vca.cv
vca.out -> filt.in
filt_env.out -[1.0]-> filt.voct
filt.out -[0.1]-> delay.in
delay.out -> lim.in
lim.out -> out.in
}
Load it as a CLAP plugin on a MIDI track (or via patch_player with
a connected MIDI controller). Play. The rest of this chapter is the
explanation.
Signal flow
A classic subtractive architecture: oscillator → mixer → amplifier →
filter → effects → output. Every stage from osc through filt is
polyphonic, so each of the 16 voices has its own independent signal
path. After the filter, the StereoDelay and StereoLimiter
operate on a stereo bus — they don’t need to be per-voice.
kbd ──voct────→ osc ──saw────→ mix ──→ vca ──→ filt ──→ delay ──→ lim ──→ out
──sq────↗ ↑ ↑
amp_env filt_env
The mono-to-stereo step happens implicitly at filt.out -> delay.in:
delay.in is a stereo input, and a mono audio source into a stereo
input silently broadcasts to both channels. No explicit MonoToStereo
needed.
MIDI input
PolyMidiToCv is the source of poly traffic for the whole patch. It
receives MIDI events from the host (DAW or patch_player), allocates
each note-on to a voice slot (LIFO stealing if all 16 are busy), and
produces three poly outputs:
- voct — pitch as V/oct (1 unit per octave). Drives
osc.voct. - gate —
1.0while a key is held,0.0when released. Tells the envelopes when to enter their release phase. - trigger — single-sample pulse on note-on. Tells the envelopes when to restart from the attack phase.
The gate / trigger distinction matters. The gate tells the envelope how long the note is held; the trigger tells it when to restart. With only a gate, restriking the same note while the previous release is still falling wouldn’t retrigger — the gate would stay high through both presses.
For DAW use, the host writes its track’s MIDI directly to the plugin; you don’t think about device selection.
Oscillator and mixing
PolyOsc produces multiple waveforms in parallel — sine, sawtooth,
square, triangle — at the pitch set by voct. This patch blends two:
osc.sawtooth -[0.6]-> mix.in[saw]
osc.square -[0.5]-> mix.in[sq]
The scale factors are the blend ratio: 60% sawtooth, 50% square. The total (1.1) is a little hot deliberately — the limiter at the end of the chain catches the peaks while keeping the bright bite of both shapes.
PolySum([saw, sq]) is a two-channel summing mixer with named
inputs. The names saw and sq are arbitrary — readable labels for
the connections. Numeric indices (PolySum(2), mix.in[0]) work
just as well.
drift: 0.04 on PolyOsc adds a small random per-voice detune that
makes massed voices sound fuller. Set it to 0.0 for a perfectly
in-tune (sterile-sounding) stack; raise it for thicker detuning.
Amplitude shaping
Without a VCA, every voice would sound continuously. The amplitude envelope shapes the dynamics of each note:
kbd.trigger -> amp_env.trigger
kbd.gate -> amp_env.gate
mix.out -> vca.in
amp_env.out -> vca.cv
PolyAdsr produces a per-voice control signal that:
- Rises to
1.0overattackseconds when triggered. - Falls to
sustain(0.7) overdecayseconds. - Holds at
sustainwhile the gate is high. - Falls to
0.0overreleaseseconds on gate-off.
PolyVca multiplies the audio by the envelope. Output is 0 when
the envelope is 0 (silent voice), audio when the envelope is 1
(full-amplitude voice).
The values here — fast 0.003 (3 ms) attack, moderate 0.18 decay,
high 0.7 sustain, short 0.18 release — give a punchy, plucked feel
that still holds notes for sustained chords. Automate the host
controls on these parameters from a DAW track to morph the envelope
shape over a phrase.
Filter and filter envelope
The filter sits after the VCA, which is the unusual choice here — classic synth topologies often put it before. Putting it after means the filter sees the amplitude-shaped voice, so filter envelope modulation interacts with the amp envelope rather than being independent.
vca.out -> filt.in
filt_env.out -[1.0]-> filt.voct
filt.voct is a V/oct cutoff input — the filter cutoff is set in
V/oct just like an oscillator’s pitch. The filter envelope adds to
the base cutoff (1.2kHz) in V/oct units, opening the filter on note
attack and closing it during release.
-[1.0]-> filt.voct is full-depth modulation. The filter envelope’s
own shape (slow attack, moderate decay, low sustain) gives the
characteristic “pluck” — bright on the onset, then darker. Lower the
scale (-[0.5]->) for a subtler sweep; raise it (-[2.0]->) for an
aggressive, almost over-the-top opening.
resonance: 0.55 adds emphasis at the cutoff frequency — nasal and
slightly vocal. saturate: true enables soft-clipping inside the
filter, adding warmth when driven hard. Both are good targets for
host automation.
Effects bus
The filter feeds a stereo delay and a stereo limiter:
filt.out -[0.1]-> delay.in
delay.out -> lim.in
lim.out -> out.in
The -[0.1]-> scaling into the delay drops the level by ~20 dB
because the limiter at the end of the chain is set to -3dB
threshold — too hot a signal hits the limiter constantly and loses
its dynamics. With this scaling the delay sits at a comfortable level
under the dry voice.
StereoDelay operates on a stereo bus: its in and out are stereo
ports. The mono → stereo broadcast at filt.out -> delay.in is
implicit; the delay itself can produce stereo movement via
pingpong: true, which alternates taps between left and right.
The StereoLimiter catches the cumulative peaks of 16 voices and
prevents clipping at the AudioOut.
What to automate from a DAW
The natural automation targets for this voice, in the order you’d typically reach for them:
| Parameter | Expression |
|---|---|
filt.cutoff | Brightness sweeps. |
filt.resonance | Squelch / vocal emphasis. |
delay.dry_wet | Effect amount. |
amp_env.attack | Soft / hard onsets. |
amp_env.release | Short / sustained tails. |
mix.in[saw] scale | Harmonic balance — saw vs square. |
To make these available as host parameters, declare them as host controls in the patch (see DSL reference, Host controls). They then show up in the DAW’s parameter list for automation, modulation, and per-track recall.
Variations
The other patches under examples/synths/:
pad.patches— slow attack, two detuned oscillator layers, long hall reverb. Same skeleton; pad-shaped envelopes.bass.patches— monophonic-feeling poly with a low-pass set for sub frequencies, tight envelopes.pluck.patches— almost no sustain, fast filter envelope, short delay.bell.patches,fm_pad.patches— FM-style spectra, longer envelopes.
Reading two or three of these side-by-side is the fastest way to see which parameters carry the character of a sound and which are incidental.
Hot-reload while authoring
When you save the file, the engine swaps in the new graph without
stopping audio. Modules whose name and type are unchanged carry
their state — osc’s phase, delay’s buffer, amp_env’s position
all survive. This is the edit-reload cycle:
treat it as a REPL for iteratively shaping a sound rather than as a
performance feature.
Anatomy of a self-contained patch
This chapter walks through examples/tracker_three_voices.patches,
a piece for three voices — bass, two leads — driven entirely by the
tracker sequencer. No external MIDI; the patch is the composition.
If Anatomy of a synth voice shows how a voice produces sound, this chapter shows how the sequencer drives it.
The patch is over 100 lines and full of detail; we’ll read it in
chunks. The full file is at examples/tracker_three_voices.patches.
Three things at once
A self-contained piece typically has three layers:
- The composition.
songandpatternblocks declaring what notes play when. Top of the file, before the patch block. - The voice machinery.
templateblocks that wrap synth-voice wiring so the patch can stamp out one per voice. Also top-of-file. - The patch. A
MasterSequencer, onePatternPlayerper voice, instances of the voice templates, mixer, effects, output.
Reading order matters: skim top to bottom for the lay of the land (songs → templates → patch), then re-read each layer in detail.
The song
song demo(bass, lead1, lead2) {
pattern bass_verse {
note: C2 . . ~ Eb2 . G1 . C2 . . ~ Ab1 . . .
vel: 1.0 . . ~ 0.8 . 0.9 . 1.0 . . ~ 0.7 . . .
}
pattern bass_chorus { ... }
pattern lead1_verse { ... }
pattern lead1_chorus { ... }
pattern lead2_verse { ... }
pattern lead2_chorus { ... }
pattern lead2_fill { ... }
play {
(bass_verse, lead1_verse, lead2_verse
bass_verse, lead1_verse, lead2_verse) * 2
(bass_chorus, lead1_chorus, lead2_chorus
bass_chorus, lead2_chorus, lead2_fill) * 2
}
}
A song declares its voices (demo(bass, lead1, lead2)). The
patterns inside reference those voice names. The play { ... }
block lists which patterns are active simultaneously for each row
of the song, with (...) * N for repetition.
Pattern syntax in one breath
note:andvel:are channel rows. The names are conventions; any identifier works. Each step on a row is one slot.C2,Eb2,G1,Ab1— note names with octave..— rest. Silence on this step; the previous gate ends.~— tie. Hold the previous note’s gate across this step without re-triggering.1.0,0.8— bare floats. Used onvel:for velocity.
Step counts must match across rows in a pattern. Here every row has
16 steps; the sequencer treats them as 16 sixteenth-notes (with
rows_per_beat: 4, four steps per beat = sixteenth notes).
Slides (C4>Eb4), continues (x), and repeats (*N) extend the
basic syntax — see the DSL reference under
Patterns and Songs for the full set.
The play block
play {
(bass_verse, lead1_verse, lead2_verse
bass_verse, lead1_verse, lead2_verse) * 2
(bass_chorus, ...) * 2
}
A row names which pattern plays on each voice for the next chunk
of song time. Commas separate columns within a row; newlines
separate rows. Parenthesised groups can be repeated with * N.
Reading the first group: row 1 plays bass_verse on bass,
lead1_verse on lead1, lead2_verse on lead2; row 2 plays the
same three again; the group repeats twice — so four iterations of
“verse” before the chorus block starts.
The voice template
template voice(attack: float = 0.01, decay: float = 0.1,
sustain: float = 0.7, release: float = 0.001,
cutoff: float = 6.0, q: float = 0.0,
glide_ms: float = 0.0) {
in: voct, gate, trigger, velocity
out: audio
module osc : Osc
module env : Adsr { attack: <attack>, decay: <decay>,
sustain: <sustain>, release: <release> }
module vca : Vca
module filt : Svf { cutoff: <cutoff>, q: <q> }
module vel_vca: Vca
module glide : Glide { glide_ms: <glide_ms> }
$.voct -> glide.in
glide.out -> osc.voct
$.gate -> env.gate
$.trigger -> env.trigger
osc.sawtooth -> filt.in
filt.lowpass -> vca.in
env.out -> vca.cv
vca.out -> vel_vca.in
$.velocity -> vel_vca.cv
vel_vca.out -> $.audio
}
A monophonic voice (not poly — see below) with the standard oscillator → filter → VCA → velocity-VCA chain. Differences from the previous chapter’s lead voice:
- Monophonic. No
Poly* prefix anywhere. The tracker plays one note per voice per step; there is no polyphony required. - Glide.
Glidesmooths the pitch CV — when a step plays a different note from the previous step, the pitch slews to it instead of jumping.glide_ms: 0.0disables it. - Velocity VCA. The pattern’s
vel:row drives a second VCA after the amp envelope, so velocity scales the dynamic shape. Svffilter. State-variable filter withcutoffin V/oct units. Cheaper than the lead voice’sPolyLowpass.
The template’s interface is voct, gate, trigger, velocity in and
audio out — the same shape PolyMidiToCv would feed, but mono.
A second template (sq_voice) is a square-wave variant for lead2
— same skeleton, swapped oscillator output and different defaults.
The patch
The actual graph. Read it top-down:
Sequencer + pattern players
module seq: MasterSequencer([bass, lead1, lead2]) {
song: demo,
bpm: 120,
rows_per_beat: 4,
loop: true,
autostart: true,
swing: 0.75
}
module bass_pp: PatternPlayer([note, vel])
module lead1_pp: PatternPlayer([note, vel])
module lead2_pp: PatternPlayer([note, vel])
seq.clock[bass] -> bass_pp.clock
seq.clock[lead1] -> lead1_pp.clock
seq.clock[lead2] -> lead2_pp.clock
MasterSequencer consumes the song (song: demo), runs at
bpm: 120 with 4 rows per beat (sixteenth notes), loops the play
block, autostarts on load, and applies 0.75 swing.
For each voice it emits a clock[<voice>] output: a stream of
“row events” that name which pattern is active, which step is
current, and the step’s data.
PatternPlayer([note, vel]) listens to a clock and unpacks the two
named channels — note and vel. It exposes per-channel outputs:
pp.cv1[note]— the pitch CV from thenote:row.pp.gate[note]— gate held high during notes (low during.).pp.trigger[note]— single-sample pulse at step start.pp.cv1[vel]— the velocity CV from thevel:row.
The names cv1, gate, trigger are the PatternPlayer’s
fixed port set; the [name] index is the channel from the
pattern.
Voices
module bass_v: voice(
attack: 0.005, decay: 0.15, sustain: 0.6, release: 0.1,
cutoff: 3.5, q: 0.3, glide_ms: 40.0
)
bass_pp.cv1[note] -> bass_v.voct
bass_pp.gate[note] -> bass_v.gate
bass_pp.trigger[note] -> bass_v.trigger
bass_pp.cv1[vel] -> bass_v.velocity
Bass uses the voice template with 40ms glide for portamento
between notes, a tight envelope, and a moderately low filter
cutoff. lead1_v and lead2_v instantiate the same shape with
different parameters; lead2_v uses sq_voice for a different
oscillator waveform.
Mixer with sends
module mix: StereoMixer([bass, lead1, lead2]) {
@bass: { level: 0.7, pan: 0.0 },
@lead1: { level: 0.5, pan: -0.4, send_a: 0.3 },
@lead2: { level: 0.5, pan: 0.4, send_a: 0.3 }
}
bass_v.audio -> mix.in[bass], ~meter+osc+spectrum(bass)
lead1_v.audio -> mix.in[lead1]
lead2_v.audio -> mix.in[lead2]
Three-channel stereo mixer. Bass is centred and loudest; the two leads are softer, panned left and right, with a 30% send to bus A.
The ~meter+osc+spectrum(bass) at the end of bass_v.audio ->
attaches a tap to the cable, exposing live meter / scope /
spectrum data to the GUI. See Visualising patches
for taps.
Effects bus
module delay: StereoDelay(2) {
delay_ms: 750, feedback: 0.4, dry_wet: 1.0
}
mix.send_a -> delay.in
delay.out -> mix.return_a
module reverb: FdnReverb {
size: 0.45, brightness: 0.6, mix: 0.2, character: plate
}
module lim: StereoLimiter { threshold: 0.85 }
mix.out -> reverb.in
reverb.out -> lim.in
module out: AudioOut
lim.out -> out.in
Two effects layers:
- Send-return.
mix.send_acarries the delay-bound signal from the leads (no bass). The delay’s wet-only output (dry_wet: 1.0) returns tomix.return_a, which the mixer sums into its main output. Standard send-return topology. - Master bus.
mix.out(everything: voices + return) → reverb → limiter →AudioOut. The reverb is plate-flavoured with moderate mix.
Design notes
A few decisions worth explaining:
Monophonic voices over poly. The sequencer plays one note per voice per step. There’s no polyphony to allocate, so each voice is a mono chain — cheaper, simpler, and the template’s parameter list controls per-voice character without any shared poly cost.
Svf over Lowpass. State-variable filter is cheaper than a
biquad cascade and the leads don’t need the steeper rolloff. Bass
might benefit from a steeper filter; an exercise for the reader.
Pattern length 16. With rows_per_beat: 4 that’s exactly four
beats per pattern. Different pattern lengths within a song are
fine (some voices can sustain across multiple steps via ~), but
keeping them aligned to common multiples simplifies the timing.
Swing on the sequencer, not per-voice. swing: 0.75 shifts
off-beats slightly later for groove. Applying it at the sequencer
means every voice swings in sync.
Autostart in the player; transport-driven in a DAW. With
autostart: true, the piece plays as soon as the patch loads in
patch_player. If you load it as a CLAP plugin in a DAW, you may
prefer autostart: false and wire seq.start to the host’s
transport — but that’s a host-integration decision, not part of the
patch itself.
Mixing the modes
There’s no rule preventing a sequenced backing layer from coexisting
with an externally-driven lead. A MasterSequencer driving a few
voices and a PolyMidiToCv driving a lead voice can live in the
same patch — the engine doesn’t care where the gate edges come from.
Self-contained vs. externally-driven is a property of the wiring, not a setting on the player.
The edit-reload cycle
When you save a .patches file, the engine builds the new graph and
swaps it in without stopping the audio. Module instances whose name
and type are unchanged carry their state across the swap; the rest
are freshly instantiated.
This makes Patches’ development loop closer to a REPL than to a compile-run-listen workflow. Save → hear the difference → save again. The audio never stops; small edits sound like parameter tweaks. That’s the edit-reload cycle.
This is a development feature, not a performance one. (Live coding on top of it is possible — the cycle is robust enough — but the design centres on iteration speed during patch authoring, not on on-stage editing.)
The reload cycle
- You edit the
.patchesfile and save. - The watcher detects the change and feeds the new source to the DSL parser.
- The parser produces a
FlatPatch— a flat list of module declarations and connections after template expansion. - The interpreter validates the FlatPatch against the module registry: module types exist, port names are real, parameter types are correct, cable kinds match.
- If validation fails, diagnostics are surfaced (TUI log, plugin log pane) and the running patch continues unchanged.
- If validation succeeds, the planner builds a new
ExecutionPlan. - The plan is sent to the audio thread via a lock-free ring buffer. The audio thread picks it up at the start of its next processing block and swaps it in.
The audio stream never stops. There is no gap, no fade. The new plan takes effect between two consecutive samples.
What survives a reload
The planner matches modules in the new patch against those in the
running patch by name and type. A module named osc of type
PolyOsc in the old patch matches a module named osc of type
PolyOsc in the new patch — its instance is carried forward, its
internal state untouched.
State that survives includes:
- Oscillator phase. A 440 Hz oscillator running for ten seconds continues from its current phase, not from zero.
- Filter state. The filter’s internal delay buffers (which determine its current resonant behaviour) are preserved.
- Envelope position. An envelope in its sustain phase stays in sustain.
- Delay buffers. A delay line full of audio retains its contents.
- VCA and mixer state. Per-channel summation accumulators.
The practical effect is that parameter edits sound smooth. Changing a filter cutoff mid-note produces a continuous sweep, not an abrupt reset.
What resets
A module is not matched, and is freshly instantiated, when:
- It is new in the patch (didn’t exist before).
- Its type changed. Replacing
OscwithPolyOsckeeps the name but the type is different, so the old instance can’t be reused. - Its name changed. Renaming
osctooscillatorlooks like a different module to the planner.
Freshly instantiated modules start from initial state: oscillator phase at zero, envelopes idle, delay buffers empty.
Modules that existed before and are absent from the new patch are removed. Their memory is reclaimed on a background thread — the audio thread doesn’t deallocate.
Parameter updates
When a surviving module’s parameters change (cutoff: 600Hz →
cutoff: 800Hz), the new values are applied to the existing
instance. The module isn’t reinstantiated — only its parameter
fields are updated.
Most modules respond on the next sample. Some (filters with biquad coefficients, oversampling stages) recompute periodically, so a change may take effect over a few samples rather than instantaneously. This usually sounds more natural than a hard parameter snap.
Connectivity changes
When you add, remove, or reroute cables, the planner rewires the buffer pool and notifies affected modules of their new port assignments. Modules that care about connectivity (an oscillator that skips computing an output waveform if nothing’s connected) can adapt.
The buffer pool itself is stable across reloads — unchanged cables keep their buffer slots, so signals on un-touched cables continue without interruption. Only the rewired cables see a fresh buffer.
Error recovery
If the new file has a syntax error or fails validation, the running patch is unaffected. Diagnostics name the file position (line, column) and the rule that failed. Fix and save again — the next successful parse triggers a reload.
This means you can save speculatively without fear. A half-finished connection, a misspelled module type, an unclosed brace — none of these crash the running audio. They just fail to reload until you fix them.
Thinking about identity
The matching rule — same name, same type — is the mental model.
- Want a module to survive a reload? Keep its name and type the same.
- Want it to reset? Change one of them.
A common pattern: rename a module temporarily to force a reset (a delay line full of stale audio, for example, can be cleared by renaming it, saving, then renaming back). Less brutally, change its type to a no-op variant and back.
Structural changes — adding new modules, removing old ones, rewiring — happen smoothly. The only thing the planner cannot carry across is a change in a module’s fundamental identity.
Working flow
A productive iteration loop:
- Start
patch_player your.patches(or load the patch as a CLAP plugin). - Make a small edit — change one parameter, one connection, add one module.
- Save.
- Listen.
- Repeat.
If you’re tempted to make a big edit (“rewrite the filter section entirely”), save halfway. The patch keeps running with the partial edit — you’ll either hear the half-rewrite sound interesting, or the reload will fail with a diagnostic that tells you exactly what to fix.
The cost of saving is small enough that “save to check” is a valid debugging step. Use it.
In the plugin
Inside a DAW, the CLAP plugin watches the file the same way the player does. Edit the patch file in any editor, save, and the plugin reloads in place. State survival rules are identical.
If you edit through the plugin’s webview (no in-place editor yet — this is via your external editor, watched), the cycle is the same. The plugin’s Reload button forces a recompile from the current file contents (useful when the file watcher missed an event, or to retry after fixing a previously-failing edit).
What about live performance?
The cycle is robust enough to perform with — the audio doesn’t drop and structural edits stay smooth. People do live-code with Patches.
But the cycle is designed around iteration speed during authoring, not around the constraints of a stage: there’s no syntax-aware editor mode, no commit/discard distinction, no “stage this change for the next downbeat” mechanism. If those things matter for your performance, you’ll either work around them or ask for them as features.
Authoring is the headline use; performing is a happy accident of the cycle being fast and crash-safe.
Visualising patches
The text DSL is the source of truth, but for an unfamiliar patch a
diagram is faster to read than the file. Patches can render any
.patches file as an SVG signal-flow graph: modules as boxes,
ports on the sides, cables as routed arrows between them.
Rendering is a feature of the language server (patches-lsp),
exposed through:
- The VS Code extension command Show Patch Graph.
- The LSP
patches/renderSvgcustom request, for any LSP client. - The
patches_svglibrary crate, for programmatic use. - The
patches-svgbinary, shipped by thepatches-svgworkspace crate (publish = false, build withcargo build -p patches-svg --bin patches-svg).
From VS Code
Install the VS Code extension. Open a .patches
file. Then either:
- Right-click in the editor → Show Patch Graph.
- Command Palette (Cmd+Shift+P / Ctrl+Shift+P) → Patches: Show Patch Graph.
A side panel opens with the SVG, scrollable and zoomable. The panel re-renders when you save the file (the LSP refreshes on each successful parse), so editing the patch and watching the graph update side-by-side is a usable authoring loop.
If the patch has a parse error, the panel shows the diagnostic instead of the (stale) graph. Fix the error and save; the graph returns.
Reading the diagram
A rendered patch is a sequence of columns laid out left-to-right by
topological depth: sources (oscillators, MIDI sources, sequencers)
on the left, sinks (AudioOut) on the right, everything else
ordered by the longest signal path that reaches it.
Inside each module box:
- The module type is the label across the top (
PolyOsc,Adsr,StereoMixer). - The instance name is below the type, smaller (
osc,env,mix). - Input ports are dots on the left edge, labelled with their port names.
- Output ports are dots on the right edge, likewise labelled.
Cables are arrows from output dots to input dots. Scaled cables
(-[0.5]->) carry the scale as a small label near the destination.
Mono cables are drawn as single lines; poly cables and stereo cables are styled slightly thicker, with a small annotation marking the kind. (Exact visual coding may evolve — when in doubt, the SVG’s CSS classes name each kind.)
The layout is computed by the rust-sugiyama crate (a Sugiyama-
family layered-graph layout). Crossings are minimised but not
eliminated; expect occasional overlaps in dense patches.
Themes
Two built-in themes:
- Dark (default) — light strokes and labels on a dark background. Suited to the VS Code dark theme.
- Light — dark strokes on a light background. Suited to the VS Code light theme and to embedding in print.
The VS Code extension currently renders with the default (dark)
theme. To render a light SVG programmatically:
#![allow(unused)]
fn main() {
use patches_svg::{render_svg, SvgOptions, Theme};
let svg = render_svg(
&flat_patch,
&flat_patch.module_descriptors,
&SvgOptions { theme: Theme::Light, ..Default::default() },
);
}
SvgOptions has a handful of additional knobs (margins, font size,
port-label visibility); see the crate’s source for the current set.
Programmatic rendering
If you want diagrams in a build pipeline (auto-generated docs,
README badges, a static-site catalogue), depend on patches_svg
directly:
[dependencies]
patches-dsl = { path = "../patches-dsl" }
patches-svg = { path = "../patches-svg" }
The flow is: parse the source with patches_dsl, flatten it
(template expansion, stereo desugaring), then call
patches_svg::render_svg with the resulting FlatPatch and the
module-descriptor table. The output is a String of SVG markup
ready to write to disk or embed.
Two consumers in-tree:
patches-lspcallsrender_svgfrom itspatches/renderSvgendpoint.patches-vscoderequests SVGs from the LSP and renders them in a webview panel.
When the diagram helps
Reading SVGs is the fastest way to:
- Onboard onto an unfamiliar patch. Layout shows the architecture at a glance: subtractive, FM, sequencer-driven, etc.
- Spot fan-out you forgot about. A module driving five destinations is obvious in the diagram; in text it’s spread across a dozen lines.
- See the feedback structure. Cycles in the graph are drawn as back-edges and are unmissable.
It’s less useful while authoring, when the text is faster to edit than the diagram is to scan. Authors typically read the text and use the diagram as a sanity check after structural changes.
Module reference
Each module type is documented with its ports, parameters, and behaviour. The canonical source of truth for port names and parameter ranges is the doc comment on each module struct in patches-modules/src/.
| Category | Modules | Purpose |
|---|---|---|
| Oscillators | Osc, PolyOsc, Lfo, PolyLfo | Waveform generation |
| Envelopes | Adsr, PolyAdsr | Amplitude and modulation shaping |
| Filters | Lowpass, Highpass, Bandpass, Svf, and poly variants | Frequency-dependent attenuation |
| Amplifiers & VCAs | Vca, PolyVca | Voltage-controlled amplitude |
| Mixers | Sum, PolySum, Mixer, StereoMixer, PolyMixer, StereoPolyMixer | Signal summing and routing |
| Noise | Noise, PolyNoise | White, pink, brown, and red noise |
| Sequencers & clocks | Clock, TempoSync, MsTicker, TriggerToSync, SyncToTrigger | Tempo-locked trigger generation |
| Tracker sequencer | MasterSequencer, PatternPlayer | Song-driven pattern sequencing |
| Drum synthesis | Kick, Snare, ClosedHiHat, OpenHiHat, Tom, Cymbal, Clap, Claves | 808-style electronic drum synthesis |
| MIDI | MidiToCv, PolyMidiToCv, MidiCC, MidiArp, MidiDelay, MidiSplit, MidiTranspose, MidiDrumset | MIDI input and processing |
| Delays & reverb | Delay, StereoDelay, FdnReverb, ConvReverb, StereoConvReverb | Delay and reverb |
| Dynamics | Limiter, StereoLimiter, Bitcrusher, Drive, TransientShaper, RingMod, PitchShift | Dynamics and nonlinear effects |
| Detectors | AudioToTrigger, PolyAudioToTrigger, AudioToGate, PolyAudioToGate | Audio → sub-sample triggers / gates |
| Stereo utilities | Pan, Balance, StereoWidth, MidSide, MonoBass | Stereo image control |
| Utilities | Glide, Tuner, PolyTuner, Quant, PolyQuant, Sah, PolySah, MonoToPoly, PolyToMono | Conversion, smoothing, quantisation |
| Primitives | DcBlocker, Comb | Small reusable DSP building blocks |
| Output | AudioOut, AudioIn, HostTransport | Audio I/O and host transport |
Oscillators
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Osc — Mono oscillator
A single-voice oscillator with multiple waveform outputs.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
frequency | float (v/oct) | 0.0 | Base pitch as V/oct above C0 (0.0 = C0 ≈ 16.35 Hz). Hz/kHz literals are converted to V/oct at parse time (440Hz → ≈4.75); bare floats are used as V/oct directly. |
drift | float | 0.0 | Slow random pitch drift amount (0 = off, 1 = max) |
fm_type | string | linear | FM response: linear or logarithmic |
Inputs
| Port | Description |
|---|---|
voct | V/oct pitch offset (added to frequency at runtime) |
fm | Frequency modulation input |
pulse_width_cv | Pulse width control for square wave (−1 to +1; maps to 0–99% duty cycle) |
phase_mod | Phase modulation offset (0–1, fraction of a cycle; wraps) |
sync | Sub-sample hard-sync trigger: phase reset with PolyBLEP correction on saw/square |
Outputs
| Port | Description |
|---|---|
sine | Sine wave |
triangle | Triangle wave |
sawtooth | Sawtooth wave (PolyBLEP anti-aliased) |
square | Square wave (PolyBLEP anti-aliased; duty cycle set by pulse_width_cv) |
reset_out | Trigger emitting the sub-sample fractional position of each phase wrap |
PolyOsc — Polyphonic oscillator
Per-voice oscillator. Each voice runs independently at the pitch set by the
voct poly cable.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
frequency | float (v/oct) | 0.0 | Base pitch as V/oct above C0 (0.0 = C0 ≈ 16.35 Hz). Hz/kHz literals are converted at parse time; bare floats are used as V/oct directly. |
drift | float | 0.0 | Slow random pitch drift amount, independent per voice (0 = off, 1 = max) |
fm_type | string | linear | FM response: linear or logarithmic |
Inputs
| Port | Description |
|---|---|
voct | Per-voice V/oct pitch offset (poly) |
fm | Per-voice FM input (poly) |
pulse_width_cv | Per-voice pulse width control for square wave (poly) |
phase_mod | Per-voice phase modulation (poly) |
Outputs
| Port | Description |
|---|---|
sine | Per-voice sine (poly) |
triangle | Per-voice triangle (poly) |
sawtooth | Per-voice sawtooth, PolyBLEP anti-aliased (poly) |
square | Per-voice square, PolyBLEP anti-aliased (poly) |
Supersaw — HyperSaw and PolyHyperSaw
A supersaw (JP-8000 style) is a stack of detuned sawtooth copies summed to
one voice. HyperSaw/PolyHyperSaw use 9 copies — one centre saw plus four
pairs of side saws, detuned symmetrically below and above the centre pitch. The
copies run at slightly different frequencies, so they slowly drift in and out of
phase with one another; that continuous beating is the wide, animated character
you cannot get from a single saw or a static wavetable.
Three controls shape the ensemble:
spread— the detune width.0collapses every copy onto the centre pitch (a plain unison saw, no beating);1pushes the outermost pair to ±50 cents. The pairs are spaced nonlinearly (inner pairs detuned much less than the outer ones).density— how many of the four side pairs are active, faded in inner→outer. The fade is loudness-normalised, so turning density up thickens the sound without a level jump.mix— the centre↔side balance.0is the clean centre saw alone;1is the full stack with the centre and sides together. The summed output is normalised to stay within[-1, 1].
FM here is vibrato, not timbre. Unlike Osc/PolyOsc, which recompute pitch
every sample, the supersaw recomputes all 144 increments (9 copies × 16 voices)
only at the control-rate interval — recomputing them per sample is not
affordable. Pitch and FM are therefore sampled at control rate: fm is a
vibrato input, and there is no hard-sync and no phase modulation (syncing
would re-align the copies and kill the beating that is the whole point). See
ADR 0078 for the design and the fixed-point, autovectorised kernel behind it.
The copies’ start phases are randomised once at construction, so there is no thin attack and no comb-filtered coincident-wrap aliasing.
A worked patch using PolyHyperSaw into a filter and amp lives in
examples/synths/hypersaw_lead.patches.
HyperSaw — Mono supersaw
A single detuned-saw ensemble. Runs the 16-voice kernel and reads one voice; mono is not the performance case.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
frequency | float (v/oct) | 0.0 | Base pitch as V/oct above C0 (0.0 = C0 ≈ 16.35 Hz) |
fm_type | string | linear | FM response: linear or logarithmic |
spread | float | 0.3 | Detune width (0 = unison, 1 = ±50 cents at the outer pair) |
density | float | 1.0 | Active side pairs, faded in inner→outer (×4 pairs) |
mix | float | 0.7 | Centre↔side balance (0 = clean centre saw, 1 = full stack) |
Inputs
| Port | Description |
|---|---|
voct | V/oct pitch offset (added to frequency at runtime) |
fm | Frequency modulation (control-rate vibrato) |
spread_cv | Adds to spread |
density_cv | Adds to density |
mix_cv | Adds to mix |
Outputs
| Port | Description |
|---|---|
out | Summed detuned-saw ensemble (PolyBLEP anti-aliased) |
PolyHyperSaw — Polyphonic supersaw
The 16-voice counterpart: one detuned ensemble per voice, driven by per-voice
voct/fm. spread/density/mix CV are mono (shared across voices) so
the detune ratios are computed once per control interval and reused for every
voice; per-voice spread is deferred.
Parameters
Same as HyperSaw (frequency, fm_type, spread, density, mix).
Inputs
| Port | Description |
|---|---|
voct | Per-voice V/oct pitch offset (poly) |
fm | Per-voice FM input (poly; control-rate vibrato) |
spread_cv | Adds to spread, shared across voices (mono) |
density_cv | Adds to density, shared across voices (mono) |
mix_cv | Adds to mix, shared across voices (mono) |
Outputs
| Port | Description |
|---|---|
out | Per-voice detuned-saw ensemble, PolyBLEP anti-aliased (poly) |
Lfo — Low-frequency oscillator
Multi-waveform LFO intended for modulation signals. Runs at audio rate.
rate is a frequency in Hz (range 0.01–20.0 Hz, default 1.0 Hz). This is
not a V/oct value — it is a plain frequency. rate_cv adds linearly to
rate in Hz (effective rate is clamped to 0.001–40.0 Hz).
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
rate | float (Hz) | 1.0 | Rate in Hz (0.01–20.0) |
phase_offset | float | 0.0 | Phase offset (0–1, fraction of a cycle) |
mode | string | bipolar | Output polarity: bipolar (−1 to +1), unipolar_positive (0 to +1), unipolar_negative (−1 to 0) |
Inputs
| Port | Description |
|---|---|
sync | Sub-sample phase reset trigger |
rate_cv | Added linearly to rate (Hz); effective rate clamped to 0.001–40.0 Hz |
sync_ms | When connected, overrides rate with this value interpreted as period in ms |
Outputs
| Port | Description |
|---|---|
sine | Sine wave |
triangle | Triangle wave |
saw_up | Rising sawtooth |
saw_down | Falling sawtooth |
square | Square wave (50% duty cycle) |
random | Sample-and-hold random value, updated once per cycle |
reset_out | Trigger emitting the sub-sample fractional position of each phase wrap |
Envelopes
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Adsr — Multi-channel DADSR envelope with built-in VCA
Multi-channel Attack-Decay-Sustain-Release envelope generator with an
optional pre-attack Delay phase and built-in VCA pass-through. One
module emits N independent envelopes (one per channel) driven by a
single shared trigger and gate.
With channels = 1 (the default) it behaves as a conventional
single-channel ADSR — env.out, env.vca_in, env.vca_out address
channel 0 implicitly. Use Adsr([amp, filt, vib]) or Adsr(3) to get
multiple envelopes per trigger/gate.
Each channel has its own delay/attack/decay/sustain/release/shape, and
an optional VCA stage: if vca_in[c] is connected, vca_out[c] emits
vca_in[c] * env[c]. Useful for tying amplitude, filter, and
modulation envelopes to the same note event without wiring N separate
modules. A rising trigger enters the per-channel Delay phase (output
held at 0 for delay[c] seconds) before Attack begins. Releasing the
gate during Delay returns the channel to Idle without emitting any
envelope.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | trigger | Shared one-sample pulse starts the envelope on every channel |
gate | mono | Shared: held high to sustain; release to enter Release on every channel |
vca_in[i] | mono | Optional audio/CV input multiplied by the channel’s envelope (i in 0..N-1) |
Outputs
| Port | Kind | Description |
|---|---|---|
out[i] | mono | Envelope level for channel i in [0.0, 1.0] |
vca_out[i] | mono | vca_in[i] * out[i] — pre-multiplied audio/CV |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
delay[i] | float | 0.0 – 10.0 | 0.0 | Pre-attack hold time in seconds (output stays at 0) |
attack[i] | float | 0.001 – 10.0 | 0.01 | Attack time in seconds |
decay[i] | float | 0.001 – 10.0 | 0.1 | Decay time in seconds |
sustain[i] | float | 0.0 – 1.0 | 0.7 | Sustain level |
release[i] | float | 0.001 – 10.0 | 0.3 | Release time in seconds |
shape[i] | enum | linear, exponential | linear | Segment shape: linear ramp or analog-style RC curve |
Env — Multi-stage breakpoint envelope with key-follow
Where Adsr has a fixed attack/decay/sustain/release shape, Env runs
an arbitrary number of (time, level, curve) stages with a designated
sustain stage — enough to express D50-style contours ADSR cannot (attack
spike → dip → secondary swell → sustain). The stage count is the
module’s channels axis: Env(5) is a five-stage envelope. One module
is one envelope (mono); use multiple instances for multiple envelopes.
Stages 0..sustain_stage form the pre-sustain contour; the envelope
holds at level[sustain_stage] while the gate is high, then runs the
remaining stages sustain_stage+1..N as the release tail on gate-off.
Designate a final stage with level = 0 for a release to silence. A
re-trigger restarts from the current level (no click), and a gate-off
during the pre-sustain contour jumps straight into the release tail.
Key-follow shortens stage times with pitch, as real resonators decay
faster up the keyboard: time_scale = 2^(-keyfollow * (voct - ref_key)),
so keyfollow = 1.0 halves all stage times one octave above ref_key.
It is applied per tick, so a bending voct re-scales the contour live.
Velocity scales stage levels: the effective velocity (1.0 if the
input is unconnected) is mapped to a level multiplier
1 - vel_depth * (1 - velocity) and latched at the trigger, so an
in-flight envelope keeps a stable scaling.
Route out to an oscillator voct / phase_mod or a filter cutoff for
the D50 attack pitch-blip or filter sweep — that’s a patch idiom, not a
port.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | trigger | One-sample pulse starts the envelope |
gate | mono | Held high to sustain; release to run the release tail |
voct | mono | 1V/oct pitch driving key-follow time-scaling (0 if unconnected) |
velocity | mono | Velocity in [0, 1] scaling stage levels (1.0 if unconnected) |
vca_in | mono | Optional audio/CV input multiplied by the envelope |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Envelope level in [0.0, 1.0] |
vca_out | mono | vca_in * out — pre-multiplied |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
time[i] | float | 0.0 – 10.0 | 0.1 | Stage i duration in seconds (i in 0..N−1, N = stage count) |
level[i] | float | 0.0 – 1.0 | 1.0 | Stage i target level |
curve[i] | enum | linear, exponential | linear | Stage i segment shape |
sustain_stage | int (structural) | 0 – 7 | 0 | Index of the held stage; later stages are the release tail |
keyfollow | float | 0.0 – 1.0 | 0.0 | Pitch time-scaling depth (1.0 halves times one octave up) |
ref_key | float | -5.0 – 5.0 | 0.0 | Reference pitch (V/oct) at which time_scale = 1 |
vel_depth | float | 0.0 – 1.0 | 0.0 | How much velocity attenuates stage levels |
PolyAdsr — Per-voice DADSR
Same envelope shape as Adsr (delay/attack/decay/sustain/release/shape)
but each voice has independent state, driven by per-voice
trigger / gate poly cables. Optional per-voice VCA via vca_in.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | poly | Per-voice sub-sample trigger |
gate | poly | Per-voice gate |
vca_in | poly | Optional per-voice audio/CV multiplied by the envelope |
Outputs
| Port | Kind | Description |
|---|---|---|
out | poly | Per-voice envelope level in [0.0, 1.0] |
vca_out | poly | vca_in * out per voice |
Parameters — same names and ranges as Adsr. Parameters apply
identically to all voices.
PolyEnv — Per-voice multi-stage envelope
The 16-voice form of Env: same multi-stage contour, key-follow, and
velocity scaling, but each voice holds independent state driven by
per-voice trigger / gate / voct / velocity poly cables. All
voices share one stage configuration. The stage count is the channels
axis (PolyEnv(5) = five stages); sustain_stage and the release-tail
semantics are identical to Env.
Key-follow and velocity are computed per voice:
time_scale = 2^(-keyfollow * (voct[v] - ref_key)) and the level
multiplier 1 - vel_depth * (1 - velocity[v]), latched at each voice’s
trigger. Unconnected voct reads as 0 and unconnected velocity as 1.0.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | poly_trigger | Per-voice rising edge starts the envelope |
gate | poly | Per-voice gate; release to run the release tail |
voct | poly | Per-voice 1V/oct pitch driving key-follow |
velocity | poly | Per-voice velocity in [0, 1] scaling stage levels |
vca_in | poly | Optional per-voice audio/CV multiplied by the envelope |
Outputs
| Port | Kind | Description |
|---|---|---|
out | poly | Per-voice envelope level in [0.0, 1.0] |
vca_out | poly | vca_in * out per voice |
Parameters — same names, ranges, and defaults as Env
(time[i], level[i], curve[i], sustain_stage, keyfollow,
ref_key, vel_depth). Parameters apply identically to all voices.
Filters
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
All filter modules implement a resonant biquad (Transposed Direct Form II).
Note: The legacy
Filtermodule name is no longer supported. UseLowpassinstead.
Lowpass / Highpass
Mono resonant filters.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cutoff | float (V/oct) | 6.0 | Cutoff as V/oct above C0: 0.0 = C0 (≈16 Hz), 4.0 = C4 (≈262 Hz), 6.0 = C6 (≈1047 Hz). Hz/kHz literals are accepted and converted at parse time. |
resonance | float | 0.0 | Resonance (0–1; approaching 1 = self-oscillation) |
saturate | bool | false | Apply soft-clip saturation in the feedback path |
Inputs
| Port | Description |
|---|---|
in | Audio input |
voct | V/oct offset added to cutoff; 1.0 = +1 octave |
fm | FM sweep: ±1 sweeps ±2 octaves around cutoff |
resonance_cv | Added to resonance |
Outputs
| Port | Description |
|---|---|
out | Filtered audio |
Bandpass
Mono resonant bandpass filter.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
center | float (V/oct) | 6.0 | Centre frequency as V/oct above C0: 0.0 = C0 (≈16 Hz), 4.0 = C4 (≈262 Hz), 6.0 = C6 (≈1047 Hz). Hz/kHz literals are accepted and converted at parse time. |
bandwidth_q | float | 1.0 | Bandwidth Q (0.1–20; higher = narrower band) |
saturate | bool | false | Apply soft-clip saturation in the feedback path |
Inputs
| Port | Description |
|---|---|
in | Audio input |
voct | V/oct offset added to center; 1.0 = +1 octave |
fm | FM sweep: ±1 sweeps ±2 octaves around center |
resonance_cv | Added to bandwidth_q |
Outputs
| Port | Description |
|---|---|
out | Filtered audio |
PolyLowpass / PolyHighpass
Per-voice versions of Lowpass / Highpass. Each voice maintains
independent biquad state. Same parameters as their mono counterparts.
CV inputs carry poly cables; modulation is applied per-voice.
Inputs
| Port | Description |
|---|---|
in | Per-voice audio (poly) |
voct | Per-voice V/oct offset added to cutoff; 1.0 = +1 octave (poly) |
fm | Per-voice FM sweep: ±1 sweeps ±2 octaves around cutoff (poly) |
resonance_cv | Per-voice resonance modulation (poly) |
Outputs
| Port | Description |
|---|---|
out | Per-voice filtered audio (poly) |
Svf
Mono State Variable Filter (Chamberlin topology). Produces lowpass, highpass, and bandpass outputs simultaneously from a single audio input. All three outputs can be used at once — they share the same two integrator state variables and cost no extra computation per connected output.
Unlike the biquad filters above, Svf is capable of self-oscillation: at
high q values the filter will sustain a sine wave at the cutoff frequency
without any audio input. Noise or other signals act as an exciter to kick the
filter into ringing.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cutoff | float (V/oct) | 6.0 | Cutoff as V/oct above C0: 0.0 = C0 (≈16 Hz), 4.0 = C4 (≈262 Hz), 6.0 = C6 (≈1047 Hz). Hz/kHz literals are accepted and converted at parse time. |
q | float | 0.0 | Resonance (0–1). The mapping is exponential: values below 0.5 give moderate resonance; above 0.9 the filter enters self-oscillation territory (Q ≈ 70–100). |
Inputs
| Port | Description |
|---|---|
in | Audio input |
voct | V/oct offset added to cutoff; 1.0 = +1 octave |
fm | FM sweep: ±1 sweeps ±2 octaves around cutoff |
q_cv | Added to q before clamping to [0, 1] |
Outputs
| Port | Description |
|---|---|
lowpass | Lowpass output |
highpass | Highpass output |
bandpass | Bandpass output |
Warning: At
qvalues above ≈ 0.95 the filter self-oscillates. Loud audio input at those settings can drive the output to clip. Scale the output down or use a VCA after the filter if this is a concern.
PolySvf
Per-voice version of Svf. Each voice maintains independent integrator state.
All parameters and CV semantics are identical to Svf; all ports carry poly
cables.
Parameters
Same as Svf: cutoff and q.
Inputs
| Port | Description |
|---|---|
in | Per-voice audio (poly) |
voct | Per-voice V/oct offset added to cutoff (poly) |
fm | Per-voice FM sweep: ±1 sweeps ±2 octaves around cutoff (poly) |
q_cv | Per-voice additive Q offset, clamped to [0, 1] (poly) |
Outputs
| Port | Description |
|---|---|
lowpass | Per-voice lowpass output (poly) |
highpass | Per-voice highpass output (poly) |
bandpass | Per-voice bandpass output (poly) |
PolyBandpass
Per-voice version of Bandpass. Same parameters as Bandpass.
Inputs
| Port | Description |
|---|---|
in | Per-voice audio (poly) |
voct | Per-voice V/oct offset added to center; 1.0 = +1 octave (poly) |
fm | Per-voice FM sweep: ±1 sweeps ±2 octaves around center (poly) |
resonance_cv | Per-voice bandwidth_q modulation (poly) |
Outputs
| Port | Description |
|---|---|
out | Per-voice filtered audio (poly) |
Amplifiers & VCAs
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Vca — Mono voltage-controlled amplifier
Multiplies an audio signal by a control signal.
Inputs
| Port | Description |
|---|---|
in | Audio input |
cv | Control voltage (0–1 typical) |
Outputs
| Port | Description |
|---|---|
out | in × cv |
PolyVca — Per-voice VCA
Same as Vca but operates on poly cables, multiplying each voice independently.
Inputs
| Port | Description |
|---|---|
in | Per-voice audio (poly) |
cv | Per-voice control voltage (poly) |
Outputs
| Port | Description |
|---|---|
out | Per-voice in × cv (poly) |
Mixers
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Sum — Unweighted mono sum
Sums N mono inputs into one mono output with no level control.
module mix : Sum(channels: 4)
Inputs
| Port | Description |
|---|---|
in[0] … in[n-1] | Mono inputs |
Outputs
| Port | Description |
|---|---|
out | Sum of all inputs |
Mixer — Mono mixer with sends and mute/solo
N-channel mono mixer. Each channel has level CV, two auxiliary send buses, and mute/solo. If any channel is soloed, only soloed (non-muted) channels contribute to the output.
module mix : Mixer(channels: 4)
Parameters (all indexed per channel)
| Parameter | Type | Default | Description |
|---|---|---|---|
level[n] | float | 1.0 | Channel gain (0–1) |
send_a[n] | float | 0.0 | Send A amount (0–1) |
send_b[n] | float | 0.0 | Send B amount (0–1) |
mute[n] | bool | false | Mute this channel |
solo[n] | bool | false | Solo this channel |
Inputs
| Port | Description |
|---|---|
in[0] … in[n-1] | Mono audio inputs |
level_cv[0] … level_cv[n-1] | Per-channel level CV (added to level) |
send_a_cv[0] … send_a_cv[n-1] | Per-channel send A CV |
send_b_cv[0] … send_b_cv[n-1] | Per-channel send B CV |
return_a | Return input added to main output |
return_b | Return input added to main output |
Outputs
| Port | Description |
|---|---|
out | Main mono mix |
send_a | Send A bus output |
send_b | Send B bus output |
StereoMixer — Stereo mixer with pan, sends and mute/solo
N-channel stereo mixer. Each channel has level, pan, two send buses, and mute/solo.
module mix : StereoMixer(channels: 4)
Parameters (all indexed per channel)
| Parameter | Type | Default | Description |
|---|---|---|---|
level[n] | float | 1.0 | Channel gain (0–1) |
pan[n] | float | 0.0 | Pan (−1 = full left, +1 = full right) |
send_a[n] | float | 0.0 | Send A amount (0–1) |
send_b[n] | float | 0.0 | Send B amount (0–1) |
mute[n] | bool | false | Mute this channel |
solo[n] | bool | false | Solo this channel |
Inputs
| Port | Description |
|---|---|
in[0] … in[n-1] | Mono audio inputs |
level_cv[0] … level_cv[n-1] | Per-channel level CV |
pan_cv[0] … pan_cv[n-1] | Per-channel pan CV |
send_a_cv[0] … send_a_cv[n-1] | Per-channel send A CV |
send_b_cv[0] … send_b_cv[n-1] | Per-channel send B CV |
return_a | Send A stereo return |
return_b | Send B stereo return |
Outputs
| Port | Description |
|---|---|
out | Main stereo output |
send_a | Send A stereo bus |
send_b | Send B stereo bus |
PolySum — Unweighted poly sum
Sums N poly inputs voice-by-voice with no level control.
module mix : PolySum(channels: 2)
Inputs
| Port | Description |
|---|---|
in[0] … in[n-1] | Poly inputs |
Outputs
| Port | Description |
|---|---|
out | Per-voice sum (poly) |
PolyMixer — Poly mixer with level and mute/solo
N-channel poly mixer with per-channel level, mute, and solo. Level CV inputs are mono (applied uniformly across all voices of that channel).
module mix : PolyMixer(channels: 2)
Parameters (indexed per channel)
| Parameter | Type | Default | Description |
|---|---|---|---|
level[n] | float | 1.0 | Channel gain (0–1) |
mute[n] | bool | false | Mute this channel |
solo[n] | bool | false | Solo this channel |
Inputs
| Port | Description |
|---|---|
in[0] … in[n-1] | Poly audio inputs |
level_cv[0] … level_cv[n-1] | Per-channel level CV (mono) |
Outputs
| Port | Description |
|---|---|
out | Per-voice sum (poly) |
StereoPolyMixer — Stereo poly mixer with pan and mute/solo
N-channel stereo poly mixer. Outputs two poly cables (left and right). Level and pan CV inputs are mono.
module mix : StereoPolyMixer(channels: 2)
Parameters (indexed per channel)
| Parameter | Type | Default | Description |
|---|---|---|---|
level[n] | float | 1.0 | Channel gain (0–1) |
pan[n] | float | 0.0 | Pan (−1 = full left, +1 = full right) |
mute[n] | bool | false | Mute this channel |
solo[n] | bool | false | Solo this channel |
Inputs
| Port | Description |
|---|---|
in[0] … in[n-1] | Poly audio inputs |
level_cv[0] … level_cv[n-1] | Per-channel level CV (mono) |
pan_cv[0] … pan_cv[n-1] | Per-channel pan CV (mono) |
Outputs
| Port | Description |
|---|---|
out_left | Per-voice left output (poly) |
out_right | Per-voice right output (poly) |
PolyToMono — Collapse poly to mono
Sums all active voices of a poly cable into a single mono signal.
Inputs
| Port | Description |
|---|---|
in | Poly input |
Outputs
| Port | Description |
|---|---|
out | Mono sum of all voices |
MonoToPoly — Broadcast mono to all voices
Copies a mono signal into every voice slot of a poly cable.
Inputs
| Port | Description |
|---|---|
in | Mono input |
Outputs
| Port | Description |
|---|---|
out | Poly cable (same value in every voice) |
StereoSum — Unweighted stereo sum
Sums N stereo inputs into one stereo output, no level control. Useful for collapsing voice or bus outputs into a single stereo path.
module mix : StereoSum(channels: 3)
Inputs
| Port | Description |
|---|---|
in[0] … in[n-1] | Stereo inputs |
Outputs
| Port | Description |
|---|---|
out | Stereo sum |
Noise generators
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Noise — Mono noise generator
Four coloured noise outputs from a single module. Only connected outputs are computed, so unused colours have no CPU cost.
The four outputs span a range of spectral slopes. White noise is the source for all other colours: pink is derived by filtering white, brown by integrating white, and red by integrating brown.
Outputs
| Port | Spectrum | Description |
|---|---|---|
white | flat | Uncorrelated samples, equal energy per Hz band |
pink | −3 dB/oct (1/f) | Equal energy per octave; useful for natural-sounding randomness and modulation |
brown | −6 dB/oct (1/f²) | Random-walk integration of white; deep, slow-moving character |
red | −9 dB/oct (1/f³) | Integration of brown; very slow drift, sub-bass emphasis |
All outputs are in the range [−1, 1]. Each instance has its own PRNG state
seeded from the module’s instance ID, so two Noise modules in the same patch
produce independent signals.
Example — white noise as a sound source
Noise noise
AudioOut out
noise.white -> out.in
Example — pink noise for modulation
Noise lfo_noise
Vca filter_env { frequency: 440Hz }
lfo_noise.pink -> filter_env.cv
PolyNoise — Polyphonic noise generator
Identical to Noise but with poly outputs. Each voice has its own independent
PRNG and filter state, so voices are uncorrelated with each other and with any
Noise instance.
Outputs
| Port | Spectrum | Description |
|---|---|---|
white | flat | Per-voice white noise (poly) |
pink | −3 dB/oct (1/f) | Per-voice pink noise (poly) |
brown | −6 dB/oct (1/f²) | Per-voice brown noise (poly) |
red | −9 dB/oct (1/f³) | Per-voice red noise (poly) |
PolyNoise is useful for adding independent per-voice variation to polyphonic
patches — for example, routing the white output through a PolyVca to add
noise to each voice’s amplitude independently.
Example — per-voice breath noise
patch {
module src : PolyNoise
module noise_vca : PolyVca
module env : PolyAdsr { attack: 0.005, decay: 0.1, sustain: 0.2, release: 0.2 }
module midi : PolyMidiToCv
midi.gate -> env.gate
env.out -> noise_vca.cv
src.white -> noise_vca.in
}
Sequencers & clocks
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Clock — Tempo-locked trigger generator
Generates bar, beat, quaver, and semiquaver trigger pulses from a configurable BPM and time signature. All outputs are derived from a single beat-phase accumulator, so they are always phase-locked to each other.
Each output fires a one-sample pulse (1.0) at the relevant boundary and is
0.0 on all other samples.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
bpm | float | 120.0 | Tempo in beats per minute (1–300) |
beats_per_bar | int | 4 | Number of beats per bar (1–16) |
quavers_per_beat | int | 2 | Subdivisions per beat: 2 = simple, 3 = compound (1–4) |
Outputs
| Port | Description |
|---|---|
bar | Fires once per bar |
beat | Fires once per beat |
quaver | Fires once per quaver (beats_per_bar × quavers_per_beat per bar) |
semiquaver | Fires once per semiquaver (twice per quaver) |
HostTransport — DAW transport readback
Exposes the host DAW’s transport state as CV signals. In standalone mode
the player synthesises a static transport (tempo from --bpm, no
playhead motion).
Outputs
| Port | Kind | Description |
|---|---|---|
playing | mono | 1.0 while the host is playing, otherwise 0.0 |
tempo | mono | Current tempo in BPM |
beat | mono | Position within the bar as fractional beats |
bar | mono | Bar index (integer-valued, increments at each bar boundary) |
beat_trigger | trigger | Fires once per beat boundary |
bar_trigger | trigger | Fires once per bar boundary |
tsig_num | mono | Time signature numerator |
tsig_denom | mono | Time signature denominator |
TempoSync — BPM + subdivision → milliseconds
Converts a tempo (in BPM) and a fixed musical subdivision into a duration
in milliseconds. Drives delay_ms-style inputs on Delay, MsTicker,
etc. for tempo-locked timing.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
subdivision | enum | 1/4 | Subdivision: 1/1, 1/2, 1/4, 1/8, 1/16, 1/32, plus dotted (1/4d) and triplet (1/4t) variants |
Inputs
| Port | Kind | Description |
|---|---|---|
bpm | mono | Tempo in BPM |
Outputs
| Port | Kind | Description |
|---|---|---|
ms | mono | Duration of one subdivision in milliseconds |
MsTicker — Millisecond-paced trigger
Fires a trigger every ms milliseconds with a corresponding gate.
Inputs
| Port | Kind | Description |
|---|---|---|
ms | mono | Inter-trigger interval in milliseconds |
reset | trigger | Resets the internal phase to zero |
Outputs
| Port | Kind | Description |
|---|---|---|
trigger | trigger | Sub-sample trigger at each tick boundary |
gate | mono | High between trigger and next reset; useful as a duty signal |
TriggerToSync / SyncToTrigger
A pair of converters between the two trigger encodings: legacy mono level pulses (≥ 0.5 high) and the sub-sample sync cable. Use when bridging modules that expect different encodings.
TriggerToSync — mono in → trigger out.
SyncToTrigger — trigger in → mono out (rising-edge pulse).
Tracker sequencer
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
The tracker sequencer is a two-module system for song-driven step sequencing.
A MasterSequencer reads a named song from the patch’s tracker data and
drives one or more PatternPlayer modules via poly clock buses. Pattern and
song data are defined in the .patches file using pattern and song
blocks (see DSL reference — Patterns & songs).
How it fits together
┌─────────────────────┐
│ MasterSequencer │
│ song: my_song │──clock[bass]──▶ PatternPlayer (channels: [note, vel])
│ bpm: 120 │──clock[lead]──▶ PatternPlayer (channels: [note, vel])
│ rows_per_beat: 4 │──clock[drums]─▶ PatternPlayer (channels: [hit])
└─────────────────────┘
The MasterSequencer walks through the song order, emitting timing and pattern-selection data on each clock bus. Each PatternPlayer decodes that data, looks up the relevant pattern, and outputs cv/trigger/gate signals per channel.
MasterSequencer — Song-driven transport
Drives song playback with transport controls, swing, and a poly clock bus per song channel.
Inputs
| Port | Kind | Description |
|---|---|---|
start | mono | Rising edge resets and begins playback |
stop | mono | Rising edge halts and resets playback |
pause | mono | Rising edge halts playback in place |
resume | mono | Rising edge continues from current position |
Outputs
| Port | Kind | Description |
|---|---|---|
clock[i] | poly | Clock bus per channel (i in 0..N−1, N = channels) |
The clock bus carries four poly voices:
| Voice | Signal | Description |
|---|---|---|
| 0 | pattern reset | 1.0 on first tick of a new pattern |
| 1 | pattern bank index | float-encoded integer (−1 = stop sentinel) |
| 2 | tick trigger | 1.0 on each step |
| 3 | tick duration | seconds per tick |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
bpm | float | 1.0–999.0 | 120.0 | Tempo in beats per minute |
rows_per_beat | int | 1–64 | 4 | Steps per beat |
song | song_name | — | none | Name of the song to play |
loop | bool | — | true | Loop at end of song |
autostart | bool | — | true | Begin playback on activation |
swing | float | 0.0–1.0 | 0.5 | Swing ratio for alternating steps |
sync | enum | auto/free/host | auto | Clock source: auto picks host when hosted, otherwise free |
Shape arguments
The channels shape argument defines the song channels. Use named aliases
to match the song’s lane names:
module seq: MasterSequencer(channels: [bass, lead, drums]) {
song: my_song, bpm: 140, rows_per_beat: 4
}
PatternPlayer — Pattern step sequencer
A generic multi-channel step sequencer that reads a poly clock bus, steps through pattern data, and outputs cv1/cv2/trigger/gate signals per channel. The PatternPlayer does not know whether its channels are notes, drums, or automation — all channels produce the same four output types.
Inputs
| Port | Kind | Description |
|---|---|---|
clock | poly | Clock bus from MasterSequencer |
Outputs
| Port | Kind | Description |
|---|---|---|
cv1[i] | mono | Control voltage 1 per channel (i in 0..N−1) |
cv2[i] | mono | Control voltage 2 per channel (i in 0..N−1) |
trigger[i] | mono | Trigger per channel (i in 0..N−1) |
gate[i] | mono | Gate per channel (i in 0..N−1) |
Shape arguments
The channels shape argument defines the pattern channels. Use named
aliases to match the pattern’s channel names:
module pp: PatternPlayer(channels: [note, vel])
Output conventions
For note channels, cv1 carries the V/oct pitch (same convention as Osc)
and trigger/gate signal note-on events. For drum/trigger
channels, trigger fires on each hit and cv2 carries the velocity value.
Step events (ADR 0077 unified grammar)
The PatternPlayer consumes the row-build pass’s resolved StepEffect
per cell — a single typed dispatch covering rests, triggered notes,
rolls, slides, step-cv changes, and continuation cells. See the
DSL reference — Step events for the
full cell taxonomy, close rule, and worked-example timelines. The
player-side behaviour is:
- Triggered cell (
value,value*N,value:cv2,value>value,value>): snap cv1/cv2 at the start of the tick and firetrigger.*Nopens a roll;value>valueopens-and-closes a one-tick slide;value>opens a multi-tick slide whose close target is resolved when the close cell arrives. - Step-cv (
/value): snap cv1 (and:cv2if present) at the tick boundary; no trigger. Gate stays high. - Slide flow (
>_,_absorbed by a slide): no trigger; cv ramps per the slide’s per-channel schedule. Gate stays high. - Slide close-in-tick (
>value): cv1 (and:cv2if present) ramp to the close value across this tick; no trigger. - Tie sustain (
_with no open modifier): gate stays high; cv held. - Tie-spread roll (
_absorbed by a*Nanchor): the N sub-triggers spread evenly across the anchor tick plus the absorbed run. Per-tick sub-event placement respects swung tick durations — each sub-event’s sample offset is computed against the current tick’s duration, not the anchor’s, so swing within a span sits precisely on the swung beats. - Rest (
.): gate drops; channel state resets (any open slide is closed at its current cv).
# tie-spread roll across two ticks then a fresh hit
hit: x*3 _ x .
# slide opens at tick 1, ramps through ticks 1+2, lands on G4 at the
# boundary, holds tick 3
note: E4> _ /G4
# slide opens, ramps continuously across three ticks, finishes inside
# tick 3
note: E4> _ >G4
# step cv without retrigger
note: E4 /G4 _
Wiring examples
Drum machine
One PatternPlayer per drum voice, each with a single hit channel:
module kick_pp: PatternPlayer(channels: [hit])
seq.clock[kick] -> kick_pp.clock
module kick_drum: Kick { pitch: 50, decay: 0.4 }
kick_pp.trigger[hit] -> kick_drum.trigger
kick_pp.cv2[hit] -> kick_vca.velocity
Melodic voice
A PatternPlayer with note and vel channels driving a synth voice:
module bass_pp: PatternPlayer(channels: [note, vel])
seq.clock[bass] -> bass_pp.clock
bass_pp.cv1[note] -> voice.voct
bass_pp.gate[note] -> voice.gate
bass_pp.trigger[note] -> voice.trigger
bass_pp.cv1[vel] -> voice.velocity
Drum synthesis (external bundle)
Source of truth: the doc comments on each module struct in the external
patches-drumscrate (in thepatches-bundlesrepo) define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
The 808-style electronic drum synthesisers documented on this page are
not in the in-tree patches-modules crate. They ship as the
patches-drums bundle from the external patches-bundles repository
and are loaded at runtime by the host’s plugin scanner. See Implementing a native plugin
for how host-loaded bundles work and the host’s stdlib search path
(patches_ffi::scanner::stdlib_scanner) for where bundles are
discovered.
Each module has a single trigger input (rising edge) and a mono out.
They are designed to be driven by the tracker sequencer (see
Tracker sequencer) but work with any trigger source.
All drum modules use zero-allocation DSP kernels from patches-dsp and are
safe for real-time use.
Kick — 808-style kick drum
A sine oscillator with a fast pitch sweep from a configurable start frequency down to a settable base pitch, shaped by an exponential amplitude decay envelope, with optional tanh saturation for grit and a transient click layer.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | mono | Rising edge triggers |
voct | mono | V/oct pitch CV; overrides sweep start frequency if connected |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Kick signal |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
pitch | float | 20–200 Hz | 55 | Base pitch of the kick |
sweep | float | 0–5000 Hz | 2500 | Starting frequency of pitch sweep |
sweep_time | float | 0.001–0.5 s | 0.04 | Duration of pitch sweep |
decay | float | 0.01–2.0 s | 0.5 | Amplitude decay time |
drive | float | 0.0–1.0 | 0.0 | Saturation amount |
click | float | 0.0–1.0 | 0.3 | Transient click intensity |
Snare — 808-style snare drum
Combines a tuned body oscillator (sine with short pitch sweep) with a
bandpass-filtered noise burst. Each path has its own decay envelope; the
tone parameter crossfades between them.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | mono | Rising edge triggers |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Snare signal |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
pitch | float | 80–400 Hz | 180 | Body oscillator base pitch |
tone | float | 0.0–1.0 | 0.5 | Body vs noise mix (0 = all body) |
body_decay | float | 0.01–1.0 s | 0.15 | Body amplitude decay time |
noise_decay | float | 0.01–1.0 s | 0.2 | Noise amplitude decay time |
noise_freq | float | 500–10000 Hz | 3000 | Noise bandpass centre frequency |
noise_q | float | 0.0–1.0 | 0.3 | Noise bandpass resonance |
snap | float | 0.0–1.0 | 0.5 | Transient snap intensity |
ClosedHiHat — Closed hi-hat
Metallic tone from six inharmonic square oscillators mixed with highpass-filtered white noise, shaped by a short decay envelope.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | mono | Rising edge triggers |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Closed hat signal |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
pitch | float | 100–8000 Hz | 400 | Base frequency of metallic tone |
decay | float | 0.005–0.2 s | 0.04 | Amplitude decay time |
tone | float | 0.0–1.0 | 0.5 | Metallic vs noise mix |
filter | float | 2000–16000 Hz | 8000 | Noise highpass cutoff |
OpenHiHat — Open hi-hat
Same metallic tone engine as closed hi-hat but with a longer decay range.
Includes a choke input so a closed hi-hat trigger can cut it short.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | mono | Rising edge triggers |
choke | mono | Rising edge chokes (cuts) the sound |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Open hat signal |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
pitch | float | 100–8000 Hz | 400 | Base frequency of metallic tone |
decay | float | 0.05–4.0 s | 0.5 | Amplitude decay time |
tone | float | 0.0–1.0 | 0.5 | Metallic vs noise mix |
filter | float | 2000–16000 Hz | 8000 | Noise highpass cutoff |
Tom — 808-style tom
Shares the kick’s basic architecture (sine oscillator + pitch sweep + amplitude decay) but with a higher pitch range, shorter sweep, and a subtle noise layer for attack texture.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | mono | Rising edge triggers |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Tom signal |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
pitch | float | 40–500 Hz | 120 | Base pitch |
sweep | float | 0–2000 Hz | 400 | Pitch sweep start offset |
sweep_time | float | 0.001–0.3 s | 0.03 | Pitch sweep duration |
decay | float | 0.05–2.0 s | 0.3 | Amplitude decay time |
noise | float | 0.0–1.0 | 0.15 | Noise layer amount |
Cymbal — Crash/ride cymbal
Uses the same metallic tone engine as hi-hats but with a higher frequency range, longer decay, and a “shimmer” parameter that adds slow LFO modulation to the partial frequencies.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | mono | Rising edge triggers |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Cymbal signal |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
pitch | float | 200–10000 Hz | 600 | Base frequency of metallic tone |
decay | float | 0.2–8.0 s | 2.0 | Amplitude decay time |
tone | float | 0.0–1.0 | 0.5 | Metallic vs noise mix |
filter | float | 2000–16000 Hz | 6000 | Noise highpass cutoff |
shimmer | float | 0.0–1.0 | 0.2 | Partial frequency modulation depth |
Clap — 808-style handclap
White noise passed through a bandpass filter, gated by a burst generator to produce the initial “clappy” retriggered transient, then shaped by a longer decay envelope for the tail.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | mono | Rising edge triggers |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Clap signal |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
decay | float | 0.05–2.0 s | 0.3 | Tail decay time |
filter | float | 500–8000 Hz | 1200 | Bandpass centre frequency |
q | float | 0.0–1.0 | 0.4 | Bandpass resonance |
spread | float | 0.0–1.0 | 0.5 | Spacing between bursts |
bursts | int | 1–8 | 4 | Number of noise bursts |
Claves — Claves
A short, bright, resonant click produced by exciting a high-Q bandpass SVF with a single-sample impulse and shaping with a fast decay envelope.
Inputs
| Port | Kind | Description |
|---|---|---|
trigger | mono | Rising edge triggers |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Claves signal |
Parameters
| Name | Type | Range | Default | Description |
|---|---|---|---|---|
pitch | float | 200–5000 Hz | 2500 | Resonant frequency |
decay | float | 0.01–0.5 s | 0.06 | Amplitude decay time |
reson | float | 0.3–1.0 | 0.85 | Bandpass resonance / ring |
MIDI input
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
MIDI events arrive on the engine-level GLOBAL_MIDI backplane (carrying up
to 5 packed events per sample). Modules that need MIDI either read the
backplane directly (MidiToCv, PolyMidiToCv, MidiCC) or sit downstream
of a MidiIn module which exposes the backplane as a midi cable so
other MIDI-processing modules can be chained.
MidiIn — Backplane → MIDI cable
Exposes the engine’s GLOBAL_MIDI backplane as a midi output cable so
that MidiSplit, MidiTranspose, MidiArp, MidiDelay, etc. can be
chained from it.
Outputs
| Port | Kind | Description |
|---|---|---|
midi | midi | MIDI events from the system input |
MidiToCv — Mono MIDI note input
Monophonic: only one note active at a time (last-note priority). Pressing a new key updates pitch immediately; releasing the top key falls back to the previously held key without retriggering. Up to 16 simultaneously held keys are tracked.
V/oct convention
voct is referenced to MIDI note 0 (C0 ≈ 16.35 Hz) at 0 V, with 1/12 V per
semitone:
| MIDI note | Note | voct |
|---|---|---|
| 0 | C0 | 0.0 |
| 12 | C1 | 1.0 |
| 60 | C4 (middle C) | 5.0 |
| 69 | A4 (concert A) | 5.75 |
Outputs
| Port | Description |
|---|---|
voct | Pitch in V/oct above C0 (MIDI note 0 = 0.0, 1/12 V per semitone) |
trigger | Sub-sample trigger on each note-on |
gate | 1.0 while any note is held or sustain pedal (CC 64) is active |
mod | CC 1 (mod wheel) normalised to [0.0, 1.0] |
pitch | Pitchbend normalised to [−1.0, 1.0] (centre = 0.0) |
velocity | Most-recent note-on velocity normalised to [0.0, 1.0] |
slur | 1.0 while a held key falls back to a still-held lower key without retriggering, otherwise 0.0 |
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
legato | bool | false | When true, holding through a note change suppresses the per-note trigger (gate stays high; only the first key triggers) |
PolyMidiToCv — Polyphonic MIDI input
Distributes incoming notes across up to 16 voices with LIFO voice stealing. When all voices are occupied the most-recently-allocated voice is stolen.
Outputs
| Port | Kind | Description |
|---|---|---|
voct | Poly | Per-voice pitch in V/oct above C0 |
trigger | Poly | Per-voice sub-sample trigger on note-on |
gate | Poly | 1.0 while the note for that voice is physically held |
velocity | Poly | Per-voice note-on velocity normalised to [0.0, 1.0] |
mod | Mono | CC 1 (mod wheel) normalised to [0.0, 1.0] |
pitch | Mono | Pitchbend normalised to [−1.0, 1.0] |
MidiCC — Control-change reader
Reads a configured MIDI CC number from the backplane and emits its normalised value as a mono signal.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
cc | int (0–127) | 1 | MIDI CC number to read |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Most-recent CC value for cc, normalised to [0.0, 1.0] |
MidiArp — Arpeggiator
Arpeggiates held notes, clocked by an external trigger (e.g. Clock).
Refer to the module doc comment in patches-modules/src/midi_arp.rs for
the current parameter set and port list.
MidiDelay — MIDI note delay
Delays incoming MIDI events by a configurable number of samples or beats,
with optional feedback for repeating echoes. See
patches-modules/src/midi_delay.rs for ports and parameters.
MidiSplit — MIDI splitter
Routes incoming MIDI notes to one of several outputs based on channel,
velocity, or pitch range. See patches-modules/src/midi_split.rs.
MidiTranspose — MIDI pitch transposer
Transposes note-on / note-off events by a configurable number of
semitones. See patches-modules/src/midi_transpose.rs.
MidiDrumset — MIDI note-to-drum mapper
Maps incoming MIDI notes to trigger outputs for drum-voice modules
(Kick, Snare, etc). See patches-modules/src/midi_drumset.rs.
Delays & Reverb
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Delay — Mono multi-tap delay
One shared 4-second delay buffer with N independent read taps. Each tap has its
own delay time, gain, feedback, tone, and drive. All tap feedbacks sum back into
the buffer write before the next sample. N is set by channels.
module dly : Delay(channels: 2) {
delay_ms[0]: 250,
delay_ms[1]: 375,
feedback[0]: 0.4,
feedback[1]: 0.3,
dry_wet: 1.0
}
Parameters (global)
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
dry_wet | float | 0–1 | 1.0 | Crossfade between dry input and wet tap sum |
Parameters (per tap i)
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
delay_ms[i] | int | 0–2000 | 500 | Tap delay time in milliseconds |
gain[i] | float | 0–1 | 1.0 | Tap output gain |
feedback[i] | float | 0–1 | 0.0 | Tap feedback amount (sent back into write) |
tone[i] | float | 0–1 | 1.0 | High-frequency roll-off in the feedback path (1 = bright, 0 = dark) |
drive[i] | float | 0.1–10 | 1.0 | Soft-clip drive in the feedback path |
Inputs
| Port | Description |
|---|---|
in | Mono audio input |
drywet_cv | Additive CV for dry_wet |
sync_ms[0] … sync_ms[n-1] | When connected, overrides delay_ms[i] with the input value in ms |
delay_cv[0] … delay_cv[n-1] | Additive CV for delay time (±1 scales ±100%) |
gain_cv[0] … gain_cv[n-1] | Additive CV for tap gain |
fb_cv[0] … fb_cv[n-1] | Additive CV for feedback amount |
return[0] … return[n-1] | Pre-gain return signal per tap (added after the raw tap read) |
Outputs
| Port | Description |
|---|---|
out | Dry/wet mixed output |
send[0] … send[n-1] | Pre-gain tap signal per tap (before return mixing) |
StereoDelay — Stereo multi-tap delay
Two 4-second delay buffers (L and R) sharing a single set of N read taps. Each
tap has pan, and an optional pingpong mode that cross-routes feedback (L→R,
R→L). N is set by channels.
module dly : StereoDelay(channels: 2) {
delay_ms[0]: 300,
delay_ms[1]: 450,
feedback[0]: 0.35,
feedback[1]: 0.35,
pingpong[0]: true,
pingpong[1]: true,
pan[0]: -0.3,
pan[1]: 0.3
}
Parameters (global)
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
dry_wet | float | 0–1 | 1.0 | Crossfade between dry input and wet tap sum |
Parameters (per tap i)
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
delay_ms[i] | int | 0–2000 | 500 | Tap delay time in milliseconds |
gain[i] | float | 0–1 | 1.0 | Tap output gain |
feedback[i] | float | 0–1 | 0.0 | Tap feedback amount |
tone[i] | float | 0–1 | 1.0 | HF roll-off in feedback path (1 = bright, 0 = dark) |
drive[i] | float | 0.1–10 | 1.0 | Soft-clip drive in feedback path |
pan[i] | float | −1–+1 | 0.0 | Stereo pan position (−1 = full left, +1 = full right) |
pingpong[i] | bool | — | false | Cross-route feedback: L feedback→R buffer, R feedback→L buffer |
Inputs
| Port | Description |
|---|---|
in | Stereo audio input |
drywet_cv | Additive CV for dry_wet |
sync_ms[0] … sync_ms[n-1] | When connected, overrides delay_ms[i] with the input value in ms |
delay_cv[0] … delay_cv[n-1] | Additive CV for delay time |
gain_cv[0] … gain_cv[n-1] | Additive CV for tap gain |
fb_cv[0] … fb_cv[n-1] | Additive CV for feedback amount |
pan_cv[0] … pan_cv[n-1] | Additive CV for tap pan |
return[0] … return[n-1] | Pre-gain stereo return per tap |
Outputs
| Port | Description |
|---|---|
out | Stereo dry/wet mixed output |
send[0] … send[n-1] | Pre-gain stereo tap signal per tap |
FdnReverb — Stereo FDN reverb
An 8-line feedback delay network (FDN) with Hadamard mixing matrix, per-line
high-shelf absorption, Thiran all-pass interpolation for LFO-modulated reads,
and stereo output via orthogonal output gain vectors. Five character archetypes
control the room model; size and brightness sweep within each archetype.
module verb : FdnReverb {
character: hall,
size: 0.6,
brightness: 0.5,
pre_delay: 0.3,
mix: 0.8
}
Parameters
| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
character | enum | plate / room / chamber / hall / cathedral | hall | Room archetype — sets delay-line scaling, LFO rate/depth, pre-delay length, and decay curve shape |
size | float | 0–1 | 0.5 | Room size within the archetype: 0 = smallest/shortest, 1 = largest/longest |
brightness | float | 0–1 | 0.5 | High-frequency decay ratio: 0 = dark (HF decays much faster), 1 = bright (HF/LF decay close together) |
pre_delay | float | 0–1 | 0.0 | Additional pre-delay (additive with size): 0 = no extra, 1 = maximum for the archetype |
mix | float | 0–1 | 1.0 | Dry/wet mix: 0 = fully dry, 1 = fully wet |
Character archetypes
| Name | RT60 range (LF) | Pre-delay | Character |
|---|---|---|---|
plate | 0.3 s – 1.5 s | up to 10 ms | Dense, smooth; no sense of room geometry |
room | 0.4 s – 2.5 s | up to 25 ms | Small to mid-size room with clear early reflections |
chamber | 0.3 s – 2.0 s | up to 20 ms | Tight, controlled decay; studio chamber character |
hall | 0.8 s – 5.0 s | up to 50 ms | Concert hall — the default |
cathedral | 1.5 s – 8.0 s | up to 80 ms | Very long, diffuse reverb tail |
Inputs
| Port | Description |
|---|---|
in | Stereo audio input (mono sources are silently broadcast L = R) |
size_cv | Additive CV for size |
brightness_cv | Additive CV for brightness |
pre_delay_cv | Additive CV for pre_delay |
mix_cv | Additive CV for mix |
Outputs
| Port | Description |
|---|---|
out | Stereo reverb output |
Convolution reverb (external bundle)
ConvReverb and StereoConvReverb are not part of the in-tree
patches-modules crate. They ship from the external
patches-fft-bundle and are loaded at runtime by the plugin
scanner — see Implementing a native plugin
for how host-loaded bundles work.
Dynamics
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Limiter — Lookahead peak limiter
Transparent peak limiter with true lookahead. The attack_ms parameter
determines how far ahead the detector looks, giving the gain envelope time to
ramp down before a transient arrives at the output — eliminating the
overshoot that zero-latency limiters suffer.
Inter-sample peaks (peaks that fall between base-rate samples) are caught by internally upsampling the detector path to 2× rate. The extra precision prevents hard-clipped peaks from sneaking through at high signal levels.
The output is hard-clipped to ±1.0 as a final safety stage.
Signal chain (per base-rate sample)
input ──► dry delay (lookahead + group delay)
│
└─► 2× interpolator
│
▼
peak window [t-L .. t]
│
▼
gain computation (attack / release smoothing)
│
▼
output = clamp(delayed_input × gain, −1.0, 1.0)
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
threshold | float | 0.9 | Peak level above which gain reduction is applied (0.0–2.0). Internally multiplied by 0.98 for a small headroom margin. |
attack_ms | float (ms) | 2.0 | Lookahead / attack time in milliseconds (0.1–50.0). Sets how early the gain ramp begins before a transient. |
release_ms | float (ms) | 100.0 | Release time in milliseconds (1.0–5000.0). Controls how quickly the gain recovers after the peak falls. |
Inputs
| Port | Description |
|---|---|
in | Audio signal to limit |
Outputs
| Port | Description |
|---|---|
out | Limited output, hard-clipped to ±1.0 |
Notes
- The module introduces a latency of
attack_msplus a small FIR group delay (~8 samples at base rate for the interpolator). Account for this when mixing a limited signal against an unlimitated reference. - All buffers are pre-allocated at
preparetime for the maximumattack_msvalue (50 ms), so changingattack_msat runtime never allocates.
StereoLimiter — Stereo lookahead limiter
Stereo variant of Limiter with linked gain reduction across channels.
See patches-modules/src/stereo_limiter.rs for the current parameter set.
Compressor — Feed-forward compressor
Feed-forward compressor with soft knee, peak / RMS detection, and a
sidechain input. When sidechain is unconnected the detector self-keys
from in (the standard hardware default — see ADR 0076).
The static gain curve is C¹-continuous across the knee region: at the
boundaries threshold ± knee_width/2 both value and slope match the
linear / no-reduction branches, so the knee introduces no kink. A high
ratio value asymptotically approaches a limiter shape; no special
casing of ratio = ∞ is required.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
threshold | float (dB) | -12 | Knee centre (−60..0 dB). |
ratio | float | 4 | Above-knee slope (1..100). High values approach a limiter. |
knee_width | float (dB) | 6 | Soft-knee width; 0 = hard knee (0..24 dB). |
attack | float (ms) | 10 | Detector attack (0.1..1000 ms). |
release | float (ms) | 100 | Detector release (1..5000 ms). |
makeup | float (dB) | 0 | Output gain trim (−24..24 dB). |
detect | enum | peak | peak (envelope on absolute value) or rms (envelope on squared). |
mix | float | 1 | Dry/wet blend (0..1). 0 returns dry. |
Inputs
| Port | Description |
|---|---|
in | Audio input |
sidechain | External detector key. Self-keys from in when unconnected. |
Outputs
| Port | Description |
|---|---|
out | Compressed audio |
StereoCompressor — True-stereo feed-forward compressor
Stereo variant of Compressor with linked detection: a single magnitude
derived from both channels drives one detector, and one gain reduction
value is applied identically to L and R. This preserves the stereo image
under asymmetric transients (a per-channel detector would shift the
image on panned hits).
Detector magnitude:
- Peak:
max(|L|, |R|) - RMS:
sqrt((L² + R²) / 2)
Parameters are identical to Compressor. The sidechain port is
stereo; a mono source patched in is broadcast to both channels per
ADR 0059.
Inputs
| Port | Kind | Description |
|---|---|---|
in | stereo | Audio input |
sidechain | stereo | External detector key; self-keys from in when unconnected. |
Outputs
| Port | Kind | Description |
|---|---|---|
out | stereo | Compressed stereo |
Gate — Threshold gate with hysteresis
Threshold gate with hysteresis, attack / hold / release ballistics, and a
sidechain input. The detector self-keys from in when sidechain is
unconnected (ADR 0076). Gating is binary in intent — there is no mix
parameter; a patch author wanting ducking-with-blend uses Compressor
with high ratio and mix < 1.
The gate opens when the rectified detector key crosses threshold (the
fire condition is armed && |x| > threshold — the threshold itself, not
threshold - hysteresis). Hysteresis controls eligibility: after a
fire the state machine disarms and only re-arms once the signal has
dropped below threshold - hysteresis. Once open the gate cannot close
for at least hold ms; after that it closes when the signal drops below
threshold - hysteresis. Attack and release set the ramp times of the
open/closed envelope.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
threshold | float (dB) | -40 | Open threshold (−80..0 dB). |
hysteresis | float (dB) | 3 | Re-arm band below threshold (0..24 dB). |
attack | float (ms) | 1 | Open-ramp time (0.01..1000 ms). |
hold | float (ms) | 10 | Minimum open time after a trigger (0..5000 ms). |
release | float (ms) | 100 | Close-ramp time (1..5000 ms). |
Inputs
| Port | Description |
|---|---|
in | Audio input |
sidechain | External detector key. Self-keys from in when unconnected. |
Outputs
| Port | Description |
|---|---|
out | Gated audio |
StereoGate — True-stereo threshold gate
Stereo variant of Gate with linked detection: one detector is fed by
max(|L|, |R|) and one gate state drives both channels. This preserves
the stereo image — per-channel detection would cause audible image shift
on asymmetric transients.
Parameters are identical to Gate. The sidechain port is stereo; a
mono source patched in is broadcast to both channels per ADR 0059.
Inputs
| Port | Kind | Description |
|---|---|---|
in | stereo | Audio input |
sidechain | stereo | External detector key; self-keys from in when unconnected. |
Outputs
| Port | Kind | Description |
|---|---|---|
out | stereo | Gated stereo |
Bitcrusher — Bit-depth and sample-rate reduction
Quantises samples to a configurable bit depth and decimates the sample
rate. See patches-modules/src/bitcrusher.rs.
Drive — Nonlinear waveshaper
Soft-clip saturation / overdrive. See patches-modules/src/drive.rs.
TransientShaper — Attack/sustain emphasis
Envelope-follower-based transient emphasis and suppression. See
patches-modules/src/transient_shaper.rs.
Detectors
Source of truth: the doc comments on each module struct in
patches-modules/src/detectors/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
The detector family converts an audio signal into control events or sustained gates by threshold-crossing the instantaneous signed sample value — not envelope, magnitude, or onset. Two sub-families share a threshold + hysteresis state machine but differ in what they emit:
- Trigger family (
AudioToTrigger,PolyAudioToTrigger) emits ADR 0047 sub-sample sync events on aTriggercable. Intended for clock recovery from oscillator-rate audio, sub-octave division (a divider chain fed by anAudioToTriggercascade), and driving hard-sync ports from an arbitrary audio source. - Gate family (
AudioToGate,PolyAudioToGate) emits sample- accurate sustained gates (0.0/1.0) on a normal mono / poly cable. Intended for sustained-state derivation from a signed-sample stream — the schmitt-trigger pattern.
Onset / transient detection (envelope follower → threshold) is a different problem and not provided here.
AudioToTrigger — Mono threshold-crossing detector
Fires a sub-sample sync event when the input crosses threshold in the
chosen direction. The event fraction is the linear-interpolated
zero-crossing position inside the current sample interval:
frac = (threshold - prev) / (curr - prev), encoded on the output
Trigger cable per ADR 0047.
Hysteresis is a two-state machine. After a rising fire the detector
disarms; it only re-arms once the signal drops below
threshold - hysteresis. Hysteresis controls eligibility, not event
location — the fire point is threshold, never
threshold - hysteresis. The falling direction is symmetric.
Bidirectional detection tracks the rising and falling arm states independently.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
threshold | float (dB) | -12 | Fire threshold (-60..0 dB) |
hysteresis | float (dB) | 3 | Re-arm band around threshold (0..24 dB) |
direction | enum | rising | Edge polarity: rising / falling / both |
cooldown | float (ms) | 0 | Debounce after fire (0..1000 ms) |
Inputs
| Port | Kind | Description |
|---|---|---|
in | mono | Audio source |
Outputs
| Port | Kind | Description |
|---|---|---|
out | trigger | Sub-sample sync event in (0, 1] on fire, 0.0 otherwise |
PolyAudioToTrigger — Per-voice threshold-crossing detector
Polyphonic variant. One independent EdgeDetector runs per voice
channel, so per-voice transitions fire on the corresponding lane of the
output PolyTrigger cable. Parameters apply identically across voices.
Inputs
| Port | Kind | Description |
|---|---|---|
in | poly | Per-voice audio source |
Outputs
| Port | Kind | Description |
|---|---|---|
out | poly trigger | Per-voice sub-sample sync event (independent lanes) |
Parameters identical to AudioToTrigger.
AudioToGate — Mono Schmitt-trigger gate
Holds a sustained gate high while signal > threshold; closes the gate
when signal < threshold - hysteresis. Values inside the schmitt band
hold the previous state. Sample-accurate per
ADR 0030 — no
sub-sample reporting.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
threshold | float (dB) | -12 | Open threshold (-60..0 dB) |
hysteresis | float (dB) | 3 | Close band below threshold (0..24 dB) |
Inputs
| Port | Kind | Description |
|---|---|---|
in | mono | Audio source |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Gate (0.0 closed, 1.0 open) |
PolyAudioToGate — Per-voice Schmitt-trigger gate
Polyphonic variant. One independent GateSchmitt runs per voice
channel, so per-voice gate states are emitted on the lanes of the output
poly cable. Parameters apply identically across voices.
Inputs
| Port | Kind | Description |
|---|---|---|
in | poly | Per-voice audio source |
Outputs
| Port | Kind | Description |
|---|---|---|
out | poly | Per-voice gate (0.0 / 1.0, independent lanes) |
Parameters identical to AudioToGate.
No stereo variants
Neither family has a stereo variant. Both threshold-cross the signed
sample value, and there is no consistent collapse of two signed streams
into one: (L + R) / 2 cancels antiphase content; max(|L|, |R|) is
rectified magnitude (contradicting the mono variants’ signed semantics —
a max(|L|, |R|) gate would behave as an envelope-above-threshold
detector, a different operation from the mono AudioToGate); and
per-channel detection contradicts a single output. A patch needing a
stereo-bus gate or trigger derives it from per-channel AudioToTrigger
/ AudioToGate instances fed by StereoSplitter, or from a dedicated
stereo magnitude-summariser (peak / RMS) whose mono output keys an
AudioToGate when envelope semantics are wanted. See
ADR 0076.
Stereo utilities
Source of truth: the doc comments on each module struct in
patches-modules/src/stereo/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
The stereo utility group covers image control — pan, balance, width,
mid-side encode/decode, and a sum-mono-bass crossover. StereoSplitter,
StereoJoiner, and StereoSum complement these but live elsewhere
today; they will migrate into this group under the source-tree
reorganisation tickets.
Pan — Equal-power mono pan
Places a mono signal in the stereo field using the equal-power law
(L, R) = in · (cos θ, sin θ) with θ = (pan + 1) · π/4. The total
power L² + R² is preserved across the sweep; at the centre each
channel receives in / √2 (−3 dB). The CV input is added to the
parameter and clamped to [-1, 1] before the angle is computed.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
pan | float | 0.0 | Base position; -1 = L, +1 = R (-1–1) |
Inputs
| Port | Description |
|---|---|
in | Mono source |
pan | Additive CV (offsets pan param) |
Outputs
| Port | Description |
|---|---|
out | Equal-power stereo L/R |
Balance — Linear stereo balance
Attenuates one side of a stereo signal while the other passes unchanged.
With balance ∈ [-1, 1], gain_L = clamp(1 − balance, 0, 1) and
gain_R = clamp(1 + balance, 0, 1). At the centre the signal passes
through unchanged; at the extremes the opposite side is silenced. The
linear ramp per side matches the mixer’s pan-law ramp shape. The CV
input is added to the parameter before clamping.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
balance | float | 0.0 | Base balance; -1 = L, +1 = R (-1–1) |
Inputs
| Port | Description |
|---|---|
in | Stereo source |
balance | Additive CV (offsets balance param) |
Outputs
| Port | Description |
|---|---|
out | Balanced stereo |
StereoWidth — Width control via internal M-S
Encodes the input to mid (M = (L+R)/2) and side (S = (L-R)/2),
scales S by width, leaves M unchanged, and decodes back:
L_out = M + width·S, R_out = M − width·S. At width = 0 both
outputs collapse to the mono sum; at width = 1 the implementation
short-circuits so the M-S round-trip is bit-exact; at width = 2
antiphase content is doubled. The CV input is added to the parameter
and clamped to [0, 2] before scaling.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
width | float | 1.0 | 0 = mono sum, 1 = unchanged, 2 = double-wide (0–2) |
Inputs
| Port | Description |
|---|---|
in | Stereo source |
width | Additive CV (offsets width param) |
Outputs
| Port | Description |
|---|---|
out | Width-scaled stereo |
MidSide — Bidirectional encode/decode
Two independent arithmetic paths in one module: the encode side reads
stereo_in (L/R) and writes (M, S) = ((L+R)/2, (L-R)/2) to ms_out;
the decode side reads ms_in (M/S) and writes (M+S, M−S) to
stereo_out. Either side can be used in isolation by leaving the other
side’s ports unconnected.
All four ports are stereo cables. The mid-side form is just a stereo
cable carrying (M, S) rather than (L, R) — nothing in the
descriptor distinguishes the two, the same constraint that already
applies to any mid/side workflow in a DAW.
Inputs
| Port | Description |
|---|---|
stereo_in | L/R input to encode side |
ms_in | (M, S) input to decode side |
Outputs
| Port | Description |
|---|---|
ms_out | (M, S) from encode side |
stereo_out | L/R from decode side |
MonoBass — Mono-summed bass via LR4 crossover
Splits the stereo input into a low band and a high band at cutoff
using Linkwitz-Riley 4th-order filters (two cascaded Butterworth
biquads each). The lows are summed to mono ((L+R)/2 then LP-filtered)
and sent identically to both output channels; the highs are HP-filtered
per channel so the stereo image above the crossover is preserved. At
the crossover frequency, the LP and HP magnitudes are each −6 dB; the
sum-flat property means an L = R input is reconstructed at unity
magnitude while differential content below the cutoff is summed away.
Use for vinyl-safe masters, club-system protection, and any source whose low end you don’t trust to be summable.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cutoff | float Hz | 120.0 | Crossover frequency (20–2000 Hz) |
Inputs
| Port | Description |
|---|---|
in | Stereo source |
Outputs
| Port | Description |
|---|---|
out | High-band stereo + low-band mono on both |
Utilities
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Glide — Portamento / pitch smoothing
Smooths a stepped input signal toward its target using a one-pole low-pass filter. Because V/oct is a log-frequency scale, linear interpolation in V/oct space gives a perceptually constant-ratio (equal-interval) glide.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
glide_ms | float (ms) | 100.0 | Glide time in milliseconds (0–10000) |
slur_amount | float | 1.0 | Multiplier applied to glide_ms while slur_in is high (0–100) |
Inputs
| Port | Description |
|---|---|
in | Input signal (typically V/oct) |
slur_in | Gate: when high, glide time is multiplied by slur_amount |
Outputs
| Port | Description |
|---|---|
out | Smoothed output |
Tuner — Pitch offset
Transposes a V/oct signal by a fixed interval expressed as octaves, semitones, and cents. All three parameters are additive:
out = in + octave + semi/12 + cent/1200
Setting all parameters to zero passes the signal through unchanged.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
octave | int | 0 | Octave shift (−8 to +8) |
semi | int | 0 | Semitone shift (−12 to +12) |
cent | int | 0 | Fine-tune in cents (−100 to +100; 100 cents = 1 semitone) |
Inputs
| Port | Description |
|---|---|
in | V/oct input |
Outputs
| Port | Description |
|---|---|
out | Transposed V/oct |
Sah — Sample and hold
Latches the in signal on each rising edge of trig (threshold 0.5) and holds
the sampled value on out until the next trigger. The held value is initialised
to 0.0 before the first trigger.
Inputs
| Port | Description |
|---|---|
in | Signal to sample |
trig | Trigger input; latch fires on the ≥ 0.5 rising edge |
Outputs
| Port | Description |
|---|---|
out | Held output value |
PolySah — Polyphonic sample and hold
Polyphonic variant of Sah. A single mono trig latches all 16 voice channels
simultaneously on each rising edge (threshold 0.5).
Inputs
| Port | Description |
|---|---|
in | Polyphonic signal to sample (16 voices) |
trig | Mono trigger; all voices are latched together |
Outputs
| Port | Description |
|---|---|
out | Polyphonic held output (16 voices) |
Quant — V/oct quantiser
Snaps a continuous V/oct signal to the nearest pitch in a user-supplied set.
The scale is declared via channels (an alias list or count), with one
pitch[i] parameter per scale degree. Each pitch is a V/oct value reduced
modulo 1.0 into [0.0, 1.0), giving an octave-invariant pitch class. The
quantiser is not restricted to 12-tone equal temperament: any microtonal or
non-Western scale can be declared by supplying the desired V/oct fractions
directly. The formula applied before quantisation is:
quantised_input = centre + in × scale
Emits a one-sample pulse on trig_out whenever the quantised pitch changes.
Example
module quant : Quant(channels: [root, third, fifth]) {
pitch[root]: C0,
pitch[third]: Eb0,
pitch[fifth]: G0
}
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
pitch[i] | float (V/oct) | 0.0 | Target pitch per scale degree (i in 0..N−1, N = channels) |
centre | float (V/oct) | 0.0 | DC offset added to the input before quantisation (−4 to +4) |
scale | float | 1.0 | Gain applied to in before quantisation (−4 to +4) |
Inputs
| Port | Description |
|---|---|
in | Continuous V/oct input |
Outputs
| Port | Description |
|---|---|
out | Quantised V/oct output |
trig_out | One-sample pulse (1.0) on each pitch change, otherwise 0.0 |
PolyQuant — Polyphonic V/oct quantiser
Applies the same quantisation logic as Quant independently to each of the 16
polyphonic voices. All voices share the same pitch[i], centre, and scale
parameters. Each voice has its own trig_out channel that fires independently
when that voice’s quantised pitch changes.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
pitch[i] | float (V/oct) | 0.0 | Target pitch per scale degree (i in 0..N−1, N = channels) |
centre | float (V/oct) | 0.0 | DC offset added to each voice before quantisation (−4 to +4) |
scale | float | 1.0 | Gain applied to each voice before quantisation (−4 to +4) |
Inputs
| Port | Description |
|---|---|
in | Polyphonic V/oct input (16 voices) |
Outputs
| Port | Description |
|---|---|
out | Polyphonic quantised V/oct output (16 voices) |
trig_out | Per-voice one-sample pulse on pitch change (16 voices) |
RingMod — Diode ring modulator
Analog diode-bridge ring modulator model (Julian Parker, DAFx-11). Produces
sum and difference frequencies. The drive parameter sets the operating point
on the diode I–V curve: low drive is near-ideal multiplication; higher drive
introduces harmonic colouring.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
drive | float (dB) | 1.0 | Diode operating point in dB (0.2–20.0); low = linear, high = saturated |
Inputs
| Port | Description |
|---|---|
signal | Audio signal to modulate |
carrier | Carrier / modulator signal |
Outputs
| Port | Description |
|---|---|
out | Ring-modulated output |
Primitives
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
Small DSP building blocks that are useful on their own and as components inside larger patches. See ADR 0076.
DcBlocker — One-pole DC removal
Thin wrapper over the shared patches_dsp::DcBlocker kernel: a
one-pole highpass at ~5 Hz. No parameters; the cutoff is fixed by the
kernel. Useful after waveshapers, bias injection, or any process that
may introduce a DC offset.
Inputs
| Port | Kind | Description |
|---|---|---|
in | mono | Audio input |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | DC-blocked output |
Comb — Universal comb (FF / FB / both)
Single module covering feed-forward (FIR), feedback (IIR), and combined
pole-zero comb topologies via a mode enum. The feedback parameter
is the coefficient on the delayed tap; its meaning depends on mode:
ff : y[n] = mix · (x[n] + g · x[n−D])
fb : y[n] = mix · y'[n] where y'[n] = x[n] + g · y'[n−D]
both : y[n] = mix · y'[n] where y'[n] = x[n] + g · x[n−D] + g · y'[n−D]
y' is the pre-mix signal — feedback recursion uses the unmixed value
so adjusting mix does not destabilise the loop.
In modes that include fb the caller is responsible for keeping
feedback strictly below 1.0. The module does not clamp on the user’s
behalf; feedback ≥ 1 produces unbounded growth (the same convention
as other recursive modules in the bundle).
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
mode | ff/fb/both | fb | Topology selection |
delay_ms | float (ms) | 10.0 | Delay-line length (0.1–500 ms) |
feedback | float | 0.5 | Delayed-tap coefficient (−0.99..0.99) |
mix | float | 1.0 | Output scale applied after the comb topology |
Inputs
| Port | Kind | Description |
|---|---|---|
in | mono | Audio input |
Outputs
| Port | Kind | Description |
|---|---|---|
out | mono | Comb output |
Audio output
Source of truth: the doc comments on each module struct in
patches-modules/src/define the canonical port names, parameter ranges, and behaviour. This page is kept in sync with those comments.
AudioOut — Stereo audio output
Sends signals to the left and right channels of the system audio output. This module must appear exactly once in a patch.
Inputs
| Port | Description |
|---|---|
in | Stereo input. A mono source is silently broadcast to both channels (L = R). |
No clamping is applied by AudioOut itself. Use attenuation (scaled cables or
a StereoMixer) to keep levels in range — summing many signals without
attenuation will overdrive the output.
Example
mix.out -[0.1]-> out.in
Implementing modules
This chapter is for adding a new module to the in-tree
patches-modules crate. The trait, descriptor templates, and
audio-thread contract are identical to the native plugin
SDK — only the packaging differs.
If you want the worked example, read the native-plugin chapter
first. The 88-line Gain module there is the smallest complete
module, and the Rust is the same whether it ships in-tree or as a
loadable bundle. This chapter covers what’s different about in-tree
modules.
What changes for in-tree
| Concern | Native plugin | In-tree module |
|---|---|---|
| Crate | New cdylib outside the tree | New file under patches-modules/src/ |
| Dependency | patches-sdk = "0.7" | patches-core, patches-dsp |
| Registration | export_plugin! macro | Registry::register::<Module>() in patches-modules::default_registry() |
| Loading | Discovered via bundle dirs | Compiled into the host |
| Distribution | Built separately, shipped as .pxm | Ships with the main release |
| Docs | Source comments only | Source comments → manual’s module reference (see below) |
The Module trait, ModuleDescriptorTemplate, parameter handles
via module_params!, and the audio-thread rules are identical.
Anything you’d write inside a plugin’s impl Module goes into an
in-tree module unchanged.
Steps for a new in-tree module
- Add a file.
patches-modules/src/my_module.rs. Use one of the existing modules as a starting template —oscillator.rs,vca.rs, andglide.rsare good shapes. - Implement
Module. Same surface as the SDK example. Use types frompatches-coredirectly instead ofpatches-sdkre-exports (patches_core::cables::*,patches_core::modules::*,patches_core::param_frame::ParamView). - Declare the module. Add
pub mod my_module;topatches-modules/src/lib.rs(andpub use my_module::MyModule;if you want it re-exported by name). - Register it. In
default_registry()inpatches-modules/src/lib.rs, addr.register::<MyModule>();. The order in this function controls nothing user-visible; group with related modules for readability. - Document it. Add a doc comment in the source per the module documentation standard (covered below). The manual’s module reference is generated from these comments.
- Test it. Use the
ModuleHarnesstest support inpatches-core::test_supportto exercise the module without a running audio engine.
DSP code lives in patches-dsp
Pure DSP algorithms — filter kernels, ADSR cores, oversampling,
delay lines, noise PRNGs — belong in patches-dsp, not
patches-modules. The split:
patches-dsp— algorithm. Takes rawf32inputs, returnsf32outputs. Nopatches-core, no module protocol, no allocation. Pure functions or state objects.patches-modules— protocol glue. Wraps a DSP kernel as a Patches module: declares ports and parameters, reads from the cable pool, calls the kernel, writes back.
Most existing modules are thin wrappers — the SVF filter module
holds an SvfCore from patches-dsp and threads cable values
through it. That separation lets the DSP code be tested in
isolation and reused by multiple modules (e.g. mono and poly
variants both reach into the same kernel).
Module documentation standard
Every module under patches-modules/src/ carries a doc comment
(/// on the struct or //! at file level) in a standard form.
This comment is the source of truth for the manual’s module
reference under docs/src/modules/.
The format, condensed (full spec in CLAUDE.md):
#![allow(unused)]
fn main() {
/// Brief one-line description.
///
/// Extended description (algorithm, CV behaviour, etc.).
///
/// # Inputs
///
/// | Port | Kind | Description |
/// |------|------|-------------|
/// | `name` | mono/poly | What it does |
///
/// # Outputs
///
/// | Port | Kind | Description |
/// |------|------|-------------|
/// | `name` | mono/poly | What it does |
///
/// # Parameters
///
/// | Name | Type | Range | Default | Description |
/// |------|------|-------|---------|-------------|
/// | `name` | float/int/bool/enum | range | `default` | What it does |
}
Port names must match the strings in the module’s descriptor
template. Indexed ports use port[i] notation with a note on the
range. Omit sections that don’t apply.
When you change a module’s ports or parameters, update this comment in the same commit. The manual regeneration tooling reads the comments as authoritative.
Audio-thread rules
Same as for native plugins, same as for the engine itself:
- No allocation on the audio thread (
process,update_validated_parameters,set_ports). - No blocking (no mutexes, no I/O, no syscalls).
- No unbounded loops.
panic = "unwind"only — the tick-boundary catch_unwind requires it.
The patches-engine integration tests run with the
audio-thread-allocator-trap feature on, which traps any heap
allocation on the audio thread and panics — useful for catching
accidental allocation in process during development. The harness
in patches-core::test_support exposes a Harness::tick that
matches the engine’s calling convention closely enough that
allocation bugs in your module’s hot path will surface there.
Cross-reference
- Writing a native plugin — the worked example. Read this for the trait surface walk-through.
- Native plugin ABI — the underlying C ABI. Not needed for in-tree modules (no FFI boundary) but useful if you want to understand how descriptors and parameter frames cross the same trait surface when packaged as a bundle.
- Engine internals — what the host does with your module: planner, cable pool, plan handoff, periodic updates.
Native plugin ABI
This is the reference for the C ABI between a Patches host and an
external native-module plugin. The ABI lets you distribute compiled
module bundles as .dylib / .so / .dll files separately from
the main Patches build — including bundles written in languages
other than Rust.
The Rust SDK (patches-sdk, covered in
Writing a native plugin) hides this
ABI behind ergonomic types. You only need this chapter if you’re
writing a plugin without the SDK — for example, an SDK for another
language.
Stability
The ABI is not yet stable. Each shipped Patches release pins a
specific ABI_VERSION; plugins built against a different version
are refused at load time. The version is currently 12
(patches-ffi-common/src/types.rs:ABI_VERSION).
The shape is unlikely to change drastically, but the byte layouts
of ParamFrame, PortFrame, and the structural blob have evolved
between versions and may evolve again. Treat the current
documentation as a snapshot; track the source.
Canonical sources of truth:
patches-ffi-common/src/— type definitions, JSON (de)serializers, wire format encoders.patches-ffi/src/loader.rs— the host’s side of the loading protocol.patches-sdk/— the Rust SDK; the simplest reference implementation of “what a plugin does”.
ABI surface in one diagram
┌─────── plugin (cdylib) ──────────────┐
│ │
│ patches_plugin_init() │ ← exported symbol
│ → FfiPluginManifest │
│ ├─ abi_version: u32 │
│ ├─ count: usize │
│ └─ vtables: *const │
│ FfiPluginVTable[count] │
│ │
│ Each FfiPluginVTable: │
│ module_template() → JSON │ ← load-time
│ prepare(...) → handle │ ← per instance
│ update_validated_parameters() │ ← audio thread
│ set_ports() │ ← audio thread
│ process() │ ← audio thread
│ periodic_update() │ ← control thread
│ drop() │ ← per instance
│ free_bytes() │ ← helper
│ │
└───────────────────────────────────────┘
Loading protocol
-
The host scans a bundle directory and
dlopens each.dylib/.so/.dll. -
It resolves the
patches_plugin_initsymbol and calls it. The plugin returns anFfiPluginManifest:#![allow(unused)] fn main() { #[repr(C)] pub struct FfiPluginManifest { pub abi_version: u32, pub count: usize, pub vtables: *const FfiPluginVTable, } } -
The host checks
abi_version. Mismatch → bundle refused, error logged, no modules registered. -
For each of the
countvtables, the host callsmodule_template(), receives a JSONModuleDescriptorTemplateblob, deserialises it once, and registers the module type. -
Per-instance work happens later, when a patch instantiates a module of that type.
The two JSON crossings
JSON crosses the FFI boundary in exactly two places, both on the control thread. Audio-thread traffic is binary.
| When | Direction | Payload | Purpose |
|---|---|---|---|
| Plugin load | plugin → host | ModuleDescriptorTemplate | Static, shape-axis-parameterised description of a module |
| Instance construction | host → plugin | ModuleDescriptor | Per-instance descriptor with axis counts resolved |
The two blobs share field names but encode different lifecycle stages:
- A template carries unresolved port and parameter declarations parameterised by named count axes (e.g. “one input port per channel”).
- An instance descriptor has those axes resolved to concrete index values for a specific instantiation (e.g. “channels = 3, so three input ports”).
The deserialiser is hand-rolled (no serde_json dependency); the
schema is the host’s, not serde’s.
JSON dialect
Standard JSON, no extensions:
- Object keys case-sensitive, exact-string matched.
- Numbers decoded through IEEE-754 double; integer fields silently
truncate (
as i64);usizefields silently widen. - String escapes standard (
\",\\,\/,\n,\r,\t,\uXXXX); other escapes pass through as the literal character. - Unrecognised keys are silently ignored. This is the additive-extension hook: new fields land here without breaking older parsers.
- Missing optional fields fall back to documented defaults; missing required fields are an error.
The canonical serialiser emits compact JSON (no whitespace).
ModuleDescriptor (instance)
{
"module_name": "Gain",
"shape": { "channels": 1 },
"inputs": [ /* PortDescriptor */ ],
"outputs": [ /* PortDescriptor */ ],
"realtime_params": [ /* ParameterDescriptor */ ],
"structural_params": [ /* ParameterDescriptor */ ]
}
| Field | JSON type | Required | Default | Description |
|---|---|---|---|---|
module_name | string | yes | — | Module type name. Must match registry entry. |
shape | object | yes | — | See ModuleShape below. |
inputs | array | no | [] | Input ports in slice order. |
outputs | array | no | [] | Output ports in slice order. |
realtime_params | array | no | [] | Audio-thread parameters. |
structural_params | array | no | [] | Control-thread-only parameters. |
The declared order of inputs and outputs is the slice index
passed to the module’s process() callback. Reordering changes
the contract.
ModuleShape
{ "channels": 1 }
shape is a self-contained object so future shape axes can be
added without revising the top-level schema. Today only channels
is exposed. Must be ≥ 1.
PortDescriptor
{
"name": "in",
"index": 0,
"kind": "mono",
"mono_layout": "audio",
"poly_layout": "audio"
}
| Field | JSON type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | — | Port name. |
index | number | no | 0 | Index within a multi-port group. |
kind | string | no | "mono" | One of "mono", "poly", "stereo". Unknown → "mono". |
mono_layout | string | no | "audio" | One of "audio", "trigger". |
poly_layout | string | no | "audio" | One of "audio", "trigger", "transport", "midi". |
Layouts must match across a connection. Only
the layout matching the port’s kind is checked.
ParameterDescriptor
{
"name": "gain",
"index": 0,
"parameter_type": { "type": "float", "min": 0.0, "max": 4.0, "default": 1.0 }
}
parameter_type is a tagged union (the template form uses kind
for the same payload — note the key difference between instance
and template JSON).
ParameterKind variants
| Tag | Wire form | Valid in |
|---|---|---|
float | { "type": "float", "min": …, "max": …, "default": … } | realtime + structural |
int | { "type": "int", "min": …, "max": …, "default": … } | realtime + structural |
bool | { "type": "bool", "default": … } | realtime + structural |
enum | { "type": "enum", "variants": [...], "default": "..." } | realtime only |
file | { "type": "file", "extensions": [...] } | structural only |
song | { "type": "song" } (or { "type": "song_name" }) | structural only |
enum variant order is part of the descriptor hash — adding or
reordering variants forces a load-time refusal. file paths are
host-resolved before being threaded through the structural blob.
For exhaustive field tables for each kind, see the canonical
implementations in patches-ffi-common/src/json/.
ModuleDescriptorTemplate (load-time)
Shape parallels ModuleDescriptor, but ports and parameters are
tagged with optional axis-expansion metadata. The host expands them
during instance construction:
{
"name": "Gain",
"axes": [ { "name": "channels" } ],
"global_inputs": [ /* PortTemplate */ ],
"per_axis_inputs": [],
"global_outputs": [ /* PortTemplate */ ],
"per_axis_outputs": [],
"realtime_params": [ /* ParameterTemplate */ ],
"structural_params": [],
"per_axis_realtime_params": [],
"per_axis_structural_params": []
}
The split between global (one per module) and per_axis (one per channel, expanded per shape-axis value at instance time) is the template’s reason for existing.
Binary wire formats (audio thread)
Three packed binary frames cross the FFI boundary at audio rate:
| Frame | When | Layout reference |
|---|---|---|
ParamFrame | update_validated_parameters() | patches-ffi-common/src/sdk.rs (encoder), patches-ffi-common/src/abi.rs (ABI shape) |
PortFrame | set_ports() | patches-ffi-common/src/port_frame.rs |
| Structural blob | prepare() | patches-ffi-common/src/structural_frame.rs |
The cable-value pool itself is a split scratch / cycle region (ADR
0072, ABI v12 — see FfiPluginVTable::process doc comment for the
current layout). Plugins reconstruct a CablePool from the two
slices.
The byte-exact tables and worked encoding examples are too long to duplicate here without staying in sync with the reference encoder. Refer to the source files listed above; they are the contract.
Descriptor hash
The host computes a stable hash over each instance descriptor
(module name, shape, ports, parameters in canonical order) and
gates load-time compatibility on it. A plugin can override the
hash with export_plugin_with_hash_override! if it knows a
descriptor change is wire-compatible; otherwise the default
canonical-byte digest is used.
Canonical encoding lives in patches-ffi-common/src/sdk.rs (search
for descriptor_hash). External SDKs must reproduce this byte
sequence exactly.
Audio-thread rules
The same real-time constraints that apply to in-tree modules apply across the FFI:
- No allocation on the audio thread (in
process,update_validated_parameters,set_ports). - No blocking — no mutexes, no I/O, no syscalls.
- No panics in plugins built with
panic = "abort". The Patches engine requirespanic = "unwind"; FFI plugin crates must respect this so the host’s tick-boundary catch_unwind can halt the engine cleanly instead of taking down the process.
Implementing in another language
The minimum surface for an SDK in any C-ABI-compatible language:
- Export
patches_plugin_initreturning anFfiPluginManifest. - Provide one
FfiPluginVTableper module type with the eight function pointers wired up. - Implement
module_templateto return JSON matching theModuleDescriptorTemplateschema above. - In
prepare, parse theModuleDescriptorJSON and store anything you need on the per-instance handle. - Implement
update_validated_parametersandset_portsagainst the binary frame formats. - Implement
processagainst the split scratch / cycle cable pool layout. - Compute the descriptor hash compatibly (or override).
The Rust SDK does all of this through the export_plugin! macro;
look at patches-sdk/src/lib.rs and a working plugin (e.g.
test-plugins/gain/) for an end-to-end reference.
Writing a native plugin
This chapter walks through building a native module plugin in Rust
with the patches-sdk crate. By the end you’ll have a .dylib /
.so / .dll that the player and the CLAP plugin both load, and
the new module type will be usable from any .patches file.
We’ll use the in-tree test-plugins/gain/ as the worked example —
about eighty lines of Rust for a working unity-gain module. The
same pattern scales to anything you can express in the Module
trait.
If you want to write the bindings without the Rust SDK (for a non- Rust SDK, say), Native plugin ABI is the reference for the underlying C surface.
What you get
A native plugin can ship one or more module types. Each module type
appears in .patches exactly like a built-in:
module g : Gain { gain: 0.5 }
There is no separate registration step in the patch file. As long as the host has been pointed at a directory containing your bundle (see Loading the plugin below), the module type is discovered at host startup.
Project layout
A plugin crate is a regular cdylib. The minimal Cargo.toml:
[package]
name = "my-gain-plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
patches-sdk = "0.7"
# Optional, if you want DSP kernels (filters, ADSR, oversampling):
# patches-dsp = "0.6"
patches-sdk is the only required dependency. It re-exports the
Module trait, descriptors, ports, the module_params! and
export_plugin! macros, and the audio-thread types. Anything
reachable from the SDK’s public API is supported; if you find
yourself depending on patches-core directly, file an issue —
the SDK gets deliberate extensions, not unannounced direct
dependencies on the foundation crates.
patches-dsp is intentionally not re-exported. If you need
filters, biquads, ADSR cores, oversampling, or similar, depend on
it explicitly.
The smallest module
test-plugins/gain/src/lib.rs, in full:
#![allow(unused)]
fn main() {
use patches_sdk::cable_pool::CablePool;
use patches_sdk::cables::{InputPort, MonoInput, MonoOutput, OutputPort};
use patches_sdk::module_params;
use patches_sdk::modules::descriptor_template::{
CountAxis, ModuleDescriptorTemplate, ParameterTemplate, PortTemplate,
};
use patches_sdk::modules::{InstanceId, ModuleDescriptor};
use patches_sdk::param_frame::ParamView;
use patches_sdk::ParameterKind;
use patches_sdk::{AudioEnvironment, Module};
use patches_sdk::{StructuralParams, BuildError};
module_params! {
Gain {
gain: Float,
}
}
pub struct Gain {
descriptor: ModuleDescriptor,
instance_id: InstanceId,
gain: f32,
input: MonoInput,
output: MonoOutput,
}
impl Module for Gain {
fn template() -> ModuleDescriptorTemplate {
const T: ModuleDescriptorTemplate = ModuleDescriptorTemplate {
name: "Gain",
axes: &[CountAxis::CHANNELS],
global_inputs: &[PortTemplate::mono("in")],
per_axis_inputs: &[],
global_outputs: &[PortTemplate::mono("out")],
per_axis_outputs: &[],
realtime_params: &[ParameterTemplate {
name: params::gain.as_str(),
kind: ParameterKind::Float { min: 0.0, max: 2.0, default: 1.0 },
}],
structural_params: &[],
per_axis_realtime_params: &[],
per_axis_structural_params: &[],
};
T
}
fn prepare(
_env: &AudioEnvironment,
descriptor: ModuleDescriptor,
instance_id: InstanceId,
_structural: &StructuralParams,
) -> Result<Self, BuildError> {
Ok(Self {
descriptor,
instance_id,
gain: 1.0,
input: MonoInput::default(),
output: MonoOutput::default(),
})
}
fn update_validated_parameters(&mut self, p: &ParamView<'_>) {
self.gain = p.get(params::gain);
}
fn descriptor(&self) -> &ModuleDescriptor { &self.descriptor }
fn instance_id(&self) -> InstanceId { self.instance_id }
fn process(&mut self, pool: &mut CablePool<'_>) {
let input_val = pool.read_mono(&self.input);
pool.write_mono(&self.output, input_val * self.gain);
}
fn set_ports(&mut self, inputs: &[InputPort], outputs: &[OutputPort]) {
self.input = MonoInput::from_ports(inputs, 0);
self.output = MonoOutput::from_ports(outputs, 0);
}
fn as_any(&self) -> &dyn std::any::Any { self }
}
patches_sdk::export_plugin!(Gain, "Gain");
}
Eighty-eight lines. Let’s read it.
module_params!
#![allow(unused)]
fn main() {
module_params! {
Gain {
gain: Float,
}
}
}
Generates a params submodule with typed parameter handles
(params::gain). The names you list here will be the parameter
names visible in .patches files ({ gain: 0.5 }).
template()
#![allow(unused)]
fn main() {
fn template() -> ModuleDescriptorTemplate { ... }
}
A const ModuleDescriptorTemplate describing the module’s name,
ports, and parameters. The fields here lower 1:1 to the JSON
described in Native plugin ABI:
name— the type name as seen in.patches.Gainhere →module g : Gainin a patch.axes— shape axes.CountAxis::CHANNELSenables achannelsshape argument. Modules with no shape variation just use&[CountAxis::CHANNELS]and ignore it.global_inputs/global_outputs— ports declared once, regardless of shape.per_axis_inputs/per_axis_outputs— ports expanded one per axis value (e.g. one input per channel for a multi-channel mixer).realtime_params— audio-thread-mutable parameters.structural_params— control-thread-only parameters; values are sealed atprepare()time and don’t appear inupdate_validated_parameters.
PortTemplate::mono("in") is shorthand for a mono audio port.
PortTemplate::poly, PortTemplate::stereo, and the _trigger
variants exist for the other kinds.
prepare()
Called once per instance, on the control thread, before the audio thread sees the module. Allocate scratch buffers here; no allocation is permitted on the audio thread.
The _structural parameter carries the resolved values of any
structural parameters (file paths, song references, etc.). For a
module with no structural params, the SDK passes an empty view.
Return Ok(self) to commit the instance; Err(BuildError::...)
to surface a load-time error in the host’s diagnostics.
update_validated_parameters()
Audio-thread. The host pushes a packed parameter frame whenever a patch parameter changes (DSL parameter change on reload, knob turn in the plugin GUI, host automation). Copy the values into your module state.
#![allow(unused)]
fn main() {
fn update_validated_parameters(&mut self, p: &ParamView<'_>) {
self.gain = p.get(params::gain);
}
}
ParamView::get is type-checked against the module_params!
declaration. If you ask for a parameter that doesn’t exist, you
get a compile error.
process()
Audio-thread. Called once per sample. Read inputs from the cable pool, write outputs back. No allocation, no blocking, no I/O, no syscalls.
#![allow(unused)]
fn main() {
fn process(&mut self, pool: &mut CablePool<'_>) {
let input_val = pool.read_mono(&self.input);
pool.write_mono(&self.output, input_val * self.gain);
}
}
For poly ports use read_poly / write_poly; for stereo,
read_stereo / write_stereo.
set_ports()
Audio-thread. Called when the host’s planner connects (or reconnects) cables to this instance. Cache the port handles you were given:
#![allow(unused)]
fn main() {
fn set_ports(&mut self, inputs: &[InputPort], outputs: &[OutputPort]) {
self.input = MonoInput::from_ports(inputs, 0);
self.output = MonoOutput::from_ports(outputs, 0);
}
}
The slice indices match the order of port declarations in
template(). MonoInput::from_ports(inputs, 0) reads the first
input.
export_plugin!
#![allow(unused)]
fn main() {
patches_sdk::export_plugin!(Gain, "Gain");
}
Macro that emits the eight C ABI entry points, the
patches_plugin_init symbol the host loads, and a descriptor-hash
symbol. The string argument is the module’s user-facing name
(matching template().name).
For multi-module bundles use export_modules! instead:
#![allow(unused)]
fn main() {
patches_sdk::export_modules!(
(Gain, "Gain"),
(Compress, "Compress"),
(Reverb, "Reverb"),
);
}
Building
cargo build --release -p my-gain-plugin
The result is in target/release/:
| OS | Filename |
|---|---|
| macOS | libmy_gain_plugin.dylib |
| Linux | libmy_gain_plugin.so |
| Windows | my_gain_plugin.dll |
This is the loadable bundle. The Patches host doesn’t care about
the filename — it loads anything with .dylib / .so / .dll
extension in a scanned directory, calls patches_plugin_init, and
registers whatever module types it finds inside.
(Some hosts use a .pxm extension as a Patches-specific marker.
The library extension still works; .pxm is just a convention so
your shell file manager doesn’t mix module bundles with unrelated
dylibs.)
Loading the plugin
The host can find your bundle in four ways. They’re tried in this priority order:
PATCHES_PLUGIN_PATHenv var. A colon-separated list of directories or specific bundle files.- CLI / GUI per-session list. The player’s
--module-path <DIR|FILE>flag (repeatable). The CLAP plugin’s Module-paths panel (Add directory… / Add dylib…). These live for the duration of one session unless you also save them via: GlobalConfig::bundle_dirsfromsettings.toml. The persisted host-scoped bundle dir list (patches-plugin-common’s global config). Add via the player’s TUI “Add bundle directory” action or the CLAP GUI’s Add directory… button; persists across sessions.- Default data directory.
<ProjectDirs.data_dir()>/bundles/, if it exists. Never auto-created.
A workspace-developer convenience also kicks in: bundles built into
target/<profile>/ of the current Patches workspace are
auto-scanned without any configuration — see stdlib_scanner in
patches-ffi/src/scanner.rs.
In practice for a one-off bundle:
patch_player --module-path target/release my_patch.patches
For permanent install, copy the dylib to a stable location and add the directory via the global config from inside the player (TUI: “Add bundle directory”) or the CLAP plugin GUI.
Audio-thread rules
The same constraints that apply to in-tree modules apply across the FFI:
- No allocation in
process,update_validated_parameters, orset_ports. Pre-allocate inprepare. - No blocking — no mutexes, no I/O, no syscalls.
- No unbounded loops — the audio block is a few hundred samples; spending milliseconds in one tick will glitch.
The host’s CPU monitor will flag instances that take too long; the
tick-boundary panic catcher halts the engine cleanly if your code
panics. (Your crate must use panic = "unwind" — the default —
not panic = "abort", or the catch_unwind machinery in the host
can’t recover.)
What to read next
- Native plugin ABI — the C surface, JSON schemas, and wire formats. Read this if you’re building tooling around the FFI or implementing an SDK in another language.
- Implementing modules — the same
Moduletrait, but for in-tree Rust modules. Useful for cross-reference; the contract is identical apart from the packaging. test-plugins/conv-reverb/— a slightly more involved example with a structural param (file path).
Engine internals
This chapter describes how Patches works under the hood: the compilation pipeline, the cable pool, the audio thread, plan handoff, and polyphony. It is aimed at contributors and anyone curious about the real-time architecture.
Compilation pipeline
A .patches file goes through four stages before audio runs:
.patches source
│
▼
Parser (patches-dsl) PEG grammar → AST with source spans
│
▼
Expander (patches-dsl) Inline templates, substitute parameters → FlatPatch
│
▼
Interpreter (patches-interpreter) Validate against module registry → ModuleGraph
│
▼
Planner (patches-planner) Graph → ExecutionPlan, reusing surviving modules
│
▼
Audio thread Tick loop: execute plan, one sample at a time
Parser. The PEG grammar in patches-dsl/src/grammar.pest defines the syntax. The parser (Pest-based) produces an AST with source location spans preserved for error reporting. The output is a File struct containing template definitions and a patch block.
Expander. Template instantiation happens here. Each module v : voice(...) is expanded into a copy of the template’s modules and connections, with names mangled to avoid collisions (e.g. v/osc, v/env). Parameter references are substituted. Cable scales are composed by multiplication at template boundaries. The output is a FlatPatch — a flat list of FlatModule and FlatConnection structs with no template nesting.
Interpreter. The FlatPatch is validated against the module registry. Module type names are resolved to descriptors. Parameters are checked against their declared types and ranges. Cable kinds (mono/poly) are verified to match between connected ports. The output is a ModuleGraph — a directed graph of typed nodes and edges.
Planner. The planner converts the ModuleGraph into an ExecutionPlan — a flat, ordered list of module slots and buffer assignments that the audio thread can execute without any graph traversal. The planner carries state between builds: it matches modules in the new graph against the previous plan by name and type, reusing existing instances (with their state intact) where possible. New modules are freshly instantiated; removed modules are listed as tombstones for cleanup.
The cable pool
Inter-module signals live in a single pool split into two regions:
- Scratch region —
[0, SCRATCH_CAPACITY). OneCableValueper slot; consumers read producers’ current-tick output. Used by every cable inside a fused (acyclic) subgraph. - Cycle region —
[SCRATCH_CAPACITY, SCRATCH_CAPACITY + CYCLE_CAPACITY).[CableValue; 2]ping-pong pair per slot; consumers read the previous tick’s value. Used by cables that carry feedback across an SCC boundary.
A single virtual cable_idx selects the region: indices below
SCRATCH_CAPACITY route to scratch, the rest to cycle. Capacities are
fixed at compile time (SCRATCH_CAPACITY = 2048, CYCLE_CAPACITY = 128);
the planner reports BufferPoolExhausted if a patch overflows either
region.
CableValue is a fixed 16-lane f32 slot:
#![allow(unused)]
fn main() {
#[repr(transparent)]
pub struct CableValue(pub [f32; 16]);
}
The cable’s declared kind — Mono, Stereo, or Poly — is static and
tells reader and writer which prefix of the array carries data: lane 0
for Mono, lanes 0–1 for Stereo, all 16 lanes for Poly. There is no
runtime tag. Constructors CableValue::mono(v), stereo(l, r), and
poly([..; 16]) zero the unused lanes; accessors as_mono, as_stereo,
as_poly read only the relevant prefix. Holding all slots at 16 lanes
lets a slot used for Mono in one plan be repurposed as Poly in the
next without reallocating the pool.
Subgraph fusion. Across SCCs the planner emits modules in topological order and routes cross-SCC cables through the scratch region, so consumers read producers’ current-tick output. This removes the per-cable one-sample lag that would otherwise accumulate audibly across long signal chains, without changing the patch author’s mental model. Cycle-bearing cables (those that close a feedback loop) live in the cycle region and retain the 1-sample delay, which makes them well-defined regardless of execution order. Calling fused modules out of topological order causes silent reads of stale data — this matters when extending the planner, not when authoring patches.
Reserved slots
The first RESERVED_SLOTS = 32 scratch indices are reserved for
infrastructure and never allocated to user cables. They are split into a
backplane at the bottom and sinks at the top of the reserved
range:
| Index | Name | Kind | Purpose |
|---|---|---|---|
| 0 | AUDIO_OUT_L | mono | Left audio output — AudioOut writes, callback reads |
| 1 | AUDIO_OUT_R | mono | Right audio output |
| 2 | AUDIO_IN_L | mono | Left audio input (AudioIn) |
| 3 | AUDIO_IN_R | mono | Right audio input |
| 4 | GLOBAL_TRANSPORT | poly | Transport frame — sample count + tempo + position |
| 5 | GLOBAL_DRIFT | mono | Slowly varying value in [-1, 1] for correlated pitch drift |
| 6 | GLOBAL_MIDI | poly | Packed MIDI frame — up to 5 events per sample |
| 7–10 | TAP_BASE..+4 | poly | Tap module storage — 4 slots × 16 lanes = 64 tap channels |
| 11–14 | HOST_CONTROL_BASE..+4 | poly | Host-control values — 4 slots × 16 lanes = 64 controls |
| 15–27 | — | — | Spare backplane capacity (no live mapping) |
| 28 | MONO_READ_SINK | mono | Disconnected mono inputs read zero from here |
| 29 | POLY_READ_SINK | poly | Disconnected poly inputs read zero from here |
| 30 | MONO_WRITE_SINK | mono | Unconnected mono outputs write harmlessly here |
| 31 | POLY_WRITE_SINK | poly | Unconnected poly outputs write harmlessly here |
The sinks sit at the top of the reserved range so the FFI plugin loader can shift plugin-visible scratch past the backplane while
still resolving plugin-relative [0, SINK_SLOTS) to the sink slots. The
backplane → sink layout is asserted at compile time.
Disconnected ports point at the sink slots. This means process never
needs to branch on connectivity — it can always call
pool.read_mono(&self.port) safely, getting zero for unconnected
inputs. Modules that want to skip work for disconnected outputs can
check output.is_connected().
Buffer stability
The cable pool persists across re-plans. Unchanged cables keep their buffer index, so CV signals on cables that were not rewired continue without interruption. Recycled and newly allocated slots are listed in the execution plan’s to_zero vector; the audio thread zeroes them before the first tick of the new plan.
Pool capacities are fixed compile-time constants
(SCRATCH_CAPACITY = 2048, CYCLE_CAPACITY = 128). The planner allocates
dyn-scratch indices above RESERVED_SLOTS via a high-water mark; cycle
indices are allocated in the cycle region in build order.
Audio thread constraints
The audio callback runs under hard real-time constraints. Violating them causes audible glitches.
No allocations. All buffers and module state are pre-allocated when the plan is built. The callback never calls Box::new, Vec::push with growth, or any other heap-allocating operation.
No blocking. No mutexes, no file or network I/O, no syscalls that may sleep.
No deallocation. Dropping a Box<dyn Module> can run arbitrary destructor code. All deallocation is routed to a background thread (see below).
Unwinding panic policy. The CLAP host and any crate loaded as an FFI plugin must build with panic = "unwind". PatchProcessor::tick catches module panics at the tick boundary and halts the engine cleanly — that only works with table-based unwinding. panic = "abort" in the workspace or plugin profiles defeats the catch and takes the host process down with the panic.
Communication with the control thread uses an rtrb lock-free single-producer / single-consumer ring buffer. The callback polls it at the start of each processing block.
The execution plan
ExecutionPlan is the audio thread’s working document. It contains:
- slots — one
ModuleSlotper active module, listing its pool index and the buffer indices for each of its input and output ports (with scale factors for scaled inputs). - active_indices — the execution order. The audio thread iterates this list and calls
processon each module. - to_zero / to_zero_poly — buffer indices to zero before the first tick.
- new_modules — freshly instantiated modules to install in the pool.
- tombstones — pool indices of removed modules, to be sent to the cleanup thread.
- parameter_updates — changed parameter maps for surviving modules.
- port_updates — new port assignments for modules whose wiring changed.
The plan is built on the control thread and sent to the audio thread as a single value via the ring buffer. Once received, the audio thread applies it in one block: zero buffers, tombstone old modules, install new ones, apply updates, then resume ticking.
Plan handoff
The sequence when a patch file is saved:
- The control thread builds a new
ExecutionPlanvia the planner. PatchEnginesends the plan through the rtrb ring buffer.- The audio callback checks for a new plan at the top of each processing block.
- If a plan is waiting: the current plan is replaced via
mem::replace. The old plan is pushed to the cleanup ring buffer as aCleanupAction::DropPlan. - The callback applies the new plan’s updates (zeroing, tombstoning, installation, parameter and port updates) and transitions to the new execution state.
- Processing resumes with the new plan.
There is a brief window between when the planner snapshots the old module state and when the audio thread installs the new plan. Module state (e.g. oscillator phase) advances during this window. This is an intentional trade-off — the alternative would require stopping the audio thread, which would cause a gap.
Module lifecycle
Every module instance has an immutable InstanceId — a monotonically increasing u64 assigned at construction. The planner uses the combination of the module’s DSL name and type name to match modules across reloads.
The module pool is a Vec<Option<Box<dyn Module>>> owned by the audio thread. Surviving modules keep their pool slot. New modules are inserted at free slots. Tombstoned modules are extracted from their slot and pushed to the cleanup thread.
The set_ports and update_validated_parameters methods are called on the audio thread when a surviving module’s wiring or parameters change. Both are required to be non-allocating and infallible.
Off-thread deallocation
Dropping a module or an old execution plan runs destructors that may allocate or block. A dedicated thread named patches-cleanup handles this. It owns the consumer end of a lock-free ring buffer and drains CleanupAction values:
#![allow(unused)]
fn main() {
enum CleanupAction {
DropModule(Box<dyn Module>),
DropPlan(Box<ExecutionPlan>),
DropParamState(Box<ParamState>),
DropParamFrame(Box<ParamFrame>),
DropMonitorMeta(Box<MonitorMeta>),
DropHostControlPlanMeta(Box<HostControlPlanMeta>),
}
}
The audio thread pushes to this buffer; the cleanup thread drops the values on its own time. If the buffer is full (which should not happen in normal operation), the audio thread falls back to dropping inline with a warning.
Cable kinds
The system has three cable kinds:
- Mono — one
f32per tick. The default. - Poly —
[f32; 16]per tick (one value per voice). Voice count is fixed at engine initialisation and shared by all poly cables. - Stereo —
[f32; 2]per tick, carried as(L, R). Storage reuses the poly slot (lanes 0–1); pool layout is unchanged.
Layouts (MonoLayout::Audio/Trigger, PolyLayout::Audio/Trigger/
Transport/Midi) refine within Mono and Poly. Stereo carries no
layout — it is exclusively audio/CV.
Connection rules
| From | To | Result |
|---|---|---|
| Mono | Mono | direct (layouts must match) |
| Poly | Poly | direct (layouts must match) |
| Stereo | Stereo | direct |
| Mono Audio | Stereo | broadcast: (s, s) |
| Stereo | Mono | rejected (CableKindMismatch) |
| Poly ↔ Stereo | — | rejected |
| Mono ↔ Poly | — | rejected (use MonoToPoly / PolyToMono) |
Mono→stereo broadcast is consumer-side: the planner sets
StereoInput::broadcast_from_mono, the cable still points at the mono
producer’s slot, and pool.read_stereo() returns (s, s) from the
underlying CableValue::Mono. No synthetic node, no extra audio-thread
work. Stereo→mono needs an explicit StereoSplitter.
Polyphony
Poly modules (PolyOsc, PolyAdsr, PolyVca, etc.) process each voice
independently within a single process call. Their ports read and
write [f32; 16] arrays via pool.read_poly and pool.write_poly.
Poly cable slots are zeroed with Poly([0.0; 16]) rather than
Mono(0.0) to prevent type mismatches during the first tick after a
hot-reload.
Periodic updates
Some modules need to recompute internal coefficients when their CV inputs change — for example, a filter whose cutoff is controlled by a cable. Recomputing on every sample would be expensive, so the Module trait exposes a periodic hook:
#![allow(unused)]
fn main() {
pub trait Module {
// ...
fn wants_periodic(&self) -> bool { false }
fn periodic_update(&mut self, _pool: &CablePool<'_>) {}
}
}
Modules whose wants_periodic() returns true override periodic_update
to read their CV inputs and update filter coefficients, frequency
targets, or other derived state. The planner queries wants_periodic()
once at plan-build time and collects matching slot indices; every
periodic_update_interval samples (typically a few dozen), the audio
thread dispatches periodic_update through the normal
&mut dyn Module path. The per-sample process call then uses these
precomputed values, keeping the hot path fast.
This replaces an earlier PeriodicUpdate trait and its
as_periodic() lookup, which were removed along with their
raw-pointer caching footgun.
Observability
Each runtime’s FFI data plane carries a small set of counters that a
non-real-time observer can sample at any time. They are updated with
Relaxed atomics on the hot paths — no allocation, no blocking, safe
from the audio thread — and exposed via RuntimeArcTables::snapshot()
(control side) or RuntimeAudioHandles::snapshot() (audio side).
RuntimeCountersSnapshot contains:
param_frames_dispatched— cumulative count ofParamFramedeliveries. The dispatcher increments this viaRuntimeAudioHandles::note_param_frame_dispatched().
The earlier FloatBuffer / ArcTable<[f32]> counters were retired with
the pipeline itself; the snapshot surface is intentionally
small now.
The attach API that routes counters to a UI or logging sink is still deferred; until then, tests and soak runs read them directly through the snapshot accessors.
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.
LSP architecture
patches-lsp is the language server backing the VS Code
extension. It runs as a per-platform native
binary bundled inside the VSIX and speaks LSP over stdio. This
chapter is for contributors.
Source: patches-lsp/.
What it provides
- Diagnostics — parse errors, validation failures, layering warnings.
- Hover — module type descriptions, port info, parameter details.
- Go-to-definition — jump to template definitions and module type registrations.
- Completions — module types, port names, parameter names.
- Inlay hints — stereo desugaring (the synthesised splitter
/
__l/__r/ joiner modules at everystereo moduledecl), off by default. - Patch graph SVG rendering — exposed as the custom
patches/renderSvgrequest; the VS Code extension’s Show Patch Graph command consumes it. - Module rescan —
patches/rescanModulesre-probes the configured bundle dirs without restarting the server.
Dual-parser architecture
The LSP holds two parsers for every .patches document:
- Tree-sitter (
patches-lsp/src/parser.rs,tree_nav.rs,tree-sitter-patches/) — error-recovering grammar, fast incremental re-parse. Drives editor-facing features that need to behave on a half-written document: syntax-aware navigation, hover-context classification, completion-context determination. - Pest (
patches-dsl::pipeline) — the engine’s canonical parser. Drives every feature that needs the patch’s semantic meaning: diagnostics, the expansion-aware hover layer, go-to-definition across templates, the SVG renderer.
Both parsers run on every document change. The tree-sitter parse is
incremental and cheap; the pest parse runs from scratch but
typically completes within ~100 µs for a real patch
(project_pest_vs_tree_sitter_perf benchmarks: pest ~100 µs, TS
~143 µs parse + ~204 µs parse+lower per file). Perf is not the
driver for this architecture — correctness on partial input is.
Why both
A single parser can’t serve both audiences:
- The engine’s pest grammar refuses to produce any AST for a patch with a syntax error. That’s the right policy for the engine — invalid input should fail loudly — but it leaves the editor with no tree to query when the user is mid-typing. Hover, completions, and signature help would go blank on every intermediate keystroke.
- A pure tree-sitter approach gives editor robustness but loses the semantic accuracy that the engine’s expansion stage provides. Template-expanded hover information (showing the concrete module instantiated from a template call, with all parameters substituted) needs the pest pipeline.
The split: tree-sitter for “what is the user looking at right now?”, pest for “what would this patch actually do if it compiled?”.
Expansion-aware hover (option C)
Hover information for a module call inside a template instantiation reflects the expanded module — pest is run alongside tree-sitter, gated on a clean parse. If pest succeeds, hover uses the expanded view; if pest fails, hover falls back to tree-sitter’s local view. The user gets the best available answer at every keystroke without the editor ever stalling.
Memory note: project_lsp_expansion_hover for the design
discussion behind this choice.
Workspace model
The LSP keeps a Workspace (patches-lsp/src/workspace/) tracking:
- The open document set.
- The latest tree-sitter tree per document.
- The latest pest parse + expansion + validation result per document.
- The module registry, populated from in-tree modules plus any
FFI bundles found in the configured
patches.moduleBundleDirs.
When a document changes, the workspace runs incremental tree-sitter, then re-runs the pest pipeline for the affected document. Both results are cached and consulted by feature handlers.
Diagnostics
Three sources:
- Parse errors from pest. Surface span, expected-rule message, location of the failure.
- Validation errors from the interpreter — unknown module types, port mismatches, missing parameters, cable-kind mismatches.
- Layering warnings from the planner — patterns the engine accepts but warns about (e.g. ambiguous fan-in, unconnected audio outputs).
Tree-sitter errors are not surfaced as LSP diagnostics. They exist only to keep tree-sitter’s tree well-formed for navigation; the user-facing parse errors come from pest, which has better diagnostic quality.
Hover
Hover dispatch uses the tree-sitter cursor context to determine what kind of thing is under the cursor (module type, port name, parameter, template ref), then routes to a handler that builds the hover content. Most handlers consult the pest-driven workspace state for the substantive content (descriptors, expanded parameter values); a few fall back to a tree-sitter-only path for syntactic positions (keyword help, literal explanations).
Source: patches-lsp/src/hover/.
Go-to-definition / navigation
Definitions resolved:
- Module type → registry entry (in-tree module’s doc location, or bundle-discovered descriptor).
- Template call → template definition (same file or include-resolved).
- Pattern / song reference → declaration site.
- Host control reference → host-control declaration.
Source: patches-lsp/src/navigation.rs.
SVG rendering
The patches/renderSvg custom request runs the pest pipeline to
produce a FlatPatch, then calls patches_svg::render_svg. The
returned SVG markup is sent back as JSON; the VS Code extension
renders it in a webview panel.
See Visualising patches for the user-facing description.
Module bundle discovery
The LSP loads module descriptors from the same FFI bundles the
runtime hosts use. The patches.modulePaths extension setting
threads through to patches_ffi::PluginScanner, which walks the
listed directories (and the env-var / default tiers) and registers
each bundle’s modules.
patches/rescanModules re-walks without restarting the LSP —
useful after installing or rebuilding a bundle while the editor
is open.
VSIX bundling
The VSIX is platform-specific. At build time
(scripts/package-vsix.sh, see the release workflow), the
patches-lsp binary for the target platform is built, copied into
patches-vscode/server/, and the TypeScript client compiled
against it. The result is one .vsix per platform; users install
the one that matches their VS Code host.
Platform target table:
| VSIX target | LSP binary path inside VSIX |
|---|---|
darwin-arm64 | server/patches-lsp (aarch64-apple-darwin) |
darwin-x64 | server/patches-lsp (x86_64-apple-darwin) |
win32-x64 | server/patches-lsp.exe |
linux-x64 | server/patches-lsp (built from source) |
The extension’s patches.lspPath setting overrides the bundled
binary, useful for running a debug build during development.
Syntax corpus policy
Any grammar change — pest or tree-sitter — requires a corresponding
entry in patches-lsp/tests/syntax_corpus/ (memory:
feedback_syntax_corpus_policy). The corpus captures fixtures
exercising the new syntactic shape so regressions surface in CI
before reaching users. Treat the corpus as part of the grammar’s
specification, not as an optional test layer.
Reading the source
For a feature trace:
- The handler is registered in
server.rs(PatchesLanguageServer::*methods or custom-method bindings). - It reads from the
Workspaceto get tree-sitter / pest state for the affected document. - It produces an LSP response from the cached state plus the active position.
Each layer is small. The dual-parser architecture is the central oddity; once you have that mental model, the rest reads as standard LSP plumbing.
Glossary
Terms used across the manual, with one or two sentences each.
Audio. Signal kind for sound. Float samples in roughly
[-1, +1] produced at the host’s sample rate.
Cable. A directed connection from one module’s output port to one module’s input port. Carries one signal stream of one kind.
CLAP plugin. The plugin packaging that lets a DAW host load
Patches as an instrument or audio effect. Built from patches-clap,
shipped as a single .dylib / .so / .dll bundle. Distinct from
native plugin — see below.
Control voltage (CV). Signal kind for modulation and pitch. Pitch follows the V/oct convention: one unit per octave.
CV. See Control voltage.
DSL. The text format Patches files are written in
(.patches). Specified in patches-dsl/src/grammar.pest; described
in DSL basics and the
DSL reference.
Edit-reload cycle. The development loop where saving a
.patches file triggers an in-place engine rebuild while audio
keeps playing. Covered in
The edit-reload cycle.
Engine. The real-time audio component. Owns the execution plan,
the module instances, and the cable pool. Sources: patches-core,
patches-engine.
Execution plan. The flat, ordered list of module slots and
buffer assignments the audio thread runs. Built by the planner from
a ModuleGraph.
Fan-in. Multiple cables converging on one input. Not directly allowed — each input takes one cable. Combine signals through a mixer module.
Fan-out. One output driving multiple inputs. Free: draw as many cables from an output as needed, no splitter required.
FlatPatch. The intermediate representation produced by the DSL expander after template inlining and parameter substitution. A flat list of module declarations and connections — the input the interpreter validates.
Fusion. Planner optimisation that routes cross-SCC cables in acyclic (feedback-free) regions through the scratch pool region so consumers read producers’ current-tick output instead of going through the one-sample cable delay. Cycle-bearing cables stay in the cycle region and keep the one-sample delay (which makes feedback well-defined). Removes lag from long signal chains while preserving feedback-loop semantics.
Gate. Signal kind held 1.0 while a note is active, 0.0
when released. Drives an envelope’s hold-and-release behaviour.
Global config. Host-scoped persisted settings (currently:
bundle directory list). Stored in a cross-platform settings.toml.
Distinct from per-patch sidecar state.
Hot-reload. Same as edit-reload — the engine swaps in a new graph without stopping audio.
LSP. Language server (patches-lsp) backing the VS Code
extension’s diagnostics, hover, completions, and SVG rendering. See
LSP architecture.
Mono. A cable that carries one sample per audio tick.
Module. A unit of computation with a fixed set of named input
and output ports. Examples: Osc, PolyAdsr, StereoMixer,
AudioOut. Implementations live in patches-modules or in
externally-built native plugin bundles.
Module graph. The validated graph of module instances and typed cables produced by the interpreter. Input to the planner.
Module type. The class of a module instance. Defines the
instance’s ports and parameters. An instance has a name (chosen
in the patch) and a type (chosen from the registry):
module osc : Osc declares an instance named osc of type Osc.
Native plugin. A module bundle built as a separate cdylib and
loaded at host startup via the Native plugin ABI.
Distinct from CLAP plugin: native plugins add module types to
Patches; the CLAP plugin embeds Patches into a DAW.
Patch. A complete .patches file: optional templates, songs,
patterns, host controls, then exactly one patch { ... } block
declaring modules and their connections.
Per-patch sidecar. Persisted state attached to a specific
patch file. In the player, written next to the .patches file as
<name>.patches.state (or under $XDG_STATE_HOME); in the CLAP
plugin, written via the host’s CLAP-state mechanism. Patch knobs,
monitor view toggles. Distinct from global config.
Plan. Shorthand for execution plan.
Planner. The component that turns a ModuleGraph into an
ExecutionPlan, reusing module instances across reloads by
name/type match. Lives in patches-planner.
Poly. A cable that carries one sample per voice per audio tick. The voice pool is 16 wide in every shipping host.
Polyphony. The ability to play multiple notes simultaneously,
each on its own voice slot. Driven by PolyMidiToCv (or a similar
poly source) into a poly module chain.
Port. A named input or output on a module. Each port has a kind (mono / poly / stereo) and, for mono and poly, a layout (audio / trigger / transport / midi) that connection-time validation checks.
REPL. The interactive loop the edit-reload cycle creates for patch authoring: save, listen, save again. Patches’ development workflow is REPL-shaped, not compile-run-listen-shaped.
SCC. Strongly connected component of the module graph — maximal subgraph in which every module reaches every other module. Cycle-bearing SCCs retain the one-sample cable delay; acyclic SCCs are fused.
Sidecar. See per-patch sidecar.
Signal kind. The type of signal a port carries: audio, CV, gate, trigger, or stereo. The planner uses kinds to validate connections at compile time. See Signal kinds and cable rules.
Stereo. Signal kind carrying (L, R) audio samples on one
cable. A symmetric stereo module uses a single stereo port rather than paired _left/_right mono ports.
Template. A named, parameterised subgraph in a .patches file.
Expanded at compile time into concrete module instances. Templates
are a DSL abstraction, not a runtime construct. See
Templates and abstraction.
Trigger. Signal kind: sub-sample-accurate event marker. On each sample a trigger cable carries 0.0 for “no
event” or a value in (0.0, 1.0] giving the event’s fractional
position within the sample (used by oscillator hard-sync, BLEP
correction, etc.). Tells an envelope when to restart from its attack
phase, regardless of whether the gate was already high.
V/oct. The pitch encoding convention: one unit = one octave;
one semitone = 1/12 unit. Used by oscillator voct inputs and by
the V/oct CV emitted by MIDI-to-CV modules.
Voice. One slot of a poly cable. A PolyAdsr or PolyOsc
runs 16 voices in parallel; voice allocation is handled by
PolyMidiToCv (or a sequencer with multiple voice outputs).
Voice allocation. Mapping incoming MIDI notes onto voice slots.
PolyMidiToCv uses LIFO stealing: when all voices are busy, the
most-recently-allocated voice is reused for the next note.
Webview GUI. The CLAP plugin’s UI, an embedded wry webview
rendering a small JS app. The engine and controller live in Rust;
the webview is a thin shell over the controller’s GuiSnapshot /
Intent types.
Changelog
The authoritative changelog is
RELEASE_NOTES.md
at the repository root. It is updated with each tagged release and
attached to the GitHub
release for that
version.
This page exists as a navigable entry point in the manual; the content lives in the repo so the release tooling has a single canonical source.