Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.0 means “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:

ArchiveUse on
patches-<version>-macos-aarch64.tar.gzApple Silicon Macs
patches-<version>-macos-x86_64.tar.gzIntel Macs
patches-<version>-windows-x86_64.msi64-bit Windows (installer, default)
patches-<version>-windows-x86_64.zip64-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.exe on Windows)
  • Patches.clap — the CLAP plugin, covered in the next chapter
  • a matching .vsix for the VS Code extension
  • examples/ — the patches under examples/ in the repo
  • patches-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 into C:\Program Files\Patches\
  • Patches.clap into C:\Program Files\Common Files\CLAP\ (the standard CLAP system path)
  • the bundled .patches examples into C:\Users\Public\Documents\Patches\examples\
  • registers .patches as a file type whose default opener is patch_player.exe, so double-clicking a .patches file launches it
  • adds an App Paths entry, so patch_player resolves from any cmd / PowerShell prompt without manual PATH editing
  • 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.exeProperties → 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:

OSInstall 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.clapPropertiesUnblock, 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/:

OSBuilt artefactCopy to
macOSlibpatches_clap.dylib~/Library/Audio/Plug-Ins/CLAP/Patches.clap
Linuxlibpatches_clap.so~/.clap/Patches.clap
Windowspatches_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

  1. Start your DAW (REAPER, Bitwig, Ableton 11.3+, FL Studio 21.2+, Studio One 6.5+, …).
  2. Trigger a plugin rescan if the host doesn’t scan on startup.
  3. Patches should appear under the Instruments category.
  4. Patches FX should appear under the Effects (or Audio Effects) category.
  5. 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.clap will list it if present; re-run macos-unquarantine.sh or 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 namePlatform
patches-vscode-darwin-arm64-<ver>.vsixApple Silicon Macs
patches-vscode-darwin-x64-<ver>.vsixIntel Macs
patches-vscode-win32-x64-<ver>.vsix64-bit Windows
patches-vscode-linux-x64-<ver>.vsix64-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:

  1. Extensions (Cmd+Shift+X / Ctrl+Shift+X).
  2. Three-dot menu → Install from VSIX…
  3. 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.exePropertiesUnblock, 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 alternative patches-lsp (e.g. a debug build under target/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 .patches file — without it the LSP can’t resolve their descriptors and hover/diagnostics will be incomplete.

Verification

  1. Open a .patches file (any from examples/ will do).
  2. Syntax should be coloured: keywords, module type names, ports, parameters distinct.
  3. Hover over a module type name (e.g. Osc) — a tooltip describes the module and lists its ports.
  4. 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 OutputPatches 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 signedcodesign --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:

  1. Right-click the file (.exe, .dll, .clap, .vsix).
  2. PropertiesGeneral tab.
  3. Tick Unblock at the bottom.
  4. 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:

FlagEffect
--no-tuiLegacy stdout frontend (no TUI).
--no-stdinWith --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.
--monitorEnable the per-instance CPU monitor tab.
--list-devicesEnumerate 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

  1. Add Patches to a MIDI track (or Patches FX to an audio track).
  2. 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).
  3. Hit Browse… and pick a .patches file. 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, FXAddPatches 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 .patches file
  • 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 tabEvent 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:

  1. song demo(bass, lead1, lead2) — declares three named voices and the patterns they play. Each pattern block defines a row of pitches and a row of velocities; ~ ties into the previous note, . continues the gate, *N repeats. play { ... } lists the patterns to chain together, with * N for repetition.
  2. template voice(...) — a parameterised synth-voice template exposing voct, gate, trigger, velocity inputs and one audio output. Internally: oscillator, ADSR, VCA, filter, glide.
  3. template sq_voice(...) — a square-wave variant for lead 2.
  4. patch { ... } — the actual graph. Inside:
    • MasterSequencer consumes the song and produces a per-voice clock plus pattern data.
    • One PatternPlayer per 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:

SyntaxMeaning
440.0, -0.5Float.
2, -1Integer.
440Hz, 2.5kHzFrequency. Converted to V/oct internally.
-6dBDecibels. Converted to linear amplitude.
7sSemitones. Converted to V/oct (s = semitone).
100cCents. Converted to V/oct.
C4, Bb2, F#5Note name. Converted to V/oct.
true, falseBoolean.
plate, linearBare 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

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

KindSample streamTypical use
AudioFloat samples, roughly [-1, +1].Sound. Anything you’d hear.
CVFloat samples, slow. V/oct convention.Pitch, modulation.
Gate1.0 (held) or 0.0 (released).Note hold.
TriggerSingle-sample 1.0 pulse.Note start.
StereoOne 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) and PolyAdsr (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 kindDestination kindAllowed?
Audio (mono)Audio (mono)yes
Audio (mono)CV / Gate / Triggeryes (intentional misuse)
Audio (mono)Stereoyes — L = R = source
StereoStereoyes
StereoMono Audiono — splitter needed
MonoPolynoMonoToPoly
PolyMononoPolyToMono
Poly AudioPoly Audioyes

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 voice template 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:

  1. Parameter list (attack: float = 0.01, ...). Typed, with optional defaults. Types are float, int, bool, str, pattern, song. Parameters without a default are required at the call site.
  2. Boundary ports (in: and out: lists). These are the template’s external interface. Inside the body, $.name refers to the boundary port name.
  3. Parameter references (<attack>). Substituted wherever a value is expected in the body: parameter values, scale factors, shape arguments. The substitution happens during expansion, so attack: <attack> becomes attack: 0.01 in 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

TypeAccepts
floatNumeric values; int coerces to float.
intInteger values only.
booltrue / false.
strQuoted string or bare identifier.
patternA pattern name (for tracker-driven templates).
songA 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 Osc for PolyOsc. 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:

MonoPoly
OscPolyOsc
AdsrPolyAdsr
VcaPolyVca
SvfPolySvf
GlidePolyGlide
MidiToCvPolyMidiToCv

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:

  1. A free voice exists. The note takes the first free slot.
  2. 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 via port[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

SyntaxTypeNotes
440.0floatbare decimal
2intbare integer
440Hzfrequencyconverted to V/oct; also 2.5kHz
-6dBamplitudeconverted to linear (0 dB = 1.0, -6 dB ≈ 0.5)
7sintervalsemitones, converted to V/oct (s = semitone)
50cintervalcents, converted to V/oct (c = cent)
C4, A#3, Bb2note nameconverted to V/oct
true / falseboolean
linearstringunquoted; "linear" also works
"hello world"stringquotes required if value contains spaces
file("ir.wav")file pathresolves 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 / StereoSum to 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 / PolyToMono to bridge mono and poly.

Cable kinds (mono / poly / stereo)

  • Mono carries one f32 per tick. The default for control and audio cables.
  • Poly carries 16 voices ([f32; 16]). Used by Poly* modules.
  • Stereo carries an (L, R) pair. Stereo modules expose a single port — stereo_delay.in, lim.out — instead of paired *_left / *_right ports.

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 / slider produce a smoothed audio-rate signal in [-1, 1].
  • toggle produces a stepped 0 / 1 signal; default is required.
  • trigger produces one-sample pulses fired by the host; no required fields.

Fields

FieldKindsOptionalNotes
displayNameallyesSurface label for the host UI.
unitknob / slideryesHint for value formatting.
defaultknob / slider / toggleknob / slider onlyToggle requires default; others optional.
low / highknob / slideryesDisplay-only metadata. Runtime mapping is the cable
range operator’s job (see Range-mapped connections).
taperknob / slideryesUI 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 = default are optional at the call site; those without are required.
  • Boundary ports are declared with in: and out: lists. Inside the template body, $.name refers 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

TypeAccepts
floatnumeric values (int-to-float coercion allowed)
intinteger values
strstring values
booltrue / false
patternpattern names — passed to a PatternPlayer cell
songsong 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 the channels list on the PatternPlayer that 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.

CellTrigger?Lead-in effect on this tickOutgoing slide / roll state
valueyessnap cv1 (and cv2) at startcloses any open slide at the boundary
value:cv2yessnap cv1 + cv2 at startcloses any open slide at the boundary
value*Nyessnap cv1; fires N sub-triggersopens a roll
value>valueyessnap cv1 at startone-tick slide that lands at end value
value>yessnap cv1 at startopens a multi-tick slide
/valuenosnap cv1 at the tick boundarycloses any open slide at the boundary
>_nocontinues / opens a slideextends or opens slide flow
>valuenoramps to value within the tickcloses the slide inside this tick
_nodepends on the channel’s stateabsorbed by roll / slide, else sustains
.norest — gate dropsresets 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:

  • value close — slide lands at value at the tick boundary entering the cell; cell then fires a fresh trigger on its own tick.
  • /value close — slide lands at value at the tick boundary; cell holds without retrigger.
  • >value close — slide ramps to value inside 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*N anchor: absorbed into the roll’s spread schedule (the N sub-triggers spread across the anchor tick + the absorbed-tie run). Span S = 1 + consecutive_underscores; sub-triggers fall at k * S / N of the span for k = 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>B
  • slide(2, A, B)A> >B
  • slide(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. A value> 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 — append verse.
  • play verse, chorus — append verse, then chorus.
  • play chorus * 2 — append chorus twice (* binds tighter than ,).
  • play (verse, chorus) * 4 — append the group four times.
  • play { row, ... } — an anonymous inline body (no section name).
  • play chorus { row, ... } — defines chorus as a song-local section and plays it once. Later play chorus / chorus references 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.
  • gate1.0 while a key is held, 0.0 when 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:

  1. Rises to 1.0 over attack seconds when triggered.
  2. Falls to sustain (0.7) over decay seconds.
  3. Holds at sustain while the gate is high.
  4. Falls to 0.0 over release seconds 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:

ParameterExpression
filt.cutoffBrightness sweeps.
filt.resonanceSquelch / vocal emphasis.
delay.dry_wetEffect amount.
amp_env.attackSoft / hard onsets.
amp_env.releaseShort / sustained tails.
mix.in[saw] scaleHarmonic 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:

  1. The composition. song and pattern blocks declaring what notes play when. Top of the file, before the patch block.
  2. The voice machinery. template blocks that wrap synth-voice wiring so the patch can stamp out one per voice. Also top-of-file.
  3. The patch. A MasterSequencer, one PatternPlayer per 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: and vel: 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 on vel: 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. Glide smooths 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.0 disables it.
  • Velocity VCA. The pattern’s vel: row drives a second VCA after the amp envelope, so velocity scales the dynamic shape.
  • Svf filter. State-variable filter with cutoff in V/oct units. Cheaper than the lead voice’s PolyLowpass.

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 the note: 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 the vel: 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:

  1. Send-return. mix.send_a carries the delay-bound signal from the leads (no bass). The delay’s wet-only output (dry_wet: 1.0) returns to mix.return_a, which the mixer sums into its main output. Standard send-return topology.
  2. 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

  1. You edit the .patches file and save.
  2. The watcher detects the change and feeds the new source to the DSL parser.
  3. The parser produces a FlatPatch — a flat list of module declarations and connections after template expansion.
  4. The interpreter validates the FlatPatch against the module registry: module types exist, port names are real, parameter types are correct, cable kinds match.
  5. If validation fails, diagnostics are surfaced (TUI log, plugin log pane) and the running patch continues unchanged.
  6. If validation succeeds, the planner builds a new ExecutionPlan.
  7. 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 Osc with PolyOsc keeps the name but the type is different, so the old instance can’t be reused.
  • Its name changed. Renaming osc to oscillator looks 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: 600Hzcutoff: 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:

  1. Start patch_player your.patches (or load the patch as a CLAP plugin).
  2. Make a small edit — change one parameter, one connection, add one module.
  3. Save.
  4. Listen.
  5. 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/renderSvg custom request, for any LSP client.
  • The patches_svg library crate, for programmatic use.
  • The patches-svg binary, shipped by the patches-svg workspace crate (publish = false, build with cargo 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-lsp calls render_svg from its patches/renderSvg endpoint.
  • patches-vscode requests 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/.

CategoryModulesPurpose
OscillatorsOsc, PolyOsc, Lfo, PolyLfoWaveform generation
EnvelopesAdsr, PolyAdsrAmplitude and modulation shaping
FiltersLowpass, Highpass, Bandpass, Svf, and poly variantsFrequency-dependent attenuation
Amplifiers & VCAsVca, PolyVcaVoltage-controlled amplitude
MixersSum, PolySum, Mixer, StereoMixer, PolyMixer, StereoPolyMixerSignal summing and routing
NoiseNoise, PolyNoiseWhite, pink, brown, and red noise
Sequencers & clocksClock, TempoSync, MsTicker, TriggerToSync, SyncToTriggerTempo-locked trigger generation
Tracker sequencerMasterSequencer, PatternPlayerSong-driven pattern sequencing
Drum synthesisKick, Snare, ClosedHiHat, OpenHiHat, Tom, Cymbal, Clap, Claves808-style electronic drum synthesis
MIDIMidiToCv, PolyMidiToCv, MidiCC, MidiArp, MidiDelay, MidiSplit, MidiTranspose, MidiDrumsetMIDI input and processing
Delays & reverbDelay, StereoDelay, FdnReverb, ConvReverb, StereoConvReverbDelay and reverb
DynamicsLimiter, StereoLimiter, Bitcrusher, Drive, TransientShaper, RingMod, PitchShiftDynamics and nonlinear effects
DetectorsAudioToTrigger, PolyAudioToTrigger, AudioToGate, PolyAudioToGateAudio → sub-sample triggers / gates
Stereo utilitiesPan, Balance, StereoWidth, MidSide, MonoBassStereo image control
UtilitiesGlide, Tuner, PolyTuner, Quant, PolyQuant, Sah, PolySah, MonoToPoly, PolyToMonoConversion, smoothing, quantisation
PrimitivesDcBlocker, CombSmall reusable DSP building blocks
OutputAudioOut, AudioIn, HostTransportAudio 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

ParameterTypeDefaultDescription
frequencyfloat (v/oct)0.0Base 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.
driftfloat0.0Slow random pitch drift amount (0 = off, 1 = max)
fm_typestringlinearFM response: linear or logarithmic

Inputs

PortDescription
voctV/oct pitch offset (added to frequency at runtime)
fmFrequency modulation input
pulse_width_cvPulse width control for square wave (−1 to +1; maps to 0–99% duty cycle)
phase_modPhase modulation offset (0–1, fraction of a cycle; wraps)
syncSub-sample hard-sync trigger: phase reset with PolyBLEP correction on saw/square

Outputs

PortDescription
sineSine wave
triangleTriangle wave
sawtoothSawtooth wave (PolyBLEP anti-aliased)
squareSquare wave (PolyBLEP anti-aliased; duty cycle set by pulse_width_cv)
reset_outTrigger 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

ParameterTypeDefaultDescription
frequencyfloat (v/oct)0.0Base 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.
driftfloat0.0Slow random pitch drift amount, independent per voice (0 = off, 1 = max)
fm_typestringlinearFM response: linear or logarithmic

Inputs

PortDescription
voctPer-voice V/oct pitch offset (poly)
fmPer-voice FM input (poly)
pulse_width_cvPer-voice pulse width control for square wave (poly)
phase_modPer-voice phase modulation (poly)

Outputs

PortDescription
sinePer-voice sine (poly)
trianglePer-voice triangle (poly)
sawtoothPer-voice sawtooth, PolyBLEP anti-aliased (poly)
squarePer-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. 0 collapses every copy onto the centre pitch (a plain unison saw, no beating); 1 pushes 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. 0 is the clean centre saw alone; 1 is 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

ParameterTypeDefaultDescription
frequencyfloat (v/oct)0.0Base pitch as V/oct above C0 (0.0 = C0 ≈ 16.35 Hz)
fm_typestringlinearFM response: linear or logarithmic
spreadfloat0.3Detune width (0 = unison, 1 = ±50 cents at the outer pair)
densityfloat1.0Active side pairs, faded in inner→outer (×4 pairs)
mixfloat0.7Centre↔side balance (0 = clean centre saw, 1 = full stack)

Inputs

PortDescription
voctV/oct pitch offset (added to frequency at runtime)
fmFrequency modulation (control-rate vibrato)
spread_cvAdds to spread
density_cvAdds to density
mix_cvAdds to mix

Outputs

PortDescription
outSummed 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

PortDescription
voctPer-voice V/oct pitch offset (poly)
fmPer-voice FM input (poly; control-rate vibrato)
spread_cvAdds to spread, shared across voices (mono)
density_cvAdds to density, shared across voices (mono)
mix_cvAdds to mix, shared across voices (mono)

Outputs

PortDescription
outPer-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

ParameterTypeDefaultDescription
ratefloat (Hz)1.0Rate in Hz (0.01–20.0)
phase_offsetfloat0.0Phase offset (0–1, fraction of a cycle)
modestringbipolarOutput polarity: bipolar (−1 to +1), unipolar_positive (0 to +1), unipolar_negative (−1 to 0)

Inputs

PortDescription
syncSub-sample phase reset trigger
rate_cvAdded linearly to rate (Hz); effective rate clamped to 0.001–40.0 Hz
sync_msWhen connected, overrides rate with this value interpreted as period in ms

Outputs

PortDescription
sineSine wave
triangleTriangle wave
saw_upRising sawtooth
saw_downFalling sawtooth
squareSquare wave (50% duty cycle)
randomSample-and-hold random value, updated once per cycle
reset_outTrigger 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

PortKindDescription
triggertriggerShared one-sample pulse starts the envelope on every channel
gatemonoShared: held high to sustain; release to enter Release on every channel
vca_in[i]monoOptional audio/CV input multiplied by the channel’s envelope (i in 0..N-1)

Outputs

PortKindDescription
out[i]monoEnvelope level for channel i in [0.0, 1.0]
vca_out[i]monovca_in[i] * out[i] — pre-multiplied audio/CV

Parameters

NameTypeRangeDefaultDescription
delay[i]float0.0 – 10.00.0Pre-attack hold time in seconds (output stays at 0)
attack[i]float0.001 – 10.00.01Attack time in seconds
decay[i]float0.001 – 10.00.1Decay time in seconds
sustain[i]float0.0 – 1.00.7Sustain level
release[i]float0.001 – 10.00.3Release time in seconds
shape[i]enumlinear, exponentiallinearSegment 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

PortKindDescription
triggertriggerOne-sample pulse starts the envelope
gatemonoHeld high to sustain; release to run the release tail
voctmono1V/oct pitch driving key-follow time-scaling (0 if unconnected)
velocitymonoVelocity in [0, 1] scaling stage levels (1.0 if unconnected)
vca_inmonoOptional audio/CV input multiplied by the envelope

Outputs

PortKindDescription
outmonoEnvelope level in [0.0, 1.0]
vca_outmonovca_in * out — pre-multiplied

Parameters

NameTypeRangeDefaultDescription
time[i]float0.0 – 10.00.1Stage i duration in seconds (i in 0..N−1, N = stage count)
level[i]float0.0 – 1.01.0Stage i target level
curve[i]enumlinear, exponentiallinearStage i segment shape
sustain_stageint (structural)0 – 70Index of the held stage; later stages are the release tail
keyfollowfloat0.0 – 1.00.0Pitch time-scaling depth (1.0 halves times one octave up)
ref_keyfloat-5.0 – 5.00.0Reference pitch (V/oct) at which time_scale = 1
vel_depthfloat0.0 – 1.00.0How 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

PortKindDescription
triggerpolyPer-voice sub-sample trigger
gatepolyPer-voice gate
vca_inpolyOptional per-voice audio/CV multiplied by the envelope

Outputs

PortKindDescription
outpolyPer-voice envelope level in [0.0, 1.0]
vca_outpolyvca_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

PortKindDescription
triggerpoly_triggerPer-voice rising edge starts the envelope
gatepolyPer-voice gate; release to run the release tail
voctpolyPer-voice 1V/oct pitch driving key-follow
velocitypolyPer-voice velocity in [0, 1] scaling stage levels
vca_inpolyOptional per-voice audio/CV multiplied by the envelope

Outputs

PortKindDescription
outpolyPer-voice envelope level in [0.0, 1.0]
vca_outpolyvca_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 Filter module name is no longer supported. Use Lowpass instead.


Lowpass / Highpass

Mono resonant filters.

Parameters

ParameterTypeDefaultDescription
cutofffloat (V/oct)6.0Cutoff 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.
resonancefloat0.0Resonance (0–1; approaching 1 = self-oscillation)
saturateboolfalseApply soft-clip saturation in the feedback path

Inputs

PortDescription
inAudio input
voctV/oct offset added to cutoff; 1.0 = +1 octave
fmFM sweep: ±1 sweeps ±2 octaves around cutoff
resonance_cvAdded to resonance

Outputs

PortDescription
outFiltered audio

Bandpass

Mono resonant bandpass filter.

Parameters

ParameterTypeDefaultDescription
centerfloat (V/oct)6.0Centre 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_qfloat1.0Bandwidth Q (0.1–20; higher = narrower band)
saturateboolfalseApply soft-clip saturation in the feedback path

Inputs

PortDescription
inAudio input
voctV/oct offset added to center; 1.0 = +1 octave
fmFM sweep: ±1 sweeps ±2 octaves around center
resonance_cvAdded to bandwidth_q

Outputs

PortDescription
outFiltered 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

PortDescription
inPer-voice audio (poly)
voctPer-voice V/oct offset added to cutoff; 1.0 = +1 octave (poly)
fmPer-voice FM sweep: ±1 sweeps ±2 octaves around cutoff (poly)
resonance_cvPer-voice resonance modulation (poly)

Outputs

PortDescription
outPer-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

ParameterTypeDefaultDescription
cutofffloat (V/oct)6.0Cutoff 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.
qfloat0.0Resonance (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

PortDescription
inAudio input
voctV/oct offset added to cutoff; 1.0 = +1 octave
fmFM sweep: ±1 sweeps ±2 octaves around cutoff
q_cvAdded to q before clamping to [0, 1]

Outputs

PortDescription
lowpassLowpass output
highpassHighpass output
bandpassBandpass output

Warning: At q values 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

PortDescription
inPer-voice audio (poly)
voctPer-voice V/oct offset added to cutoff (poly)
fmPer-voice FM sweep: ±1 sweeps ±2 octaves around cutoff (poly)
q_cvPer-voice additive Q offset, clamped to [0, 1] (poly)

Outputs

PortDescription
lowpassPer-voice lowpass output (poly)
highpassPer-voice highpass output (poly)
bandpassPer-voice bandpass output (poly)

PolyBandpass

Per-voice version of Bandpass. Same parameters as Bandpass.

Inputs

PortDescription
inPer-voice audio (poly)
voctPer-voice V/oct offset added to center; 1.0 = +1 octave (poly)
fmPer-voice FM sweep: ±1 sweeps ±2 octaves around center (poly)
resonance_cvPer-voice bandwidth_q modulation (poly)

Outputs

PortDescription
outPer-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

PortDescription
inAudio input
cvControl voltage (0–1 typical)

Outputs

PortDescription
outin × cv

PolyVca — Per-voice VCA

Same as Vca but operates on poly cables, multiplying each voice independently.

Inputs

PortDescription
inPer-voice audio (poly)
cvPer-voice control voltage (poly)

Outputs

PortDescription
outPer-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

PortDescription
in[0]in[n-1]Mono inputs

Outputs

PortDescription
outSum 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)

ParameterTypeDefaultDescription
level[n]float1.0Channel gain (0–1)
send_a[n]float0.0Send A amount (0–1)
send_b[n]float0.0Send B amount (0–1)
mute[n]boolfalseMute this channel
solo[n]boolfalseSolo this channel

Inputs

PortDescription
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_aReturn input added to main output
return_bReturn input added to main output

Outputs

PortDescription
outMain mono mix
send_aSend A bus output
send_bSend 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)

ParameterTypeDefaultDescription
level[n]float1.0Channel gain (0–1)
pan[n]float0.0Pan (−1 = full left, +1 = full right)
send_a[n]float0.0Send A amount (0–1)
send_b[n]float0.0Send B amount (0–1)
mute[n]boolfalseMute this channel
solo[n]boolfalseSolo this channel

Inputs

PortDescription
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_aSend A stereo return
return_bSend B stereo return

Outputs

PortDescription
outMain stereo output
send_aSend A stereo bus
send_bSend B stereo bus

PolySum — Unweighted poly sum

Sums N poly inputs voice-by-voice with no level control.

module mix : PolySum(channels: 2)

Inputs

PortDescription
in[0]in[n-1]Poly inputs

Outputs

PortDescription
outPer-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)

ParameterTypeDefaultDescription
level[n]float1.0Channel gain (0–1)
mute[n]boolfalseMute this channel
solo[n]boolfalseSolo this channel

Inputs

PortDescription
in[0]in[n-1]Poly audio inputs
level_cv[0]level_cv[n-1]Per-channel level CV (mono)

Outputs

PortDescription
outPer-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)

ParameterTypeDefaultDescription
level[n]float1.0Channel gain (0–1)
pan[n]float0.0Pan (−1 = full left, +1 = full right)
mute[n]boolfalseMute this channel
solo[n]boolfalseSolo this channel

Inputs

PortDescription
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

PortDescription
out_leftPer-voice left output (poly)
out_rightPer-voice right output (poly)

PolyToMono — Collapse poly to mono

Sums all active voices of a poly cable into a single mono signal.

Inputs

PortDescription
inPoly input

Outputs

PortDescription
outMono sum of all voices

MonoToPoly — Broadcast mono to all voices

Copies a mono signal into every voice slot of a poly cable.

Inputs

PortDescription
inMono input

Outputs

PortDescription
outPoly 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

PortDescription
in[0]in[n-1]Stereo inputs

Outputs

PortDescription
outStereo 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

PortSpectrumDescription
whiteflatUncorrelated 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

PortSpectrumDescription
whiteflatPer-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

ParameterTypeDefaultDescription
bpmfloat120.0Tempo in beats per minute (1–300)
beats_per_barint4Number of beats per bar (1–16)
quavers_per_beatint2Subdivisions per beat: 2 = simple, 3 = compound (1–4)

Outputs

PortDescription
barFires once per bar
beatFires once per beat
quaverFires once per quaver (beats_per_bar × quavers_per_beat per bar)
semiquaverFires 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

PortKindDescription
playingmono1.0 while the host is playing, otherwise 0.0
tempomonoCurrent tempo in BPM
beatmonoPosition within the bar as fractional beats
barmonoBar index (integer-valued, increments at each bar boundary)
beat_triggertriggerFires once per beat boundary
bar_triggertriggerFires once per bar boundary
tsig_nummonoTime signature numerator
tsig_denommonoTime 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

NameTypeDefaultDescription
subdivisionenum1/4Subdivision: 1/1, 1/2, 1/4, 1/8, 1/16, 1/32, plus dotted (1/4d) and triplet (1/4t) variants

Inputs

PortKindDescription
bpmmonoTempo in BPM

Outputs

PortKindDescription
msmonoDuration of one subdivision in milliseconds

MsTicker — Millisecond-paced trigger

Fires a trigger every ms milliseconds with a corresponding gate.

Inputs

PortKindDescription
msmonoInter-trigger interval in milliseconds
resettriggerResets the internal phase to zero

Outputs

PortKindDescription
triggertriggerSub-sample trigger at each tick boundary
gatemonoHigh 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

PortKindDescription
startmonoRising edge resets and begins playback
stopmonoRising edge halts and resets playback
pausemonoRising edge halts playback in place
resumemonoRising edge continues from current position

Outputs

PortKindDescription
clock[i]polyClock bus per channel (i in 0..N−1, N = channels)

The clock bus carries four poly voices:

VoiceSignalDescription
0pattern reset1.0 on first tick of a new pattern
1pattern bank indexfloat-encoded integer (−1 = stop sentinel)
2tick trigger1.0 on each step
3tick durationseconds per tick

Parameters

NameTypeRangeDefaultDescription
bpmfloat1.0–999.0120.0Tempo in beats per minute
rows_per_beatint1–644Steps per beat
songsong_namenoneName of the song to play
loopbooltrueLoop at end of song
autostartbooltrueBegin playback on activation
swingfloat0.0–1.00.5Swing ratio for alternating steps
syncenumauto/free/hostautoClock 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

PortKindDescription
clockpolyClock bus from MasterSequencer

Outputs

PortKindDescription
cv1[i]monoControl voltage 1 per channel (i in 0..N−1)
cv2[i]monoControl voltage 2 per channel (i in 0..N−1)
trigger[i]monoTrigger per channel (i in 0..N−1)
gate[i]monoGate 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 fire trigger. *N opens a roll; value>value opens-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 :cv2 if 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 :cv2 if 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 *N anchor): 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-drums crate (in the patches-bundles repo) 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

PortKindDescription
triggermonoRising edge triggers
voctmonoV/oct pitch CV; overrides sweep start frequency if connected

Outputs

PortKindDescription
outmonoKick signal

Parameters

NameTypeRangeDefaultDescription
pitchfloat20–200 Hz55Base pitch of the kick
sweepfloat0–5000 Hz2500Starting frequency of pitch sweep
sweep_timefloat0.001–0.5 s0.04Duration of pitch sweep
decayfloat0.01–2.0 s0.5Amplitude decay time
drivefloat0.0–1.00.0Saturation amount
clickfloat0.0–1.00.3Transient 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

PortKindDescription
triggermonoRising edge triggers

Outputs

PortKindDescription
outmonoSnare signal

Parameters

NameTypeRangeDefaultDescription
pitchfloat80–400 Hz180Body oscillator base pitch
tonefloat0.0–1.00.5Body vs noise mix (0 = all body)
body_decayfloat0.01–1.0 s0.15Body amplitude decay time
noise_decayfloat0.01–1.0 s0.2Noise amplitude decay time
noise_freqfloat500–10000 Hz3000Noise bandpass centre frequency
noise_qfloat0.0–1.00.3Noise bandpass resonance
snapfloat0.0–1.00.5Transient 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

PortKindDescription
triggermonoRising edge triggers

Outputs

PortKindDescription
outmonoClosed hat signal

Parameters

NameTypeRangeDefaultDescription
pitchfloat100–8000 Hz400Base frequency of metallic tone
decayfloat0.005–0.2 s0.04Amplitude decay time
tonefloat0.0–1.00.5Metallic vs noise mix
filterfloat2000–16000 Hz8000Noise 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

PortKindDescription
triggermonoRising edge triggers
chokemonoRising edge chokes (cuts) the sound

Outputs

PortKindDescription
outmonoOpen hat signal

Parameters

NameTypeRangeDefaultDescription
pitchfloat100–8000 Hz400Base frequency of metallic tone
decayfloat0.05–4.0 s0.5Amplitude decay time
tonefloat0.0–1.00.5Metallic vs noise mix
filterfloat2000–16000 Hz8000Noise 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

PortKindDescription
triggermonoRising edge triggers

Outputs

PortKindDescription
outmonoTom signal

Parameters

NameTypeRangeDefaultDescription
pitchfloat40–500 Hz120Base pitch
sweepfloat0–2000 Hz400Pitch sweep start offset
sweep_timefloat0.001–0.3 s0.03Pitch sweep duration
decayfloat0.05–2.0 s0.3Amplitude decay time
noisefloat0.0–1.00.15Noise 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

PortKindDescription
triggermonoRising edge triggers

Outputs

PortKindDescription
outmonoCymbal signal

Parameters

NameTypeRangeDefaultDescription
pitchfloat200–10000 Hz600Base frequency of metallic tone
decayfloat0.2–8.0 s2.0Amplitude decay time
tonefloat0.0–1.00.5Metallic vs noise mix
filterfloat2000–16000 Hz6000Noise highpass cutoff
shimmerfloat0.0–1.00.2Partial 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

PortKindDescription
triggermonoRising edge triggers

Outputs

PortKindDescription
outmonoClap signal

Parameters

NameTypeRangeDefaultDescription
decayfloat0.05–2.0 s0.3Tail decay time
filterfloat500–8000 Hz1200Bandpass centre frequency
qfloat0.0–1.00.4Bandpass resonance
spreadfloat0.0–1.00.5Spacing between bursts
burstsint1–84Number 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

PortKindDescription
triggermonoRising edge triggers

Outputs

PortKindDescription
outmonoClaves signal

Parameters

NameTypeRangeDefaultDescription
pitchfloat200–5000 Hz2500Resonant frequency
decayfloat0.01–0.5 s0.06Amplitude decay time
resonfloat0.3–1.00.85Bandpass 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

PortKindDescription
midimidiMIDI 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 noteNotevoct
0C00.0
12C11.0
60C4 (middle C)5.0
69A4 (concert A)5.75

Outputs

PortDescription
voctPitch in V/oct above C0 (MIDI note 0 = 0.0, 1/12 V per semitone)
triggerSub-sample trigger on each note-on
gate1.0 while any note is held or sustain pedal (CC 64) is active
modCC 1 (mod wheel) normalised to [0.0, 1.0]
pitchPitchbend normalised to [−1.0, 1.0] (centre = 0.0)
velocityMost-recent note-on velocity normalised to [0.0, 1.0]
slur1.0 while a held key falls back to a still-held lower key without retriggering, otherwise 0.0

Parameters

NameTypeDefaultDescription
legatoboolfalseWhen 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

PortKindDescription
voctPolyPer-voice pitch in V/oct above C0
triggerPolyPer-voice sub-sample trigger on note-on
gatePoly1.0 while the note for that voice is physically held
velocityPolyPer-voice note-on velocity normalised to [0.0, 1.0]
modMonoCC 1 (mod wheel) normalised to [0.0, 1.0]
pitchMonoPitchbend 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

NameTypeDefaultDescription
ccint (0–127)1MIDI CC number to read

Outputs

PortKindDescription
outmonoMost-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)

ParameterTypeRangeDefaultDescription
dry_wetfloat0–11.0Crossfade between dry input and wet tap sum

Parameters (per tap i)

ParameterTypeRangeDefaultDescription
delay_ms[i]int0–2000500Tap delay time in milliseconds
gain[i]float0–11.0Tap output gain
feedback[i]float0–10.0Tap feedback amount (sent back into write)
tone[i]float0–11.0High-frequency roll-off in the feedback path (1 = bright, 0 = dark)
drive[i]float0.1–101.0Soft-clip drive in the feedback path

Inputs

PortDescription
inMono audio input
drywet_cvAdditive 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

PortDescription
outDry/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)

ParameterTypeRangeDefaultDescription
dry_wetfloat0–11.0Crossfade between dry input and wet tap sum

Parameters (per tap i)

ParameterTypeRangeDefaultDescription
delay_ms[i]int0–2000500Tap delay time in milliseconds
gain[i]float0–11.0Tap output gain
feedback[i]float0–10.0Tap feedback amount
tone[i]float0–11.0HF roll-off in feedback path (1 = bright, 0 = dark)
drive[i]float0.1–101.0Soft-clip drive in feedback path
pan[i]float−1–+10.0Stereo pan position (−1 = full left, +1 = full right)
pingpong[i]boolfalseCross-route feedback: L feedback→R buffer, R feedback→L buffer

Inputs

PortDescription
inStereo audio input
drywet_cvAdditive 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

PortDescription
outStereo 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

ParameterTypeRangeDefaultDescription
characterenumplate / room / chamber / hall / cathedralhallRoom archetype — sets delay-line scaling, LFO rate/depth, pre-delay length, and decay curve shape
sizefloat0–10.5Room size within the archetype: 0 = smallest/shortest, 1 = largest/longest
brightnessfloat0–10.5High-frequency decay ratio: 0 = dark (HF decays much faster), 1 = bright (HF/LF decay close together)
pre_delayfloat0–10.0Additional pre-delay (additive with size): 0 = no extra, 1 = maximum for the archetype
mixfloat0–11.0Dry/wet mix: 0 = fully dry, 1 = fully wet

Character archetypes

NameRT60 range (LF)Pre-delayCharacter
plate0.3 s – 1.5 sup to 10 msDense, smooth; no sense of room geometry
room0.4 s – 2.5 sup to 25 msSmall to mid-size room with clear early reflections
chamber0.3 s – 2.0 sup to 20 msTight, controlled decay; studio chamber character
hall0.8 s – 5.0 sup to 50 msConcert hall — the default
cathedral1.5 s – 8.0 sup to 80 msVery long, diffuse reverb tail

Inputs

PortDescription
inStereo audio input (mono sources are silently broadcast L = R)
size_cvAdditive CV for size
brightness_cvAdditive CV for brightness
pre_delay_cvAdditive CV for pre_delay
mix_cvAdditive CV for mix

Outputs

PortDescription
outStereo 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

ParameterTypeDefaultDescription
thresholdfloat0.9Peak level above which gain reduction is applied (0.0–2.0). Internally multiplied by 0.98 for a small headroom margin.
attack_msfloat (ms)2.0Lookahead / attack time in milliseconds (0.1–50.0). Sets how early the gain ramp begins before a transient.
release_msfloat (ms)100.0Release time in milliseconds (1.0–5000.0). Controls how quickly the gain recovers after the peak falls.

Inputs

PortDescription
inAudio signal to limit

Outputs

PortDescription
outLimited output, hard-clipped to ±1.0

Notes

  • The module introduces a latency of attack_ms plus 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 prepare time for the maximum attack_ms value (50 ms), so changing attack_ms at 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

ParameterTypeDefaultDescription
thresholdfloat (dB)-12Knee centre (−60..0 dB).
ratiofloat4Above-knee slope (1..100). High values approach a limiter.
knee_widthfloat (dB)6Soft-knee width; 0 = hard knee (0..24 dB).
attackfloat (ms)10Detector attack (0.1..1000 ms).
releasefloat (ms)100Detector release (1..5000 ms).
makeupfloat (dB)0Output gain trim (−24..24 dB).
detectenumpeakpeak (envelope on absolute value) or rms (envelope on squared).
mixfloat1Dry/wet blend (0..1). 0 returns dry.

Inputs

PortDescription
inAudio input
sidechainExternal detector key. Self-keys from in when unconnected.

Outputs

PortDescription
outCompressed 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

PortKindDescription
instereoAudio input
sidechainstereoExternal detector key; self-keys from in when unconnected.

Outputs

PortKindDescription
outstereoCompressed 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

ParameterTypeDefaultDescription
thresholdfloat (dB)-40Open threshold (−80..0 dB).
hysteresisfloat (dB)3Re-arm band below threshold (0..24 dB).
attackfloat (ms)1Open-ramp time (0.01..1000 ms).
holdfloat (ms)10Minimum open time after a trigger (0..5000 ms).
releasefloat (ms)100Close-ramp time (1..5000 ms).

Inputs

PortDescription
inAudio input
sidechainExternal detector key. Self-keys from in when unconnected.

Outputs

PortDescription
outGated 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

PortKindDescription
instereoAudio input
sidechainstereoExternal detector key; self-keys from in when unconnected.

Outputs

PortKindDescription
outstereoGated 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 a Trigger cable. Intended for clock recovery from oscillator-rate audio, sub-octave division (a divider chain fed by an AudioToTrigger cascade), 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

ParameterTypeDefaultDescription
thresholdfloat (dB)-12Fire threshold (-60..0 dB)
hysteresisfloat (dB)3Re-arm band around threshold (0..24 dB)
directionenumrisingEdge polarity: rising / falling / both
cooldownfloat (ms)0Debounce after fire (0..1000 ms)

Inputs

PortKindDescription
inmonoAudio source

Outputs

PortKindDescription
outtriggerSub-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

PortKindDescription
inpolyPer-voice audio source

Outputs

PortKindDescription
outpoly triggerPer-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

ParameterTypeDefaultDescription
thresholdfloat (dB)-12Open threshold (-60..0 dB)
hysteresisfloat (dB)3Close band below threshold (0..24 dB)

Inputs

PortKindDescription
inmonoAudio source

Outputs

PortKindDescription
outmonoGate (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

PortKindDescription
inpolyPer-voice audio source

Outputs

PortKindDescription
outpolyPer-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

ParameterTypeDefaultDescription
panfloat0.0Base position; -1 = L, +1 = R (-1–1)

Inputs

PortDescription
inMono source
panAdditive CV (offsets pan param)

Outputs

PortDescription
outEqual-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

ParameterTypeDefaultDescription
balancefloat0.0Base balance; -1 = L, +1 = R (-1–1)

Inputs

PortDescription
inStereo source
balanceAdditive CV (offsets balance param)

Outputs

PortDescription
outBalanced 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

ParameterTypeDefaultDescription
widthfloat1.00 = mono sum, 1 = unchanged, 2 = double-wide (0–2)

Inputs

PortDescription
inStereo source
widthAdditive CV (offsets width param)

Outputs

PortDescription
outWidth-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

PortDescription
stereo_inL/R input to encode side
ms_in(M, S) input to decode side

Outputs

PortDescription
ms_out(M, S) from encode side
stereo_outL/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

ParameterTypeDefaultDescription
cutofffloat Hz120.0Crossover frequency (20–2000 Hz)

Inputs

PortDescription
inStereo source

Outputs

PortDescription
outHigh-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

ParameterTypeDefaultDescription
glide_msfloat (ms)100.0Glide time in milliseconds (0–10000)
slur_amountfloat1.0Multiplier applied to glide_ms while slur_in is high (0–100)

Inputs

PortDescription
inInput signal (typically V/oct)
slur_inGate: when high, glide time is multiplied by slur_amount

Outputs

PortDescription
outSmoothed 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

ParameterTypeDefaultDescription
octaveint0Octave shift (−8 to +8)
semiint0Semitone shift (−12 to +12)
centint0Fine-tune in cents (−100 to +100; 100 cents = 1 semitone)

Inputs

PortDescription
inV/oct input

Outputs

PortDescription
outTransposed 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

PortDescription
inSignal to sample
trigTrigger input; latch fires on the ≥ 0.5 rising edge

Outputs

PortDescription
outHeld 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

PortDescription
inPolyphonic signal to sample (16 voices)
trigMono trigger; all voices are latched together

Outputs

PortDescription
outPolyphonic 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

ParameterTypeDefaultDescription
pitch[i]float (V/oct)0.0Target pitch per scale degree (i in 0..N−1, N = channels)
centrefloat (V/oct)0.0DC offset added to the input before quantisation (−4 to +4)
scalefloat1.0Gain applied to in before quantisation (−4 to +4)

Inputs

PortDescription
inContinuous V/oct input

Outputs

PortDescription
outQuantised V/oct output
trig_outOne-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

ParameterTypeDefaultDescription
pitch[i]float (V/oct)0.0Target pitch per scale degree (i in 0..N−1, N = channels)
centrefloat (V/oct)0.0DC offset added to each voice before quantisation (−4 to +4)
scalefloat1.0Gain applied to each voice before quantisation (−4 to +4)

Inputs

PortDescription
inPolyphonic V/oct input (16 voices)

Outputs

PortDescription
outPolyphonic quantised V/oct output (16 voices)
trig_outPer-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

ParameterTypeDefaultDescription
drivefloat (dB)1.0Diode operating point in dB (0.2–20.0); low = linear, high = saturated

Inputs

PortDescription
signalAudio signal to modulate
carrierCarrier / modulator signal

Outputs

PortDescription
outRing-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

PortKindDescription
inmonoAudio input

Outputs

PortKindDescription
outmonoDC-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

ParameterTypeDefaultDescription
modeff/fb/bothfbTopology selection
delay_msfloat (ms)10.0Delay-line length (0.1–500 ms)
feedbackfloat0.5Delayed-tap coefficient (−0.99..0.99)
mixfloat1.0Output scale applied after the comb topology

Inputs

PortKindDescription
inmonoAudio input

Outputs

PortKindDescription
outmonoComb 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

PortDescription
inStereo 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

ConcernNative pluginIn-tree module
CrateNew cdylib outside the treeNew file under patches-modules/src/
Dependencypatches-sdk = "0.7"patches-core, patches-dsp
Registrationexport_plugin! macroRegistry::register::<Module>() in patches-modules::default_registry()
LoadingDiscovered via bundle dirsCompiled into the host
DistributionBuilt separately, shipped as .pxmShips with the main release
DocsSource comments onlySource 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

  1. Add a file. patches-modules/src/my_module.rs. Use one of the existing modules as a starting template — oscillator.rs, vca.rs, and glide.rs are good shapes.
  2. Implement Module. Same surface as the SDK example. Use types from patches-core directly instead of patches-sdk re-exports (patches_core::cables::*, patches_core::modules::*, patches_core::param_frame::ParamView).
  3. Declare the module. Add pub mod my_module; to patches-modules/src/lib.rs (and pub use my_module::MyModule; if you want it re-exported by name).
  4. Register it. In default_registry() in patches-modules/src/lib.rs, add r.register::<MyModule>();. The order in this function controls nothing user-visible; group with related modules for readability.
  5. 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.
  6. Test it. Use the ModuleHarness test support in patches-core::test_support to 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 raw f32 inputs, returns f32 outputs. No patches-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

  1. The host scans a bundle directory and dlopens each .dylib / .so / .dll.

  2. It resolves the patches_plugin_init symbol and calls it. The plugin returns an FfiPluginManifest:

    #![allow(unused)]
    fn main() {
    #[repr(C)]
    pub struct FfiPluginManifest {
        pub abi_version: u32,
        pub count: usize,
        pub vtables: *const FfiPluginVTable,
    }
    }
  3. The host checks abi_version. Mismatch → bundle refused, error logged, no modules registered.

  4. For each of the count vtables, the host calls module_template(), receives a JSON ModuleDescriptorTemplate blob, deserialises it once, and registers the module type.

  5. 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.

WhenDirectionPayloadPurpose
Plugin loadplugin → hostModuleDescriptorTemplateStatic, shape-axis-parameterised description of a module
Instance constructionhost → pluginModuleDescriptorPer-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); usize fields 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 */ ]
}
FieldJSON typeRequiredDefaultDescription
module_namestringyesModule type name. Must match registry entry.
shapeobjectyesSee ModuleShape below.
inputsarrayno[]Input ports in slice order.
outputsarrayno[]Output ports in slice order.
realtime_paramsarrayno[]Audio-thread parameters.
structural_paramsarrayno[]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"
}
FieldJSON typeRequiredDefaultDescription
namestringyesPort name.
indexnumberno0Index within a multi-port group.
kindstringno"mono"One of "mono", "poly", "stereo". Unknown → "mono".
mono_layoutstringno"audio"One of "audio", "trigger".
poly_layoutstringno"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

TagWire formValid 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:

FrameWhenLayout reference
ParamFrameupdate_validated_parameters()patches-ffi-common/src/sdk.rs (encoder), patches-ffi-common/src/abi.rs (ABI shape)
PortFrameset_ports()patches-ffi-common/src/port_frame.rs
Structural blobprepare()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 requires panic = "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:

  1. Export patches_plugin_init returning an FfiPluginManifest.
  2. Provide one FfiPluginVTable per module type with the eight function pointers wired up.
  3. Implement module_template to return JSON matching the ModuleDescriptorTemplate schema above.
  4. In prepare, parse the ModuleDescriptor JSON and store anything you need on the per-instance handle.
  5. Implement update_validated_parameters and set_ports against the binary frame formats.
  6. Implement process against the split scratch / cycle cable pool layout.
  7. 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. Gain here → module g : Gain in a patch.
  • axes — shape axes. CountAxis::CHANNELS enables a channels shape 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 at prepare() time and don’t appear in update_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/:

OSFilename
macOSlibmy_gain_plugin.dylib
Linuxlibmy_gain_plugin.so
Windowsmy_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:

  1. PATCHES_PLUGIN_PATH env var. A colon-separated list of directories or specific bundle files.
  2. 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:
  3. GlobalConfig::bundle_dirs from settings.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.
  4. 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, or set_ports. Pre-allocate in prepare.
  • 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.)

  • 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 Module trait, 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). One CableValue per 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:

IndexNameKindPurpose
0AUDIO_OUT_LmonoLeft audio output — AudioOut writes, callback reads
1AUDIO_OUT_RmonoRight audio output
2AUDIO_IN_LmonoLeft audio input (AudioIn)
3AUDIO_IN_RmonoRight audio input
4GLOBAL_TRANSPORTpolyTransport frame — sample count + tempo + position
5GLOBAL_DRIFTmonoSlowly varying value in [-1, 1] for correlated pitch drift
6GLOBAL_MIDIpolyPacked MIDI frame — up to 5 events per sample
7–10TAP_BASE..+4polyTap module storage — 4 slots × 16 lanes = 64 tap channels
11–14HOST_CONTROL_BASE..+4polyHost-control values — 4 slots × 16 lanes = 64 controls
15–27Spare backplane capacity (no live mapping)
28MONO_READ_SINKmonoDisconnected mono inputs read zero from here
29POLY_READ_SINKpolyDisconnected poly inputs read zero from here
30MONO_WRITE_SINKmonoUnconnected mono outputs write harmlessly here
31POLY_WRITE_SINKpolyUnconnected 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 ModuleSlot per 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 process on 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:

  1. The control thread builds a new ExecutionPlan via the planner.
  2. PatchEngine sends the plan through the rtrb ring buffer.
  3. The audio callback checks for a new plan at the top of each processing block.
  4. If a plan is waiting: the current plan is replaced via mem::replace. The old plan is pushed to the cleanup ring buffer as a CleanupAction::DropPlan.
  5. The callback applies the new plan’s updates (zeroing, tombstoning, installation, parameter and port updates) and transitions to the new execution state.
  6. 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 f32 per 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

FromToResult
MonoMonodirect (layouts must match)
PolyPolydirect (layouts must match)
StereoStereodirect
Mono AudioStereobroadcast: (s, s)
StereoMonorejected (CableKindMismatch)
Poly ↔ Stereorejected
Mono ↔ Polyrejected (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 of ParamFrame deliveries. The dispatcher increments this via RuntimeAudioHandles::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 between patches-clap and the player’s TUI.

Single dylib, two descriptors

The bundle is one cdylib. The CLAP entrypoint exports a factory that advertises two descriptors, backed by the same PatchesClapPlugin struct (patches-clap/src/descriptor.rs):

IDNameFirst featureWhere it appears
com.vulpus-labs.patchesPatchesinstrumentDAW instrument browser slot.
com.vulpus-labs.patches.fxPatches FXaudio-effectDAW effect browser slot.

Both descriptors carry the same set of feature tags (instrument, synthesizer, audio-effect, stereo); only the order differs. Hosts that bucket plugins by the first feature tag (Bitwig, Live, Logic) put the two descriptors in different slots from the same dylib. Hosts that don’t care about ordering see both descriptors with identical capability and surface them wherever they prefer.

Per-host project state keys by descriptor ID. A project saved against Patches will not silently re-bind to Patches FX on reload — they are distinct identities even though they share an implementation.

Plugin lifetime

clap_entry::init()
  └── factory::create(descriptor_id) → PatchesClapPlugin
        ├── init()       (host gives us the audio thread environment)
        ├── activate()   (start engine, open audio I/O)
        ├── process()    (called per audio block by the host)
        ├── deactivate() (stop engine, close audio I/O)
        └── destroy()    (drop)

process() runs the engine for the host-supplied block. Parameter changes from the host arrive as a CLAP parameter event stream and are translated into the controller’s Action::ParamSet (or equivalent) before reaching the audio thread.

GUI: wry webview

The plugin GUI is an embedded webview, parented to the host’s window via wry. Source: patches-clap/src/gui.rs.

The webview loads a single bundled HTML/CSS/JS document (assets/index.html, assets/app.css, assets/app.bundle.js — the last is generated by npm run build and checked in so cargo builds without a Node toolchain).

IPC

JS → Rust: the webview calls window.ipc.postMessage(JSON). The JSON is parsed into an Intent enum (patches-plugin-common::Intent) and lowered into an Action for the controller.

Rust → JS: the GUI builds a GuiSnapshot (a serialisable projection of the current state) and pushes it via evaluate_script("window.__patches.applyState(...)") at up to ~30 Hz, skipping the push when the serialised snapshot hasn’t changed.

Live tap data (scope frames, spectrum bins, meter levels) flows through a separate path with its own dedupe and cadence, so a high-rate scope update doesn’t interfere with snapshot dedupe (and vice versa).

Why a webview

The trade-off chosen for the GUI was: maximise authoring agility for an unsettled UI vocabulary (lots of small experiments per feature, easy live-reload while developing), accept the binary-size and ABI-surface cost of bundling a webview. The webview model also gives us a clean serialisation boundary — anything in the GUI is defined by what crosses the GuiSnapshot / Intent types — which made it natural to factor the underlying logic into patches-plugin-common.

patches-plugin-common: controller / action / state delta

The plugin’s state mutation logic lives in patches-plugin-common::Controller. The shape:

Action (from GUI intent, host event, or file watcher)
  → Controller::apply(action)
      → mutates internal state
      → returns StateDelta describing what changed
  → caller reacts (push snapshot to GUI, save sidecar, etc.)

Action is a closed enum:

  • UI gestures: Browse, Reload, LoadPath, AddModulePath, RemoveModulePath, Rescan, AddBundleDir, SetTapOpt, SetWindowSize, SavePreset, LoadPreset.
  • Host events: Activate, Deactivate, StateLoad, HaltObserved, DiagnosticsDrained.

StateDelta flags which downstream effects need to fire — typically snapshot_changed (push a new snapshot to the webview), persistable_changed (host should re-save the project), global_config_changed (write settings.toml to disk), plan_recompile (rebuild and hand off a new ExecutionPlan), requires_restart (the change can only take effect after the host reactivates the plugin).

The webview is a thin shell over this model: every button is an intent JSON, every state visible in the UI is a field on GuiSnapshot. The same controller backs the player’s ratatui TUI (via the Env trait abstracting filesystem and host interaction).

Persistence

Two scopes, deliberately separated:

Per-patch state (host-owned)

The plugin returns CLAP state on save and accepts it on load. State captures the loaded patch’s identity (file path + verbatim DSL source as fallback), host-control values, per-tap display options, GUI window size, and the per-patch module-paths list. The DAW serialises this into its project file; reloading the project restores all of it.

If the original .patches file no longer resolves at the original path, the plugin falls back to the embedded DSL source so the project still plays. Browse in the GUI re-points it at a live file.

Global config (plugin-owned)

The bundle_dirs setting and any other host-scoped settings live in a cross-platform settings.toml under the user’s config directory. Resolved through patches-plugin-common::GlobalConfig, written when the user changes the value via the GUI (immediate save, not deferred). The CLAP path’s bundle-dir resolution adds to the env-var / constructor-supplied / default-data-dir tiers used elsewhere.

A sandboxed host that denies filesystem writes will get a graceful no-op on save; reads tolerate a missing or unreadable config.

Real-time boundaries

The plugin maintains the same real-time boundaries as the player. The CLAP-supplied audio thread is treated as the engine’s audio thread; no allocation, blocking, or syscalls in process. The control thread runs the controller, the file watcher, the LSP client (none), webview IPC, and persistence; it communicates with the audio thread through the engine’s lock-free rings (plan handoff, cleanup queue).

The webview ticker runs on its own thread and produces snapshots at its cadence. The host’s main thread shows the webview.

Halt handling

PatchProcessor::tick is wrapped in catch_unwind. On a module panic, the engine halts cleanly and sends a HaltObserved action to the controller, which surfaces the diagnostic in the GUI log pane and stops further process invocations from producing audio until the patch is reloaded.

This is why the workspace must keep panic = "unwind". With panic = "abort", the catch can’t intercept and the host process crashes instead.

Reading the source

If you’re tracing a feature end to end:

  1. The user gesture is a button or text input in the webview (patches-clap/assets/src/).
  2. JS posts an Intent via window.ipc.postMessage (patches-plugin-common/src/gui.rs).
  3. The plugin lowers it into an Action and passes to Controller::apply (patches-plugin-common/src/controller.rs).
  4. The controller mutates state and returns a StateDelta.
  5. The plugin reacts: pushes a new snapshot to the webview, asks the host to re-save, writes settings.toml, or whatever the delta indicates.

Each step is small. The reducer-style split keeps the actual logic testable without a CLAP host or a webview attached.

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 every stereo module decl), off by default.
  • Patch graph SVG rendering — exposed as the custom patches/renderSvg request; the VS Code extension’s Show Patch Graph command consumes it.
  • Module rescanpatches/rescanModules re-probes the configured bundle dirs without restarting the server.

Dual-parser architecture

The LSP holds two parsers for every .patches document:

  1. 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.
  2. 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:

  1. Parse errors from pest. Surface span, expected-rule message, location of the failure.
  2. Validation errors from the interpreter — unknown module types, port mismatches, missing parameters, cable-kind mismatches.
  3. 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 targetLSP binary path inside VSIX
darwin-arm64server/patches-lsp (aarch64-apple-darwin)
darwin-x64server/patches-lsp (x86_64-apple-darwin)
win32-x64server/patches-lsp.exe
linux-x64server/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:

  1. The handler is registered in server.rs (PatchesLanguageServer::* methods or custom-method bindings).
  2. It reads from the Workspace to get tree-sitter / pest state for the affected document.
  3. 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.