Clausters

Clausters is a port of SuperCollider's scsynth audio server to Rust: a real-time audio synthesis server controlled over OSC (UDP, default port 127.0.0.1:57110). A single process opens the audio device, keeps a tree of nodes (synths and groups), and receives OSC commands to create and destroy synths, set parameters, manage buses and buffers — all with sample-accurate scheduling.

It is conceptually compatible with scsynth (same node-tree model, the same /s_new, /n_set, /c_set, /b_* commands) but uses its own def formats instead of the binary .scsyndef. Its main addition over scsynth is the FaustDef — a synth definition written in Faust and JIT-compiled by the server — as an alternative to SuperCollider's UGen graphs, which Clausters also supports through its own JSON SynthDef format.

Highlights

  • Hard real-time audio thread: no allocation, locks or I/O in the audio callback. Commands arrive pre-built over lock-free FIFOs; freed memory leaves through a garbage FIFO and is dropped off the audio thread. Guarded by assert_no_alloc tests.
  • Two def formats, both loaded hot over OSC: a flat SynthDef JSON list of UGens (/d_recv, default synth feature), and Faust defs — Faust source or a JSON box tree — JIT-compiled with the LLVM backend (/d_faust, faust feature). The two families are independent build features: enable both or ship a single-family server.
  • Sample-accurate scheduling: NTP-timetagged bundles split the audio block at the event's exact frame, plus a direct sample clock (/clock, /sched) as a drift-free client timebase.
  • Offline (NRT) rendering: the same engine renders scores to WAV without an audio device, bit-identically to a live take.
  • Auto-sorted groups (/g_sortMode): execution order inferred from the buses each def reads and writes — no manual /n_before bookkeeping.
  • Parallel groups (/g_parallel + --workers N): independent children run on several cores, bit-identical to the sequential result.
  • Control/bus mapping (/n_map, /n_mapa): any control or Faust parameter can track a control or audio bus, live, every block.
  • Several transports: OSC over UDP and TCP (both on by default) and WebSocket (--ws), plus local shared memory (--shm) and an in-process C ABI for embedding, with the sample clock and control buses readable in mapped memory.
  • Configuration & persistence: a shared TOML config file (user and per-project layers) behind every boot flag, and defs persisted and reloaded across runs (--data-dir).

How to read this book

The API reference for the library crate is the rustdoc: cargo doc --open.

License

GPL-3.0-or-later (the embedded libfaust is GPLv2+).

Getting started

This chapter takes you from a checkout to a sound: build the server, run it, play a note over OSC, and render a score offline. (This is the English, user-facing path; the Spanish GUIA.md is a maintainer QA checklist that walks every feature.)

Requirements

  • A recent stable Rust toolchain (rustup, edition 2024).

  • On Linux, the default build targets PipeWire (the standard on current systems): libasound2-dev, libpipewire-0.3-dev and clang. The default binary hard-links libpipewire, so it expects PipeWire present at runtime.

  • faust (the FaustDef family, on by default): libfaust built with the LLVM backend — a one-time from-source build, since distro packages ship without it; the recipe is in Contributing. To build with nothing installed, drop the feature: cargo build --no-default-features --features synth,realtime,midi,pipewire,rtprio.

  • Optional, only for the matching feature:

    • midi-jack (route live MIDI through JACK for PipeWire-native MIDI): libjack-jackd2-dev.

    On Ubuntu 26.04:

    # default build (PipeWire audio + ALSA-seq MIDI)
    sudo apt install build-essential libasound2-dev libpipewire-0.3-dev clang
    # optional features
    sudo apt install libjack-jackd2-dev          # --features midi-jack
    

    For a build with no PipeWire dependency, use plain ALSA: cargo build --no-default-features --features synth,realtime,midi. The engine core builds and runs with no feature at all (though without a def family — synth and/or faust — there is nothing to instantiate).

Build

cargo build --release

The default features realtime + pipewire pull in the cpal audio backend with its native PipeWire host (it falls back to ALSA at runtime if PipeWire is absent). To build the engine without an audio device (CI, tests, offline rendering only), disable them: cargo build --no-default-features.

Run the server

cargo run --release

It opens the audio device and listens for OSC on UDP 127.0.0.1:57110, printing a one-line banner. It is silent until you create a synth. Stop it with /quit or Ctrl-C. Useful flags:

cargo run --release -- --workers 3        # DSP threads for /g_parallel groups
cargo run --release -- --shm /dev/shm/clausters   # shared-memory transport

Play a sound

The server speaks OSC, so any OSC client works. The bundled osc_ping example is the quickest — in a second terminal, with the server running:

cargo run --example osc_ping -- beep      # default synth at 440 Hz, retuned, freed
cargo run --example osc_ping -- map       # /n_map + /n_mapa demo (control & audio buses)
cargo run --example osc_ping -- status quit

By hand with oscsend (from liblo):

oscsend localhost 57110 /s_new siii default 1000 1 0   # play "default" at the root
oscsend localhost 57110 /n_set isf 1000 freq 330       # retune it
oscsend localhost 57110 /n_free i 1000                 # stop it

default is a built-in sine def; define your own with /d_recv (see Defs, UGens & the OSC protocol).

Sandbox note for the test harness: some environments isolate the network between shell invocations, so a server started in one invocation is unreachable from the next. Run the server and client in the same invocation there (server in the background with &, then the client). See Contributing.

Render a score offline (NRT)

No audio device needed — the same engine renders a score to WAV:

python3 examples/json_client.py score    # writes /tmp/clausters_score.osc
cargo run --release -- --nrt /tmp/clausters_score.osc /tmp/out.wav

A score is the scsynth binary format: length-prefixed OSC bundles whose timetags count seconds from the start of the render. Options: --rate, --channels, --format int16|int24|float, --workers. See the NRT mode section of Defs, UGens & the OSC protocol.

Where to go next

Configuration

Clausters reads a single TOML configuration file, shared by the server and every client. It is read-only to the programs: you edit the files; the programs never write them. Machine-written state (the def store, boot.json, midi.json) lives separately, under the data directory.

The file sets defaults only. A value passed on the command line — or to a client constructor, or to a server launched with explicit flags — always wins over the file.

Where the files live

Two layers combine, the project layer overriding the user layer:

  1. User — the first of:
    • $CLAUSTERS_CONFIG (a direct path to the file),
    • $XDG_CONFIG_HOME/clausters/config.toml,
    • %APPDATA%\clausters\config.toml (Windows),
    • ~/.config/clausters/config.toml.
  2. Project — the nearest clausters.toml found by walking up from the current working directory, the way Cargo finds Cargo.toml. The first match wins; the search stops at the filesystem root.

Precedence

From highest to lowest:

command-line flag  >  project clausters.toml  >  user config.toml  >  built-in default

The merge is field by field: a key absent from the project file keeps the user file's value, and a key absent from both falls back to the program's compiled default. Unknown keys are ignored, so a newer file stays readable by an older build.

Schema

Every key is optional; an absent key takes the built-in default. Sections are grouped by audience.

[server]
workers = 0              # DSP worker threads; 0 lets the server choose
sample_rate = 48000      # imposed output rate in Hz; 0 follows the device
audio_buses = 128        # audio bus count
control_buses = 1024     # control bus count
taps = 8                 # audio-tap rings for oscilloscopes; 0 disables
tap_frames = 16384       # per-tap ring capacity in samples (a power of two)
persist = true           # persist/reload defs; false is like --no-persist
# data_dir = "/path"     # def store location (else the XDG data dir)
# shm = "/clausters"     # shared-memory segment path for local clients
# tcp = true             # TCP transport (on by default at the OSC port):
#                        # false disables it, a port number moves it
# ws = 57120             # WebSocket transport: true = default port, or a number
# max_frame = 16777216   # largest OSC frame on TCP/WebSocket, in bytes
# midi = "clausters"     # virtual MIDI input: true = default name, or a name

[client]                 # the Python (and future) client
host = "127.0.0.1"       # server host
port = 57110             # server UDP port
latency = 0.0            # seconds added to each event's timetag

[gui]                    # the GUI host
host_port = 57210        # port for the host's script-facing front (UDP + TCP)
# tcp = true             # the front's TCP leg (on by default at host_port):
#                        # false disables it, a port number moves it
# max_frame = 16777216   # largest OSC frame on the TCP leg, in bytes
# server = "127.0.0.1:57110"  # also attach the client leg to this audio server
# shm = "/clausters"          # map this segment for meters/scopes
# data_dir = "/path"          # GuiDef store location

[standalone]             # the self-contained app (GUI + embedded server)
gui = "drone"            # the saved GuiDef to open when --standalone has no name
boot = true              # run the GuiDef's boot messages and boot.json preset
# data_dir = "/path"     # bundle location

The tcp, ws and midi keys are toggles that may also carry a value: true enables the transport at the program's default port or name, false disables it, and a number (or, for midi, a string) enables it at that specific port or name. TCP is the one transport that is on by default (at the OSC port, alongside UDP), so its true is the implicit state and false (or --no-tcp) is the meaningful override.

Per-program use

  • Server — the [server] section supplies the defaults for every flag of clausters (--workers, --sample-rate, --audio-buses, --control-buses, --taps, --tap-frames, --tcp/--no-tcp, --ws, --max-frame, --midi, --shm, --data-dir, --no-persist). A flag on the command line overrides the file.
  • GUI host — the [gui] section supplies the defaults for clausters-gui (--port, --tcp/--no-tcp, --max-frame, --server, --shm, --data-dir, --headless); the [standalone] section supplies the standalone launch. clausters-gui --standalone with no name opens [standalone].gui. A --config <path> flag reads one specific file instead of the user+project chain.
  • Python client — the [client] section provides the defaults for Session.live() / Server (host, port, latency); the [server] section provides the ServerOptions defaults (audio_buses, control_buses, sample_rate). See the Python client's own documentation.

The standalone app

With a bundle saved under a data directory (a GuiDef plus its SynthDefs, GraphDefs, FaustDefs and an optional boot.json), the GUI can launch the whole thing with no language interpreter:

clausters-gui --standalone --data-dir /path/to/data

The embedded audio server loads the data directory's defs and boot.json, the GuiDef's own boot messages run, and its window opens. Set [standalone].gui so the name can be omitted, and [standalone].data_dir so --data-dir can be too — then the launch is just clausters-gui --standalone.

Def schemas

User-facing reference: wire formats and OSC commands. For the server's internals — threads, memory lifecycle, invariants, adding UGens — see architecture.md.

Clausters accepts instrument definitions in two wire formats, both loaded hot over OSC (UDP, default port 127.0.0.1:57110):

formatOSC commandavailability
SynthDef JSON — a flat list of UGens/d_recv <blob>synth feature (default)
Faust def — Faust source or a JSON box tree/d_faust <name> <string>faust feature

The two families are independent build features and combine freely; a server built without one replies /fail (naming the missing feature) to that family's def command. See the feature matrix in Using clausters as a library and BUILD.md.

Both reply asynchronously: /done with the command (and the def name for /d_faust), or /fail with a human-readable error. Once loaded, defs of either kind are instantiated, controlled and freed the same way:

The async barrier (/sync)

/sync <int id> is the general way to wait for asynchronous work: the server replies /synced <id> once every async command received before this /sync has completed — Faust compiles (/d_faust), SynthDef sends (/d_recv) and buffer jobs (/b_*), each of which runs on its own FIFO worker thread. Use it instead of matching individual /dones when you fire several async commands and just want to know they have all landed (e.g. after sending a def without waiting, before the /s_new that needs it). It is a real barrier, not a round-trip of /status: a /synced guarantees the prior compiles/jobs are installed.

/s_new  name id addAction targetID [ctlName value]...   # ctl args: s f pairs
/n_set  id ctlName value...                             # one control per pair
/n_setn id ctl numControls value...                    # a consecutive range
/n_fill id ctl numControls value                       # fill a range with one value
/n_map  id ctlName busIndex...                          # control bus -> control
/n_mapa id ctlName busIndex...                          # audio bus  -> control
/n_mapn id ctl busIndex numControls...                 # map a range to consecutive buses
/n_mapan id ctl busIndex numControls...                # audio-bus range variant
/s_get  id ctl...                    -> /n_set id (ctl value)...        # read controls
/s_getn id ctl numControls...        -> /n_set id (ctl numControls value...)...
/n_free id...
/n_trace id...                                          # log a node's state (debug)
/s_noid id...                                           # acknowledged (see note)
/d_free name...                                         # SynthDef JSON only

Engine facts that apply to every def: blocks of 64 samples; by default 128 audio buses (0..outputs are the hardware outputs, bus 0 = left; outputs..outputs+inputs are the hardware inputs) and 1024 control buses; a pool of 1024 sample buffers filled by the /b_* commands; all signals are f32 at the configured sample rate. Everything sized at boot, never at compile time: the bus counts by --audio-buses (≤128) and --control-buses, the sample rate by --sample-rate (default 48000; PipeWire honors it per-application), the hardware channel counts by --outputs (default: the device's) and --inputs (default 0), the pre-allocated pools by --max-nodes, --max-buffers, --max-graph-children and --max-ugen-inputs (≤32), and the audio-tap region by --taps and --tap-frames (defaults 8 × 16384; see "Audio taps" below). Each flag also has a [server] config key of the same name (max_nodes, outputs, …), with the CLI winning. A client reads the live configuration with /server_info/server_info.reply [audio_buses, control_buses, output_channels, block_size, nominal_sr, actual_sr, input_channels, max_nodes, max_buffers, max_graph_children, max_ugen_inputs, taps, tap_frames, max_frame], so it can size its own bus/node allocators from the server instead of assuming the defaults. The first six fields are the stable original set; the S7 capacities, the tap region shape and the stream-transport frame ceiling (--max-frame — what a client should size bulk requests like /b_getn chunks from, see "Transports" below) are appended, so a client that reads only the first six keeps working.

Server health is one poll away: /status/status.reply [1, ugens, synths, groups, defs, avgCPU, peakCPU, nominal_sr, actual_sr, late_blocks] — the scsynth field set plus one appended int. avgCPU and peakCPU are the audio thread's per-block processing time as a percentage of the block budget (block_size / sample_rate of wall time — at 48 kHz a 64-frame block must be computed in 1.33 ms): the average is an exponential moving average with a ~1 s time constant, the peak is the worst single block since the previous /status poll (reading it resets the window, so every poll reports the peak of its own interval; expect it to sit well above the average — the callback must fit its worst block, not its mean). late_blocks counts, cumulatively since boot, the blocks whose processing exceeded their budget — the engine-side xrun proxy. It is conservative: when the device quantum is larger than one block (PipeWire typically runs 256–1024 frames), a single late block can be absorbed by the rest of the cycle, so an occasional increment is a warning, a steady climb is audible trouble (cross-check with pw-top's ERR column). Both meters read the real callback only: in an offline render they just measure render speed. examples/stress.rs builds a capacity ramp on top of this reply — see Examples.

Live audio input. With --inputs N the server also opens the default input device and exposes its N channels on audio buses outputs..outputs+inputs, so In (or In.ar) reading those bus indices returns live device input — a microphone, a loopback, another app's output routed in via PipeWire/JACK. Without --inputs, those buses read as silence (no input device is opened). The count is negotiated with the host and may be clamped; the value actually opened is the input_channels field of /server_info.reply (0 if the input device was unavailable).

Mapping controls to buses (/n_map, /n_mapa)

/n_set writes a control once. /n_map id ctl bus instead binds the control to a control bus: the node re-reads that bus at the start of every block, so the control tracks whatever any client (/c_set) or synth (OutCtl) writes there — no further /n_set. /n_mapa is the same against an audio bus. Both take any number of ctl bus pairs, by control name or index, and work for UGen controls and Faust parameters alike.

A busIndex of -1 removes the mapping (the control keeps its last value); a later /n_set on the same control also clears it and fixes the value.

Ranges of controls (/n_setn, /n_fill, /n_mapn, /n_mapan)

The -n variants address consecutive controls in one message, for defs with array-like control blocks. /n_setn id ctl numControls value... sets numControls controls starting at ctl (by name or index) from the value list; several (ctl, numControls, values...) groups may follow. /n_fill id ctl numControls value fills such a range with a single value (repeatable in (ctl, numControls, value) triples). /n_mapn id ctl busIndex numControls maps numControls consecutive controls to numControls consecutive buses starting at busIndex (busIndex = -1 unbinds the whole range); /n_mapan is the audio-bus form. Like /n_set//n_map, all four accept a group id and propagate down its subtree, and clearing/setting a control that is used as a bus index re-sorts auto/parallel groups.

Reading control values (/s_get, /s_getn)

/s_get id ctl... is the read counterpart of /n_set: it replies /n_set id (ctl value)... with each requested control's current value (by name or index). /s_getn id ctl numControls... reads a range, replying /n_set id (ctl numControls value...).... Values come from the server-side node mirror, so they reflect the latest /n_set//n_setn//s_new. An unknown node or out-of-range control replies /fail.

/s_noid and /n_trace

/s_noid id... exists for scsynth compatibility. In scsynth it releases integer node IDs back to the pool; Clausters assigns IDs per client (auto IDs come from a reserved server range) and never reuses a live or freed ID under a new node, so there is nothing to release — the command validates the IDs name live synths and replies /done. /n_trace id... is a debug aid: it logs each node's current control values (or a group's children) to the server console through the clausters::osc trace target, no OSC reply (matching scsynth's console trace).

Pausing and resuming nodes (/n_run)

/n_run id flag … (pairs of nodeID, flag) pauses (flag = 0) or resumes (flag = 1) a node — a synth or a whole group (a paused group skips its entire subtree). A paused node stays in the tree and keeps its state; it is just skipped during processing, so it is silent and consumes no CPU, and resumes exactly where it left off. This is what resumes a synth parked by doneAction 1 (pauseSelf) — pause is not terminal. An unknown id replies /fail.

Addressing a group (scsynth group semantics)

/n_set, /n_map and /n_mapa accept a group id as well as a synth id. Addressed to a group, the command transfers each named control down the group's subtree to every synth (and Faust node) that has a control of that name, recursing through subgroups and stopping at each synth — the standard scsynth behaviour, so one message moves a parameter across a whole bank of nodes. A node without a matching control name is simply skipped; an empty group is a no-op; an unknown id replies /fail. Addressed to a single synth, the command sets only that synth, as before.

Because a control is one value per block, /n_mapa samples one frame of the audio bus per block (control rate) — this matches scsynth for a control-rate control; there are no audio-rate controls here (feed an audio signal through In/an input bus instead). Mapping a control that is used as a bus index makes the node a dynamic barrier for auto/parallel groups, and an audio map adds that bus to the node's reads so the dependency analysis stays correct.

Moving nodes in the tree (/n_before, /n_after, /g_head, /g_tail, /n_order)

Execution order within a group is the child order, and these commands rewrite it. /n_before id target / /n_after id target move a node just before/after a sibling (any number of id target pairs). /g_head group id / /g_tail group id move a node to the head/tail of a group (pairs of group id). /n_order addAction target id... moves several nodes to one place at once, keeping their listed order: addAction selects 0 head of the target group, 1 tail, 2 before the target node, 3 after it.

All of these are disabled inside an auto-sorted group (/g_sortMode … 1): there the execution order is recomputed from the bus-connection DAG, so a manual move replies /fail — use auto-sort, or a manually-ordered group, but not both. (This is why /n_order earns its place only in manual groups: it is a batch /n_before//n_after//g_head//g_tail.)

Control buses (/c_set, /c_setn, /c_fill, /c_get, /c_getn, /c_stream)

Control buses are shared f32 slots any client or synth reads and writes — the glue between /c_set and /n_map. /c_set bus value... writes single buses (pairs); /c_setn bus numBuses value... writes a consecutive range from a value list (repeatable groups); /c_fill bus numBuses value fills a range with one value (repeatable triples). /c_get bus... reads single buses, replying /c_set (bus value)...; /c_getn bus numBuses... reads ranges, replying /c_setn (bus numBuses value...).... Unset buses read 0.0. The immediate forms write the shared atomics on the network thread; inside a timed bundle the writes travel to the audio thread so they land on the exact scheduled sample.

Beyond scsynth, /c_stream periodMs bus... subscribes the sending client to a periodic snapshot of the listed buses: the server acks /done /c_stream, sends one /c_set (bus value)... immediately, and keeps sending one every periodMs (clamped to a 10 ms floor; at most 128 buses per subscription) without further requests. It is the network counterpart of reading the control buses from the shared-memory segment, for clients that cannot map it — a browser GUI's meters and scopes over WebSocket. One subscription per client, replaced by each new /c_stream; periodMs <= 0 (or an empty bus list) cancels. A subscription dies with its TCP/WebSocket connection; over UDP or the shared-memory ring it lasts until an explicit cancel or /quit (the /notify posture). Not schedulable in a timed bundle.

Audio taps (/tap, /tap_stream)

Audio taps are the audio-rate counterpart of the control buses' data plane: pre-allocated single-channel sample rings inside the shared-memory segment (--taps of them, --tap-frames samples each — defaults 8 × 16384; --taps 0 disables the region), sized at boot and reported in /server_info.reply. They exist so a scope can see the samples of a live signal: SuperCollider answers this with ScopeOut2 writing into scope buffers; here the ring is a first-class segment region and routing into it is a command, not a UGen.

/tap tapIndex bus routes audio bus bus into tap ring tapIndex: from the next block on, the audio thread appends that bus's samples to the ring (RT-safe — one memcpy and one atomic store per block), where a local peer (the GUI host's oscilloscope) reads the newest window straight out of shared memory, with zero per-frame messages. bus = -1 stops the tap. No ack, like /n_map (it only flips routing state; failures reply /fail) — sequence with /sync when needed.

/tap_stream periodMs frames tapIndex... is the network counterpart, for clients that cannot map the segment — a browser oscilloscope over WebSocket, or headless capture. The server acks /done /tap_stream, then sends, immediately and every periodMs (10 ms floor), one /tap_data tapIndex endPosition blob per listed tap: the newest frames samples of its ring as raw little-endian f32, with endPosition (int64) the tap's stream position — total samples ever written — at the window's end, so consecutive snapshots can be placed on the tap's own sample axis (they overlap or gap by exactly the position delta). frames is clamped to the subscriber's transport bound — 8192 for a datagram-bounded client (UDP, the shared-memory ring), up to the --max-frame ceiling for a TCP/WebSocket client — and always to half the ring; at most 8 taps per subscription; a tap that has not yet filled a window sends nothing. Same subscription posture as /c_stream: one per client, replaced on each call, periodMs <= 0 (or no taps) cancels, dies with its TCP/WebSocket connection, not schedulable in a timed bundle.

Timed bundles

OSC bundles carry an NTP timetag. The immediate tag (1) executes on arrival; a future timetag is converted to a position on the server's sample clock and the whole bundle fires sample-accurately: the engine splits the audio block at the event's exact sample, so a /s_new scheduled mid-block starts on that very frame. Bundles with equal times run in arrival order; late bundles run immediately (and are logged). Nested bundles are scheduled independently by their own timetags.

Schedulable inside a timed bundle: /s_new, /n_set, /n_setn, /n_fill, /n_map, /n_mapa, /n_mapn, /n_mapan, /n_free, /n_before, /n_after, /n_order, /g_head, /g_tail, /g_new, /g_freeAll, /g_deepFree, /c_set, /c_setn, /c_fill, /u_cmd, /g_sortMode, /g_parallel, /graph_new, /graph_voice. Anything else (defs, buffers, queries, server commands) replies /fail … cannot be scheduled in a timed bundle — load defs and buffers first, then schedule the notes.

/clearSched flushes the whole timed-bundle queue: every bundle waiting on the sample clock is dropped (their heap freed off the audio thread), and the command replies /done /clearSched. Use it to abort a scheduled score — the scsynth panic button.

Also beyond scsynth: auto-sorted groups. /g_sortMode groupID 1 makes a group keep its children in dependency order inferred from the buses each def reads and writes — no more manual /n_before bookkeeping; query what the server inferred with /g_queryTree (scsynth-compatible reply) and /g_dumpGraph. See auto-order.md and examples/auto_order.py. The same analysis powers parallel groups: /g_parallel groupID 1 (with the server started as --workers N) runs a group's independent children on several cores, bit-identically to the sequential result — see parallel.md.

Beyond scsynth, the server also exposes its sample clock directly: /clock queries the sample counter, the actual sample rate and the server's OSC time captured with the counter (the master-clock anchor), and /sched <int64 target> <blob> schedules a packet at an absolute sample instead of an NTP time — same queue, same sample accuracy, drift-free by construction. See sample-clock.md and examples/sample_clock.py.

It also keeps a shared transport — a beat grid for phase-aligning several clients on that sample clock, plus a DAW-style rolling state. /transport with no args queries it (replies /transport.reply <int64 originSample> <double tempo> <int32 defined> <int32 playing> <double position>, defined 0 until one is set; the last two fields are appended, so older clients reading the first three still work); /transport <int64 originSample> <double tempo> sets the grid (last writer wins, stopped at position 0, replies /done). Beat b is sample originSample + b·rate/tempo. The rolling state rides on top: /transport_play [<double position>] starts playing (from position, or where it last stopped), /transport_stop stops, /transport_locate <double position> sets the song position; each replies /done and needs a grid defined first. Any change is pushed to every /notify client as a /transport.reply, so a responder re-aligns or rolls its playhead live. It is an in-memory registry the server stores, serves and broadcasts but never schedules audio from — clients read it to start their routines/playheads on the same beat. See sample-clock.md.

UDP is not the only transport: local clients can speak the same OSC through shared memory (clausters --shm <path>) or run the whole server in-process through the embed C ABI, with the sample clock and the control buses readable and writable directly in mapped memory. See ipc.md and clients/python/clausters/ipc.py.

The server also speaks TCP, on by default on the same port 57110 (alongside UDP — separate namespaces; --no-tcp disables it, --tcp [port] moves it). UDP is always on — it is the base transport and cannot be turned off (there is no TCP-only mode); it is the boot/discovery protocol a client probes first, and it doubles as internal infrastructure, since the TCP loop wakes itself with a zero-length datagram to the server's own UDP socket. Each OSC packet — message or bundle — is length-prefixed: a 4-byte big-endian byte count followed by exactly that many OSC bytes, the same framing scsynth uses; replies come back framed the same way over the one connection. TCP gives a reliable, ordered, connection-oriented channel — no silent packet loss, and no datagram size limit: a frame may be as large as the --max-frame ceiling (default 16 MiB, advertised in /server_info.reply), which is a DoS guard on the untrusted length prefix, not a protocol limit. That makes TCP the natural command plane for large payloads — whole defs, big /b_getn chunks — while timing still rides on bundle timetags / /sched, so arrival latency does not affect when a scheduled command fires. The Python client speaks it through OscTcpInterface.

The server also speaks WebSocket when started with clausters --ws [port] (default port 57120) — always available, like TCP and shared memory, not behind a build feature. This is the transport a browser can reach — a browser cannot open a raw UDP socket or map shared memory, but speaks WebSocket natively — so it is what lets a web-hosted client drive the server (and the server "run in the browser"). It carries the same OSC the same way the other transports do, multiplexed into the same loop and waking it with the same zero-length-UDP trick; the one framing difference from TCP is that each OSC packet is one WebSocket binary message, so the frame boundary is the packet boundary and there is no length prefix (replies come back as binary messages). Inbound bytes validate through the same single decode door as every transport, and messages are bounded by the same --max-frame ceiling TCP applies to its length prefix. The Python client speaks it through OscWsInterface; a browser uses the native WebSocket API (see examples/ws_ping.py and examples/ws_ping.html). ws:// only — TLS (wss://) is out of scope, terminate it at a reverse proxy if needed.

NRT mode (offline rendering)

The same engine renders scores to WAV without an audio device:

clausters --nrt score.osc out.wav [--rate 48000] [--channels 2] [--format float|int16|int24]

A score is the scsynth binary format: OSC packets back to back, each preceded by its byte count as a big-endian int32. Timetags count seconds from the start of the render (the immediate tag is time 0); bundles fire sample-accurately exactly like in real time, so an offline render equals a perfectly timed live take. The render ends at the time of the last bundle, whose commands produce no sound — close every score with a dummy bundle (a final /n_free) to set the duration.

Unlike the live server, a score bundle may also contain the asynchronous commands /d_recv, /d_faust, /d_free and the /b_* family: they complete synchronously before time advances (scsynth NRT semantics), and any failure aborts the render with the offending event's time and message. Queries (/status, /b_query, /b_get, /b_getn, /c_get) are errors in a score.

python3 examples/json_client.py score writes an example score; cargo run --release --example bench measures graph throughput offline.

SynthDef JSON (/d_recv)

The blob is a JSON object:

{
  "name": "default",
  "controls": [
    {"name": "freq", "default": 440.0},
    {"name": "amp",  "default": 0.2}
  ],
  "ugens": [
    {"kind": "SinOsc", "inputs": [{"control": 0}]},
    {"kind": "Mul",    "inputs": [{"ugen": 0}, {"control": 1}]},
    {"kind": "Out",    "inputs": [{"const": 0.0}, {"ugen": 1}]}
  ]
}
  • name — the key used by /s_new and /d_free. Re-sending a name replaces the def (existing synths keep playing the old one).

  • controls (optional) — named parameters addressable from /s_new and /n_set, with their default values and an optional type and lag (see Control types below).

  • ugens — the signal graph in execution order. Each input is one of:

    input formmeaning
    {"const": x}a constant (finite) float
    {"control": i}the i-th entry of controls
    {"ugen": i}the single output of an earlier ugen (i < own index)

    Forward references are rejected, which forces an acyclic, topologically ordered graph. Every ugen has exactly the arity listed below — wrong input counts, unknown kinds, out-of-range references and non-finite constants all come back in /fail naming the offending node (e.g. ugens[2].inputs[0]: control 7 out of range (have 2)).

    Each ugen may also carry an optional "rate" — its output calculation rate, one of "ir", "kr", "ar", "dr" (see Calculation rates below). Omitted, it defaults per kind (ar for signal UGens); the compiler rejects a rate a kind does not implement, or an illegal coercion, naming the node.

Control types

A control entry may carry a "rate" (its type) and a "lag" time, both optional:

fieldmeaning
"rate": "kr"(default) a plain control: one value per block, settable any time with /n_set, mappable to a bus with /n_map//n_mapa
"rate": "tr"a trigger: a /n_set value holds for exactly one block, then the engine resets it to 0 — so a rising edge fires once (drives an EnvGen gate, a sample-and-hold, …)
"rate": "ir"a scalar: read once when the synth starts and then frozen; a later /n_set is ignored (it pairs with the ir UGen rate, so it may feed an ir input like Rand/BufFrames.ir)
"lag": ton a kr control, smooth its changes with an implicit one-pole Lag over t seconds — the server inserts a real Lag UGen at compile time, so a stepped /n_set glides instead of jumping
"lag_down": twith "lag", use separate up (lag) and down (lag_down) times (an inserted VarLag)

Example: {"name": "freq", "default": 440.0, "lag": 0.1} glides over 100 ms; {"name": "gate", "default": 0.0, "rate": "tr"} is a one-shot trigger. Audio-rate controls are not a control type — feed an audio signal through In/an input bus and /n_mapa instead.

UGen catalog (built-in kinds)

The kind field is an opaque string as far as the protocol is concerned: the schema above is the whole contract (name + inputs + optional rate + static fields). What follows is a separate thing — the catalog of UGens the server currently ships, which the server resolves at runtime and grows independently of the wire format (each is one descriptor entry in src/dsp/registry.rs; a client never enumerates them, it just names one and the server validates). So this table is reference, not part of the protocol: an unknown name simply fails with /fail … unknown kind.

kindinputsoutput
SinOscfreq (Hz)sine by f64 phase accumulation, starts at phase 0
Impulsefreq (Hz)single-sample 1.0 every freq Hz, 0.0 between; the first output sample is always an impulse, so a /sched'd /s_new places it on an exact frame; freq 0 emits one impulse then silence (f64 phase, drift-free)
WhiteNoiseuniform white noise in ±1
BinaryOpUGena, bone of a table of binary operators, chosen by the op name — see the operator note below
UnaryOpUGenaone of a table of unary operators, chosen by the op name — see the operator note below
Add, Sub, Mul, Diva, bsample-wise arithmetic; thin aliases for the add/sub/mul/div operators (kept so existing defs are unchanged)
MulAdda, b, ca·b + c in one UGen (the multiply-accumulate the server fuses)
Sum3a, b, ca + b + c
Sum4a, b, c, da + b + c + d
Inbuscopies an audio bus (read once per block)
InCtlbusa control-bus value, constant over the block
OutCtlbus, signalwrites the signal's latest per-block value to a control bus (the write side of InCtl); passes the signal through as its output
Outbus, signalsums the signal into an audio bus
ReplaceOutbus, signaloverwrites the bus instead of summing
PlayBufbufnum, chan, rate, loopbuffer player with linear interpolation; rate is frames per output sample (1.0 = the server rate — multiply by BufRateScale(bufnum), i.e. file_sr / server_sr, for the file's pitch); starts at frame 0, silent at the end unless looping
BufRdbufnum, chan, phase, loopreads the buffer at a phase signal in frames (linear interpolation); out-of-range phases wrap when looping, clamp otherwise
BufSampleRatebufnumthe buffer's own sample rate (Hz), block-constant
BufRateScalebufnumfile_sr / server_sr; feed PlayBuf's rate (rate: BufRateScale(buf) * pitch) to play at the file's true pitch without the client knowing either rate
BufFramesbufnumframe count, block-constant
BufChannelsbufnumchannel count, block-constant
BufDurbufnumduration in seconds (frames / file_sr), block-constant
Oscbufnum, freq (Hz), phase (rad)interpolating wavetable oscillator; bufnum must hold a wavetable-format buffer (see /b_gen below); phase is an offset in radians
OscNbufnum, freq (Hz), phase (rad)non-interpolating oscillator over a plain (non-wavetable) buffer; rawer/cheaper than Osc
VOscbufpos, freq (Hz), phase (rad)like Osc but the buffer number is a signal: reads wavetables bufpos and bufpos+1 and crossfades by its fractional part, so sweeping bufpos morphs a bank of adjacent tables (allocate them contiguously, same size)
Shaperbufnum, inwaveshaper: maps in (in ±1, clamped) through a transfer table in wavetable format (typically /b_gen cheby); the table's first point is in = −1, its last in = +1
DiskInchanstreams a file from disk (mono per UGen — chan picks the channel); needs a path field; loop restarts at end of stream; see streaming note below
DiskOutsignalstreams signal to a mono WAV on disk; needs a path field; format is the WAV sample format; passes signal through as its output
LocalInchannelreads synth-private feedback channel channel (a constant); see feedback note below
LocalOutchannel, signalwrites signal into synth-private feedback channel channel (a constant); also passes signal through as its own output
EnvGengate, levelScale, levelBias, timeScale, doneAction, envelope arraybreakpoint envelope; gate-driven, with a doneAction that can free the node — see the envelope note below
Lagin, timeone-pole smoother: in lagged over time seconds (symmetric); time 0 passes through; primed to the first input (no glide up from 0)
VarLagin, lagUp, lagDownone-pole smoother with separate rise (lagUp) and fall (lagDown) times
SampleRatethe engine sample rate in Hz; init-rate (ir) by default
Randlo, hione uniform random value in [lo, hi), drawn once at synth init and held; init-rate only (its inputs must be constants/ir)
Demandtrig, reset, sourcedemand driver: pulls the next value from its demand source on each rising edge of trig, holds it between triggers; a rising reset restarts the source; 0 before the first trigger — see the demand note below
Dseqrepeats, v0, v1, …demand source (dr only): yields the value list in order, repeats times (≤ 0 loops forever), then signals end-of-stream; only valid as a Demand source
SendTrigin, id, valueon each trigger of in, sends /tr nodeID id value to /notify clients; output is silence — see the side-effect note below
SendReplytrig, replyID, value0, value1, …on each trigger of trig, sends a custom OSC message cmdName nodeID replyID value… (the cmdName is a static label field, default /reply); output is silence
Polltrig, in, trigidon each trigger of trig, posts label: value (the in value) to the server console (a static label field) and, when trigid ≥ 0, also sends /tr nodeID trigid value; passes in through as its output
FFTin, activeopens a spectral chain: windows in and transforms it to a spectral frame once per hop (active > 0 runs, ≤ 0 holds); static fields fft_size (default 1024), hop (fraction, default 0.5), wintype (default 0 = Hann); the window is also settable live via /u_cmd — see the FFT-chain note below
PV_MagAbovechain, thresholdpasses only bins whose magnitude is above threshold, zeroing the rest; chain is the wire from an earlier FFT/PV_*
PV_MagBelowchain, thresholdpasses only bins whose magnitude is below threshold
PV_BrickWallchain, wipebrick-wall band limit: wipe > 0 zeroes the top fraction of bins (low pass), wipe < 0 the bottom (high pass), 0 passes everything (wipe in −1..1)
IFFTchaincloses a spectral chain: inverse-transforms each fresh frame and overlap-adds it back to audio; fft_size/wintype are inherited from the chain's FFT (given only on the FFT)

Envelopes (EnvGen). EnvGen plays a breakpoint envelope, modelled on SuperCollider's. Its inputs are five fixed signals followed by a flat envelope array: gate, levelScale, levelBias, timeScale, doneAction, then initLevel, numSegments, releaseNode, loopNode, then four values per segmenttarget, duration, shape, curve. The output is envelope · levelScale + levelBias; timeScale stretches every segment's duration. A rising gate (re)triggers from initLevel; while the gate is held the envelope sustains at releaseNode (an index into the levels — hold that level until release); when the gate falls it plays the segments from releaseNode on. releaseNode < 0 disables the sustain, so the envelope plays straight through (a one-shot). With a loopNode (an index < releaseNode), the held phase cycles the segments in [loopNode, releaseNode) instead of holding a single level, carrying the level at the release node back as the loop's start; the release still plays out from releaseNode when the gate falls. loopNode < 0 disables looping. The shape numbers are 0 step, 1 linear, 2 exponential (needs same-sign, non-zero levels), 3 sine, 4 welch, 5 custom-curvature (bent by the curve value: 0 linear, positive starts slow, negative starts fast), 6 squared, 7 cubed, 8 hold. When the last segment finishes the UGen applies its doneAction — scsynth's full set (0–15), with the freeing done on the audio thread through the garbage FIFO, never a blocking free:

#action#action
0do nothing8free this synth and every following node in its group
1pause this synth (kept in the tree; resume with /n_run)9free this synth and pause the preceding node
2free this synth10free this synth and pause the following node
3free this synth and the preceding node11free this synth; if the preceding node is a group, deep-free it, else free it
4free this synth and the following node12free this synth; if the following node is a group, deep-free it
5free this synth; if the preceding node is a group, free all its children, else free it13free this synth and every other node in its group
6free this synth; if the following node is a group, free all its children14free the enclosing group (this synth included; if it is the un-freeable root, frees just the synth)
7free this synth and every preceding node in its group15free this synth and resume the following node

The relative actions (3–13, 15) resolve the node's previous/next sibling and head/tail-of-group from the tree's execution order. Any input can be a signal, so a control can drive the gate or scale the levels/times live. The Python client builds the array with the Env breakpoint helper (Env.adsr, Env.perc, Env.asr …, plus release_node/loop_node) and the env_gen callable.

Operator UGens (BinaryOpUGen/UnaryOpUGen). Rather than a distinct kind per math operation, one generic UGen per arity carries the operator as a static op field (alongside kind/inputs) — the operator's name. So {"kind": "BinaryOpUGen", "op": "mul", "inputs": [a, b]} multiplies. Every operator is one entry in clausters_core::builtins — the same code the client's value FFI runs off the audio thread — so a value a client computes ahead of time and the UGen on the audio thread are bit-identical for the native ops. A missing or unknown op fails the def with /fail naming the node. The Add/Sub/Mul/Div kinds remain as aliases for the add/sub/mul/div operators. (Internally each operator also has a stable integer id for the C ABI, but that is an implementation detail — defs and clients only ever use the name.)

Binary operators (BinaryOpUGen op): add (a+b), sub (a−b), mul (a·b), div (a/b), mod (a%b), pow (aᵇ), min, max, atan2, gt (a>b), lt (a<b), ge (a>=b), le (a<=b), eq, ne, bitand, bitor, bitxor, lshift, rshift, hypot (√(a²+b²)), ring1 (ab+a), ring2 (ab+a+b), ring3 (a²b), ring4 (a²b−ab²), sumsqr (a²+b²), difsqr (a²−b²), sqrsum ((a+b)²), sqrdif ((a−b)²), absdif (|a−b|), thresh (a<b?0:a), clip2 (clip to ±b), excess (a−clip2), round (to step b), trunc (to step b). Comparisons and bitwise ops follow Faust's convention (a boolean is 0.0/1.0; bitwise acts on the i32 casts).

Unary operators (UnaryOpUGen op): neg, abs, sin, cos, tan, asin, acos, atan, exp, exp10, log, log10, log2, sqrt, floor, ceil, rint (ties to even), as_int, as_float, squared (), cubed (), recip (1/x), frac, sign, sinh, cosh, tanh, midicps, cpsmidi, midiratio, ratiomidi, dbamp, ampdb, octcps, cpsoct, distort (x/(1+|x|)), softclip.

The tables are open — a new operator is one more clausters_core::builtins entry, no renumbering. The Python client maps its operators and math methods (%, min, >, .midicps(), .distort(), …) to these names automatically, and exposes mul_add/sum3/sum4 for the fused kinds.

Calculation rates (ir/kr/ar/dr). Every ugen output has a rate, chosen with the optional "rate" field or defaulted per kind. ar (audio rate) is one value per sample — the normal signal wire, and the default. kr (control rate) is one value per block, recomputed each block; downstream it reads as a constant over the block. ir (initial rate) is computed once, when the synth starts, then held for the node's life — a fixed SampleRate, a Rand seed, a BufFrames.ir snapshot; its inputs must themselves be constant/ir (nothing that varies can be frozen). dr (demand rate) is pulled, not run — see the demand note. Coercion is one-way: a slower rate feeds a faster input for free (a constant broadcast over a block), but an ar signal cannot feed an ir input, and a dr output may only feed a Demand source. Audio-rate controls are not a thing here — feed an audio signal through In/an input bus (as noted under /n_mapa).

Demand-rate sequences (Demand/Dseq). A demand ugen produces values only when pulled. Dseq is a source — a stream of numbers (repeats passes over a value list, then exhausted); it never runs in the normal per-block order. Demand is the driver that pulls it: on each rising edge of its trig it demands the next value and holds it until the next trigger (a rising reset restarts the stream; the output is 0 before the first trigger, and holds the last value once the source is exhausted). Wire a trigger source (an Impulse, a /n_set trigger later) into Demand's trig, and a Dseq into its source, to step through a sequence sample-accurately — the foundation the rest of the demand family (Dseries/Dwhite/Duty, later) builds on. This is a minimal driver today: end-of-stream is a held value, and one demand source per driver.

Side-effect UGens (SendTrig/SendReply/Poll). Some UGens exist for a side effect — an OSC reply or a console post — not for audio on a bus, and a def may contain only these with no Out at all (the server requires at least one UGen, never an Out). All three fire on a trigger: a signal crossing from ≤ 0 up to > 0. SendTrig replies /tr nodeID id value; SendReply replies at a custom address (its static label field, default /reply) carrying nodeID replyID value… for an arbitrary value list; Poll posts label: value to the server console and, with a non-negative trigid, also emits a /tr. The replies reach every client registered with /notify (SendReply's custom address is delivered verbatim). Mechanically the reply leaves the audio thread through a lock-free FIFO — the same discipline as the /n_go//n_end node events — so triggering never allocates or blocks; a burst of triggers beyond the per-block buffer is dropped (best-effort, like the node events). The id/replyID/trigid and values can be any signal (sampled at the trigger). The Python client builds them with send_trig/send_reply/poll and passes them as SynthDef roots.

Frequency-domain chain (FFT/PV_*/IFFT). Spectral processing bookends a chain of PV_* (phase-vocoder) UGens between an FFT and an IFFT: FFT windows an audio input and transforms it to a complex frame once per hop; each PV_* mutates that frame (only on the blocks a fresh one is ready); IFFT inverse-transforms and overlap-adds it back to audio. Wire them in order — FFT's output feeds the first PV_*, whose output feeds the next, and the last feeds IFFT — exactly like scsynth's chain. A def may of course also feed IFFT's audio into filters, Out, etc.

The chain is not block-rate: FFT/PV_* are control rate (kr, a per-block ready marker on the wire) and only do work on hop boundaries; IFFT is audio rate. The window size is a static field (fft_size, a power of two in 256/512/1024/2048/4096) given only on the FFT — the compiler propagates it (and the window type) to the rest of the chain, so PV_*/IFFT need no size. An unsupported size, or a PV_*/IFFT whose first input is not a spectral chain, fails the def with /fail.

Analysis and resynthesis use the same window (Hann by default), and the overlap-add is window-normalized (divided by the steady-state window-overlap denominator, COLA), so a plain FFTIFFT reconstructs the signal at unity gain, delayed by the transform latency (one window). The window type is settable live per instance with /u_cmd <nodeID> <ugenIndex> window <wintype> (-1 rectangular, 0 Hann, 1 sine, 2 Welch, 3 Hamming, 4 Blackman) — the first consumer of the typed per-UGen command surface.

Where the frame lives (deviation from scsynth). scsynth threads the frame through a client-allocated buffer whose bins the audio thread mutates in place, which would break Clausters' rule that a pool buffer is immutable once built. So the frame lives in synth-private scratch allocated when the synth is instantiated (like the LocalIn/LocalOut feedback buffer, and the moral equivalent of SuperCollider's LocalBuf), freed with the synth — no /b_alloc is required and the sample-buffer pool stays fully immutable. A future extension may add copying the frame into a buffer for inspection/sharing.

Feedback (LocalIn/LocalOut). The graph is a DAG — UGens cannot be wired in a cycle. To feed a signal back, write it with LocalOut and read it with LocalIn: they share a per-synth buffer that persists across blocks, so the value read is what was written one control block (64 samples) earlier. LocalIn for a channel must appear before its LocalOut (the compiler enforces this; it is what makes the delay exactly one block), and the channel index must be a constant. Use any number of channels (mono each, like buses). This is block-rate feedback — good for feedback delays, block feedback-FM, resonant combs (a one-channel loop resonates at sampleRate / 64). Sample-accurate (sub-block) feedback is not possible across composed UGens; fuse the loop into one node — a recursive UGen or a Faust def (/d_faust with ~).

Streaming disk I/O (DiskIn/DiskOut). These stream to/from disk in real time, so arbitrarily long files never touch the buffer pool (PlayBuf/BufRd load the whole file first). Each is self-contained: one background I/O thread plus a lock-free ring shared with the audio thread, opened at /s_new and closed when the synth is freed. The audio thread only pushes/pops the ring — never blocks. A ring underrun (disk too slow) plays silence; a DiskOut overrun drops samples; both are rare with the ~1 s ring. They carry extra static fields in the UGen spec (alongside kind/inputs): path (required), loop (DiskIn, default false), and format (DiskOut, int16|int24|float, default int16). Both are mono per UGen like the buffer readers: DiskIn extracts one channel (chan) — a stereo file is two DiskIns; DiskOut writes a mono WAV — record stereo with two DiskOuts. DiskIn streams one file frame per server sample with no resampling (pitch follows the sample-rate ratio, as in scsynth's DiskIn); these UGens spawn a thread each, so they are for a handful of streams, not per-voice. Example DiskOut UGen: {"kind": "DiskOut", "inputs": [{"ugen": 0}], "path": "/tmp/rec.wav", "format": "float"}.

Output happens exclusively through Out/ReplaceOut; a def without them is silent. Several synths with Out on the same bus mix. Bus-index inputs are ordinary signals, sampled at the first frame of each block and clamped to the valid range.

Buffer readers are mono (one output per UGen, unlike scsynth's multi-output PlayBuf): the chan input picks the channel, and two readers with the same inputs stay sample-locked, so a stereo file is two UGens. Neither has a trigger or done action yet.

Buffers (/b_*)

/b_alloc     bufnum frames [channels=1]                  # zeroed buffer
/b_allocRead bufnum path [fileStart=0] [numFrames=0=all] # shape from the file
/b_read      bufnum path [fileStart=0] [numFrames=-1=all] [bufStart=0]
/b_write     bufnum path [header="wav"] [format="int16"|"int24"|"float"] [numFrames=-1] [startFrame=0]
/b_zero      bufnum
/b_gen       bufnum cmd flags args...  # fill/generate — see the wavetable section
/b_free      bufnum
/b_close     bufnum                    →  /done /b_close bufnum   # see note
/b_query     bufnum...                 →  /b_info  bufnum frames channels sampleRate ...
/b_get       bufnum index...           →  /b_set   bufnum index value ...
/b_getn      bufnum [start count]...   →  /b_setn  bufnum start count value ...
/b_export    bufnum path               →  /done /b_export bufnum

/b_alloc, /b_allocRead, /b_read, /b_write, /b_zero, /b_gen and /b_free are asynchronous: the work happens on a dedicated NRT thread (one queue, so commands on the same buffer complete in submission order) and the reply is /done <cmd> bufnum or /fail <cmd> reason. Buffers keep the file's sample rate (the server never resamples — see PlayBuf's rate above); integer WAVs are scaled to ±1. /b_read requires an allocated buffer and keeps its shape; channel-count mismatches fail. Reading decodes by content, not extension: WAV goes through hound (exact, int24-aware), and FLAC, OGG/Vorbis, MP3, MP4/AAC, ALAC, AIFF and CAF decode through symphonia (whole-file decode, then slice — compressed formats have no cheap exact frame seek). /b_write still emits WAV only, and leaveOpen (streaming) is not supported. /b_close bufnum closes the soundfile a streaming buffer left open — scsynth pairs it with DiskIn/DiskOut; since Clausters has no streaming buffers yet (every /b_read//b_write reads or writes the whole file and closes it), it validates the buffer is live and replies /done /b_close bufnum, forward-compatible with the streaming UGens.

Table generation and the wavetable format (/b_gen)

/b_gen bufnum cmd flags args… fills an already-allocated buffer with a computed signal — additive spectra, a waveshaping curve, or a copy from another buffer. Like /b_read it reads the target's shape from the current contents, so a /b_gen right after a /b_alloc must be separated by a /sync (the alloc has to complete first). It runs on the same NRT queue and replies /done /b_gen bufnum.

The flags int packs three bits, normalize(1) + wavetable(2) + clear(4) — the usual value is 7 (all three). normalize scales the result to a peak magnitude of 1; clear starts from silence (without it the new signal is added on top of the buffer's current contents); wavetable stores the result in the interleaved wavetable format below (an N-sample buffer then holds N/2 period points). copy takes no flags.

cmdargsfills with
sine1flags, amp…additive sine partials; amp[k] is the amplitude of harmonic k+1
sine2flags, (freq amp)…partials at arbitrary (possibly fractional) harmonic numbers
sine3flags, (freq amp phase)…as sine2 with a per-partial phase in radians
chebyflags, amp…a waveshaping transfer function Σ amp[k]·T_{k+1}(x) of Chebyshev polynomials over x∈[−1,1] (amp[0] weights T₁, the linear/passthrough term); read by Shaper
copydstStart srcBufnum srcStart numSamplesoverlays numSamples of another buffer onto this one (numSamples < 0 = to the end of the shorter side)
envlevel0, (level time shape curve)…discretizes a break-point envelope across the whole buffer (see below); no flags

env fills the buffer with a break-point curve — the buffer-world form of an automation curve. The arguments are the same decomposition an EnvGen carries: an initial level0, then one (level, time, shape, curve) quad per segment, where shape is the envelope-shape number (0 step, 1 linear, 2 exponential, 3 sine, 4 welch, 5 custom-curve, 6 squared, 7 cubed, 8 hold) and curve is read only by the custom shape. Each output sample evaluates the segment it falls in through the same shared math (clausters-core's envshape) the EnvGen UGen plays, so a curve drawn or edited on a client (the bpf editor) and this buffer read identically. Segment times are relative — only their proportions matter, since the buffer holds the curve shape; playback rate maps it onto real time (e.g. a PlayBuf whose rate spans the buffer over the desired duration). The mono curve is written to every channel. A client reads it back onto a control bus to drive /n_map-ed controls.

The wavetable format. An interpolating oscillator (Osc/VOsc) reads a period stored not as raw samples but as scsynth's interleaved offset/slope pairs: for each point i the buffer holds [2·a[i] − a[i+1], a[i+1] − a[i]]. With the fractional phase frac∈[0,1), a sample is then one fused multiply-add — x0 + (1+frac)·x1 = a[i] + frac·(a[i+1] − a[i]) — with no branch. sine1/2/3 build periodic (wrapping) tables; cheby builds a non-wrapping one (it holds its endpoint, since a transfer curve is not periodic). A wavetable-format buffer is meant for Osc/VOsc/Shaper, not PlayBuf; a plain (non-wavetable) /b_gen buffer is a normal signal (read it with OscN or BufRd). This is the buffer-world counterpart of a Faust def's small embedded waveform table (see Tables and waveforms under Faust defs): the same idea — precompute a period or a transfer curve numerically — for the UGen graph instead of a JIT def.

/b_query, /b_get and /b_getn are synchronous reads, answered from the network-side buffer mirror (state as of the last completed command). /b_get reads single samples by flat (interleaved) index; /b_getn reads ranges, with count clamped to what the buffer holds from start — a request past the end returns only the available samples, and an unallocated buffer returns count 0. Sample indices are flat across channels (frame * channels + channel), so a stereo buffer reads as interleaved L R L R .... Large buffers are read in client-chosen chunks sized to the client's transport: over TCP/WebSocket a chunk may be as large as the /server_info frame ceiling (megabytes per round-trip), over UDP each reply must fit one datagram.

/b_export bufnum path writes the buffer's raw samples — flat, interleaved, little-endian f32 — to a local file and replies /done /b_export bufnum (or /fail on a missing buffer or write error). It is the bulk-data path for a same-machine consumer: where /b_getn chunks a buffer over the network, /b_export puts a multi-megabyte buffer in a file the consumer memory-maps and reads zero-copy, with no per-sample OSC. It is synchronous on the network thread (not the audio thread), reading the same mirror as /b_query. The reader pairs the file with the buffer's channel count (from /b_query) to de-interleave. (This is what the GUI host's mapped-path waveform reads.)

Faust defs (/d_faust)

/d_faust name payload — the payload is Faust source unless its first non-whitespace byte is {, in which case it is JSON: a box tree (root {"op": …}, below) or a signal tree (root {"signals": […]}, see JSON signal tree). All three are JIT-compiled (LLVM) on a dedicated compiler thread; expect the /done//fail reply a few milliseconds later.

Controls

A Faust synth exposes, in this order:

  1. every UI element of the def (hslider, vslider, nentry, button, checkbox) addressed by its label;
  2. two reserved names: out — first output bus (default 0, hardware left) — and in — first input bus, for defs that process signal. A def with N outputs writes (sums) to buses out .. out+N-1; same for inputs.
/s_new fsine 2000 1 0 freq 330 out 1
/n_set 2000 freq 660

If a def declares its own out/in control, the def's wins.

Faust source payloads

Any complete Faust program; import("stdfaust.lib") and friends resolve against the stdlib installed with libfaust (<prefix>/share/faust). Compilation is single precision (FAUSTFLOAT = f32) with -ftz 2 (recursive state below the normal float range flushes to zero — decaying tails cannot stall the audio thread in subnormal math), and the sample rate is fixed per instance at /s_new time.

/d_faust fsine 'import("stdfaust.lib"); freq = hslider("freq", 440, 20, 20000, 0.01); process = os.osc(freq) * 0.2;'

JSON box tree payloads

The JSON mirrors Faust's Box API one-to-one: every node denotes a box expression, and the tree is the process definition. Two shorthands: a JSON number is a constant box (int if integral, real otherwise), and the strings "_" / "!" are the wire and cut primitives. Everything else is an object with an "op" field:

opfieldsFaust equivalent
int, realvalueconstant
wire, cut_, !
seq, par, split, mergein: array of ≥ 2 boxes, folded left: , <: :>
recin: exactly 2 boxes~
add sub mul div fmod pow min max atan2 gt lt ge le eq ne and or xorin: exactly 2 boxesbinary operators
sin cos tan asin acos atan exp exp10 log log10 sqrt abs floor ceil rint round intcast floatcastin: exactly 1 boxunary functions
delayin: signal, delay length@
select2in: selector, then 2 branchesselect2
select3in: selector, then 3 branchesselect3
hslider, vslider, nentrylabel, init, min, max, stepnamed control
button, checkboxlabelnamed control (0/1)
hgroup, vgrouplabel, in: exactly 1 boxcontrol grouping
waveformvalues: non-empty array of numberswaveform{…} — outputs the (size, content) pair
rdtablein: size, init, ridx — or 2 boxes when a waveform stands in for (size, init)rdtable
rwtablein: size, init, widx, wsig, ridx — or 4 boxes starting with a waveformrwtable
faustsrcescape hatch: a complete Faust program compiled to a composable box, with stdlib access

Two consumers build this format today: the Python client's clausters.defs.boxes module (the box algebra as composable Python values; see the client book's defs chapter) and machine-generated graphs (the GUI host). faust fragments are memoized by src within one compilation: the same source text yields the same box, so a client that reuses one fragment value many times (duplicating the subtree in the JSON) gets one computation and one compile — every CDSPToBoxes evaluation would otherwise mint fresh recursion symbols and defeat the sharing (the CSE suite in tests/faust_box.rs pins this).

Example — sin(2π·phasor(freq)) * 0.2 with freq as a named control (wrap(x) = x - floor(x), phasor = (+(freq/SR) : wrap) ~ _):

{"op": "mul", "in": [
  {"op": "sin", "in": [{"op": "mul", "in": [
    6.283185307179586,
    {"op": "rec", "in": [
      {"op": "seq", "in": [
        {"op": "add", "in": ["_", {"op": "div", "in": [
          {"op": "hslider", "label": "freq",
           "init": 440.0, "min": 20.0, "max": 20000.0, "step": 0.01},
          48000.0]}]},
        {"op": "split", "in": ["_",
          {"op": "sub", "in": ["_", {"op": "floor", "in": ["_"]}]}]}]},
      "_"]}]}]},
  0.2]}

The faust op is the bridge to the stdlib — an embedded program becomes a box you can compose with primitives:

{"op": "seq", "in": [
  {"op": "faust", "src": "import(\"stdfaust.lib\"); process = os.osc(330);"},
  {"op": "mul", "in": ["_", 0.2]}
]}

Tables and waveforms

waveform embeds a small lookup table in the def itself: the client computes the values numerically — a wavetable period, a waveshaping transfer function — instead of formatting them into Faust source. The box outputs the (size, content) pair the table primitives expect, so the usual idiom is rdtable with just two inputs:

{"op": "rdtable", "in": [
  {"op": "waveform", "values": [0.0, 0.25, 0.5, 0.75]},
  {"op": "intcast", "in": [{"op": "and", "in": [
    {"op": "intcast", "in": [{"op": "sub", "in": [
      {"op": "rec", "in": [{"op": "add", "in": ["_", {"op": "int", "value": 1}]}, "_"]},
      {"op": "int", "value": 1}]}]},
    {"op": "int", "value": 3}]}]}
]}

rdtable also accepts the explicit (size, init, ridx) form with plain boxes, and rwtable (size, init, widx, wsig, ridx) is a table written and read at audio rate. Sizes must be constant expressions and indexes integers (intcast), as in Faust.

This waveform box is the Faust-side counterpart of /b_gen's wavetables (see Table generation and the wavetable format under Buffers): waveform inlines a small table inside one def for a self-contained JIT program, whereas /b_gen fills a server buffer shared by the whole UGen graph and read by Osc/Shaper. Same idea — precompute a period or a transfer curve numerically — at two different scales.

Soundfiles read server buffers. Faust's soundfile("<bufnum>", n) primitive binds to the server buffer whose index is its label (a plain integer string), e.g. soundfile("0", 1) reads buffer 0. At /s_new the instance's soundfile is filled from that buffer's current contents (deinterleaved to Faust's planar layout); a non-numeric label or an empty/missing slot yields a silent placeholder, so a def always instantiates. The primitive's outputs are [length, sampleRate, channel0 … channel_{n-1}] and the read index saturates at the part length, exactly as in stock Faust. The bind is a snapshot taken at instantiation — re-/s_new to pick up a buffer that changed; loading is mono-or-more by the buffer's own channel count (Faust reads up to n). For streaming a bus instead of a static buffer, the older path still works too: route a PlayBuf/BufRd through an audio bus and read it via the def's reserved in control, so both def families stay composable on the same buses.

Errors

Structural problems (unknown ops, missing fields, wrong arities in "in") fail during interpretation and the /fail message carries the path of the offending JSON node from the root $, e.g. at $.in[1].op: unknown op "mul3". Semantic errors — composition arity mismatches, dangling inputs — are reported by the Faust compiler verbatim, prefixed with the path of the fragment for faust ops.

JSON signal tree (the Signal API)

A third /d_faust format maps Faust's lower-level Signal API (Csig*) instead of the box algebra. It is selected by the shape of the JSON: a root object keyed by "signals" ({"signals": [ … ]}) is a signal tree, anything else starting with { is a box tree, and a non-{ payload is raw source. The "signals" array lists one node per DSP output (this is how a signal def declares more than one output).

Where boxes compose point-free, signals are explicit: there is no implicit wire ("_"), inputs are addressed by index ({"op": "input", "index": n}), delays are explicit (delay/delay1), and feedback is explicit — {"op": "recursion", "in": [body]} with {"op": "self"} inside the body (the CsigRecursion/CsigSelf pair, one implicit sample of delay). That single recursive node is sample-accurate feedback fused into one DSP — the thing the UGen graph's block-rate LocalIn/LocalOut cannot do.

opfieldsSignal API
int, realvalueCsigInt / CsigReal (a bare number works too)
inputindexCsigInput
delayin: signal, delayCsigDelay
delay1in: 1 signalCsigDelay1
recursionin: 1 body using selfCsigRecursion
selfCsigSelf (only inside a recursion body)
add sub mul div rem fmod remainder pow min max atan2 gt lt ge le eq ne and or xor lsh rshin: exactly 2 signalsbinary ops (bit ops/shifts need integer operands)
sin cos tan asin acos atan exp exp10 log log10 sqrt abs floor ceil rint intcast floatcastin: exactly 1 signalunary functions
select2, select3in: selector, then 2 / 3 signalsCsigSelect2 / CsigSelect3
hslider, vslider, nentrylabel, init, min, max, stepnamed control
button, checkboxlabelnamed control (0/1)
fconst, fvarctype: "int"/"real", name, file (optional)CsigFConst / CsigFVar — a runtime scalar resolved at instance init
hbargraph, vbargraphlabel, min, max, in: 1 signalpassive monitor (passes the signal through)
waveformvalues: non-empty array of numbersCsigWaveform (size is int(len))
rdtablein: size, init, ridxCsigReadOnlyTable
rwtablein: size, init, widx, wsig, ridxCsigWriteReadTable

Differences from the box schema: no seq/par/split/merge, hgroup/vgroup, the "_"/"!" shorthands or the faust source escape hatch (those are box/UI-tree concepts); round is absent upstream (rint rounds); N-ary mutual recursion (selfN/recursionN) is not exposed — like the box ~, single recursion is the surface. Errors carry the node path the same way (at $.signals[0].in[1]: …).

The sample rate enters the graph through fconst, not as a baked number: {"op": "fconst", "ctype": "int", "name": "fSamplingFreq", "file": "<math.h>"} is the runtime constant behind Faust's ma.SR, resolved when the def is instantiated, so a def stays in tune at whatever rate the engine (or NRT renderer) runs. ma.SR itself is that value clamped to [1, 192000]; the Python client wraps the whole thing as signals.sr(). (ma.PI, by contrast, is a plain numeric literal — no fconst needed.)

Example — a one-pole lowpass y = (1-a)·x + a·y' reading audio input 0, the explicit-feedback idiom:

{"signals": [{"op": "recursion", "in": [
  {"op": "add", "in": [
    {"op": "mul", "in": [
      {"op": "sub", "in": [1.0, {"op": "hslider", "label": "a",
        "init": 0.9, "min": 0.0, "max": 0.999, "step": 0.001}]},
      {"op": "input", "index": 0}]},
    {"op": "mul", "in": [
      {"op": "hslider", "label": "a",
        "init": 0.9, "min": 0.0, "max": 0.999, "step": 0.001},
      {"op": "self"}]}]}]}]}

GraphDef (/d_graph, /graph_new) — node-graph programs

A GraphDef is a third kind of persistent def. Where a SynthDef/FaustDef stores one synthesis node, a GraphDef stores a whole configuration of member nodes wired by buses — an effect chain, a mixer, a layered instrument — instantiated as one unit. It exposes a named parameter surface: ports that map to inner member controls (with optional scaling), so the running instance is driven through the port names, never the private member node ids. A GraphDef instantiates entirely into primitives the server already has (a group, member /s_news, /n_map wiring), so nothing new touches the audio thread.

/d_graph <blob|string> loads a GraphDef from a JSON spec: it validates the structure (cheap — no JIT) and stores it, replying /done//fail like the other def commands and persisting it when a data directory is configured. /d_free name... removes it (and SynthDefs/FaustDefs of the same name).

{
  "name": "chain",
  "buses": [{"name": "mix", "rate": "audio", "channels": 1}],
  "members": [
    {"def": "tone", "controls": {"out": "mix", "freq": 220.0}},
    {"def": "amp",  "controls": {"in": "mix", "out": "OUT"}, "maps": {"level": "lfo"}}
  ],
  "surface": {"gain": [{"member": 1, "control": "level", "mul": 1.0, "add": 0.0}]},
  "defaults": {"gain": 0.5}
}
  • members — each references an existing SynthDef or FaustDef by def (resolved at instantiation, both kinds identically), with initial controls. A control value that is a number is a literal; a string names an internal bus to wire that control to (its bus-selecting control — out/in on a Faust def, or whatever control feeds an Out/In UGen). The reserved string "OUT" wires to hardware bus 0. A member with "voice": true is a per-voice member (see below); the default (false) is a shared member.
  • buses — internal buses, private to each instance (rate "audio" or "control", channels default 1). They are allocated per instantiation from a reserved range at the top of the bus space — the top 32 audio buses and top 128 control buses (so 96..128 and 896..1024 at the default counts, shifting with --audio-buses/--control-buses) — so they never collide with client-allocated buses (the same idea as the reserved MIDI/auto node-id ranges). Two instances of one GraphDef get disjoint buses.
  • maps (per member, optional) — binds a member control to an internal control bus via /n_map.
  • surface — the named ports. Each maps to a list of {member, control} targets, with optional mul/add linear scaling of the incoming value. One port may drive several inner controls, each scaled differently (e.g. a freq port playing a detuned pair). This is the difference from a bare group /n_set, which can only broadcast one value to controls that happen to share a name.
  • defaults — surface-port values applied at instantiation, overridable per instance.

/graph_new name id addAction targetID [port value]... instantiates a GraphDef: it creates an auto-sorted group (the member execution order follows the bus connections) at id (or -1 for a server-assigned id), holding the shared members, then applies the shared defaults and the given port value overrides. /n_set id port value... on that group id resolves the port names against the surface (never the member ids); a port absent from the surface is ignored. /n_free id (or /g_deepFree) tears the instance down (with all its voices) and reclaims its private buses. Instantiation is atomic: a missing member def or bus shortfall fails with no partial instance. GraphDefs work in NRT scores too (scored like any def at time 0).

Shared vs per-voice, and /graph_voice

A GraphDef splits into a shared part (members without voice) and a per-voice part (members with "voice": true) — the model of a polyphonic instrument: the shared part (the private bus, a mixer, effects) exists once; each note adds a voice. A surface port maps either to shared members or to voice members, never a mix (a /fail at /d_graph otherwise): shared ports apply to the instance, voice ports to each voice.

/graph_new instantiates only the shared members. /graph_voice instanceID id [port value]... then spawns the per-voice members as a sub-group at the head of the instance (the auto-sort orders it before the shared mixer that reads its bus), wired to the same private buses, applying the voice-port defaults and overrides. /n_set voiceID port value... resolves against the voice's surface; /n_free voiceID frees just that voice. A /graph_voice on an instance whose def has no voice members (or on an unknown instance) /fails.

A GraphDef can be bound to MIDI exactly like a SynthDef/FaustDef: /midi_bind channel graphname [target addAction gate] spawns the shared instance once at bind time and every note becomes a /graph_voice into it (note → freq port, velocity → amp port, note-off frees the voice or, for a gate-aware binding, sets its gate port to 0). The GraphDef must have per-voice members. /midi_unbind frees the shared instance (and all its voices).

MIDI control protocol (standard channel-voice actuation)

Besides OSC, the server can be driven by standard channel-voice MIDI — note on/off, velocity, aftertouch, pitch-bend, control change, program change. This is the primary MIDI path: a note actuates a synthesis node and an expressive message sets a named control, exactly the surface a sequencer or DAW already speaks. (SysEx, when it lands, is reserved for the non-musical control plane — def load, buffers, topology — and is never a tunnel for OSC commands.)

A channel must first be bound to an instrument. Binding and mapping are OSC commands (so they ride the same reliable path as the rest of the protocol); the note/control events themselves arrive over the OS's standard MIDI. Start the server with --midi [name] (RT only) to open a virtual ALSA input port (default name clausters) — the same system MIDI any controller or DAW uses; route anything into it (aconnect, a keyboard through the kernel, a DAW). Live input is MIDI 1.0 (7-bit, widened internally to the high-resolution form); the full MIDI 2.0/UMP resolution is preserved on the client's persistence path (the clausters-midi crate's MIDI 2.0 clip file). Network MIDI is a separate, deliberately out-of-scope idea.

  • /midi_bind channel instrument [target] [addAction] [gate]: bind a MIDI channel (0255: the classic 16 plus the extended UMP group×channel space) to an instrument def — a SynthDef, a FaustDef, or a GraphDef, actuated identically. Default target is the root group (0), default add-action 0 (head). gate non-zero marks the def gate-aware (see note off below). The default control map is freq/amp, matching the client Event convention. A GraphDef instrument spawns its shared instance at bind time and turns each note into a /graph_voice (see the GraphDef section); it must have per-voice members.
  • /midi_unbind channel: remove the binding and free every voice still sounding on that channel.
  • /midi_map channel selector name: route a message type to a control. Selectors: note (→ frequency control, default freq), vel (→ amplitude, default amp), gate (gate control name), bend (pitch-bend → control), pressure (channel aftertouch → control), poly (per-note aftertouch → the note's voice), ccN (control change number N → control), progN (program N → an instrument def name to switch to).

Actuation semantics, per message type (each with its named conversion):

  • Note on/s_new instrument <voiceID> addAction target freq <midi2freq(note)> amp <velocity2amp(vel)>. Voice IDs come from a reserved server-side range (≥ 3_000_000), disjoint from client IDs and the /s_new -1 auto range. A note-on for an already-sounding (channel, note) frees the old voice first. Velocity 0 is a note-off.
  • Note off/n_free <voiceID>, or /n_set <voiceID> <gateControl> 0 when the binding is gate-aware.
  • Poly aftertouch/n_set on that note's voice; channel aftertouch, control change, pitch-bend/n_set on every live voice of the channel, for the mapped control. Unmapped expressive messages and unbound channels are silently ignored (a running MIDI stream never errors).
  • Program change → re-selects the channel's instrument def from its prog-mapped table (no-op if unset).

Conversions (note → midi2freq, velocity → velocity2amp, aftertouch, bend, cc, program) take MIDI 2.0 / UMP resolution (16-bit velocity, 32-bit controllers/pressure/bend) and produce the f32 a control zone wants — no 7-bit quantization. MIDI 1.0 is backward-compatible: classic 7/14-bit input is accepted and widened up to those, so the same controls are driven either way. Because a MIDI voice is realized as the same /s_new//n_set//n_free an OSC client would send, it is byte-identical to the OSC equivalent (tests/midi.rs guards this).

Node tree introspection

The node tree is delivered to clients as structured replies — never scraped from the server's logs. Three queries (all answered by the network-side tree mirror, so they never touch the audio thread):

  • /g_queryTree <groupID> [flag]/g_queryTree.reply — the whole subtree from groupID (use 0 for the root), scsynth-compatible. Args: flag, the group and its child count, then depth-first per node: ID and child count (-1 marks a synth), the def name for synths, and — when flag is 1 — the control count followed by (name|index, value) pairs.
  • /n_query <nodeID>... → one /n_info per node — per-node detail beyond the tree shape. Layout: nodeID, parentID, prevID, nextID, isGroup; then for a group headID, tailID (-1 if empty); for a synth defName, control count + (name|index, value) pairs, map count + (controlIndex, bus, audio) triples (the /n_map//n_mapa bindings), and the inferred reads/writes bus lists as two strings ("0,16", or "-" when none). Siblings are -1 when absent.
  • /g_dumpGraph <groupID>/g_dumpGraph.reply [groupID, text] — a human-readable rendering of the inferred bus graph (what each child reads/writes and the current order). A debugging aid; for machine use prefer /g_queryTree//n_query.

The Python client wraps these as Server.query_tree() (nested dict), Server.node_query() (per-node dict) and Server.dump_graph() (string).

Server logging and verbosity

The server logs to stderr through tracing, at five levels (error, warn, info, debug, trace). The startup banner and the NRT render summary go to stdout (they are program output, not logs). The audio thread never logs: it reports conditions over the lock-free FIFOs and the network thread emits them, so logging never breaks real-time safety.

The level is set, in increasing precedence, by:

  • the CLI flags -v (info), -vv (debug), -vvv (trace), -q (errors only); default is warn;
  • the RUST_LOG environment variable, an EnvFilter directive that also filters per module — e.g. RUST_LOG=clausters::osc=trace to see only OSC traffic;
  • at runtime, from a client, with two OSC commands (both reply /done):
    • /verbosity <int|string> — an int level (-1 errors … 3 trace) or an EnvFilter directive string. Lets a client retune the server's logs without restarting.
    • /dumpOSC <flag> — toggles the OSC-traffic dump (the clausters::osc trace target). Unlike scsynth, this is not an ad-hoc console print: it routes through the same logging system, controllable by /verbosity/RUST_LOG, on stderr.
    • /error <mode>1 posts command failures to the server console (default), 0 silences them. The /fail OSC reply is always sent regardless (clients rely on it); /error only gates the server-side console logging. scsynth's bundle-local -1/-2 forms are not separately supported — the persistent 0/1 toggle is the model that fits our logging (a deliberate deviation).

Note that these control the server's own logs (on the server's stderr); the node tree is delivered to clients as structured data, never scraped from logs — see Node tree introspection above (/g_queryTree, /n_query, /g_dumpGraph).

Server and UGen commands (/cmd, /u_cmd)

Two extension commands carry out-of-band instructions that are neither node nor bus state. Both are typed and discoverable — the deliberate replacement for scsynth's untyped /cmd//u_cmd argument blobs (a command name plus validated typed args, errors naming the offending field, like compile).

  • /cmd <name> args... — a server-wide command. name selects a handler; the built-in ping replies /done /cmd ping (it proves the surface). An unknown name replies /fail /cmd "unknown server command …". New server commands register here.
  • /u_cmd <nodeID> <ugenIndex> <name> args... — a command addressed to one UGen instance inside a synth. It validates the node is a UGen synth and ugenIndex is in range (a Faust synth is one opaque block, not a UGen graph, so it is rejected), packs the numeric args inline (up to 8, so nothing heap-allocated crosses to the audio thread), and routes them to that UGen on the audio thread; the command name is hashed to a stable selector both sides agree on. The first real consumer is the FFT chain: /u_cmd <nodeID> <ugenIndex> window <wintype> swaps an FFT/IFFT's analysis/synthesis window live (see the FFT-chain note above). A UGen that does not recognize the command name ignores it, so a /u_cmd to a valid target is otherwise accepted silently.

Persisting defs across restarts

The real-time server can persist loaded defs to a data directory and reload them automatically on the next start, so a client need not re-send its instrument library every session. It is on by default; control it with two flags:

clausters --data-dir <dir>   # where defs are stored/reloaded
clausters --no-persist       # disable for this run

With no --data-dir, the directory is $CLAUSTERS_DATA_DIR if set, else $XDG_DATA_HOME/clausters, else ~/.local/share/clausters. Persistence applies to the real-time server only; offline --nrt renders never read or write it.

A client can also load defs on demand from an arbitrary path, complementing the boot-time reload: /d_load <path> loads one SynthDef spec file (the Clausters def format — the same SynthDefSpec JSON /d_recv carries), and /d_loadDir <dir> loads every *.json SynthDef in a directory (a single unreadable/invalid file fails the whole command, naming it). Both compile through the /d_recv path (so the def is also persisted under its name) and reply /done. GraphDefs load through /d_graph, Faust defs through /d_faust.

The def kinds live in subdirectories of a defs/ directory (so the data directory itself is free for other persistent aspects); midi.json and boot.json sit at the top level:

pathwritten oncontent
<dir>/defs/synthdefs/<name>.json/d_recvthe SynthDefSpec JSON, verbatim
<dir>/defs/faustdefs/<name>.json/d_fausta record: the original Faust source/JSON, the libfaust version, and the payload's SHA-256
<dir>/defs/faustdefs/<name>.<sha>.bc/d_faustthe compiled LLVM bitcode (a speed cache)
<dir>/defs/graphdefs/<name>.json/d_graphthe GraphDefSpec JSON, verbatim
<dir>/midi.json/midi_bind//midi_unbind//midi_mapthe MIDI bindings (channel → instrument + target + control map)
<dir>/boot.jsonauthored by the user/clientthe boot preset: standalone GraphDefs to instantiate at startup

GraphDefs reload after the synth/faust defs (their members reference those names); validation is structural only, so a member def that is still missing at load is caught later, at /graph_new.

MIDI-standalone: bindings + boot preset

So the server can be played from a MIDI controller with no OSC programming at all, the MIDI bindings persist too. Every /midi_bind//midi_unbind//midi_map rewrites midi.json; at startup — after the defs and GraphDefs are in place, so a binding's instrument name resolves — each binding is re-established (a GraphDef binding re-instantiates its shared instance). The minimal workflow becomes: drop a SynthDef/FaustDef (or a GraphDef) and a binding in the data dir once, then every later clausters --midi --data-dir <dir> comes up already bound — connect a controller (aconnect) and play. The default control map (note→freq, velocity→amp, note-off→/n_free/gate) makes a restored binding immediately playable.

boot.json is an optional, user-authored boot preset: a JSON array of standalone GraphDefs to instantiate at boot (an always-on reverb bus, a drone, a mixer), each {"graph": "<name>", "ports": {"<port>": value, ...}}. They are instantiated (the equivalent of /graph_new <name> -1 0 0 ...) after the bindings, so a fresh boot comes up already wired. The boot order is defs → graphdefs → bindings → boot preset. All of it honours --no-persist (off → nothing read or written) and the data-dir resolution; it applies to the real-time server only (NRT never persists).

The stored definition (the JSON) is always the source of truth: it is transparent, human-readable, and what gets recompiled. The Faust .bc is a non-authoritative cache — on reload the server re-creates the factory from bitcode (skipping Faust's front-end) only when the libfaust version still matches and the file is intact; otherwise it silently recompiles from the source and rewrites the cache. A libfaust upgrade therefore invalidates every .bc automatically. /d_free <name> deletes both files. Re-sending a name overwrites them.

Reloading is incremental: the socket starts serving immediately and the library loads in the background on the compiler thread, so a large Faust library does not delay startup — a def simply "does not exist yet" until its reload finishes.

Generating defs programmatically

examples/json_client.py (Python, stdlib only) builds all three formats with a few helper functions and drives the whole lifecycle over OSC — use it as a reference client. The equivalence of the families is pinned down by the golden tests in tests/faust_parity.rs: a UGen graph, the box translation and the signal translation render side by side in one engine and must agree (bit-exactly for stateless arithmetic on shared input, within float tolerance for oscillators, since SinOsc accumulates phase in f64 and Faust in f32).

The GUI protocol (/gui_*)

The GUI host is a separate peer, not part of the audio server: it owns the windows, the widgets and the GPU, and a script drives it over OSC — the same encoding the audio server speaks, only the vocabulary differs. Its default port is 57210 (clear of the audio server's 57110/57120), on UDP and TCP alike: like the audio server, the host accepts length-prefixed OSC over TCP by default (--no-tcp disables it, --max-frame sets the frame ceiling, default 16 MiB), and the Python GuiHost connects over TCP by default — so a /gui_def tree with its blobs, the largest payload in the system, is not bounded by a UDP datagram.

This page is the wire reference. The why behind it — the host's two roles, the declarative protocol, the GPU substrate, the composition views — is in Clients and language bindings; its internals and the recipe for adding a widget are in Architecture.

Commands

MessageMeaning
/gui_def id json [blob…]Build a whole widget tree in one message. json is the GuiDef document (below); trailing blobs carry bulk data a widget references by index. Re-sending an existing id redefines it (the old subtree is freed first), exactly as re-sending a SynthDef replaces it.
/gui_set id key value …Update one live widget's properties. Types are preserved (an OSC int stays an int). A value that is logically an array (a curve's break-points, a patch's wires) rides as its JSON string, since an OSC key/value is a scalar.
/gui_free idDestroy a widget and its subtree. Freeing a window-rooted def closes its window.
/gui_query idAsk for a widget's state. Replies /gui_info id type key value …; an empty type ("") means no such widget — the host answers either way, as the audio server replies even on a miss.
/gui_bind id "server" address prefix…Forward this widget's value straight to the audio server, bypassing the script: on every change the host sends address with the fixed prefix arguments followed by the value (e.g. "/n_set" 1001 "freq" makes the widget send /n_set 1001 freq <value>). A bound widget stops emitting /gui_event.
/gui_bind id(no target) Remove the binding; the widget emits events again.
/gui_load nameInstantiate a persisted GuiDef by name (the host replays it as its saved /gui_def). Needs a data directory.

There is no save command: a GuiDef whose root carries a name prop is persisted on /gui_def, the way a named def is persisted on /d_recv. That is what lets a host boot a whole interface with no script attached (the standalone path).

The GuiDef document

One tree, one document — mirroring SynthDef/GraphDef. Every node is:

{"id": 10, "type": "slider", "min": 0.0, "max": 1.0, "label": "gain",
 "children": []}
  • type names the widget; every other key is a property of it.
  • id addresses the node for /gui_set, /gui_free, /gui_query, bindings and events. The root's id is the one given to /gui_def.
  • children nests (containers only: window, panel, track).
  • bind as an inline prop registers a binding declaratively, so a saved GuiDef carries its own (no separate /gui_bind at boot).

The wire form is deliberately generic ({id, type, props, children}): a new widget kind never changes the protocol, and a host that does not know a type lays it out but does not paint it — old hosts and new scripts still interoperate.

Bulk data (waveform samples, a peak cache) never rides the JSON: a widget names a local path/cache the host maps (or fetches, in a browser), a server buffer it pulls over its client leg, or — for small bodies only — a trailing blob.

Events

The host pushes back to the script that built the window:

MessageMeaning
/gui_event id <value>A control changed: a float (slider/knob/number), an int (toggle 0/1, menu index, button press), or a string (text).
/gui_event id <tag> <flat values…>A view wrote data back. The tag names what was edited; the values are flat OSC primitives (never a new address — see below).
/gui_closed idThe window was closed by the user.

The edit-back payloads:

TagArgumentsSent by
"points"t v shape curve per break-pointthe bpf editor, and an automation clip — one payload, whichever view drew the curve
"notes"start dur pitch velocity channel per notethe pianoroll view (and a clip's roll) — MIDI notes edited
"osc"time label per eventthe pianoroll view — OSC event markers edited
"clip"offset dur (timeline units)a clip moved or resized
"wire"member control bus (an empty bus = unwired)a graph patch rewired
"locate"position (timeline units)a lane's time ruler (or its empty space) clicked — the transport is being seeked there
"selection"start len (samples)a selection dragged on a timeline view
"view"start len (samples)the shared navigation window zoomed or panned
"view_y"start len (0..1)the vertical display window zoomed or panned

Edited data flows as a payload, never a new address: the /gui_* family does not grow per widget.

The widget catalog

The authoritative per-widget reference — every property, its default and its meaning — is the Python client's builder documentation, since that is how a script actually names these. The catalog itself:

TypeWhat it isNotable properties
windowA top-level window (a GuiDef root)title, w, h, layout
panelA nestable containerlayout
labelStatic texttext
knob, slider, numberContinuous controlsmin, max, value, label (vertical on a slider)
button, toggleMomentary / latchinglabel, value
text, menuA string field, a choicevalue / options, index
meterA control-bus level, read from the server's shared segmentbus, min, max
scopeAn oscilloscope: a control bus, or an audio tapbus / tap, trigger, hold
phasescopeA goniometer (stereo field) over two tapstap, tap2, hold
spectrumA live spectroscope over a taptap, fft_size, averaging
nodetreeThe server's node graph, livegroup, controls
waveformThe editor-grade waveform: multichannel lanes, rulers, selection, playhead, linked navigationthe data (data/blob/buffer/path/cache), channels, ruler, ruler_y, sel_*, playhead_at, link, offset
spectrogramThe editor-grade spectrogram, the same chromethe data, window_size, hop, freq_scale, db_floor/db_ceil, colormap
bpfA drawable break-point envelope, played by the server's own shape mathpoints, min, max, duration, exp
pianorollThe editor-grade piano-roll: a keyboard, a MIDI-note grid, a velocity lane and an OSC-event lane; the same chrome and navigation as the heavy viewsnotes (start dur pitch velocity channel quintuples), osc (time label pairs), min/max (pitch window), snap, velocity, osc_lane, midi_in (live MIDI painting: the native host opens its virtual input port and paints incoming notes — at the running playhead, or step-entry on the snap grid), ruler, sel_*, playhead_at, y_start/y_len, link
plotA static plot of a signaldata/blob/path, min, max
trackA multitrack lane, holding clip children on the window's shared time axislabel, height, snap, ruler, tempo, sample_rate, playhead_at, playhead, link
clipA placed rectangle spanning [offset, offset + dur] — the graphic unit. Its bodies layer: a take, a piano-roll of events, and an automation curve over themoffset, dur, the take (buffer/path/cache/data/blob), notes, points (+ points_min/points_max, the curve's own value axis), min, max, label
graphA patcher of a bus-wired node graph: member boxes, bus nodes, a wire per connectionmembers, buses, wires, label
canvasA script-supplied WGSL shader over the widget areashader, params, buses

A timeline view (and a lane) shows the transport two ways, and they are different things: playhead_at anchors the line to the engine clock (it is the clock value at timeline position 0, so the line sweeps as the audio runs), while playhead is a static cursor — where a located, stopped transport sits. Both are group-wide, so every lane shows the one cursor; a negative value is none. Clicking a lane's ruler moves the cursor and sends "locate".

The lanes of a window share one navigation group: they zoom, pan and carry a playhead as one, and the axis spans the composition (the longest clip end). The same group model links the heavy views — an explicit link id joins or splits it, and a /gui_set of view_*/sel_*/playhead_at on any member applies group-wide.

The sample clock as client timebase (/clock and /sched)

Clausters extends the scsynth protocol with a second way to time events: scheduling directly on the server's sample clock instead of on NTP wall-clock timetags. scsynth has no equivalent — its clients schedule via NTP plus a fixed latency margin and live with the drift.

Why

The OS clock and the audio device's crystal are two different oscillators; they drift apart by tens of ppm — milliseconds per minute. An NTP-timetagged bundle is converted to a sample position on arrival, so every bundle re-anchors against two clocks that disagree: a melody scheduled seconds ahead lands cleanly, but long-running patterns slowly slide relative to the audio, and two clients' grids slide relative to each other.

Inverting the relationship removes the problem at the root: the client asks the server where its sample counter is, models that counter against its own monotonic clock, and addresses every event by absolute sample number. The audio clock becomes the master; the OS clock is just the thing the client uses to decide when to send.

Protocol

Two messages, coexisting with the NTP path (both feed the same engine queue — NTP-scheduling clients and sample-scheduling clients can talk to the same server simultaneously):

/clock                    →  /clock.reply  h <samples>  d <sampleRate>  t <oscTime>
/sched  h <target> b <packet>
  • /clock replies with the engine's sample counter (int64 h: samples processed since the server started), the actual device sample rate (double d) and the server's OSC/NTP time captured together with the counter (timetag t). The counter is the same clock NTP bundles are converted to. The (oscTime, samples) pair is an anchor: it ties the server's sample axis to OSC time at one instant, so a client can convert a logical OSC time T to a sample with sample ≈ samples + (T − oscTime)·rate and place events on the server's clock without first modelling the counter against its own local clock — the basis for one server acting as a master clock for several clients sharing one drift-free sample axis. Clients that only need the older two-field form ignore the trailing timetag.
  • /sched schedules a complete OSC packet (the blob) to execute atomically at the absolute sample target — sample-accurately, the engine splits the processing block at that exact frame, like a timed bundle. The blob may be a single message or a bundle; all its leaf messages fire at the target and any timetags inside the blob are ignored: one /sched is one instant. The schedulable command set is the same as for timed bundles (/s_new, /n_set, /n_free, /n_before, /n_after, /g_new, /g_freeAll, /g_deepFree, /c_set); anything else replies /fail naming the offending message. Past targets execute at the start of the next block, like late NTP bundles. An i (int32) target is tolerated for hand-written clients, but real targets outgrow int32 in under 13 hours at 48 kHz — use h.

Why a container message instead of a special timetag: the OSC bundle timetag is NTP-formatted by specification, so reinterpreting it would silently break every standard client. /sched keeps the two timebases explicit and per-event.

/sched is itself not schedulable inside a timed bundle (it is the scheduling), and neither /clock nor /sched are valid in NRT scores — score timetags are already converted to exact sample positions offline, so the offline world needs no clock model at all.

The client model

The reference implementation is examples/sample_clock.py (Python, stdlib only). The recipe:

  1. Anchor: read the local monotonic clock (t0), send /clock, read it again (t1) on the reply. Pair the returned counter with the midpoint (t0+t1)/2; the half-width (t1−t0)/2 bounds the anchor's error.
  2. Model: fit sample(t_local) = a + b·t_local over a sliding window of anchors — least squares with forgetting, in the spirit of JACK's DLL and Ableton Link. Until there are two anchors, use the nominal sample rate as the slope.
  3. Schedule ahead: convert musical time to sample targets once (step = round(beat_seconds × rate)) and send each event's /sched comfortably before the model says its target will be reached.

Two properties make this robust where it sounds fragile:

  • Query latency does not matter. The anchor only needs bounded uncertainty, not low latency, because nothing fires at query time — events are scheduled ahead. An anchor error shifts the entire grid by a constant; it cannot accumulate.
  • Relative timing is exact by construction. Two targets N samples apart fire exactly N samples apart, forever — no clock conversion sits between scheduled events. The OS clock's drift only affects how early each /sched is sent, which the scheduling margin absorbs.

A shared transport (phase alignment)

The sample clock gives every client a common, drift-free sample axis, so two clients locked to one server schedule on the same timeline. It does not, by itself, put them in phase: each client's routine still starts whenever it starts, at an arbitrary point on that axis. Aligning several clients on the same beat needs a shared beat grid — an origin and a tempo everyone agrees on.

/transport is that grid, hosted on the server so any client can join it. It also carries a DAW-style rolling state — whether it is playing and the song position:

/transport                                →  /transport.reply  h <originSample>  d <tempo>  i <defined>  i <playing>  d <position>
/transport  h <originSample> d <tempo>    →  /done  "/transport"          (set the grid, stopped at 0)
/transport_play  [d <position>]           →  /done  "/transport_play"     (start rolling)
/transport_stop                           →  /done  "/transport_stop"
/transport_locate  d <position>           →  /done  "/transport_locate"   (set the song position)
  • With no arguments /transport queries the grid; defined is 0 until a client has set one. The playing/position fields are appended, so a client reading only the first three still works.
  • With (originSample, tempo) it sets the grid (last writer wins), stopped at position 0. Beat b maps to sample originSample + b·rate/tempo, so a client joins by reading the grid and quantizing its routine's start onto the next beat boundary. Because the grid lives on the sample axis, the alignment is sample-exact for clients locked to the master.
  • /transport_play//transport_stop//transport_locate drive the rolling state (a conductor): play from a song position, stop, or seek. Each needs a grid defined first.

Every change is pushed to every /notify client as a /transport.reply, so a client with a responder on it re-aligns or rolls its playhead live — no polling. The server stores, serves and broadcasts the transport but never schedules audio from it — it is shared control, not a sequencer; each client rolls its own playhead on the shared grid. It is in-memory (the sample counter resets on restart, so an origin only means anything within one run) and ownership is last-writer-wins.

Caveats

  • The counter counts processed samples, not heard ones: it runs one device buffer (plus the output latency) ahead of the speakers. For aligning with the outside world (recording overdubs, syncing to video), add the device latency; for pure scheduling it cancels out.
  • The counter pauses on xruns (no samples are processed). Periodic re-anchoring absorbs this — another reason to keep anchoring in the background rather than anchoring once.
  • The counter advances in device-buffer jumps (the callback processes its blocks in a burst), so a single anchor sees up to a buffer of quantization. Like query latency this is bounded noise: it widens the slope estimate until the anchor window spans a long baseline (real crystal drift, tens of ppm, needs minutes to resolve), but it never affects when a scheduled event fires.

Auto-sorted groups (/g_sortMode, /g_queryTree, /g_dumpGraph)

In scsynth, execution order is the client's problem: a node reading an audio bus must be placed after the nodes writing it, by hand, with add actions and /n_before//n_after — get it wrong and you hear silence or a one-block delay. Clausters can instead infer the dependency graph from the bus connections themselves and keep a group sorted automatically, so groups behave like the channels of a multitrack editor: drop nodes in, the server wires the order.

Commands

/g_sortMode  groupID mode      # 1 = auto-sort, 0 = back to manual
/g_queryTree [groupID] [flag]  →  /g_queryTree.reply  (scsynth format)
/g_dumpGraph [groupID]         →  /g_dumpGraph.reply  (debug string)
  • /g_sortMode groupID 1 sorts the group's children right away and re-sorts on every change that can affect the order: a node added or removed, a def's bus retargeted via /n_set, a subtree moved in. The root group (0) is allowed. Inside an auto group, manual /n_before//n_after reply /fail — ordering is the group's job now; mode 0 hands it back. All of it is schedulable in timed bundles and usable in NRT scores.
  • /g_queryTree [groupID = 0] [flag = 0] replies the scsynth-style tree listing: flag, the group, its child count, then depth-first per node the ID and child count (-1 marks a synth), the synth's def name, and — with flag = 1 — control count and (name, value) pairs.
  • /g_dumpGraph [groupID = 0] replies a human-readable view of what the analysis inferred: per child, the buses read and written and whether it is dynamic (see below).

How the analysis works

Per def, the server records which audio buses each node reads (In, a Faust def's in..in+inputs) and writes (Out, ReplaceOut, a Faust def's out..out+outputs):

  • A bus index that is a constant or a control is static. Controls use the node's current value — an /n_set on a control used as a bus index re-analyzes and re-sorts on the spot.
  • A bus index computed by a signal (a UGen wire) cannot be analyzed: the node is marked dynamic and acts as a conservative barrier — it keeps its position and nothing is sorted across it.

A node that writes a bus another reads must run first; that is the whole DAG. ReplaceOut counts as read+write (it consumes what is on the bus), so an insert fx (In 16 … ReplaceOut 16) lands after the sources summing into 16 and before the readers. Pure summing writers to the same bus get no edge between each other — mixing commutes.

Two things the sort deliberately does not do:

  • Cycles are not "solved". Legitimate feedback (read-before-write across two nodes) keeps the nodes' current relative order, which means one block (64 samples) of feedback delay — exactly like a return send in a multitrack editor. Chained insert fx on one bus are a cycle by this definition too, and they correctly keep their insertion order.
  • Nothing crosses a dynamic barrier, even when the static part of the graph would want to. If you need dynamic bus routing and auto-sorting, isolate the dynamic node in its own (manual) group.

Sorting is per group, treating each child as a unit: a child group counts as the union of its whole subtree's reads/writes. Nested auto groups sort themselves independently.

Mechanics and caveats

Everything runs on the network thread against a mirror of the node tree; re-sorts reach the audio thread as ordinary node moves, so the engine (and its real-time guarantees) is untouched. The mirror reflects commands as sent: messages inside a not-yet-fired timed bundle are mirrored immediately, so /g_queryTree can briefly show a future state, and a re-sort racing a scheduled bundle converges at the next change. Order inside an auto group is a consequence of the analysis — clients should stop carrying add-action logic for ordering and just use head/tail.

python3 examples/auto_order.py demonstrates the whole flow: a source → fx → master chain built deliberately backwards, silent in a manual group, audible the moment /g_sortMode 1 arrives, with the inferred graph printed before and after.

Parallel groups (/g_parallel and --workers)

A graph that no longer fits in one core can run its independent parts on several: Clausters can process the children of a marked group in parallel stages derived from the same bus analysis that powers the auto-sorted groups (auto-order.md). The result is the analogue of supernova's ParGroup, except the independence is inferred and verified by the engine instead of promised by the user — a wrong declaration cannot corrupt audio, it just serializes.

Usage

clausters --workers 3                       # real-time server, 3 DSP threads
clausters --nrt score.osc out.wav --workers 3   # faster offline renders too
/g_parallel groupID mode    # 1 = process children in stages, 0 = strict order

Without --workers (the default) everything stays sequential; /g_parallel is then a no-op flag, accepted and remembered. The flag is schedulable in timed bundles and valid in NRT scores, like /g_sortMode.

The layout that benefits is independent chains: subgroups (voices, mixer channels) under one parallel group, each chain writing its own buses. Each child of the parallel group — synth or whole subgroup — is one unit; units that touch disjoint buses run concurrently.

How stages are formed

Per processing block the engine scans the group's children in order, batching them greedily into a stage while each next unit:

  • writes nothing the stage already reads or writes, and
  • reads nothing the stage writes.

A conflicting unit closes the stage and opens the next one, so anything that must be ordered (two writers summing into the same bus, a reader after its writer) is automatically serialized — same-bus mixing keeps the child order, exactly like sequential execution. A dynamic unit (a bus index computed by a signal: the engine cannot know what it touches) always runs alone. Nested parallel groups dispatched to a worker run sequentially inside it (parallelism does not nest in v1).

The masks come from the same per-def analysis as the auto-sorted groups (In/Out/ ReplaceOut and the Faust reserved in/out buses, constants and controls; /n_set on a control used as a bus index re-ships the masks), but the copy the scheduler trusts lives in the engine, shipped with each synth — stage safety never depends on network-side state.

Determinism

Parallel rendering is bit-identical to sequential rendering. Stage members touch pairwise disjoint buses and read nothing the stage writes, so their results are independent of worker interleaving; stages and the serialized conflicts preserve child order. tests/parallel.rs pins this down for live engines and NRT renders, including across an /n_set that retargets a bus. Goldens, the RT/NRT sample-identity guarantee and the --workers flag therefore compose: workers only change wall-clock time.

Workers and the real-time path

--workers N spawns N DSP threads at startup. The audio thread (the conductor) publishes each stage with a few atomic stores, the workers and the conductor race through it with an atomic work-stealing cursor, and the conductor spin-waits (bounded, no locks) for completion. Idle workers spin briefly, then yield, then park; waking a parked worker costs one unpark syscall when audio resumes — while audio runs hot they never park. The conductor path allocates nothing (tests/rt_safety.rs covers it, parallel dispatch included).

Measure before reaching for it: cargo run --release --example bench ends with a /g_parallel section (8 chains × 125 sines on disjoint buses) comparing worker counts — on the development machine it peaks around 3× with 3 workers and degrades past the physical core count. A graph that already fits in one core gains nothing; a stage with fewer units than workers caps the speedup at the unit count.

Examples

Runnable demos live in examples/ (Rust and Python) and clients/python/. Unless noted, the Python ones need the server running first (cargo run --release) and use the standard library only.

Rust examples (cargo run --example <name>)

examplewhat it showsrun
osc_pingMinimal OSC client for manual testing: subcommands status, beep, vibrato, map (the /n_map//n_mapa demo), quit.cargo run --example osc_ping -- beep
benchGraph-throughput benchmark (offline): how many copies of a graph fit in real time at 48 kHz, plus the parallel-group speedup. With the faust feature (default), an apples-to-apples UGen-vs-Faust section runs the same DSP (the parity-test sine) through both engines to isolate per-synth audio-loop overhead.cargo run --release --example bench
stressSingle-core stress test against a running server (the real-time complement to bench, which is offline): ramps nodes of an n-sinusoid def while watching the server's own CPU meter and late-block counter in /status.reply, and reports the last stable count before the peak load crosses --limit or a block runs late. Two axes: --sines (DSP weight per node) and the ramp step (per-node engine overhead). The client is portable and works against any build; the default server build runs real-time-scheduled (rtprio), so the numbers measure DSP throughput — against a server built without that feature they measure scheduling jitter instead (see BUILD.md).cargo run --release --example stress -- --sines 10
render_goldenRegenerates the golden reference WAVs in tests/golden/ from the shared scenes — run it and listen before committing.cargo run --example render_golden

Python clients (examples/)

scriptwhat it shows
sequencing.pyThe high-level clausters client: pattern sequencing with Session + Pbind + value patterns, and the one seam that runs the same phrase offline (NRT render) or live (UDP) by swapping the session. The flagship intro to the client library.
synthdef.pyBuilds a UGen SynthDef from Python (lowercase callables → graph → /d_recv), instance-based with no global build context, and proves it renders byte-identically to the server's built-in default def.
tcp_client.pyThe same Server facade over TCP (OscTcpInterface, length-prefixed OSC; the server listens by default) instead of UDP.
midi_file.pyRenders an event pattern to a Standard MIDI File (.mid, or --clip for a 16-bit-velocity MIDI 2.0 clip): the same Pbind/TempoClock targets a MidiServer destination instead of the OSC Server (double dispatch), rendering each note as MIDI on/off and writing the file through the clausters-midi crate. No server, no audio.
midi_live.pyPlays the same pattern live out a virtual OS MIDI port (MidiRtInterface, clausters-midi --features live): note-ons at the beat, note-offs scheduled — route it to a synth or the server's own --midi input with aconnect.
json_client.pyGenerates defs as JSON and drives the server over OSC. Subcommands: status, ugen, faust (box API), signal (Faust Signal API: a recursion/self sine + a one-pole lowpass on noise), wavetable, buffer, bundle, feedback (a LocalIn/LocalOut resonant comb), score (writes an NRT score to /tmp/clausters_score.osc).
auto_order.pyAuto-sorted groups: builds a source → fx → master chain reversed on purpose and repairs it with one /g_sortMode.
group_set.pyscsynth group semantics: three voices share a group and one /n_set on the group ramps all of them at once (the parameter propagates down the subtree).
graphdef.pyA GraphDef (node-graph program): a two-oscillator voice wired through a private internal bus, built with the GraphDef builder and driven through its named surface — one freq port mapping to both oscillators (the second scaled to a fifth), which a bare group /n_set cannot do.
graphdef_poly.pyA polyphonic GraphDef: a shared mixer (instantiated once with server.graph) plus a per-voice oscillator marked voice=True, spawned per note with server.graph_voice — the same shared/per-voice model a /midi_bind to a GraphDef uses.
sample_clock.pyThe sample clock as master timebase: models sample(t) from /clock anchors and schedules with /sched.
shm_client.pyThe shared-memory transport (--shm): the same OSC with no sockets anywhere.
clock_recorder.pyThe shared-memory sample clock made checkable: reads ShmClient.clock directly (no round trip), schedules a pristine Impulse exactly every N samples with /sched, records the real output (pw-record) and reports the impulse spacing/jitter/drift. Duration is free (--seconds), seconds to hours.
embed_render.pySynchronous offline render through the embed C ABI (ctypes), no server process.

The OSC encoding/decoding in these scripts is hand-rolled (stdlib only); they double as a compact reference for the wire format.

Installed-package examples (clients/python/examples/)

These import clausters from the installed package (no sys.path shim, no target/), so they show the wheel workflow: pip install ./clients/python then run from anywhere. See the client README.

scriptwhat it shows
offline_render.pyFully self-contained: a Session.nrt Pbind rendered to a WAV through the bundled embed renderer — no server, no audio device, no source checkout.
live_udp.pyThe same pattern live over UDP to a running server (Session.live), proving the offline/live seam from an installed package.

Shell

scriptwhat it shows
persistence.shDef persistence: /d_faust a def with --data-dir, quit, then restart and instantiate it without re-sending — it reloaded from disk (with its bitcode cache). Needs oscsend (the faust feature is on by default).
midi_standalone.shMIDI-standalone: set up a SynthDef + a GraphDef + a /midi_bind once, quit, then restart with --midi and the binding is back with no OSC — play it from a controller via aconnect. Needs oscsend.

Python binding (clients/python/clausters/ipc.py)

The reusable local-transport library (standard library + ctypes): a shared-memory client and the embed façade with a synchronous request() call. It is the clausters.ipc module of the high-level Python client package (clients/python/clausters/), and its Clausters/ShmClient/render are re-exported from the top-level clausters package. See Local transports & embedding for the API.

Using Clausters as a library

The crate is both a binary (the server) and a library (rlib + cdylib). This chapter is the conceptual map; the precise API reference is the rustdoc — run cargo doc --open.

[dependencies]
clausters = { path = "…" }   # or a git/version dependency

The engine (server::engine) knows nothing about the audio backend: it processes blocks of BLOCK_SIZE frames against in-memory slices, so you can drive it from your own code exactly like the cpal callback or the offline renderer do.

Feature flags

featuredefaultwhat it adds
synthyesthe SynthDef family: the UGen library, the def compiler (/d_recv) and UGenSynth. Independent from faust — the two def families combine freely, and a single-family build works (with neither, the engine core still builds but has no defs to instantiate).
realtimeyesthe cpal backend (the live server). Disable it for offline/embedded use with no audio device.
pipewireyesnative PipeWire audio backend on Linux/BSD via cpal's pipewire host. cpal::default_host() prefers PipeWire, falling back to ALSA at runtime, so no code changes. Default (the binary hard-links libpipewire); drop it with --no-default-features for a plain-ALSA build. Building it needs libpipewire-0.3-dev and clang.
midi-jacknoroutes live MIDI through midir's JACK backend instead of ALSA seq (needed on PipeWire systems — the ALSA-seq backend panics on the timestamp of events bridged from PipeWire). Building it needs libjack-jackd2-dev.
faustnothe FaustDef family: libfaust embedding (Box API + LLVM JIT, /d_faust). Needs libfaust built with the LLVM backend — see Contributing.
embednothe C ABI (clausters_* exports) for embedding the server in another process — see Local transports & embedding.

Offline rendering (the simplest entry point)

server::render turns a score into samples. A Score is the binary score format (or built in memory from (time, messages) pairs); RenderConfig picks the rate, channels and worker count.

use clausters::server::render::{render_to_wav, render_to_vec, RenderConfig, Score};

let score = Score::load("score.osc")?;          // or Score::from_bytes / Score::new
let cfg = RenderConfig { sample_rate: 48_000.0, channels: 2, workers: 0 };

// Straight to a WAV file ("int16" | "int24" | "float"):
let stats = render_to_wav(&score, &cfg, "out.wav", "float")?;

// …or to an interleaved Vec<f32> for further processing / tests:
let (samples, stats) = render_to_vec(&score, &cfg)?;
Ok::<(), String>(())

Rendering is deterministic and bit-identical to a live take of the same score (and to the parallel path with workers > 0).

Driving the live engine directly

engine_pair(sample_rate, channels) returns the two halves of the design:

  • Engine — the audio side. Call process_block(&mut [f32]) to fill an interleaved output buffer; this is what the cpal callback calls. It must not allocate, so build everything before handing it over.
  • EngineHandle — the control side. send(Cmd) pushes a fully-built command over the lock-free FIFO; collect_garbage() drains freed memory (drop it here, off the audio thread); counters() exposes node/ugen counts.
use clausters::server::engine::{engine_pair, Cmd};
use clausters::osc::translate::CmdTranslator;
use clausters::rosc::OscMessage;

let (mut engine, mut handle) = engine_pair(48_000.0, 2);

// Build commands from OSC messages with the same translator the server uses
// (it resolves def names, allocates synths, keeps the tree mirror):
let mut translator = CmdTranslator::new(48_000.0);
let mut cmds = Vec::new();
let msg = OscMessage { addr: "/s_new".into(), args: vec![/* … */] };
translator.translate(&msg, &mut cmds)?;
for cmd in cmds { let _ = handle.send(cmd); }

// Audio thread: pull blocks.
let mut out = vec![0.0f32; 64 * 2];
engine.process_block(&mut out);
handle.collect_garbage();
Ok::<(), String>(())

You can also construct Cmd values directly (see the rustdoc for the enum) if you don't need OSC. engine_pair_with_workers adds a DSP worker pool for /g_parallel groups; engine_pair_full wires an IPC segment so external processes share the control buses and sample clock.

Embedding in another process (C ABI)

With the embed feature the cdylib exposes a versioned C ABI (clausters_render for synchronous offline renders, plus a live in-process server: clausters_open / send / poll / clock / ctl_*). The boundary passes only plain f32/integers. Full reference, the shared-memory transport, and the stdlib-only Python binding are in Local transports & embedding.

Local transports: shared memory and embedding (--shm, the C ABI)

OSC over UDP makes Clausters controllable from anywhere — but a client on the same machine (or in the same process) shouldn't need a network stack to feel like part of one application. Clausters separates the encoding from the transport: OSC stays the only wire format everywhere, and beside UDP there are two local transports built on one shared segment:

transportprocessesstartclient
UDPanyclaustersanything that speaks OSC
shared memory2, same machineclausters --shm <path>clients/python/clausters/ipc.py (ShmClient), or any mmap
in-process (embed)1cargo build --features embed,realtimethe cdylib's C ABI, Clausters in the Python binding

All three coexist: a --shm server still serves UDP; the embedded server keeps an ephemeral localhost socket as a debug escape hatch. Commands from any transport land in the same queue with the same semantics (timed bundles, /sched, /g_sortMode, …).

The segment

A single memory region (ABI v3; 660 160 bytes with the default 1024 control buses and 8 audio taps of 16 384 samples) holding, in order:

  • Header: magic "CLAU", layout version — checked on attach, a mismatch refuses to connect (the scsynth plugin-ABI lesson: every binary boundary is versioned) — the device sample rate, the sample clock, the control-bus count and the tap region shape (tap count and per-tap ring capacity), so a client maps the whole file and derives every offset from the header alone.
  • Command plane: two SPSC byte rings (64 KiB each) of length-prefixed OSC packets — client→server commands, server→client replies. Unlike UDP, a full ring gives backpressure (the push fails and you retry) instead of silently dropping packets. Ring contents are untrusted bytes: the server validates exactly as it does UDP datagrams, and garbage resyncs the ring instead of wedging it.
  • Data plane: the control buses as raw f32-bit atomics — --control-buses of them. These are the control buses: the engine's InCtl reads these very words, so a client write is live on the next 64-sample block with no command, no round trip, no scheduling. (For sample-accurate changes, keep using a timed /c_set — the data plane trades precision timing for immediacy.) The sample clock in the header is the anchor: a read costs a memory load with zero transport jitter.
  • Audio taps (trailing): --taps single-channel sample rings of --tap-frames samples each (a power of two; 0 taps removes the region). Each slot is a cache-line-aligned monotonic cursor (total samples ever written) followed by the ring. /tap tapIndex bus routes an audio bus into a ring: the audio thread appends that bus's block every block (one memcpy + one Release store — RT-safe) and a peer reads the newest window lock-free, cursor-double-checked, each display frame. The audio-rate sibling of the control buses, and what a GUI oscilloscope draws from with zero per-frame messages; clients that cannot map the segment stream the same windows with /tap_stream (see schemas.md).

For two processes, put the segment on a memory filesystem (/dev/shm/...). The transport keeps one ring client per segment and the server polls the ring on a 2 ms tick instead of a cross-process semaphore — command latency is bounded by that tick; the data plane has no latency at all. (Semaphore wakeups, multiple ring clients and Windows named mappings are explicitly future work.)

The embed C ABI

With --features embed the build also produces libclausters.so — the canonical language-agnostic surface. Per the project's boundary rule, only basic structures cross it: byte pointers in, flat f32 arrays (pointer + length), integers and error strings out. Check clausters_abi_version() first; it moves in lockstep with the segment layout.

uint32_t clausters_abi_version(void);

// The synchronous "scientific" call: render a binary score (the --nrt
// format) and return interleaved float32 samples. NULL on error.
float *clausters_render(const uint8_t *score, size_t len,
                        double sample_rate, uint32_t channels,
                        uint32_t workers, uint64_t *out_frames,
                        uint8_t *err, size_t err_cap);
void clausters_free_samples(float *ptr, uint64_t samples);

// A full live server in this process (audio device + engine + network
// loop); commands are OSC packets delivered by function call.
Clausters *clausters_open(uint32_t workers, uint8_t *err, size_t err_cap);
int32_t clausters_send(Clausters *, const uint8_t *packet, size_t len);
int64_t clausters_poll(Clausters *, uint8_t *buf, size_t cap);
uint64_t clausters_clock(Clausters *);          // data plane, block-accurate
double clausters_sample_rate(Clausters *);
void clausters_ctl_set(Clausters *, uint32_t index, float value);
float clausters_ctl_get(Clausters *, uint32_t index);
void clausters_close(Clausters *);

JavaScript reaches the same surface through Node/Deno FFI; in a browser there is no shared memory between processes, so that target waits for a wasm build (where the "segment" becomes a SharedArrayBuffer in-process).

Synchronous calls

Asynchronous replies are the right server model and the wrong interactive ergonomics. The fix lives in the client: a blocking facade — send the request, block this thread with a timeout until the reply arrives. The server and the audio thread never wait on anything; "synchronous" is purely the caller's view, which is why it composes with every transport:

  • over UDP it already exists (json_client.Client.reply blocks);
  • over the ring, ShmClient.request() / Clausters.request();
  • fully synchronous with no server at all: clausters.render(score) — score bytes in, array('f') out, ready for analysis (numpy.frombuffer wraps it without copying — the client's choice, never a dependency).

Correlation is by serialization: one request in flight per client, like scsynth clients in practice. A protocol-level correlation token stays deferred until someone actually needs concurrent queries.

Python binding

clients/python/clausters/ipc.py (re-exported from the clausters package), stdlib only:

from clausters import ShmClient, Clausters, render

c = ShmClient("/dev/shm/clausters")     # two-process
print(c.clock, c.sample_rate)           # data plane reads
c.ctl_set(7, 0.5)                       # live next block, no command
reply = c.request(osc_bytes)            # sync command round trip

with Clausters(workers=2) as s:         # in-process server
    s.send(osc_bytes); print(s.clock)

samples, frames = render(score_bytes)   # sync offline render

Demos: examples/shm_client.py (attach, watch the clock, fade a synth by writing a control bus in shared memory) and examples/embed_render.py (render a score synchronously and write a WAV). Pure-Python caveat: the ring cursors rely on aligned 32-bit accesses being effectively atomic (true on x86-64/aarch64); the Rust sides use real atomics.

The binding loads the cdylibs by this precedence: the CLAUSTERS_LIB / CLAUSTERS_FFI_LIB env override, then the copies bundled in the wheel (clausters/_libs/, staged at build time), then the workspace target/{release,debug}/ of a source checkout. So an installed pip wheel is self-contained (no target/ needed), while a plain checkout still works after cargo build. Packaging details and install recipes are in clients/python/README.md and the clients chapter.

Clients and language bindings

Clausters is a server; clients drive it. This chapter is the cross-language map: the one native contract every client sits on, the Python client built on it, and the path to a JavaScript client and to distributable packages. The client work lives in the clients/ tree; this is the architectural overview.

One contract: the C ABI

Everything a client needs that must be native lives behind a single, versioned C ABI — the scsynth plugin-ABI lesson: every binary boundary is versioned and checked. Two cdylibs expose it, and the boundary rule is the same for both: only flat data crossesf32/f64/integers, pointer+length arrays, NUL-terminated error strings. Never a library type (a numpy array can view a returned pointer, but that is the client's choice, not a dependency).

cdylibcratewhat it iskey entry points
libclausters_fficlausters-ffi over clausters-corethe shared numeric/timing coreclausters_core_abi_version, clausters_core_unary/_binary (builtins), clausters_core_whitenoise, clausters_core_beats_to_secs/_secs_to_samples/…, clausters_sched_* (beat queue), clausters_clocksync_* (sample-clock model), clausters_rng_* (seeded value stream), NTP timetag packing, quant_delay, degree_to_midinote
libclaustersclausters (feature embed)the server as a libraryclausters_abi_version, clausters_render (offline), clausters_open/_send/_poll/_clock/_ctl_* (in-process live server)
libclausters_midiclausters-midi over midly/midi2/midirMIDI I/Oclausters_midi_write_smf (.mid), clausters_midi_write_clip (MIDI 2.0 clip), clausters_midi_free; with --features live: clausters_midi_output_open/_send/_close (virtual port)

Beside the in-process embed path, the same OSC reaches the server over UDP or shared memory (--shm); see Local transports & embedding. So a client has three ways to talk to the server (UDP, shm, embed) and one way to reach the native core (libclausters_ffi) — all language-agnostic.

Why a shared core at all: the builtins, the seeded white noise and the beat/second/sample math are compiled once in clausters-core and used by both the server's UGens and every client, so client-side results match the server by construction for the operations the server computes natively.

The rule extends to everything a client computes about values or time, so a port rebinds the core instead of reimplementing behaviour. Concretely, the core owns: the clock's beat-ordered scheduler queue (min time, stable insertion order for ties), the sample-clock tracking model (the least-squares sample = a + b·t fit behind locking a clock to a server over the network), the seeded value stream the random patterns draw from (one u64 state word, so a seeded sequence replays identically in every client language — a host language's own RNG never leaks into sequenced values), NTP timetag packing (one rounding rule, identical bits for identical instants), quantization (quant boundary snapping) and the pitch-space math (scale degree → MIDI note). What each language keeps is its control flow (the coroutine driver that resumes routines) — and, as the one documented exception, the OSC byte codec: structured message arguments cannot cross a flat C ABI, so encode/decode of the wire bytes stays per-language while every time value inside them comes from the core.

The Python client

clients/python/ is the reference client, a selective port of SuperCollider's class library (sc3) covering both def formats (FaustDefs and UGen-graph SynthDefs). It is pure Python at runtime: it reaches the core through ctypes over libclausters_ffi, and speaks ordinary OSC bytes to the server (UDP, TCP, or shm/embed via the transport module). It mirrors the native contract in three layers — base (server-agnostic timing and values), seq (events and patterns) and defs (the Faust/UGen definitions and the Server, whose swappable interface is the live-RT / offline-NRT seam).

It has its own documentation — a guide and the generated API reference: the clausters Python client book.

This is also the proof the contract is language-agnostic: Python — a non-Rust language — already drives the whole system (core math, offline render, live server) purely through the C ABI and OSC. Nothing in the boundary is Python-specific.

The GUI host: a scriptable peer

The GUI (clients/gui) is another peer in the system, not code compiled into the audio server — the same decoupling Clausters already uses for audio. Just as SuperCollider's sclang builds Qt widgets by sending messages to a separate widget engine, a GUI host process owns the windows, widgets and the GPU and speaks a widget protocol; scripts (Python now, JavaScript later) send it widget commands and receive interaction events, while the audio server is untouched. The host plays two roles in one process: a GUI server for the languages and a client of the audio server (it reads buffers, control buses and the node tree and can send control back). A bound widget can forward its value straight to the audio server, bypassing the script, for low-latency control.

Two design choices carry the rest:

  • Declarative, def-shaped protocol. A whole widget tree is one document, not a stream of per-widget messages — mirroring SynthDef/GraphDef. /gui_def <id> <json> builds a tree in one message (JSON as payload, OSC as framing, the single osc::decode_packet door), /gui_set updates a live widget, /gui_free frees a subtree. The wire form is generic ({id, type, props, children}) so the protocol never changes when a widget type is added; an unknown type is laid out but not painted, so old and new hosts interoperate.
  • A web-capable GPU substrate, one stack for both targets. The heavy widgets (waveform, spectrogram, scopes) are custom GPU rendering written against wgpu/WGSL, which runs natively today and under WebGPU in a browser unchanged — so the native desktop host comes first and the browser target is reached by swapping the surface, not forking the renderers. The heavy views follow one rule — never resolve the signal finer than the screen: work is bounded by samples_per_px, and the expensive analysis (a peak pyramid, an STFT) is treated as a cache that moves through local shared resources (mmap natively, fetch in the browser), never chunked over the wire.

The wire reference — the commands, the GuiDef document, the events and the widget catalog — is The GUI protocol.

The widget catalog runs from the ordinary controls (labels, knobs, sliders, numbers, buttons, toggles, text, menus) through the live meters and scopes (meter, scope, phasescope, spectrum, nodetree) to the editor-grade views: the heavy waveform and spectrogram (multichannel lanes, adaptive rulers, a draggable selection, a playhead tracking the engine clock, linked navigation groups), the drawable bpf envelope editor, a static plot, a shader canvas — and the composition views below. Their reference is the Python builders' documentation, since that is how a script names them.

The composition views: a multitrack editor and a patcher

The newest arc of the GUI is a DAW-style multitrack editor, and it exists to put a client-side arrangement model on screen: a track is a lane, a clip is a placed rectangle spanning [offset, offset + dur] on a time axis the lanes of a window share (they zoom and pan as one navigation group, and the axis spans the composition). A clip's body is one of three, and the choice is the only thing that differs between them:

  • a take — a server buffer, a mapped file or a prebuilt peak cache, decimated to the clip's pixel width through the shared peak pyramid, so a minutes-long clip costs a screen's worth of columns and never rides the wire as JSON;
  • a piano-roll of note events (time and pitch);
  • an automation curve — the bpf model, placed on the lane and editable in place, evaluated through the same envelope-shape math the server's EnvGen plays.

Everything is editable back: dragging a clip or its edge emits "clip", dragging a break-point emits "points", and the script's model — not the widget tree — is what those events change. A logical group (members wired to each other through buses, the shape a GraphDef expresses) is not a timeline at all, so it draws as a graph patch instead: member boxes, bus nodes, and a wire per connection, where dragging a port onto a bus rewires that control. The patch is deliberately undirected — a GraphDef knows that a control touches a bus, and which end writes is the server's own analysis (see Design decisions).

The Python side of all this is clausters.gui.Editor: it draws a composition into that window, applies the edit-backs onto the arrangement, and re-renders it — so the graphic is not a picture of the music, it is the music. Its user documentation is the composition chapter of the Python client's book.

The GUI host in the browser

The GUI host (clients/gui) also compiles to WebAssembly and runs in a browser tab: the same widget protocol, layout, renderers and interaction as the desktop host, over a <canvas>. It renders through WebGPU where the browser truly supports it and WebGL2 otherwise (~99% browser reach), and it talks to a separate audio server over WebSocket — start the server with --ws (default port 57120). There is no in-process engine in the browser; the embed/standalone path is native-only.

The browser fills the host's data paths over the network instead of shared memory and mapped files:

  • Meters/scopes/canvas buses: the host subscribes the buses its widgets read with /c_stream and the server streams /c_set snapshots back at ~30 fps over the same WebSocket — the network counterpart of the shared-memory segment (see the control-bus commands in Def schemas).
  • Audio-tap views: the host subscribes the audio taps its audio-rate scopes, phasescopes (a stereo pair) and live spectra read with /tap_stream and the server streams /tap_data windows back — the network counterpart of the segment's tap rings (see the audio-tap commands in Def schemas). The phasescope's correlation and goniometer geometry and the spectrum's FFT all come from clausters-core, so the browser computes them in wasm identically to the desktop.
  • Bulk waveform/spectrogram/plot/clip data: a path/cache reference is fetched as a URL against the page origin (raw f32 samples — every interleaved channel kept — a prebuilt peak-pyramid cache, or an STFT cache; the pyramids and STFT lanes for raw fetches are built in wasm — the analysis lives in clausters-core), and a server buffer reference is pulled over /b_query + chunked /b_getn on the WebSocket leg. The editor chrome of the two heavy views (multichannel lanes, adaptive time/Hz rulers, the selection overlay, the vertical y_start/y_len view window) renders through the same shared frame path as the desktop — a /gui_set of any of it displays identically in the browser, while the pointer gestures (drag-select, pan, the y-ruler zoom) are native-only today; the playhead is driven by polling /clock once per animation tick instead of reading the shared segment's sample clock. The linked navigation groups (an explicit link prop shares one horizontal view, selection and playhead across timeline views; /gui_set of view_start/view_len/sel_*/playhead_at on any member applies group-wide) live in the host core's protocol dispatch, so they behave identically in the browser.

Quick start (from clients/gui/; one-time setup: rustup target add wasm32-unknown-unknown and cargo install wasm-bindgen-cli --version <the wasm-bindgen version in Cargo.lock>):

./web/build.sh                       # cargo wasm build + wasm-bindgen into web/
(cd web && python3 -m http.server)   # then open http://localhost:8000/

The page loads the bundle and calls the wasm entry point start(), which returns the binding surface (GuiBridge) the page drives: def(id, json) feeds a GuiDef (the same JSON the Python builders emit), feed(packet) pushes any raw /gui_* OSC packet, poll() drains the outbound /gui_event//gui_info//gui_closed packets, and connect_server(url) attaches the audio-server leg to a --ws server (a bind-ed widget then forwards straight to it, with no script round-trip). web/index.html is a documented throwaway harness with a panel, a meters and a bulk demo — it proves the host; the product browser client is the separate TypeScript track below.

A JavaScript client (planned)

A JS client mirrors the Python one with no new native work: it sits on the same C ABI and the same OSC.

  • Native bridge: Node/Deno N-API (or Deno.dlopen) over libclausters_ffi/libclausters for desktop; WebAssembly for the browser (the core compiled to wasm; the server itself targets wasm only for the offline render path).
  • Mirror the layers: base/seq/defs map across directly. The one language-specific piece is the coroutine driver — JS generators / async instead of Python's yield; the clock and the rest are the same value/time logic.

Distribution

  • Python (done): a platform-tagged wheel that bundles the cargo-built cdylibs (libclausters_ffi, and libclausters with embed,realtime) inside the package (clausters/_libs/), so an installed package is self-contained — no target/ directory, no build step at import. The runtime stays stdlib-only; the loaders prefer the bundled copy, falling back to the workspace target/ in a source checkout. A setup.py build hook runs cargo build and stages the libraries; python -m build --wheel clients/python produces the wheel. See the Python client book for the install recipes and the env knobs (CLAUSTERS_WORKSPACE, CLAUSTERS_CARGO_FEATURES, …). The wheel is Faust-enabled: it bundles libfaust and the libLLVM it JITs with beside the cdylibs, so a FaustDef compiles on a machine with neither installed. Cross-platform CI wheels (cibuildwheel / manylinux) are still future work.
  • JavaScript (planned): an npm package with prebuilt N-API addons per platform and a wasm build for the browser.
  • Reproducible Faust build (done for native/CI/release): the faust feature needs libfaust built with the LLVM backend. It is now vendored under third_party/faust.pin (the exact commit + LLVM version) and build-faust.sh (one recipe: fetch, build, install, stage libLLVM) — so local dev, CI and the release wheel produce a deterministic bundle instead of whatever the build host had. The npm N-API and wasm targets still need their own vendored builds.

Status at a glance

PieceState
Shared core + C ABI (clausters-core/clausters-ffi)done
Python client (base/seq/defs, incl. UGen SynthDef)done
Cross-language docs + sequencing exampledone
Python wheels packagingdone
MIDI interfaces in the Python client (MidiServer, SMF / MIDI 2.0 clip export, live port)done
Client-side OSC/MIDI responders (OscFunc/MidiFunc)done
Browser GUI host (wasm bundle; meters over /c_stream, bulk over fetch//b_getn)done
Arrangement model in the Python client (elements, recursive groups, rendering)done
Multitrack editor + patcher (tracks/clips, piano-roll, automation curves, graph) and the driver that binds them to the arrangementdone
Reproducible third_party Faust build (pin + script; native/CI/release)done
JavaScript client + npm (incl. its Faust build)planned

Architecture (developer documentation)

How Clausters is built, where everything lives, and the invariants a change must not break. User-facing documentation (wire formats, OSC commands) is in schemas.md; the design decisions and upstream-bug findings are in decisions.md, and the forward roadmap is PLAN.md at the repository root.

Threads

         OSC/UDP+TCP           cmd FIFO (SPSC, pre-built commands)
 client ◄────────► network ─────────────────────────► audio (cpal callback)
                   thread  ◄───────────────────────── Engine::process_block
                     │   garbage FIFO + event FIFO
          ┌──────────┴──────────┐
          ▼                     ▼
      NRT thread          Faust compiler thread   (feature `faust`)
   (disk I/O, buffers)    (libfaust JIT, ~10 ms/def)
  • Network thread (osc::server::OscServer::run): owns the UDP socket (100 ms read timeout — each timeout tick collects garbage and async results; 2 ms when an IPC ring is attached, which it also drains every iteration), parses every packet — datagram or ring — through osc::decode_packet, builds commands fully allocated — boxed synths, pre-reserved group child lists — and pushes them into the command FIFO. It also owns all lookup tables: the def tables, the node-ID→def mirror, the buffer mirror, and the tree mirror (osc::graph::TreeMirror inside osc::translate::CmdTranslator) — topology, per-node control values and bus usage, fed by the same Cmd stream the engine gets and rolled back by rejection garbage; it answers /g_queryTree, /n_query (per-node detail) and /g_dumpGraph, and drives the auto-sorted groups without touching the audio thread. All replies (/done, /fail, /n_go//n_end, queries) are sent from here.
  • Audio thread (the cpal callback, server::backend): runs Engine::process_block on 64-frame blocks. Per block: drain the command FIFO, fire scheduled bundles whose time falls inside the block (splitting it at the exact sample), walk the node tree in depth-first order, push dead memory to the garbage FIFO. It never allocates, locks or does I/O, and re-arms dsp::denormals::flush_to_zero() on every callback. Each block is timed for the CPU meter, and with the default rtprio feature it runs under real-time scheduling (SCHED_FIFO/RR) — see Real-time health.
  • Audio input thread (the cpal input callback, server::backend, opt-in via --inputs N): a second cpal stream on the default input device. Its callback runs on its own real-time thread and only pushes the decoded interleaved f32 frames into a lock-free ring; at the top of each block Engine::process_block pops one block's worth into audio buses outputs..outputs+inputs (before any node runs), where In reads them like any bus. The two streams are decoupled by the ring: an overrun drops input samples on the callback side, an underrun reads as silence on the engine side — neither ever blocks. The output-channel count (--outputs) is negotiated with the host the same way as the sample rate (requested first, device default as fallback).
  • NRT thread (server::nrt): all /b_* work — allocation, file reading (WAV via hound, compressed/other formats via symphonia), WAV writing via hound, zeroing. One queue, so commands on the same buffer complete in submission order. Produces immutable buffers the network thread installs with Cmd::SetBuffer.
  • Disk I/O threads (dsp::disk, DiskIn/DiskOut): one background thread per streaming UGen instance, spawned at /s_new (build) and joined when the synth's Box is dropped on the network thread (via the garbage FIFO). It decodes (symphonia) or encodes (hound) between disk and a lock-free rtrb ring the audio thread pops/pushes; the audio thread never does disk I/O. Self-contained: no engine, OSC or ProcessCtx involvement.
  • DSP workers (server::workers, opt-in via --workers N): a fork-join pool the audio thread conducts to process the stages of /g_parallel groups. Atomic work stealing, bounded spinning, park/unpark only across idle gaps; each worker arms flush-to-zero at spawn. With 0 workers (the default and the whole test suite) the pool is inert and everything is sequential.
  • Faust compiler thread (faust::compiler, feature faust): JIT compilation of /d_faust defs. libfaust does not tolerate concurrent compilation in one process (SIGSEGV), so every compiling FFI call holds the process-wide ffi_lock(); instantiating from a finished factory is concurrency-safe and happens on the network thread.
  • TCP I/O threads (osc::tcp, on by default at the OSC port; --no-tcp disables, --tcp [port] moves it): an acceptor thread plus one reader thread per connection turn each TCP byte stream into whole, length-prefixed OSC frames and hand them to the network thread over an mpsc channel — drained every loop iteration like the IPC ring. The command processing stays single-threaded; these threads only do I/O. A reader pings the network thread's own UDP socket with a zero-length datagram after queuing a frame, so the loop wakes immediately instead of waiting for the GC tick. Replies write back through the connection's write half (the network thread owns it; &TcpStream is Write, so a reply needs only a shared borrow). No async runtime, no new dependency.
  • MIDI input thread (midi::live, feature midi, opt-in via --midi [name]): midir opens a virtual ALSA-seq input port and runs the input callback on its own thread, which decodes each MIDI 1.0 message (midi::parse_midi1, widening to the internal high-resolution form) and hands the ChannelVoiceMessage to the network thread over an mpsc channel — drained every loop iteration, exactly like the TCP frames, with the same zero-length-datagram wake. The network thread does the actuation (CmdTranslator::translate_midi realizes each message as the equivalent /s_new//n_set//n_free); the MIDI thread only decodes. The audio thread is never involved.

Offline rendering (server::render, the --nrt CLI mode) uses no threads at all: one thread drives both halves of engine_pair, runs NRT jobs and Faust compilations synchronously between blocks (scsynth NRT semantics), and arms flush-to-zero once at the start. Because scheduled commands go through the same engine queue as in real time, an offline render is sample-identical to a perfectly timed live take.

The realtime backend (cpal) sits behind the realtime feature (on by default); the engine itself knows nothing about cpal, which is what makes the offline mode and the integration tests possible.

Real-time health (scheduling, CPU meter, affinity)

The portable, always-on piece is the CPU meter; the default rtprio feature adds the Linux-specific scheduling around it. Together they answer "how many voices fit before the audio breaks", reliably (see examples/stress.rs for the harness that asks it).

  • CPU meter (Engine::process_block, always on, portable): every block is timed with Instant::now (clock_gettime(CLOCK_MONOTONIC) through the vDSO — no allocation, no lock, no kernel trap, so it is RT-safe) and published through the Counters atomics as a fraction of the block budget: an average (EMA, ~1 s time constant), a peak (bitwise fetch_max; non-negative floats order like their bits) that resets on every read so each /status poll sees its own window, and a cumulative late-block counter (block wall time > budget — the engine-side xrun proxy; conservative when the device quantum spans several blocks). /status.reply reports all three (avg/peak as percentages in the scsynth fields that were previously zero, the late count appended). Offline renders run the same code and just measure render speed.
  • Real-time scheduling (rtprio feature, default): cpal promotes the callback thread to SCHED_FIFO/RR through audio_thread_priorityRTKit over DBus, the same unprivileged path every PulseAudio/PipeWire client uses, so no RLIMIT_RTPRIO configuration is needed on a desktop. It is the default because it is the standard operating mode of every production audio client on Linux: without the promotion the DSP thread runs as SCHED_OTHER and competes with every normal process, and scheduling jitter — not throughput — breaks the audio well below 100% CPU (typically around half the theoretical single-core capacity; the callback must fit its worst block, and unscheduled peaks run at 2–3× the average). Because a promotion can fail silently, the callback publishes the policy the kernel actually gave it (one-shot syscalls on its 64th callback — a cold path) and the binary logs it shortly after boot: audio thread is real-time: SCHED_RR priority 10, or a warning naming the likely fix. RTKit promotes with SCHED_RESET_ON_FORK OR'd into the policy; the log masks it. Everything the feature compiles is isolated in server::rt (no-op stubs off Linux), so portability is unaffected and dropping the feature yields a build with no DBus dep and no RT watchdog — at the SCHED_OTHER capacity cost above.
  • CPU affinity (--pin cpu[,cpu...], part of rtprio): the first CPU pins the audio callback thread (it pins itself on its first callback — the thread is spawned deep inside cpal/PipeWire, so nobody else can reach it earlier); the remaining CPUs are assigned round-robin to the clausters-dsp-N workers by a /proc/self/task scan at boot. Pinning trades load-balancing for cache residency and steadier per-block times; measure it with examples/stress.rs rather than assuming it helps.

Real-time privileges cut both ways: RTKit imposes RLIMIT_RTTIME (~200 ms of continuous real-time CPU without blocking) on the thread it promotes, so a server driven past 100% sustained load — where the callback no longer sleeps between cycles — gets SIGXCPU from the kernel, whose default disposition kills the process with a core dump. That is the watchdog working as designed (a runaway SCHED_FIFO thread would otherwise freeze the machine); small device quanta (PIPEWIRE_QUANTUM=64/48000) reach that regime sooner because there is less slack per cycle. The rtprio binary therefore arms a SIGXCPU guard at boot, before the stream exists (server::rt::install_sigxcpu_guard): the handler demotes the audio thread back to SCHED_OTHER with async-signal-safe syscalls and writes one line to stderr — the audio degrades, the server survives (a restart re-promotes it). The overload rule is thus the same in every build: overload breaks the sound, never the process. Keep stress ramps below 100% anyway (the harness's default --limit 90 exists for this). Note also that the real deadline is the device quantum, not the 64-frame engine block: the engine's late-block counter measures against the block budget, so with a large quantum it is a conservative early warning, and with quantum = 64 it is nearly the ground truth.

Two measured facts to keep in mind when reading the meter (numbers from a desktop at 48 kHz, release build): applying a burst of node insertions inside one block costs real budget (~5–10 µs per /s_new apply, plus the new synth's first-touch page faults — the wire buffers are allocated on the network thread but first written on the audio thread), so a client that adds hundreds of nodes at once can make that one block late without any sustained overload; and the peak sits naturally at 2–3× the average at low load (per-cycle wake-up cost, cache refill, frequency scaling), converging toward the average as the core saturates — capacity planning must budget for the peak.

Logging (src/logging.rs) uses tracing. The binary installs the subscriber (logging::init, stderr, level from -v/-vv/-q or RUST_LOG); the library and embed users do not, so the macros are no-ops for them. The filter is a runtime-reloadable EnvFilter, so the OSC commands /verbosity (set the level/directive) and /dumpOSC (overlay clausters::osc=trace) let a client retune the server's logs live. The audio thread never calls a tracing macro: every condition it detects (a rejected command, a dropped bundle) leaves through the garbage FIFO and is logged by the network thread, so logging stays clear of the real-time path.

Module map

PathContents
src/server/engine.rsThe core: Engine (audio half), EngineHandle (network half), Cmd, Garbage, the FIFOs, the schedule queue, the sample clock
src/server/backend.rscpal glue: BlockAdapter slices arbitrary callback sizes into 64-frame engine blocks (feature realtime)
src/server/nrt.rsNRT thread, NrtJob/run_job (also called synchronously by the renderer), audio reading (read_audio: WAV via hound, other formats via symphonia) and WAV write helpers
src/server/workers.rsworker pool: stage publish/steal/wait protocol for parallel groups
src/server/ipc.rsthe versioned shared segment — data plane (clock, control buses, audio-tap rings) + OSC byte rings (--shm and embed transports)
src/embed.rsthe embed C ABI (feature embed, exported by the cdylib)
src/logging.rstracing setup: init (binary-only subscriber, stderr), runtime-reloadable filter behind /verbosity and /dumpOSC
src/server/render.rsOffline mode: Score (binary scsynth score format), render/render_to_vec/render_to_wav
src/node/mod.rsNodeTree (fixed slab), SynthNode trait, groups, add actions, moves
src/dsp/mod.rsUGen trait, ProcessCtx, buses, the cache-line-aligned Block, block/bus-count constants
src/dsp/<ugen>.rsOne file per UGen family (sinosc, binop, io, noise, buf)
src/dsp/registry.rsUGenKind: name parsing, input arity, construction
src/dsp/buffer.rsImmutable sample buffers and the engine-side pool
src/dsp/denormals.rsPer-thread flush-to-zero (x86-64 MXCSR, aarch64 FPCR)
src/synthdef/SynthDef JSON wire format, validation/compilation, UGenSynth instance
src/osc/mod.rsdecode_packet — the only entry point for incoming OSC bytes
src/osc/server.rsThe network thread: socket loop, immediate handlers, replies
src/osc/tcp.rsTCP transport (on by default): acceptor/reader threads, length-prefixed framing, TcpHub
src/osc/translate.rsCmdTranslator: OSC message → Cmd, shared by the live server and the renderer; owns the tree mirror
src/osc/graph.rsbus-usage analysis, the network-side TreeMirror, the stable topological sort behind /g_sortMode
src/osc/graphdef.rsGraphDef spec/instance types + the private-bus RangeAllocator; instantiation lives in translate.rs
src/midi/standard channel-voice MIDI actuation — message-type conversions (convert.rs), bindings/voice state, MIDI 1.0→2.0 widening; live input via midir/ALSA (live.rs, feature midi). CmdTranslator::translate_midi realizes a message as the equivalent /s_new//n_set//n_free on the network thread (the audio thread is untouched)
src/faust/libfaust embedding: hand-written FFI, compiler thread, JSON→Box interpreter (boxes.rs), FaustDef/FaustSynth; soundfile("<bufnum>", n) is filled from a server buffer at instantiation (SoundfileData in synth.rs)
src/main.rsCLI: realtime server (default) or --nrt renderer

Memory lifecycle

The rule behind everything: memory is allocated on the network (or NRT / compiler) thread, used on the audio thread, and freed back on the network thread. The audio thread only moves pointers.

  1. A command arrives over OSC. The network thread builds the complete object — e.g. /s_new boxes a UGenSynth with its UGens and wires, /g_new pre-reserves the child list — and pushes a Cmd into the command FIFO.
  2. process_block drains the FIFO and plugs the object into the tree: O(1), no allocation.
  3. When a node dies (/n_free, replace actions, or an EnvGen done action — see below), the boxed synth leaves through the garbage FIFO as a Garbage variant; the network thread drops it (collect_garbage), updating its mirrors.
  4. Rejected commands (duplicate ID, unknown target, full slab/group) come back as Garbage::RejectedSynth/RejectedGroup so the memory still dies off the audio thread.

Two shared structures cross threads without the FIFOs:

  • Control buses: 1024 atomics (dsp::ControlBuses). Immediate /c_set and /c_get are served directly on the network thread; the audio thread reads them through InCtl. A scheduled /c_set must land on its exact sample, so it travels as Cmd::SetControlBus instead. With an IPC segment the backing array lives in shared memory: other processes write the same atomics.
  • Audio taps: single-channel sample rings in the IPC segment (--taps × --tap-frames; segment ABI v3). /tap sends Cmd::SetTap, which flips an entry in the engine's pre-allocated tap table; at the end of every block the audio thread appends each routed bus's block to its ring — one memcpy plus one Release store per tap, then the clock store, so a reader that sees clock N sees block N. Readers (the GUI host's oscilloscope over shared memory, /tap_stream on the network thread) copy the newest window lock-free with a cursor double-check; tests/rt_safety.rs guards the write.
  • Buffers: Arc<Buffer>, immutable once installed. The NRT thread builds them, Cmd::SetBuffer swaps them into the engine pool, the replaced Arc returns as Garbage::FreedBuffer. "Mutating" commands (/b_zero, /b_read into an existing buffer, /b_gen filling a wavetable) build a replacement instead of touching shared memory. The network thread keeps a mirror for /b_query//b_write and for validation.

Done actions (self-freeing nodes)

A UGen can ask for its node to be freed, paused or resumed once it finishes: the UGen trait has a done(&self) -> DoneAction hook, checked right after process. EnvGen is the first user — it returns its doneAction when its last segment ends, one of scsynth's full 0–15 set (DoneAction::from_i32 maps the float; see the table in schemas.md). The synth aggregates its UGens' actions (UGenSynth::done_action takes the strongest). The tree walk handles them by kind: None does nothing; PauseSelf is applied inline — the node's paused flag is set and it is skipped (silent, state kept) from the next block on. A paused node (synth or group — a paused group skips its whole subtree) is resumed by clearing that flag: the /n_run command (Cmd::RunNodeNodeTree::set_paused), which makes PauseSelf non-terminal, and the freeSelfResumeNext action. Every other actionFreeSelf, FreeGroup, and the relative ones — is recorded (id plus action code) into a lock-free finished-node queue (NodeTree's done_nodes/done_actions/done_count, index reservation by fetch_add so the parallel workers can push concurrently), drained once per block, after the whole tree has run (Engine::process_blockNodeTree::apply_done_action). Freeing after the full walk (not mid-traversal) keeps the tree stable while it is being read, and makes a re-queue from a mid-block schedule split — or two synths both freeing the same group — harmless (freeing an already-gone id is a no-op). The queue's atomics only need to be race-free for the concurrent reservation; visibility to the drain is provided by the worker pool's join.

The relative actions (freeSelfAndPrev/Next, freeSelfToHead/Tail, freeSelfPause/Resume a neighbour, freeSelfAndFreeAllIn/DeepFree a neighbour group, freeAllInGroup) act on the finishing node's siblings. apply_done_action resolves the previous/next sibling (and, for the head/tail runs, iterates them) from the parent group's ordered child list — before freeing self, since freeing shifts positions — then reuses the ordinary free/free_all/deep_free/set_paused paths. All of that runs in the drain on the audio thread, so it stays allocation-free: sibling resolution is index arithmetic and the frees reuse the tree's pre-allocated free_stack/dfs_stack. Everything leaves on the garbage FIFO exactly like an /n_free, never dropped on the audio thread.

Side-effect replies (SendReply/SendTrig/Poll)

A UGen can emit an OSC reply or a console post from the audio thread — SendTrig (/tr), SendReply (a custom address) and Poll (a console line, plus /tr). These carry a payload out (a node id, a reply id, a value list, a name), so unlike the POD node events they need a message type: dsp::ReplyMsg, a fully inline, Copy struct (a fixed name buffer + a fixed value array, no heap) so buffering and shipping one never allocates. Two hops keep it RT-safe and off the parallel-scheduler hot path: (1) during process each reply UGen detects triggers (a crossing from ≤ 0 to > 0) and buffers up to 8 messages in a fixed inline array of its own; (2) the tree marks any synth that ran a reply UGen into a lock-free slot queue (NodeTree's reply_slots/reply_count, the same fetch_add index-reservation as the done-action queue, so the M13 workers can push while holding their slot), and Engine::process_block drains it once after the whole blockNodeTree::drain_replies walks the marked synths, stamps each message with the node id, and pushes it into the reply FIFO (ReplyMsg, SPSC, drained on the network thread). This mirrors the done-action discipline exactly: buffer on the audio thread, publish through the worker join, drain after the walk (a synth re-marked by a mid-block schedule split is harmless — the first drain empties its buffer, later ones find nothing). On the network thread collect_garbage turns each ReplyMsg into OSC: Trig/tr nodeID id value to every /notify client, Reply → the custom address with nodeID replyID value…, Poll → a tracing console line (and a /tr when its trigid is set). The console post lands on the network thread, never the audio thread — the same reason the audio thread never calls a tracing macro. A def with only these UGens and no Out is valid (the compiler requires ≥1 UGen, never an Out); UGenSynth precomputes a has_reply_ugens flag so a synth without them is never marked or drained.

Control/bus mapping (/n_map, /n_mapa)

/n_set writes a control once; /n_map//n_mapa make a control follow a bus, re-read at the start of every block. Each synth carries a ControlMap table parallel to its controls (node::ControlMap, pre-allocated at build — map_control only flips an entry, never grows it). At the top of process, before any UGen runs, the synth pulls each live mapping into its control/zone: a control bus value (/n_map), or one frame of an audio bus sampled at control rate (/n_mapa — controls are one value per block, and Faust zones are scalar, so there is no audio-rate control). Writing straight to the control storage, never through set_control, keeps the mapping intact; a /n_set does go through set_control, which clears the mapping first, so an explicit set always wins (scsynth semantics). Cmd::MapControl carries it to the engine and is schedulable in bundles like /n_set.

This feeds the bus analysis: the network-side mirror records each node's live maps, and fold_maps_into_usage adds an audio map's bus to the node's reads and marks the node a dynamic barrier when a mapped control is used as a bus index — so auto/parallel groups stay correct under mappings.

Typed controls (tr/ir/lag)

A control carries a type (SynthDef::control_types, from the def's "rate") that shapes how the engine treats it. Two are handled inside UGenSynth, RT-safe:

  • Trigger (tr). After the UGen loop, process resets every trigger control to 0. A /n_set therefore holds for exactly the one block it landed in, so a UGen watching for a rising edge (an EnvGen gate) fires once. No extra state — the reset is unconditional and cheap.
  • Scalar (ir). set_control ignores a write to a scalar control once initialized is set (the same S1 flag). The initial /s_new values are applied on the network thread before the first block, so they take; a later /n_set is dropped, matching scsynth. In the compiler an ir control counts as Rate::Ir, so it may feed an ir UGen input.

Lag is not a control type — it is an inserted UGen. A control with a "lag" compiles to a real Lag (or VarLag with lag_down) prepended to the graph, reading the raw control; every reference to that control is rewritten to the smoother's wire (synthdef::compile, the lagged pass, which shifts the original UGens down by the number of inserted smoothers and remaps their wire indices). This keeps one lag implementation shared with the client-facing Lag UGen — no bespoke control-smoothing path. The inserted smoothers run at audio rate, so a stepped control glides per sample toward its block-constant target.

Node moves and the auto-order guard

/n_before//n_after//g_head//g_tail//n_order all lower to Cmd::MoveNode { id, target, place }, and Place (node::Place) is the four-way position: Before/After place next to a sibling (target is that sibling), Head/Tail place first/last in a group (target is that group). Both NodeTree::move_node (audio thread) and its TreeMirror twin resolve the destination group from place — the target's parent for sibling moves, the target itself for head/tail — then do the same reject checks (root, cycle, full group). /n_order addAction target id... places its first node relative to target and chains each following node After the previous one, so the listed order is preserved. The network thread rejects any manual move whose destination is an auto-sorted group (TreeMirror::is_auto_group) with /fail, since there the order is recomputed from the bus DAG — /n_order is therefore only meaningful in manual groups, where it is a batch of the pairwise moves.

Range and query commands (/n_setn, /c_getn, /s_get, …)

The -n/range commands add no new engine Cmds: /n_setn//n_fill expand to SetControl per index, /n_mapn//n_mapan to MapControl per index (buses advancing with the control), /c_setn//c_fill to atomic writes (or SetControlBus in a bundle) per bus — all reusing the group-propagation and re-sort machinery of their scalar forms. The query commands are pure network-thread reads of the mirrors: /s_get//s_getn read the node mirror's control values and reply /n_set; /c_getn reads the control-bus atomics and replies /c_setn; /n_trace logs a node's mirror state. /s_noid and /b_close are compatibility acknowledgements (Clausters does not reuse node IDs, and has no open streaming soundfiles yet).

Group /n_set//n_map and GraphDef

Both live entirely on the network thread, in CmdTranslator, and lower into the same Cmds as a hand-written /s_new//n_set//n_map would — the audio thread learns nothing new.

  • Group propagation. /n_set//n_map//n_mapa addressed to a group walk the TreeMirror subtree (control_targetscollect_subtree_synths, recursing through subgroups, stopping at synths) and emit one SetControl/MapControl per descendant that has a control of that name (resolved through node_defs). Engine SetControl/MapControl on an unknown id are no-ops, so the fan-out is safe even against a node freed concurrently.

  • GraphDef. osc::graphdef holds the GraphDefSpec/GraphInstance/GraphVoice types and a RangeAllocator (a contiguous-run busy map) over the reserved private-bus ranges (audio 96..128, control 896..1024). /d_graph parses, validates structurally and stores the spec (persisted by defstore as defs/graphdefs/<name>.json, reloaded after the synth/faust defs). /graph_new instantiates the shared members: a fallible phase first (pre-build the member synths via make_synth, allocate the private buses — freeing them back on a shortfall) so the infallible phaseAddGroup (auto-sorted, so the sort orders members by their bus wiring), member AddSynths with their bus-selecting controls set to the allocated buses, /n_map wiring, then the resolved surface — never leaves a partial instance. The shared steps factor into alloc_graph_buses/build_members/resolve_ports, reused by /graph_voice, which spawns the per-voice members as a sub-group at the head of the instance (the sort then orders it before the shared mixer via the sub-group's aggregate usage_of). A resolved surface (port → [(node_id, control_index, mul, add)]) lives per instance and per voice; /n_set on an instance or a voice id is intercepted (graph_set) and routed through that surface, never the member ids. /n_free of a voice forgets it (free_graph_node); of an instance reclaims its private buses and drops its voices. A GraphDef bound to MIDI (/midi_bind) spawns the shared instance at bind time and each note becomes a /graph_voice. All of it also runs in NRT scores (the renderer shares translate).

Preallocated capacities and what happens when they fill

Audited: tests/capacity.rs overflows each structure on purpose and pins the behavior below.

StructureCapacity (default)Boot flagWhen full
Command FIFO1024reply /fail … command FIFO full (render mode: abort with the event time)
Garbage FIFO1024spills into a 64-slot holding list retried next block; if that also fills, the memory is leaked (mem::forget) — leaking is the only RT-safe option left
Event FIFO (/n_go//n_end)2048events are POD and best-effort: dropped silently
Reply FIFO (SendReply/SendTrig/Poll)2048POD reply messages, best-effort: dropped silently (plus a per-block per-UGen buffer of 8)
Schedule queue (timed bundles)1024the bundle is rejected and returned whole as a non-empty Garbage::SpentBundle (render mode: abort)
Node slab1024 (node::MAX_NODES)--max-nodescommand rejected → Garbage::Rejected*
Children per non-root group256 (node::MAX_GROUP_CHILDREN)--max-graph-childrencommand rejected → Garbage::Rejected*
Buffer pool1024 (buffer::NUM_BUFFERS)--max-buffers/b_* validates the index up front and replies /fail
Inputs per UGen32 (dsp::MAX_UGEN_INPUTS, hard ceiling)--max-ugen-inputs/d_recv rejects the def with /fail
Audio buses128 (dsp::NUM_AUDIO_BUSES, hard ceiling)--audio-busesbus-index inputs are clamped per block
Control buses1024--control-busesout-of-range reads return 0.0, writes are ignored
IPC rings64 KiB each--shmbackpressure: push fails, the producer retries; nothing is dropped (a full reply ring drops the reply with a log — the client stopped draining)

The boot-flag column marks what is configurable at server start (S7): the four pool sizes plus the buses and the hardware I/O channels (--outputs/--inputs). Every one sizes a slab or Vec built once at startup — fixed at runtime, never at compile time — fed by the same config-file → flag precedence as the other options ([server] keys max_nodes, max_buffers, max_graph_children, max_ugen_inputs, outputs, inputs). Two carry a compile-time hard ceiling the flag clamps to, because they size a fixed-width structure the audio thread relies on: audio buses at 128 (the BusUsage mask is a u128) and UGen inputs at 32 (the per-UGen input list is a stack array in synthdef::instance). A client discovers the live values with /server_info (see schemas.md). tests/capacity.rs overflows the configurable slabs at both the default and a small custom size.

No wire-buffer or RT-memory pool (vs scsynth). scsynth builds the synth graph on the audio thread, so it cannot allocate there and instead pre-sizes global pools at boot: wire buffers (-w), real-time memory for UGen-internal buffers like delay lines (-m), plus max-nodes/-buffers/-buses (-n/-b/-a/-c). Clausters builds each synth on the network thread (off the audio thread) and ships the finished node over the command FIFO, so the per-graph memory is owned per synth and freed per synth through the garbage FIFO: the inter-UGen wires are a Vec<Block> sized to the UGen count in synthdef::instance (cache-line-aligned via Block's #[repr(C, align(64))]), and any future buffer-owning UGen (a delay line, say) allocates its ring in new() the same way. There is therefore no -w and no -m to size — that memory scales with the synths actually running, is contiguous and aligned, never fragments a pool, and never touches the audio thread. Only the counts in the table above are fixed at boot; of those, the buses are the meaningful synthesis capacity (the rest are throughput/headroom limits).

Def persistence (server::defstore, faust::cache)

Loaded defs optionally outlive the process: when a data directory is configured (default on, --data-dir/--no-persist/$CLAUSTERS_DATA_DIR, resolved by defstore::resolve_data_dir), /d_recv and /d_faust write the def to disk and the server reloads everything on startup. The wire format and layout are in schemas.md; the mechanics:

  • Source of truth is the definition, never a compiled artifact. A FaustDef's factory is opaque LLVM JIT state and is not serialized. defs/synthdefs/<name>.json stores the SynthDefSpec verbatim; defs/faustdefs/<name>.json stores a FaustRecord (original source/JSON + libfaust version + payload SHA-256). Reloading recompiles from that — the same path a fresh /d_recv//d_faust takes.
  • The Faust bitcode (<name>.<sha>.bc) is a non-authoritative speed cache (layer "A"). On reload faust::cache::try_restore re-creates the factory from bitcode — skipping Faust's front-end — only if the libfaust version matches and the file reads cleanly; any miss falls back to a full compile and rewrites the cache. So a libfaust upgrade invalidates every .bc, and a corrupt cache can never serve a wrong def. The .bc is named by the payload's SHA so a stale file (interrupted overwrite) is never paired with a fresher record.
  • Threading. Writes happen on the network thread (/d_recv) or the compiler thread (/d_faust, which holds the factory); both are non-RT. Reloads are queued on the compiler thread with client = None (no reply) and drained by collect_faust_results, so the socket serves immediately and a large library loads incrementally. All disk writes are atomic (temp file + rename).
  • MIDI-standalone. midi.json persists the MIDI bindings (rewritten on every /midi_bind//midi_unbind//midi_map); boot.json is a user-authored preset of standalone GraphDefs. attach_store reloads in a fixed boot order — defs → graphdefs → bindings → boot preset — so a binding's instrument and a boot graph's name already resolve; restoring a GraphDef binding re-instantiates its shared instance (CmdTranslator::restore_binding). All of it runs at boot on the network thread, emitting the same pre-built commands as the live path, so the server comes up playable from a controller with no client.

Clocks and scheduling

The engine publishes its sample clock — samples processed since start — as an AtomicU64 (EngineHandle::current_samples). The conversion from an OSC NTP timetag to an absolute sample position happens on the network thread (timetag_delta_secs against the system clock, then delta × sample rate against the stream clock); the engine itself never looks at wall time. A timed bundle becomes Cmd::Schedule { time, cmds } in a pre-allocated, stably-sorted queue; when its sample falls inside a block, the block is genuinely split at that frame — ProcessCtx carries offset + frames, and every UGen/synth processes the sub-range — unlike scsynth, which quantizes to block boundaries and needs OffsetOut. The spent Vec shell of an executed bundle returns through the garbage FIFO (Garbage::SpentBundle) to be freed on the network side.

The NTP conversion is one of two front-ends to the same queue: /sched carries an absolute sample target directly — no wall clock involved — and /clock exposes the counter so clients can model the sample clock as their master timebase (see docs/sample-clock.md). In offline rendering, score timetags are seconds from render start, and the renderer pushes the same Cmd::Schedule commands — that single shared code path is the sample-identity guarantee, and it is why scheduling fixes must never fork between the two modes.

/clearSched (Cmd::ClearSched) flushes the whole queue: the engine drains the schedule Vec (keeping its capacity — no dealloc on the audio thread) and ships each bundle's Vec<Cmd>, with its boxed synths, back as Garbage::SpentBundle to be freed on the network side. So even the panic button stays RT-safe.

Feedback (LocalIn/LocalOut)

A SynthDef graph is a DAG by construction: the compiler rejects any input that references a UGen at index >= i (src/synthdef/mod.rs), and UGens exchange whole blocks through wires processed in topological order. So a feedback loop cannot be wired directly — and intra-block, multi-UGen feedback is impossible in any block-processing engine, because the wire between two UGens only carries a finished block.

LocalIn/LocalOut give the SuperCollider answer: a synth-private feedback bus with one control block (64 samples) of delay. LocalOut writes a signal into UGenSynth::locals[channel] — a Vec<Block> that, unlike the per-UGen wires, persists across process_block calls. LocalIn reads that buffer. Because the compiler requires LocalIn to precede LocalOut for a channel, within a block LocalIn reads the buffer before LocalOut overwrites it — so it sees the previous block's value. That read-before-write order is the entire delay mechanism; no double buffering. It holds under mid-block schedule splits too: each slice reads then writes its own [offset..offset+frames] sub-range.

These two are the one place a UGen needs synth-private state that ProcessCtx (global, shared by the parallel scheduler) cannot carry, so UGenSynth::process handles them inline (matching on def.ugens[i].kind) instead of through the UGen trait; src/dsp/local.rs holds only placeholder structs. They touch no global bus, so osc::graph::ugen_usage gives them empty BusUsage and feedback synths still parallelize safely (private state, nothing shared). The channel index must be a constant (so the buffer is sized at compile time, SynthDef::num_locals).

The delay is block-rate, not sample-accurate: a one-channel loop is a comb resonating at sampleRate / 64. For a sample-accurate recursive filter (one-pole, biquad) or sub-block feedback, the loop must be fused into a single node — a recursive UGen written as one process with internal state (the SinOsc pattern), or a Faust def, whose ~ operator (CboxRec) compiles the whole loop into one compute with the state inside the instance. That fusion is exactly why FaustSynth exists.

The frequency-domain chain (FFT/PV_*/IFFT)

Spectral processing (src/dsp/spectral.rs) is the second consumer of synth-private state, and a second kind of "not every block": the frame rate (fr). An FFT opens a chain, PV_* UGens transform the frame, an IFFT closes it back to audio. Like the demand (dr) sub-list, this is not block-execution as usual — but where dr is a pull, fr is a hop gate: FFT emits one spectral frame every hop's worth of input samples, and the PV_*/IFFT only do work on the slices a fresh frame is ready.

The three UGens share one SpectralChain — the packed complex frame ([dc, nyquist, re₁, im₁, …], the clausters_core::fft::rfft_into layout), a ready flag, and the hop advance. It is synth-private scratch in UGenSynth::chains, allocated at instantiation and persistent across blocks, exactly like the feedback locals. Each spectral UGen carries a compile-assigned chain slot: the FFT (SpectralRole::Source) gets a fresh slot, recorded in SynthDef::spectral_sizes; each PV_*/IFFT (Filter/Sink) follows its input-0 wire back to the upstream spectral UGen and inherits that slot (and window size, so the size is given only on the FFT). UGenSynth::process special-cases ExecMode::Spectral — it resolves the slot and calls UGen::process_spectral(ctx, inputs, output, &mut self.chains[slot]); the wire between the UGens only enforces topological order (chains is a distinct field from ugens, so both borrow mutably at once). This is the same "synth-coordinated execution" mechanism as LocalIn/LocalOut and the demand driver, generalized: the small closed set of ExecModes the engine special-cases.

The transform stays RT-safe by construction: FFT keeps a sliding input buffer, IFFT an overlap-add tail and an output FIFO, all sized at build (network thread); the per-hop rfft_into/irfft_into run in pre-allocated scratch (microfft is zero-allocation). The advance carried on the chain keeps analysis and resynthesis in lockstep no matter how the hop relates to the block size (the hop is effectively quantized up to the processing slice, as scsynth transforms at block granularity). The IFFT overlap-add is window-normalized: it divides each output sample by a precomputed steady-state overlap (COLA) denominatorΣ window[phase + i·hop]², one value per hop phase — so a bare FFTIFFT reconstructs at unity gain (one window of latency), and a spectrally modified frame or the under-overlapped startup fades cleanly instead of over-amplifying where the window is small (dividing by a running partial window sum would blow up those edges). FFT/IFFT accept a live /u_cmd window <wintype> (the first consumer of the S6 per-UGen command surface) through UGen::command, rebuilding the window off any hop.

Why not scsynth's buffer. scsynth's chain threads the frame through a client-allocated buffer the audio thread mutates in place. That would violate the immutability invariant of the sample-buffer pool (below), so Clausters keeps the frame in synth-private scratch instead — no /b_alloc, the pool stays immutable, and the chain is freed with the synth (the LocalBuf model). Copying a frame into a pool buffer for inspection is a possible future extension.

Invariants — do not break these

  1. The audio thread never allocates, frees, locks or does I/O. Engine::process_block and everything it calls — including the parallel dispatch (atomics, bounded spins, at worst an unpark) and the CPU meter (Instant::now is a vDSO clock read, not a syscall). Guarded by tests/rt_safety.rs (assert_no_alloc); new processing code must stay under that umbrella. The only exception (rtprio builds only) is deliberate, one-shot and before steady state: the first callbacks run server::rt::RtSetup (optional self-pinning, one scheduling read), a handful of syscalls that never recur.
  2. Commands arrive fully built. If a handler needs the audio thread to "finish" constructing something, the design is wrong.
  3. All incoming OSC bytes decode through osc::decode_packet. Whatever the transport — UDP datagrams and IPC ring contents are equally untrusted, and one entry point keeps decoding (and any future hardening) in one place. It is a thin wrapper over rosc::decoder::decode_udp.
  4. RT and NRT render sample-identically. Same engine, same schedule queue, same FPU mode — flush-to-zero is armed in the cpal callback, in render() and in every DSP worker at spawn, and Faust factories get -ftz 2. Keep all the call sites (tests/denormals.rs, Faust tail test in tests/golden.rs). One deliberate exception: the Faust compiler runs inside dsp::denormals::normal_precision — libfaust's front-end does real double math (interval typing, LLVM folding) that must see IEEE semantics even when the NRT renderer compiles scored defs on its armed render thread (its interval assertions abort the process on a flushed bound); the guard restores the armed mode on exit, so the processing invariant is untouched. Corollary: parallel execution is bit-identical to sequential — a stage only batches children with pairwise disjoint bus usage, verified by the engine against its own masks (tests/parallel.rs). Never weaken the stage partition rule: concurrent same-bus access is not just wrong ordering, it is the unsafe contract of Buses::audio_mut and of the per-slot UnsafeCells in NodeTree.
  5. Buffers are immutable once installed. Replace, never mutate. A recording UGen would need a new scheme — design it, don't poke holes.
  6. Synth output goes only through Out/ReplaceOut (and the Faust reserved out mapping). There is no implicit output.
  7. The core builds and tests with any combination of the def-family featuressynth (SynthDefs/UGens, default) and faust (FaustDefs) are independent and combinable, and the engine core builds with neither (and without libfaust installed, and without realtime/cpal: the renderer and the whole test suite run deviceless). Everything Faust hides behind #[cfg(feature = "faust")]; the UGen library, the def compiler and UGenSynth hide behind #[cfg(feature = "synth")]. The node tree only knows dyn SynthNode, which is what keeps the families symmetrical.
  8. Binary boundaries are versioned. The IPC segment layout and the embed C ABI share one version constant (ipc::ABI_VERSION), checked on attach/load; tests/ipc.rs pins the layout size. Any layout or C-ABI change bumps it — never ship an unversioned boundary (the scsynth plugin-ABI lesson).
  9. Determinism in tests. Golden scenes must be reproducible: no wall-clock, no global seeds shared across parallel tests (WhiteNoise seeds from a global counter — keep it out of golden scenes), tolerances per tests/golden.rs (1e-4: libm differs across platforms, same machine is bit-exact).

How to add a UGen

Using a hypothetical Lag (one-pole smoother, inputs in and time):

  1. DSP — new file src/dsp/lag.rs (or extend a family file):

    use crate::dsp::{ProcessCtx, UGen, at};
    
    pub struct Lag {
        y: f32, // state lives in the struct, initialized in new()
    }
    
    impl Lag {
        pub fn new() -> Self { Self { y: 0.0 } }
    }
    
    impl UGen for Lag {
        fn process(&mut self, ctx: &mut ProcessCtx, inputs: &[&[f32]], output: &mut [f32]) {
            for i in 0..output.len() {
                let x = at(inputs[0], i);          // block or single-sample input
                let t = at(inputs[1], i).max(0.0);
                let coeff = coeff_for(t, ctx.sample_rate);
                self.y += coeff * (x - self.y);
                output[i] = self.y;
            }
        }
    }

    Rules: no allocation/locks/I/O in process (the struct is built on the network thread — allocate there, in new()); read inputs with at() so constants, controls and wires all work; output.len() is the slice length, not BLOCK_SIZE — scheduled bundles split blocks. Only bus-touching UGens need ctx.offset (see src/dsp/io.rs): bus slices must be indexed at offset..offset+frames.

  2. Registersrc/dsp/registry.rs: add one row to the UGENS table, a UGenDescriptor giving the wire name, arity, default_rate, the allowed rates, its exec mode, bus role, whether it needs_path, and a build closure. Declare the module in src/dsp/mod.rs. That is the whole registration — there is no central match kind: the compiler and the bus analysis read the descriptor fields, so a def with the wrong input count, an unknown kind or a disallowed rate already fails in /d_recv with a pointed error, generically. A variable-arity UGen (like EnvGen, whose envelope array grows with the segment count) uses Arity::Variadic; the compiler then skips the exact-count check but still rejects a def whose inputs exceed MAX_UGEN_INPUTS (the per-synth input array is stack-sized to that bound). For a plain signal processor you copy a SinOsc-style row and change the name/arity/build; the rate fields default to audio-or-control rate (see the next section). A UGen that frees its own node (an envelope reaching its end) also implements the done(&self) -> DoneAction trait method; it is polled after every process, and FreeSelf routes the node to the tree's finished-node queue and out through the garbage FIFO (see "Done actions" above). Default is DoneAction::None. A UGen that takes out-of-band commands (an FFT setting a buffer, a streaming reader seeking) implements the command(&mut self, cmd: &UGenCmd) trait method: /u_cmd nodeID ugenIndex name args... validates the target on the network thread, hashes name to a stable selector (dsp::ugen_cmd_selector), packs the numeric args inline (no heap crosses to the audio thread), and Cmd::UGenCommand routes them to SynthNode::ugen_commandugens[index].command. Match self's selectors against ugen_cmd_selector("myCommand"); unknown selectors are a no-op. Default ignores every command. It runs on the audio thread — stay allocation-free. A UGen that emits an OSC reply or console post (like SendReply/SendTrig/Poll) returns true from is_reply() and buffers ReplyMsgs during process into a fixed inline array, draining them in drain_replies(node_id, sink); the tree marks such synths and the engine ships the messages out through the reply FIFO after the block (see "Side-effect replies" above). Both defaults are no-ops, so a normal UGen ignores this. Stay allocation-free — the ReplyMsg is Copy and inline for exactly that reason. A UGen in a spectral chain (FFT/PV_*/IFFT) sets exec: ExecMode::Spectral and a spectral role (Source/Filter/Sink), and implements process_spectral(ctx, inputs, output, chain) instead of process: it reads/writes the synth-private SpectralChain the synth resolves by its compile-assigned slot (see "The frequency-domain chain" above). Only FFT (a Source) declares a window size (config.fft_size, sized at build); the compiler propagates it and the slot to the downstream filters/sink.

  3. Tests — a signal-level unit test (render offline, assert on the numbers: frequency by zero crossings, RMS, impulse/step response — see tests/synthdef.rs and the audio-testing skill). If the UGen has state, also assert it behaves across block splits (see tests/scheduling.rs patterns). RT-safety is covered as long as process follows rule 1 — tests/rt_safety.rs exercises the tree with assert_no_alloc, extend its scene if the UGen does something novel (first buffer reader, first bus feedback, …). Add a golden scene only if the UGen is meant to be regression-pinned (then regenerate with cargo run --example render_golden and listen before committing).

  4. Document — the UGen kinds table in docs/schemas.md (name, inputs, output semantics), and a smoke step in GUIA.md if it adds audible behavior worth checking by ear.

Adding an arithmetic operator, not a UGen. Math operations (min, clip2, midicps, …) are not new kinds — they are entries in the two generic op UGens, BinaryOpUGen/UnaryOpUGen (src/dsp/binop.rs, src/dsp/unop.rs), which carry the operator by name in their op field. To add one you add a variant (its scalar formula, and a name()/from_name spelling) to the BinaryOp/UnaryOp enums in clausters_core::builtins — the shared crate — and nothing else: the compiler resolves the wire name to the op, the server's op UGens compute it, and the client's value FFI computes the same op off the RT path, so the two agree bit-for-bit (guarded by tests/core_parity.rs, which drives the whole table through the real UGen::process). This is the C0 "single source of truth for native ops" discipline; the registry's op_family field marks the two op-UGen rows so the compiler validates the operator name. Each op also has a stable integer id for the C ABI (from_u32), but that never crosses the wire — defs and clients use names. The fused MulAdd/Sum3/Sum4 (src/dsp/fused.rs) are ordinary fixed kinds that compose the core operators. Prefer an operator over a new kind for anything expressible as a pure per-sample function of its inputs.

Calculation rates (ir/kr/ar/dr)

Every UGen output carries an explicit rate, the same four scsynth uses, decided per instance in the def ("rate": "kr") or by the kind's default. The rate is what makes the output wire's shape and schedule explicit instead of implied:

ratewirewhen it runsexample
arfull [Block] (one value/sample)every blockSinOsc, Out
krlength-1 (one value/block)every block, oncea control-rate Mul
irlength-1 (one value, then held)once, at synth initSampleRate, Rand, BufFrames.ir
drnone — pulledon demand by a driverDseq under Demand

Downstream this is invisible: a kr/ir wire is a length-1 slice, which at() broadcasts as a constant, so an ar consumer reads it the same as a constant. The synth (synthdef::instance) sizes each output slice by the producer's rate, and reads ir wires straight back on later blocks.

Each UGen's rate metadata is data on its descriptor: default_rate (the rate when the def omits one) and rates (which rates the UGen implements). There is no central rate switch — a new oscillator/filter/math UGen just sets default_rate: Rate::Ar and rates: R_KR_AR (audio-or-control rate), the same as SinOsc. Only the exceptions differ in their row: the scalar ir UGens (SampleRate, Rand) and the dr source (Dseq) widen or move the set, and the whole-block I/O UGens narrow it to ar only (a length-1 wire would drop the block they read/write).

The compiler infers each output rate, checks it against the descriptor's rates, and validates coercion generically: lower rates widen into higher-rate inputs for free, so the only rejections are an ir UGen fed anything non-ir (it is frozen at init — a varying source can't be), and dr crossing the block boundary (below).

The ir init pass. ir UGens are computed once, on the first process block; their wire then holds the value for the node's life (wires persist across blocks) and the UGen is skipped from then on (an initialized flag). This runs on the audio thread — not in UGenSynth::new — because the value often needs ctx (the sample rate, the buffer pool), which only exists there; it stays RT-safe because an ir process only reads. Rand.ir is the sharp test: recomputing it would give a new number every block, so it only stays constant because the pass runs it exactly once.

The dr sub-list contract. A demand UGen is not in block-execution order. It is a sub-list its driver owns: the driver (Demand) pulls one value at a time through UGen::demand, the source (Dseq) yields the next value of its stream per pull (NaN = exhausted). The synth wires the two in UGenSynth::process exactly like LocalIn/LocalOut are special-cased: the source is skipped in block order and reached only through the driver's step callback, so there is a single mutable path to it (no aliasing) and no allocation. A dr wire may therefore feed only a demand driver's source slot — the compiler rejects a dr output anywhere else, and a driver whose source slot is not dr. This is the substrate the wider demand family (Dseries/Dwhite/Duty) builds on, and it shares its "not evaluated every block" idea with FFT's frame-rate chains.

Faust integration notes

The lifecycle is factory (compiler thread, ffi_lock, -single -ftz 2, stdlib include) → FaustDef (factory + params probed once with UIGlue) → FaustSynth instance (created and inited on the network thread, only compute runs on the audio thread, dies through the garbage FIFO holding its Arc<FaustDef> so the factory outlives every instance). Details and traps: the faust-embedding skill and src/faust/* module docs.

Why Faust UI labels are the control names (a deliberate decision, not a leftover): a def's parameters are named by whoever writes the def — exactly like the controls array in the UGen JSON format. Inventing a second naming layer (renaming, indices-only, a Clausters-side mapping table) would add a translation step for zero expressiveness: the label is the parameter's name in both def families, and /s_new//n_set address both identically. Group paths (hgroup/vgroup) are ignored on purpose — bare labels, first declaration wins on collision. The two reserved names out/in (first output/input bus) come after the def's own params, and a def that declares its own out/in control wins over the reserved meaning. UI elements are plain values written by /n_set (zone stores, RT-safe) and can also be bound to a bus with /n_map//n_mapa — the same mechanism unifies UGen controls and Faust zones (see Control/bus mapping above). The two reserved out/in routing controls are not mappable.

The arrangement layer: where it lives

The arrangement model and its multitrack editor are client-side — the server knows nothing of them, and nothing in this section runs on the audio thread. What makes the layer thin is that every one of its concepts is a name for something the system already had; this is the map, for when you have to change one and want to know what else it touches. (The user-facing explanation is the composition chapter of the Python client's book; the reasoning behind it is in Design decisions.)

ConceptWhat it already wasWhere
Element (onset, duration)a placed item on a timelineclients/python/clausters/form/element.py, seq/timeline.py
Group, concretea Timeline (client) projected onto server groups and timetagged bundlesform/group.py, seq/timeline.py, src/node/mod.rs
Group, logicala GraphDef — the bus-wired configuration the server already expressesform/group.py (to_graphdef), defs/graphdef.py, src/osc/graphdef.rs
Eventseq.Event (parameters in one action)seq/event.py
List (order, no concrete time)a Python list, or a Patternseq/pattern.py
Buffer (a list at constant time)defs.Buffer over the server's immutable buffersdefs/buffer.py, src/dsp/buffer.rs
Set (mixed placement — a track)seq.Timelineseq/timeline.py
Function (a process)a def (SynthDef/FaustDef/GraphDef) or a Pbind/Routinedefs/, seq/pattern.py, base/stream.py
Automation (a curve)an Env discretized into a control buffer, read onto a busseq/automation.py, /b_gen "env", src/dsp/io.rs (OutCtl)
Change of state (generator → generated)evaluating a def or bouncing a patternTimeline.from_pattern, session.py, src/server/render.rs
Rendering (in time)timetagged bundles (RT) or a Score (NRT) — one flattening, two destinationsform/render.py, seq/timeline.py (Playhead), src/server/render.rs
The editor driver (data ↔ view)— the one piece that is new, and the only one that knows bothclients/python/clausters/gui/editor.py
Graphic unit (a clip: length = duration)the placed rectangle and its bodiesclients/gui/src/host/track.rs
Base level (coarser or finer)the LOD rule, and a group collapsed to a summary or resolved into lanesclients/gui/src/{waveform,spectrogram}.rs, gui/editor.py
Shared time axis, playhead, cursorthe navigation groups (linked views), grown to hold lanesclients/gui/src/host/timeline.rs

Two boundaries hold this together, and both are worth defending: clausters.form imports nothing from the GUI (it is pure and transport-agnostic — the piece a future client factors into the shared core), and the driver is the only converter between the arrangement's beats and the view's timeline samples.

The GUI host: structure, and how to add a widget

The GUI (clients/gui) is a separate process, not code linked into the audio server — it is a peer that speaks the /gui_* protocol to the language clients and, with its own client leg, speaks the audio protocol to the server (see Clients and language bindings for the why). Its internals follow one split, and every rule below falls out of it:

PathContents
src/host/mod.rsThe protocol core: the Host, the command dispatch (/gui_def//gui_set//gui_free//gui_query//gui_bind), the Registry, HostEffect
src/host/widget.rsWidgetKind — the typed tree the renderer reads — plus its JSON parse and its /gui_set apply
src/host/frame.rsThe one frame renderer: places the tree (layout), builds the flat-geometry mesh and uploads the heavy GPU views. Both fronts call it, so the browser is pixel-faithful by construction
src/host/{gui,web}.rsThe two fronts: native (winit/wgpu, sockets, mmap) and browser (canvas/WebGPU, WebSocket, fetch). Event sources and sinks only
src/host/interact.rsPointer logic over the typed tree — hit-test, value writes, the edit-back payloads — shared by both fronts
src/host/{track,pianoroll,bpf,plot,graph,nodetree,meters,…}.rsOne module per flat view: pure over a Mesh, unit-tested without a window. pianoroll is the note core (the notes, their mapping, drawing, hit-test, editing) shared by the dedicated pianoroll widget and the multitrack clip's roll body, so the two never disagree — the bpf::place_point reuse move again
src/{waveform,spectrogram,viewport}.rsThe heavy GPU views and the navigation window (View)
src/host/timeline.rsThe navigation groups: the shared window/selection/playhead of linked views and of the multitrack's aligned lanes
src/host/{bulk,fetch,shm,mapfile}.rsThe data seams: a local resource is mapped (native) or fetched (browser), a server buffer is pulled over the client leg, control buses are read from the shared segment

Four rules hold the design together, and breaking any of them is what a review looks for:

  • Generic on the wire, typed in the renderer. A GuiDef is {id, type, props, children} and nothing more. A new widget is a new WidgetKind variant plus a handler — the /gui_* vocabulary never grows for it, and an unknown type is laid out but not painted, so an old host and a new script still interoperate.
  • Never resolve the signal finer than the screen. Work is bounded by samples_per_px; the expensive analysis (a peak pyramid, an STFT) is a cache reached through a local shared resource, never chunked over the wire.
  • Value math belongs to the core, display math to the host. Anything a headless client could also want — envelope shapes, peak pyramids, FFT windows, scales, tempo/sample arithmetic — lives in clausters-core and is shared; only geometry, hit-testing and chrome are gui-side. This is why the bpf editor and the server's EnvGen cannot drift: they evaluate the same function.
  • Edit-back is a payload, not an address. A view that writes data back emits /gui_event <id> <tag> <flat values…>"points" for a curve, "clip" for a placement, "wire" for a connection. The widget tree stays the source of truth on the host side; the script's model stays it on the other.

Adding a widget

Take a hypothetical meterbar. The steps are always the same:

  1. The typed kind — a WidgetKind::MeterBar { … } variant in host/widget.rs, its arm in the JSON parse (Widget::build) and, for every live-updatable prop, an arm in WidgetKind::apply (that is /gui_set).
  2. The view — a module host/meterbar.rs, pure over a Mesh: layout, draw, and (if it is interactive) a hit-test. No GPU, no platform: that is what makes it unit-testable, and the tests go beside it. A heavy view (one that needs a texture or a vertex buffer) instead gets a GPU slot in frame.rs and follows the LOD rule above.
  3. The frame — collect the placed widget in frame::render and draw it. Base mesh for the view, overlay mesh for chrome that must read on top (selection, playhead, wires in flight).
  4. Interaction, if any — the hit-test and the mutation go in host/interact.rs (shared, so both fronts behave the same); the gesture (which button, which drag state) belongs to the front. If the widget writes data back, add its flat event payload here — never a new OSC address.
  5. The Python builder — a function in clients/python/clausters/gui/guidef.py returning the node dict, with the docstring that is the widget's user reference (the API page is generated from it).
  6. Tests and an example — pure tests for the layout/hit-test/edit, and a clients/python/examples/gui_*.py when the widget is user-facing.

A new element kind (a new primitive of the arrangement) is the client's business, not the host's: it lands in clients/python/clausters/form/, and it reaches the screen through the editor driver (clausters/gui/editor.py), which is the only module that knows both the arrangement and the widget tree. The Python book's composition chapter is its user documentation.

Extending the server: the plugin question

scsynth grew a binary plugin API (UnitCmd/interface tables) whose ABI broke with struct or feature changes, and keeping out-of-tree UGens alive became a maintenance tax. Rust removes the temptation: there is no stable Rust ABI, so dynamically loaded Rust plugins are off the table in v1, by decision and not omission:

  • Extending Clausters = adding code to the crate (the section above). The documented internal surface — UGen + ProcessCtx + the registry, and SynthNode for whole-synth implementations — is the contract, and it can evolve freely because everything compiles together.
  • The runtime-extensible path for users is deliberately not Rust: send a Faust def (/d_faust, JIT-compiled, sandboxed by the same SynthNode boundary). Most "I need a custom UGen" cases are a Faust one-liner.
  • If dynamic plugins ever become necessary, the lesson from scsynth applies: the boundary must be a versioned C ABI (or a wasm interface), checked at load time — the same policy already planned for the shared-memory layout and embed cdylib. Do not expose Rust types across it.

Design decisions & findings

The non-obvious choices behind Clausters, and the upstream bugs we had to work around — the "why it is this way" that the code alone cannot explain. Each entry is written in ADR spirit: context → decision → consequence.

This is a curated record, not a changelog. What shipped and when lives in the git history; the full historical build journal (kept for reference, frozen) is history/build-log.md. When a milestone makes a choice with non-obvious context, add a short entry here — not a per-milestone diary.

Threading and the RT-safe boundary

The OSC network thread may allocate, lock and do I/O freely; the audio thread (the cpal callback, or render() in NRT) may do none of those. Everything the audio thread needs arrives fully pre-built over lock-free FIFOs, and freed memory leaves through a garbage FIFO to be dropped on the network thread.

  • Defs and their control-name tables live only on the network thread. The HashMap<String, Arc<SynthDef>> and a mirror node_id → Arc<SynthDef> (kept from /s_new, cleaned on Garbage::Freed) stay off the audio thread, so Cmd::SetControl is plain-old-data and the audio thread never compares strings. /d_free only removes the map entry — live synths keep their Arc (exact scsynth semantics).
  • UGen wiring allocates nothing. UGenSynth::process builds each UGen's inputs in a fixed stack array (MAX_UGEN_INPUTS) via split_at_mut over the wires; the topological order guarantees inputs only read earlier wires. Guarded by assert_no_alloc.
  • Asynchronous command semantics are deliberate. A /status immediately after a command may report the old count: commands apply at the start of the next block. That is scsynth's model, not a race.

SynthNode: one trait, symmetric def families

The node tree and FIFOs handle Box<dyn SynthNode>, never a concrete synth type. This was introduced before it was strictly needed so that a second def family (Faust) could join the tree without touching the engine or the tree. Consequence: the synth (SynthDef/UGen) and faust (FaustDef) families are independent Cargo features that combine freely — new work feature-gates against dyn SynthNode and stays symmetric.

Buses, execution order, and the network-thread mirror

  • Control buses bypass the command FIFO. /c_set//c_get operate directly on shared atomics from the network thread; a synth sees the change on its next block — the same effect as routing through the FIFO, without the traffic.
  • Execution order is audible and testable. Out sums, ReplaceOut overwrites; the order tests use a ReplaceOut(0.0) "silencer" that wins or loses a bus depending on whether it runs after or before the source.
  • The mirror can run ahead of the engine. The auto-order mirror reflects commands when sent, so a scheduled future bundle is already mirrored; queryTree may briefly show that future state and a re-sort converges on the next change. Correctness never depends on the mirror (see parallel groups).

Parallel groups are bit-identical to sequential

The safety of parallelism must not depend on the network mirror (which can run ahead). So BusUsage masks travel to the engine inside Cmd::AddSynth (and are re-sent by Cmd::SetUsage when a /n_set touches a control used as a bus index), and stage partitioning happens on the audio thread with its own data — pure bitops, no allocation. A greedy per-block rule in child order closes a stage on the first bus conflict, so writers to the same bus serialize in order.

Consequence: a stage's members touch pairwise-disjoint buses and never read what the stage writes, so results are independent of interleaving and the stages preserve order — --workers changes only wall-clock time, never the samples. Golden and RT/NRT-identity tests guard this.

Denormal protection (three independent pieces)

Subnormals appear in recursive states decaying to zero (filter tails, envelopes, Faust recursions) and resolve 10–100× slower in microcode on many CPUs — exactly as a sound fades out. Three guards, kept in lockstep:

  1. dsp::denormals::flush_to_zero() puts the calling thread in flush-to-zero mode (MXCSR FTZ+DAZ on x86-64, FPCR.FZ on aarch64, via inline asm — the _mm_setcsr intrinsics are deprecated). Re-armed in every cpal callback and armed at the start of render(): FTZ changes results, so NRT must arm it too to stay sample-identical to live.
  2. -ftz 2 in every Faust factory flushes recursive variables below the normal range, independent of architecture and thread FPU mode.
  3. The one exception: the Faust compiler path runs inside dsp::denormals::normal_precision — libfaust's interval typing aborts under FTZ/DAZ, and the guard restores the armed mode on exit.

Guarded by tests/denormals.rs and the Faust-tail test in tests/golden.rs.

Faust embedding: decisions and upstream bugs

  • Ubuntu's libfaust is unusable for embedding — built without the LLVM backend and shipped without headers. libfaust must be built from source with the LLVM backend; the reproducible recipe lives in BUILD.md.
  • A hand-written FFI, not a crate. faust-build/faust-types do Faust→Rust codegen at build time (they need the faust compiler and static DSP source) and do not embed the JIT; there is no maintained libfaust binding. We bind ~30 functions by hand against the real headers, avoiding a libclang build dependency. Because dynamic linking is lazy, a mistyped FFI symbol only fails when called — so a "kitchen sink" test exercises every schema op once.
  • libfaust does not tolerate concurrent compilation. Two compiler threads in one process SIGSEGV — Faust's global compiler state is not thread-safe, even for createCDSPFactoryFromString. Fix: a process-global compiler::ffi_lock() around every compilation call. A server has a single compiler thread, but the test harness (and any multi-server embedder) needs the lock. With every FFI compilation path behind it, the faust suites now run in the ordinary parallel harness; the historical --test-threads=1 rule is retired (a SIGSEGV there would mean a path skipped the lock).
  • Instantiation does not take the lock. Creating instances from an already-compiled factory is independent of the compiler's global state (JIT code + malloc; FaustLive does it concurrently with compilations). The lock stays only for compiling. (Open caveat: deleteDSPFactory may touch the global factory table and can flake under parallel test load — non-blocking for a real single-compiler server.)
  • Upstream bug — boxFmod() returns abs. boxFmod() in compiler/box_signal_api.cpp returns the abs primitive (a copy-paste bug, present through master-dev). Workaround: build fmod from a CDSPToBoxes("process = fmod;") fragment instead of the binding.
  • Upstream bug — the cos box returned abs likewise; fixed the same way.

Faust is a default feature, and the wheel ships it

faust used to be an opt-in Cargo feature, purely to keep a development build free of the libfaust dependency. The cost of that convenience landed on the user: the packaged artifacts were built with the default features, so an installed wheel could not compile a FaustDef at all — /d_faust replied /fail, and one of the two def families was, in practice, unavailable. Two families we document as peers cannot have one of them missing from the product.

So faust joins the default set, and the packaging bundles it:

  • A default build needs libfaust (built with the LLVM backend, a one-time from-source install; BUILD.md). Building without it stays supported and tested — --no-default-features --features synth,realtime,… is a SynthDef-only server with no libfaust on the machine — but it is now the explicit choice, not the default one.
  • The wheel carries libfaust and libLLVM in clausters/_libs/, staged by clients/python/build_native.py off the built artifacts (keyed by the exact soname the loader asks for). libLLVM is ~130 MB, which takes the wheel from a few MB to ~50 MB packed. That is not accidental weight: the Faust JIT is LLVM, and the alternative (static LLVM inside libfaust) is no lighter — the server binary and the embed cdylib would each embed their own copy, where the bundled shared library is loaded once by both.
  • DT_RPATH, not DT_RUNPATH. build.rs emits -Wl,--disable-new-dtags with an rpath of $ORIGIN, $ORIGIN/../_libs and the build prefix. The $ORIGIN entries make the artifacts relocatable (the wheel's _bin/clausters finds ../_libs/libfaust.so.2), and DT_RPATH is required because only it is inherited by transitive dependencies: libfaust itself carries no rpath, so its libLLVM is resolved through ours. With RUNPATH the loader would fall back to the system libLLVM — or find none.

CI with a default faust: one shared libfaust, and a baseline JIT CPU

Making faust default turned every job that links the default build (clippy, the default-set tests, the Python client, the release wheel/server) into a libfaust consumer, where before only one dedicated job needed it. Two choices keep that cheap and green:

  • One libfaust job builds it; everyone else restores a cache. A single job runs the from-source build and, crucially, stages the libLLVM it JITs with into <prefix>/lib beside libfaust.so. Downstream jobs needs: it and restore the cache through the .github/actions/libfaust composite; the same DT_RPATH that makes the wheel self-contained (<prefix>/lib, inherited transitively) then resolves both libfaust and its libLLVM, so a consumer needs no LLVM runtime installed — only the restore. A warm cache makes every downstream job free. release.yml uses the same composite, so the wheel build no longer sets up libfaust by hand.
  • The build is vendored and pinned, so the bundle is reproducible. The Faust source is too heavy to commit (it stays git-ignored under third_party/faust), so reproducibility lives in two committed files: third_party/faust.pin (the exact commit — the unpatched base, keeping the boxcos/boxfmod canaries valid — and the LLVM version) and third_party/build-faust.sh (the one recipe). The composite, release.yml and a developer all run that script and read that pin — the CI cache key included — so the bundled libfaust/libLLVM are the same everywhere, not whatever the build host defaulted to. LLVM is pinned to 18: it is Ubuntu 24.04's default (CI installs it from the distro repos, no apt.llvm.org), and the distro build targets a baseline x86-64, which keeps the wheel's ~130 MB libLLVM portable — it runs on any x86-64 CPU. A newer upstream build (e.g. apt.llvm.org's 21) is built for a higher baseline and can itself hit an illegal instruction on older machines, so it is the wrong thing to ship; a developer who happens to have another LLVM builds locally with LLVM_CONFIG=llvm-config-NN (the FFI surface is version-independent).
  • CI pins a baseline JIT target (CLAUSTERS_FAUST_TARGET). The Faust factory JITs for "" = the host machine, and LLVM tunes the code to the detected CPU — correct and fast on a real machine, which is why production leaves it unset. But virtualized CI runners can report CPU features the hypervisor then traps, so host-tuned JIT code hits an illegal instruction at run time on some Azure SKUs (seen intermittently, VM-dependent). The override forces a baseline x86-64 target. It must be a full triple (x86_64-unknown-linux-gnu:x86-64): faust sets mcpu from the string, but with an empty triple newer LLVM still host-detects the CPU features and re-introduces the crash — only a concrete triple pins it down. The override is a plain env var read by faust::compiler::host_target, so only CI opts in.

Def persistence: transparent JSON + a non-authoritative bitcode cache

Two layers, decided with the user:

  • B — JSON is the transparent source of truth. synthdefs/<name>.json is the SynthDefSpec verbatim; faustdefs/<name>.json is a FaustRecord (source/JSON + libfaust version + payload sha256). Reload = recompile from there, by the same path as a fresh /d_recv//d_faust. The FaustDef itself is never serialized — its factory is opaque LLVM JIT state.
  • A — the LLVM bitcode cache is non-authoritative. faustdefs/<name>.<sha16>.bc is restored only if the libfaust version matches and the file reads well; any miss recompiles from source and rewrites it. A libfaust upgrade invalidates every .bc automatically, and a corrupt cache never serves a wrong def (named by payload sha, so a stale .bc never pairs with a newer record).

MIDI: standard channel-voice, byte-identical to OSC, in a shared crate

  • A MIDI voice realizes the same OSC commands an OSC client would send. CmdTranslator::translate_midi maps note-on → /s_new (with freq/amp from named conversions), note-off → /n_free or /n_set gate 0, aftertouch/CC/bend → /n_set on live voices. Reusing the OSC path makes a MIDI voice byte-identical to its OSC equivalent, and the reserved voice-ID range (MIDI_NODE_ID_BASE) stays disjoint from client and /s_new -1 IDs.
  • MIDI lives in a reusable native crate, not the Python client. The original plan (MIDI 1.0 in a Python library) was scrapped: MIDI belongs in crates/clausters-midi (a versioned C ABI, shared by client and server), with the message layer in MIDI 2.0/UMP for high resolution (16-bit velocity, 32-bit controllers, no 7-bit loss), persistence to a MIDI 2.0 clip file and to .mid (SMF) for interop.

One global transport; the server broadcasts control, not audio

Decided with the user: a single global transport, and a conductor's play/stop/locate drives every client's playhead in lockstep. The server broadcasts transport control (/transport_play|stop|locate, pushed to /notify clients) and never schedules audio — each client rolls its own playhead on the shared grid. The /transport.reply grew playing and position fields, kept backward-compatible (older clients read the first three).

One seeded random context per script

Per-pattern seeds were a design error (corrected with the user): a piece is only reproducible end-to-end if everything random shares one seedable context — the sclang model. main.seed(n) seeds the root; every Routine/Stream derives its own generator at creation from the creating context; a draw uses the running routine's generator (thread-local), falling back to the root outside a routine. One root seed reproduces a whole script in creation order, and concurrent routines stay reproducible per routine regardless of wake interleaving. The generator itself is the core's seeded Rng over the FFI, so a seeded script replays identically in every client language.

Real-time scheduling: opt-in, then restored as default

A two-step decision worth recording because it reversed:

  • First made opt-in (M24b): the default rtprio promotion tripped RTKit's RLIMIT_RTTIME watchdog under sustained overload and the kernel killed the process with SIGXCPU — a silent death that hung clients. Overload must break the audio, never the process.
  • Then restored as default (M24c): with a SIGXCPU guard in place (sustained overload demotes the audio thread to SCHED_OTHER — audio degrades, server survives), only the performance cost of not scheduling RT remained. Measured on one machine: ~300 stable 1-sine nodes as SCHED_OTHER (124% peak at 36% avg) vs ~500+ as SCHED_RR (92% peak at 34% avg) — the average misleads, the callback must fit its worst block. An RT-scheduled callback is the standard operating mode of every production Linux audio client, so the default came back, kept safe by the guard.

Editor y-axis navigation: gestures on the ruler strip, props in display units

The editor-grade views (waveform, spectrogram) zoom and pan vertically. Two choices were on the table and both are worth recording:

  • Gesture surface: the y-ruler strip, not a modifier over the body. The wheel over the strip zooms the vertical axis (anchored at the cursor's height within its lane) and dragging the strip pans it; the wheel over the body stays horizontal zoom and plain/Shift drag keep selecting/panning time. Spatial separation needs no modifier chord, leaves every existing body gesture untouched, and matches the audio-editor convention (Audacity, most DAWs pan/zoom an axis by grabbing its ruler). The strip only exists when the ruler is on (ruler_y != "off"), which is also the only time the axis has visual feedback to navigate against.
  • Prop shape: y_start/y_len in normalized display units (0 = axis bottom, 1 = top; 0, 1 = no zoom; a non-positive y_len resets), on the shared EditorProps — not amplitude/frequency values. Display coordinates make the anchor math linear and unit-independent: the spectrogram's window survives switching freq_scale between linear/log/mel/bark without moving what is on screen (the nonlinearity lives entirely in the shader's display→bin mapping, as with the shader uniforms), and the waveform's amplitude window composes with AMP_MARGIN without leaking it into the protocol. Living in the widget tree means /gui_set drives it and the browser renders it through the same shared frame path with zero extra wiring (display + /gui_set parity; drag/wheel gestures stay native for now). Changes emit /gui_event id "view_y" y_start y_len, the "view" posture.

The y state is deliberately per widget while the horizontal view is slated to move into shared navigation groups (linked views): two lanes of one file scroll in lockstep in time, but each keeps its own vertical slice.

Linked views: explicit groups in the host core, shaped for the multitrack view

The linked editor views (a waveform lane and a spectrogram lane navigating as one item) settled four decisions:

  • Grouping is explicit — a link (int) prop — not implicit by shared source. An editor item legitimately wants unlinked views of one file (compare two zoom levels), and a link legitimately spans sources (aligned takes), so the source is the wrong grouping key on both sides. A negative link unlinks live, and the widget carries its current view along (unlink-to-diverge is the workflow); joining an existing group adopts it.
  • Group events carry the interacted member's id, not a group id. Every /gui_event names a widget id; a group id is not a widget and would fork the event shape. A gesture emits "view"/"selection" once — linked members repaint but do not re-emit — and a script that cares which lane was touched gets that for free.
  • Shared chrome is plain composition, not an automatic strip. A stack of linked lanes that wants one time ruler sets ruler:"off" on all but one — existing containers plus existing props, no new container kind, no protocol change. An automatic shared strip would need cross-widget layout coupling for something a prop already expresses.
  • The group state lives in the host core and navigates in session-timeline units. The state (horizontal view + selection + playhead anchor, in host/timeline.rs) is keyed by group, not by window: membership spans windows and fronts, the group's length is the max over its members' registered data extents (a shorter take just ends earlier), and both fronts drive it through the same Host ops — the /gui_set interception lives in the core dispatch, so the browser gets group semantics with no front code. This shape is deliberately the seat of the future multitrack (DAW-style) view: a clip item there is a member with a placement (a start offset) on the same shared timeline — the one designed extension point; today every member sits at offset 0. Selection/playhead are mirrored group→members' EditorProps so everything that reads the widget tree (renderer, playhead animation, readouts) keeps working; the group is the single writer, so the mirrors cannot drift.

Edit-back-to-data: flat event payloads, the binding path generalized to lists

The bpf envelope editor is the first widget that writes data back, and the pattern it establishes is meant to be reused verbatim by the later drawn-buffer and automation-lane cases. Four decisions:

  • Edited data flows to the script as new event payloads, never new addresses. An edit emits /gui_event <id> <tag> <flat values...> — for the envelope, "points" followed by t v shape curve per breakpoint, ints kept int and floats float. The /gui_* address family does not grow per widget; bulk edits (a drawn buffer region) would ride one blob in the existing samples_to_blob little-endian f32 layout. The flat-primitives boundary rule holds on the way back exactly as on the way in.
  • The server-bound direction is the widget-value binding generalized to a list. A bound editor forwards its whole edited structure straight to the audio server — Binding::message_args/Host::forward_args send addr prefix… values…, the multi-argument form of the bound knob's addr prefix… value — bypassing the script exactly as /gui_bind promises. No new binding vocabulary: the same destination, prefix and prune rules.
  • The host's mapped resources stay read-only. Edits mutate the host's typed tree and flow out over the two channels above; the mmap bulk path is never written through. Anything that must land in a shared file or a server buffer goes through the server (or the script), so ownership of bulk resources stays single-writer.
  • The envelope shape math lives once, in the core. The editor draws segments through clausters_core::envshape::shape_value — relocated from the server, whose EnvGen now delegates to it (the same move the forward FFT made) — so what the editor draws is what the server plays by construction. Its FFI export is deferred until a client actually evaluates envelopes client-side (the tap-reader precedent): the Python leg only maps breakpoint lists to/from Env, which needs no curve evaluation. The breakpoint model, hit-testing and drag clamps are display logic and stay gui-side (host/bpf.rs).

The widget model itself is deliberately the future automation-lane shape: values in any [min, max] (bipolar, unipolar, on/off lanes via the hold shape — SC's step jumps to the target level at segment start, so a step segment shows the next point's value), every standard EnvGen transition curve, an optional exponential display scale for frequency-like ranges, and times over an explicit [0, duration] domain — so a multitrack automation view later composes this model instead of designing a new one.

The multitrack editor: beats in the data, samples on the axis, and one converter

The arrangement places elements in beats; the multitrack view places clips in timeline samples. Two units, and the temptation is to pick one. Both are load-bearing:

  • The view must be in samples. A clip's body is audio data, and the placement work established that a member's data sample 0 sits at its offset. A take placed at its own frame count then lands 1:1 on the axis, and its waveform draws where it sounds. In beats, every body would need a scale factor that only the client knows.
  • The arrangement must be in beats. Its elements are musical, tempo-relative, and they render onto a beat clock. Baking a sample rate into them would tie a composition to one engine.

Decision: keep both, and make the editor driver the only converter — one beat is sample_rate / tempo timeline units. It is also where a musical quant becomes the lane's pixel-drag grid, so the grid a clip is dropped on is the grid the arrangement re-schedules on. The arithmetic itself is the core's (beats_to_secssecs_to_samples), never a second implementation, so a port inherits it.

Consequence: clausters.form stays pure and transport-agnostic (it is the piece a future TypeScript client factors into clausters-core), the host stays unit-free (it only knows "timeline units"), and the whole conversion is a handful of lines in one client module. Its dependency arrow is one-way — the editor imports the arrangement, never the reverse — the same call that moved the envelope point helpers out of the GUI submodule so seq would not depend on it.

A buffer sounds through an instrument, not by itself

Rendering a Buffer element was deferred with a note that it "needs an instrument". The temptation on picking it back up was to give the arrangement a built-in sampler def.

Decision: a buffer is data, and it sounds through the def named to play itBuffer(buf, instrument="take"), whose event carries the buffer number in a buf control. A buffer with no instrument is still a perfectly good element: it draws in the editor and contributes its extent, but emits no event.

Consequence: clausters.form ships no DSP and no def of its own (it stays the client-side structure it claims to be), the instrument stays the user's — any def that reads a buffer works, at any quality — and the "data vs. process" split the layer is built on holds at its most tempting exception. The one concession is practical: such an event sets legato = 1 so a take sounds its whole length, where a note's default would cut it short.

The patcher shows a connection, not a direction

The logical side of a composition — members wired to each other through buses — is drawn by the graph widget. The obvious design is a directed patch: outputs on one side, inputs on the other, arrows between them. It cannot be built honestly.

Context: a GraphDef says only that a member's control names a bus ({"out": "mix"}, {"in": "mix"}). Which end of that bus writes and which reads is not in the data — it is the server's analysis, which sorts the graph topologically before it runs. A view could guess the direction from a control's name ("out" writes, "in" reads), but that is a convention, not a contract: a def is free to name its controls anything, and the renderer would then draw arrows that are simply wrong.

Decision: the patch is bipartite — member boxes on one side, bus nodes on the other, and a wire per (member, control) ↔ bus pair. It shows the connection, which is what the data knows, and leaves the direction to the engine, which is what decides it.

Consequence: the view can never lie about signal flow, and the edit stays well-defined: rewiring means pointing a control at another bus (or at none), one wire per control, which is exactly the mutation the group and the GraphDef both accept. A directed rendering could be layered on later — but only from information the server surfaces, not from a name.

The arrangement model: five primitives, one recursive group

A sequencing layer (a timeline of items, a playhead) is enough to play music and not enough to compose it. A composition is an element inside an element — a phrase inside a section, a take against a melody, a generator that has not been evaluated yet — and the client had no name for that. The question was what to add.

Context. The tempting answer is a bag of editor types: an audio clip, a MIDI clip, an automation lane, a folder track, each with its own fields, its own view and its own rendering path. That is how a DAW grows, and it is also why a DAW's granularity stops where its type list stops: you can edit a clip, but not "the inside of that clip at the level you happen to care about".

Decision. A closed algebra instead of a type list. An element is anything bounded that produces a unit of meaning — in one of two modes, and this is the axis the layer turns on: generated (the rendered thing, editable data) or a generator (the algorithm that renders it). Evaluating the second into the first is the change of state — and it is a compositional act, not an optimization: what separates the two modes is not data versus process but what can be done with them. A generated element is random-access (an audio file plays backwards, slices, edits in place); a generator is forward-only (it evaluates, in order, and that is all). Bouncing is what turns something you can only produce into something you can manipulate. An element carries only two temporal properties — an onset and a duration, either of which may be absent (which gives it a temporal character: a segment, a punctual event, a relative segment, or a pure abstract context). There are exactly five kinds, and each is a thin adornment over something the client already had, never a reimplementation of it: an Event (parameters in one action), a List (strict order, no concrete time), a Buffer (a list at constant time), a Set (mixed placement — a track), and a Function (a process: server DSP, or a generator of sequences). The one genuinely new structure is the Group: a recursive placement of elements by offset, in two kinds — concrete (a relation in time) and logical (a relation of processing, wired by buses). A group's temporal relation (successive / simultaneous / mixed) is derived from where its members sit, never declared.

Two consequences of the shape are load-bearing:

  • Rendering is a change of state, not a second engine. Rendering flattens the tree — accumulating nested offsets into absolute beats — into the flat timeline the client already plays, bouncing any contained generator in the same pass. RT and NRT differ only in the destination, so the offline render stays sample-identical to what was heard.
  • The base level is the view, not the data. How coarse or fine a group is shown (a summary rectangle, or its members resolved into lanes) is a property of the look, so the same structure serves the whole scale from a section to a note to a parameter — which is the granularity a type list cannot buy.

Consequence. The layer is pure and transport-agnostic: no DSP, no protocol, no GUI — the piece a future client factors into the shared core. It carries the temptations too: a Buffer is data and sounds only through the instrument named to play it, and a logical group emits the bus-wired configuration the server already expresses (a GraphDef) rather than a wiring language of its own. Both exceptions were resolved in the algebra's favour, and both are recorded above.

The piano-roll: OSC events get their own lane, and the notes live once

The editor-grade pianoroll draws the two message families a sequence carries — MIDI notes and OSC events — and it had to place them without lying about what each is.

Context. A note has a pitch, so it maps naturally onto the grid's vertical axis (pitch × time). An OSC event does not: a /trig or a /cue is a moment, not a pitch. Forcing it into the grid means inventing a vertical position for something that has none — the same kind of lie the patcher refused when it declined to guess signal direction from a control's name.

Decision. MIDI notes draw in the pitch × time grid; OSC events draw as flags in a separate lane below it, on the shared time axis but with no pitch pretence. Velocity gets its own lane too (the DAW convention), so the grid stays one clean plane of notes.

Reuse. The note primitives — the notes, the pitch/time mapping, the drawing, the hit-test, the drag clamps — live once in host::pianoroll, shared by the dedicated pianoroll widget and the multitrack clip's roll body (which now delegates its drawing there). This is the bpf::place_point move again (G22h): a mapping-free core both the widget-local and the clip-placed editors call, so the two can never disagree on where a note is drawn or grabbed. The Note carries velocity/channel (a real MIDI note), and the wire form is the flat quintuple start dur pitch velocity channel — a length that is a multiple of five is read as quintuples, anything else as a legacy start dur pitch triple list, so old data still parses.

Placement (the G7b rule). The one piece of general musical knowledge — the MIDI-note ↔ name / black-key spelling on the keyboard — went to clausters_core::scale (note_name/pitch_class/is_black_key), beside the perceptual frequency scales; its FFI export is deferred until a client evaluates it client-side (the envshape/tap-reader precedent). Everything else is display-only and stays gui-side. The edit-back stays flat payloads, not new addresses: "notes" (start dur pitch velocity channel …) and "osc" (time label …) — the fourth use of the one edit-back pattern.

Multi-note selection: the marquee is the time selection, restricted in pitch. The piano-roll needed a multi-note selection without stealing the grid's one free gesture — a plain drag on empty grid is the heavy views' time selection (the linked group follows it), and a DAW marquee wants that same drag. The resolution is that there is no second gesture: the marquee is the shared time selection, restricted in pitch. Dragging the empty grid keeps driving the linked views' time span exactly as before, and the notes inside the time × pitch rectangle become the selected set (Alt+click toggles one note in or out; Delete/Backspace removes the set; dragging a selected note moves the block rigidly, its clamp computed as one so an edge stops the block instead of folding it; the velocity lane nudges the set relatively, each note saturating on its own). The set itself is native view state, not a wire prop: it lives in the widget state, clears when the script replaces notes (the indices would dangle), and every block edit reaches the script as the same flat "notes" payload — the wire did not grow, so every client consumes block edits with the code it already had.

Edit-back to the data (the Editor's dedicated view). When the Editor opens an element as a dedicated piano-roll, a per-note edit writes back only to a generated element: a Track's editable Timeline is rebuilt from the "notes" payload (times converted to beats, the OSC/MIDI events sharing the timeline preserved). A generator (Pbind/Routine) is forward-only — there is no second note to rewrite until it evaluates again — so its bounced notes are shown read-only, and bouncing it to a Track is what makes them editable. This is not a new rule: it is the generated/generator distinction of the arrangement model (above) doing its job at the granularity of a single note. OSC events stay display-only in this view too: the (time, label) flag is a lossy projection of the message, so writing it back would silently drop the arguments.

Transport roles: TCP as the default command plane, UDP as the probe

Decision. The server accepts TCP on the OSC port by default (--no-tcp opts out), and clients connect their command interface over TCP by default while keeping the UDP boot-or-attach probe. Each transport has one role: UDP is discovery and small real-time control (scsynth compatibility included), TCP is the command plane, shared memory is the data plane (taps, control buses), WebSocket is the browser's TCP, and the in-process link is the packaged standalone's (no sockets at all).

Why. A UDP datagram cannot exceed ~64 KB (the OS rejects the send), and off loopback anything over one MTU fragments at the IP layer, where a single lost fragment silently drops the whole packet. That bounds exactly the payloads a complex application needs to move — whole defs, GuiDef trees, buffer chunks, query replies — while the deployments this server targets (loopback as the common case, controlled networks for sound installations) get nothing back from staying datagram-only. TCP's framing makes size a configuration, not a protocol property: the length-prefix ceiling exists only so an untrusted prefix cannot drive an allocation, so it is a boot option (--max-frame, default 16 MiB, advertised in /server_info.reply for clients to size bulk requests from) rather than a constant — no limit is hard-wired, per the rule that the project must stay usable as a desktop/mobile application without arbitrary ceilings. Timing is unaffected by the switch: it rides on bundle timetags and /sched, never on arrival time. Replies became transport-aware in the same move (a /tap_stream window may fill a whole frame for a stream client; UDP keeps the datagram-safe clamp), and the IPC command rings deliberately stayed at 64 KiB — large payloads ride TCP even locally, and growing the ring would bump the versioned segment layout for no demonstrated need.

Package SemVer is decoupled from the binary ABI counters

Compatibility is tracked by two monotonic integer countersABI_VERSION (the shm segment layout + embed C ABI) and CORE_ABI_VERSION (the core FFI surface) — not by the package's SemVer. A release bumps a counter only when that boundary changes incompatibly, and both are advertised by runtime calls (clausters_abi_version(), clausters_core_abi_version()) that a peer checks on attach/load, refusing to connect on a mismatch.

Why. A binary boundary needs a check that a already-compiled peer can make at runtime, before it trusts a single byte of layout — SemVer strings cannot do that job, and the two boundaries evolve on independent cadences (the core FFI is already at v9 while the embed/IPC ABI is at v3). A monotonic integer per boundary is exactly the scsynth plugin-ABI lesson: every binary seam is versioned and verified where it is crossed. SemVer is left to govern the package — what cargo/pip resolves — where it belongs.

The one linkage rule keeps them from drifting into contradiction: a release that bumps either counter must also bump SemVer's breaking tier (the minor while the major is 0, per standard pre-1.0 SemVer where the minor acts as the major; the major once at 1.0). The reverse is not required — a minor can ship purely additive source-API work without touching either counter. The mechanical release rules live in CLAUDE.md ("Versioning"); this entry is the why.

The node-tree model and DAWs (design rationale)

Why Clausters keeps SuperCollider's node-tree model, what that model buys and costs against the channel-graph model of a digital audio workstation (DAW), and — in detail — how the auto-sorted-group dependency analysis relates to a DAW's plugin delay compensation (PDC), including why Clausters has no PDC today and how one would fit. This is background and rationale; the user-facing behavior of auto-sorted groups is in auto-order.md, and the implementation invariants are in architecture.md.

Three models in one sentence

  • DAW (channel graph). A fixed, hierarchical graph of channels. Each track is a linear chain of insert plugins feeding buses/groups and finally the master; sends branch off to return buses. The user draws the topology in a UI and the engine derives the execution order from the routing. The unit is the channel, not the voice.
  • scsynth (node tree). An ordered tree of nodes (groups and synths). Execution order is the depth-first traversal of the tree, and nothing else. Signal routing is separate, through globally numbered buses. Making "who runs first" agree with "who writes the bus another reads" is the client's manual job (add actions, /n_before, /n_after).
  • Clausters. The same tree and buses as scsynth for the baseline semantics, plus two departures: it can infer execution order from the bus routing (auto-sorted groups, /g_sortMode) — recovering the DAW's automatic ordering without giving up the tree — and it builds every synth off the audio thread, which removes several historical limits of scsynth (RT/wire memory pools, block-boundary scheduling).

Axis-by-axis comparison

Topology and expressiveness

Node tree (SC / Clausters)Channel graph (DAW)
UnitVoice / synth, ephemeralChannel, persistent
StructureOrder tree + orthogonal busesFixed routing graph
Arbitrary routingTotal: any synth to any busBounded to inserts + N sends
Dynamic voicesNative (spawn/kill a synth per note)Outside the model (handled inside a plugin)

The tree's strength is that order is orthogonal to routing. The signal graph is not wired into the structure; it is emergent from which buses each node reads and writes, and it can be reconfigured live without rebuilding anything. The granularity is the voice, not the channel — which is why the model expresses granular synthesis, massive polyphony and structures that appear and vanish, all of which a DAW only does inside a plugin (a black box).

That same orthogonality is the model's main cost. In a DAW "this runs after that" is visible in the routing. In the node tree, order and routing are two independent things the user must keep consistent by hand: a synth placed at the wrong point of the tree produces silence or a one-block delay, with no error. This is the central ergonomic defect of the model, and the one auto-sorted groups address (see below).

Execution order, feedback and cycles

A DAW resolves order by analysing the routing graph, and hides cycles behind an implicit buffer delay on return sends. In the node tree, order is the tree, and feedback is made explicit with buses and a read-before-write across nodes, which introduces one control block (64 samples) of delay — the same as a return send, but visible.

A SynthDef graph is a DAG by construction (the compiler rejects an input referencing a later UGen), so a feedback loop cannot be wired directly; LocalIn/LocalOut give the synth-private one-block feedback path, and the auto-sort does not "solve" cycles — it preserves the members' current relative order, which is exactly one block of delay. This is honest: sample-accurate feedback within a block is impossible in any block-processing engine (the wire between two UGens carries only a finished block), DAWs included; they merely hide it. For a sample-accurate recursive filter the loop must be fused into a single node — a recursive UGen, or a Faust def whose ~ operator compiles the whole loop into one compute. See the Feedback section of architecture.md.

Timing and scheduling

This is where the synthesis-server model, and Clausters' implementation in particular, is structurally ahead of the typical DAW. A DAW ties scheduling to the transport and to PDC, and in practice quantizes automation and events to the buffer size. scsynth carries timetagged bundles but quantizes commands to the block boundary (hence OffsetOut). Clausters splits the block at the exact sample: ProcessCtx carries offset + frames and every UGen processes the sub-range. It also renders bit-identical between real time and offline (same engine, same schedule queue, same FPU mode), so an offline render equals a perfectly timed live take — something a DAW rarely guarantees between playback and bounce. See Clocks and scheduling in architecture.md.

Concurrency and parallelism

A DAW parallelizes per channel automatically and transparently — its historical strength. scsynth's engine is single-threaded (parallel groups exist only in a fork). Clausters has an opt-in fork-join pool (--workers N) that processes the stages of parallel groups (/g_parallel) with the guarantee that parallel execution is bit-identical to sequential: a stage only batches children with pairwise-disjoint bus usage, verified against the engine's own masks. The trade-off is that it stays explicit (you mark groups parallel) rather than the DAW's automatic per-channel parallelism, and it only pays off when the graph actually has disjoint parallelism.

Memory and RT-safety

This is where the implementation departs most from both scsynth and a plugin host. scsynth builds the synth graph on the audio thread, so it cannot allocate there and instead pre-sizes global pools at boot: wire buffers (-w), real-time memory for UGen-internal buffers (-m), plus max nodes/buffers/buses. A plugin host, in turn, lets each plugin allocate its own memory (often on the audio thread), so RT-safety depends on every third-party plugin.

Clausters allocates on the network (or NRT / compiler) thread, uses on the audio thread, and frees back on the network thread; the audio thread only moves pointers (guarded by assert_no_alloc). Consequently there is no -w and no -m to size: the inter-UGen wires are a per-synth Vec<Block> (cache-line aligned), memory scales with the synths actually running, is contiguous, never fragments a pool and never touches the audio thread. Fixed boot capacities remain (node slab, audio/control buses, etc. — see the capacity table in architecture.md), but those are throughput/headroom limits, not per-graph memory pools. The price is that every synth is fully built before it reaches the audio thread, on the network thread.

Extensibility

The DAW industry's winning model is the binary plugin (VST/AU/CLAP-style): a stable C ABI, a large ecosystem, variable sandboxing — its biggest market advantage and its biggest source of fragility (ABI breakage, crashes from third-party code). scsynth copied that pattern (UnitCmd/interface tables) and paid the price: the ABI broke on struct/feature changes and out-of-tree UGens became a maintenance tax.

Clausters declines it by decision, not omission: there is no stable Rust ABI, so there are no dynamically loaded Rust plugins in v1. Extending the server means adding code to the crate (the UGen + ProcessCtx + registry contract). The runtime-extensible path for users is a Faust def (/d_faust, JIT-compiled, sandboxed by the same SynthNode boundary) — most "I need a custom UGen" cases are a Faust one-liner. If dynamic plugins ever become necessary, the policy is fixed: a versioned C ABI or a wasm interface, checked at load. This avoids the plugin-ABI fragility of both scsynth and DAWs, at the cost of a third-party UGen marketplace.

State, persistence and remote control

A DAW's state is the document (the session): single-user, monolithic, a proprietary project format. The node-tree model is client/server over OSC: the server has no notion of a "song"; state lives in the defs and the live tree. Clausters adds def persistence, MIDI bindings and a boot preset, and a standalone mode playable from a controller with no client. The model decouples fully — multiple clients/languages against one engine, network control, embeddable as a library, offline render as a first-class citizen. The flip side is that there is no timeline/arrangement/undo — the very product of a DAW. The scope is genuinely different: the node tree is an engine, not a workstation.

Where each model wins

The node-tree model (and Clausters on it) wins in: voice granularity and dynamic polyphony; arbitrary routing orthogonal to order; deterministic, sub-block timing with bit-identical offline render; formal, verified RT-safety; the decoupled client/server, embeddable, multi-language, network-controllable design; and extensibility without ABI fragility (Faust JIT as a sandboxed plugin).

DAWs win in: ergonomics (order is visible in the routing — nobody produces silence from a misplaced add action; auto-sorted groups close much of this gap, but only for the statically analysable graph); the binary-plugin ecosystem; the complete workstation product (timeline, arrangement, persistent mix, undo, comping — all outside the node-tree model's scope); and mature, transparent per-channel parallelism.

Clausters' specific contribution is to take scsynth's model and close its three classic weaknesses without leaving the paradigm: the manual-ordering burden (→ auto-sort from buses), the hand-sized RT memory pools and on-audio-thread build (→ off-thread build, per-synth memory), and the block quantization and RT/NRT divergence (→ sub-block split, bit-identity). And it deliberately rejects the one pattern where scsynth imitated DAWs and it went wrong: the binary plugin ABI.

Dependency analysis versus plugin delay compensation (PDC)

Auto-sorted groups and a DAW's PDC look like the same feature but are not. They are the two complementary problems that appear once execution order is no longer wired into the structure:

The auto-sort solves ordering (in what sequence do I run the nodes so a reader runs after its writer, within the same block?). The PDC solves latency alignment (given that nodes introduce latency, how much delay do I add to the short paths so the summing points stay in phase?). A DAW gets ordering for free and spends its engineering on PDC. SuperCollider does the inverse.

Why ordering is trivial in a DAW and the whole problem in SC

In a DAW the topology is the routing, so deriving execution order from it is a trivial topological sort the host always does — there was never an "ordering mode" because order was never a user decision. scsynth does the opposite by design: order (the tree) is orthogonal to routing (global numbered buses). You gain expressiveness (any node to any bus, live re-routing) but lose the free derivation: the tree does not know that synth A writes bus 16 that synth B reads. Auto-sorted groups are, literally, recovering the DAW's free derivation on top of a model that had dropped it on purpose.

The Clausters dependency analysis in detail

Everything below runs on the network thread against TreeMirror (a mirror of the node tree fed by the same commands the engine gets); re-sorts reach the audio thread as ordinary Cmd::MoveNodes, so the engine is untouched. The code is src/osc/graph.rs.

  • The atom: BusUsage as a bitmask. Each node reduces to reads and writes (masks over the audio buses, 1 << bus) plus a dynamic flag. An edge between two nodes is then just writes_a & reads_b != 0 — a bit AND.
  • Extraction: what is read, what is written. ugen_usage walks the def's UGens and looks only at three: In (reads), Out (writes), ReplaceOut (reads and writes — it consumes what is on the bus). Each one's bus index is classified by where it comes from: a constant is a static index; a control is static but tracked (recorded in bus_controls, because an /n_set on it changes the routing and forces re-analysis); a signal-computed index (a wire) is not analysable and sets dynamic = true. faust_usage is analogous — a Faust synth reads in..in+inputs and writes out..out+outputs, and its two reserved routing controls sit after the UI params, so they are always static indices.
  • The sort: stable Kahn with explicit degradation on cycles. stable_topo_sort builds the adjacency before[i][j] = writes_i & reads_j, injects dynamic barriers as edges against everything by current position, and runs Kahn always picking the earliest ready unit. Two properties follow: stability (pure summing writers to the same bus have no edge between them — mixing commutes — so insertion order is preserved), and cycles are not solved (on a deadlock the earliest remaining unit is released; cycle members keep their current relative order = one block of feedback delay). It is O(n²) per group, where n is one group's child count, not the whole tree; nested groups sort independently, each subgroup collapsed to the union of its subtree's usage (usage_of).
  • Incremental re-analysis. The sort re-runs on every change that can alter the DAG: a node added/removed, a subtree moved, an /n_set on a bus_control, or an /n_map//n_mapa touching an audio map or a bus-index control (set_map, fold_maps_into_usage).

What a PDC does, and why it is a different computation

A PDC starts from the fact that each plugin reports its latency in samples (a lookahead compressor: +N; a linear-phase EQ: +M). The routing graph already gives the order; the problem is that parallel paths that sum accumulate different latencies and arrive out of phase at the mix point. The host solves a longest-path problem: for each node it takes the maximum accumulated latency over its inputs and inserts compensating delay lines on the shorter inputs so all confluent signals align. It is O(V+E) over the global routing graph (alignment is an end-to-end property, not local to a group) and it produces a new physical artifact: delay buffers inserted into the graph.

Auto-sort (Clausters)PDC (DAW)
QuestionIn what order do I run?How much delay do I add?
Inputper-node bus reads/writesper-node reported latency
Computationtopological sort, O(n²) per grouplongest path, O(V+E) global
Outputa permutation of children (MoveNode)delay lines inserted
Granularityblock (64 samples)sample
"Unknowable" frontiersignal-computed index → dynamic barrierplugin with no/wrong report → misalignment
Runtime costmirror on network thread, zero on audiorecompute on a non-audio thread, re-latch

The shared "unknowable frontier"

The deepest parallel is not the mechanics but how each treats what it cannot analyse — the same philosophy applied to the two halves of the problem. The auto-sort, facing a signal-computed bus index, does not guess: it marks the node dynamic and freezes it as a barrier. Conservative correctness — possibly suboptimal (it will not sort across the barrier) but never a silently wrong order; the user's remedy is to isolate the dynamic node in its own manual group. The PDC, facing a plugin that reports latency wrongly, variably, or not at all, also cannot: the result is a misalignment the host does not fix, and the user's remedy is analogous (pin the latency by hand or isolate the plugin). Both are static analyses over node-declared metadata (bus index / reported latency), and both degrade to an observable default at the "signal-dependent / undeclared" boundary.

The real gap: Clausters has no PDC

Clausters recovers the "easy" half (ordering, which SC had made hard) but does not solve the half a DAW considers hard — latency alignment — and the reason is structural and, today, benign. A UGen in Clausters processes sample-for-sample within the block; it does not report latency because, in the normal case, it has none (there is no latency() on the UGen/SynthNode trait). So there is nothing to align in the typical static chain: source → fx → master comes out with zero inter-path skew.

Latency does exist in two places: (a) the one-block delay each cycle/feedback introduces (LocalIn/LocalOut, or a cycle the sort preserves), and (b) any dynamic barrier forcing an extra bus hop. If path A is source → fx → master (0 extra) and path B passes through a feedback node (+64 samples) before summing into the master, the two are 64 samples out of phase and the auto-sort does not compensate it — a PDC would. The current model hides the PDC problem because its unit of latency is zero-or-one-block and parallel paths rarely differ in feedback-hop count. But the problem is not solved, it is absent for now: the moment a UGen with real intrinsic latency appears (an FFT convolver with lookahead, a pitch-shifter, a linear-phase filter written in Faust), the alignment between parallel paths becomes an audible skew the current infrastructure does not see.

How a PDC would fit (future extension)

The base is already in place and the design admits it cleanly:

  1. Add latency(&self) -> usize (in samples) to the trait, default 0. Most UGens stay at 0; a FaustSynth with a delay/FFT reports its own.
  2. The DAG already exists: stable_topo_sort gives the order; one only has to annotate each writes_i & reads_j edge with accumulated latency and run longest-path over the same mirror, on the network thread. The block-rate feedback delay is a fixed contribution to the count.
  3. Compensation is inserted as what the engine already inserts: a delay UGen of N samples on the short paths, or an offset in ProcessCtx (which already carries offset/frames for the sub-block split).
  4. Determinism: any PDC-added latency must be identical between real time and offline (an engine invariant). Since the computation is on the mirror and never touches the audio thread, it fits the same pattern as the auto-sort — but it would need pinning in the test suite. The alignment being global (across nested-group boundaries that today collapse in usage_of) is the non-trivial part.

In short, the auto-sort is the topological sort of a PDC without the latency annotation. Clausters has already built the ordering half a DAW gets for free (static bus analysis, conservative degradation on the unanalysable, zero audio-thread cost) and left the PDC half for when an intrinsic latency that does not exist today shows up. The mirror infrastructure leaves the door open to close the gap without touching the engine.

Contributing

Working on Clausters itself. The internals — threads, memory lifecycle, invariants, how to add a UGen — are in Architecture; this chapter is the practical setup around it.

Build and test

cargo build                 # core, no audio device needed to compile
cargo test                  # the full core suite
cargo clippy --all-targets  # lints (kept clean)
cargo doc --no-deps         # the API reference

The core must always build and test without any feature and without libfaust installed, and with any combination of the def-family features synth/faust (both, either alone, or neither — most integration suites are gated on synth, so the featureless run is thin by design).

System build dependencies (Ubuntu 26.04)

# default build (PipeWire audio + ALSA-seq MIDI)
sudo apt install build-essential pkg-config libasound2-dev libpipewire-0.3-dev clang
# only for the matching optional feature:
sudo apt install libjack-jackd2-dev          # --features midi-jack
# plain-ALSA build (no PipeWire libs):
#   cargo build --no-default-features --features synth,realtime,midi

pipewire is a default feature (the target systems always ship PipeWire), so the default binary hard-links libpipewire and expects it at runtime. clang is only used at build time by bindgen (it parses the PipeWire C headers to generate bindings); the toolchain itself stays rustc + gcc. The midi-jack build links against jackd2's libjack but resolves to PipeWire's libjack under pw-jack at runtime.

The faust feature (default)

The FaustDef family is on by default, so a plain cargo build / cargo test needs libfaust built with the LLVM backend on the machine. Distro packages (e.g. Ubuntu's libfaust2t64) ship without it and without headers, so it is built from source and installed under ~/.local, once. The reproducible recipe is in BUILD.md (the "Building libfaust from source" section). build.rs locates the library through FAUST_PREFIX, falling back to ~/.local, then /usr/local.

FAUST_PREFIX=~/.local cargo test           # the prefix is only needed if it is not ~/.local
cargo test --no-default-features --features synth,realtime   # no libfaust needed

Every compilation FFI call goes through faust::compiler::ffi_lock(), which serializes libfaust within the process (instantiating from an already-compiled factory is concurrency-safe). That lock is what lets the Faust suites run in the ordinary parallel test harness; a SIGSEGV in a faust_* suite is the signature of an FFI path that skipped it.

build.rs also writes a DT_RPATH of $ORIGIN, $ORIGIN/../_libs and the build prefix, so the artifacts are relocatable and a distribution can bundle libfaust and its libLLVM beside them (which is exactly what the Python wheel does — see clients/python/build_native.py). DT_RPATH rather than DT_RUNPATH: only the former is inherited by transitive dependencies, and it is libfaust, not our binary, that needs to find libLLVM.

Real-time safety (non-negotiable)

Engine::process_block and everything it calls must never allocate, free, lock or do I/O. Commands arrive fully pre-built over a lock-free FIFO; freed memory leaves through the garbage FIFO and is dropped on the network thread. tests/rt_safety.rs guards this with assert_no_alloc — run it after touching anything on the audio path. Denormals are flushed to zero on every processing thread (dsp::denormals::flush_to_zero() plus -ftz 2 for Faust); see the RT-safety notes in Architecture.

End-to-end testing in a sandboxed shell

Some CI/sandbox environments isolate the network between shell invocations: a server started in one invocation is unreachable from the next, and UDP packets to localhost are silently lost. Always run the server and client in the same invocation — server in the background with &, then the client, then kill it:

(./target/debug/clausters & PID=$!; sleep 1.5; \
 ./target/debug/examples/osc_ping status quit; kill $PID 2>/dev/null)

OSC decoding

All incoming OSC bytes decode through osc::decode_packet, the single entry point (every transport funnels through it). It is a thin wrapper over rosc::decoder::decode_udp — keep that one door so decoding and any future hardening stay in one place.

Continuous integration

GitHub Actions runs the same checks this page asks for by hand (.github/workflows/ci.yml); every job maps to a local command, so a red job is reproducible with the same line:

  • lintcargo fmt --check and cargo clippy --workspace --all-targets -- -D warnings, for the root workspace and for clients/gui (its own workspace). The tree is clippy-clean; keep it that way.
  • testcargo test --workspace across the def-family feature matrix: default, --no-default-features, --no-default-features --features synth, and default plus embed.
  • guicargo test in clients/gui plus the wasm build gate (clients/gui/check-wasm.sh).
  • pythonpython clients/python/build_native.py --debug, then pytest in clients/python.
  • docs — both mdBooks with the same mdBook version Read the Docs uses, and the pydoc-markdown API page for the client book.
  • faust — the default cargo test covers it, with libfaust built from source at the commit pinned in the workflow (the recipe in third_party/BUILD-FAUST.md) and cached; a cache hit makes the job cheap. Upgrading libfaust = bumping FAUST_SHA there after verifying locally. The featureless / SynthDef-only runs (--no-default-features) are what keep the build green on a machine with no libfaust.

Releases and publishing

  • Tagged releases (.github/workflows/release.yml): pushing a v* tag builds the self-contained Python wheel (client + embedded server + standalone binary; Linux x86_64 for now) and a server-binary tarball, publishes the wheel to PyPI and attaches both to a GitHub release. PyPI auth is Trusted Publishing (OIDC — no stored token): the PyPI project must list this repository with workflow release.yml and environment pypi as a trusted publisher, and the repository needs a pypi environment. There is deliberately no sdist: the package compiles cdylibs from the Rust workspace, which an sdist of clients/python would not contain.
  • Read the Docs hosts the two books as two projects pointing at the same repository, each selecting its config under Settings → Advanced → Path to configuration file: the server/workspace book uses the repo-root .readthedocs.yaml, the Python client book uses clients/python/.readthedocs.yaml.

Conventions

  • Language: everything under src/, tests/ and examples/ (code, comments, strings, test names) is in English, as are the roadmap files PLAN.md / clients/PLAN.md and the design record docs/decisions.md. GUIA.md (root + clients/python/GUIA.md) stays in Spanish (maintainer-facing manual smoke checklists); this book and the rustdoc are the English documentation.
  • Closing a milestone means, where applicable: code plus tests, a clear commit message (that is the record of what shipped — there is no separate per-milestone log), the PLAN.md roadmap checkbox updated, developer/user docs where the feature touches them (docs/architecture.md, docs/schemas.md, module docs), and a commented examples/ entry for user-facing features. Add a short entry to docs/decisions.md only when a choice has non-obvious context, and a GUIA.md smoke step only when a new human-audible/visual behavior appears — neither is a per-milestone obligation.

Project skills

Domain knowledge lives in .claude/skills/: realtime-audio (RT thread rules, lock-free patterns, cpal), scsynth-osc (the OSC protocol and node-tree model), ugen-dsp (UGen DSP algorithms), audio-testing (testing audio without ears: NRT, golden files, signal asserts, no-alloc), faust-embedding (the libfaust C API and lifecycles), and faust-language (writing Faust and transposing it to the Signal/Box APIs — sample-level feedback, physical modeling). Process skills: clausters-python (idiomatic client use), clausters-gui (the GUI host and GuiDefs) and documentation (how to write and place docs — the Diataxis split, the generated API references, and the dev/decision docs).

Editing this book

The book sources are the Markdown files in docs/; docs/SUMMARY.md is the table of contents and book.toml the config. Build and preview with mdBook:

cargo install mdbook
mdbook serve     # live preview at http://localhost:3000
mdbook build     # generates ./book (git-ignored)
mdbook test      # type-checks Rust code snippets