clausters - Python client
clausters is the reference high-level Python client for the Clausters audio server, ported selectively from SuperCollider's class library (sc3). It covers both of the server's definition formats as peers: UGen-graph SynthDefs and FaustDefs — and a FaustDef, in turn, is built equally from the signal API, the box API or Faust source.
It is pure Python at runtime: it reaches the shared native core through ctypes over the clausters-ffi cdylib, and speaks ordinary OSC bytes to the server (UDP, TCP, shared memory, or an in-process embedded server). A NumPy user can wrap a returned array('f'), but NumPy is never a dependency — only flat data crosses the binding.
This is the package documentation. The server itself — the OSC protocol, the def formats (SynthDef JSON and Faust), the node-tree model, the C ABI contract, and how to embed it — is documented separately in the Clausters server book; this site links to it rather than repeating it. Two books, one per platform.
Components
- A server-agnostic timing and value layer (
clausters.base): scalar/list math backed by the same core the server uses (so results match by construction), operator overloading, theRoutine/yieldcoroutine layer, aTempoClockthat does timing only, and a choice of timebase (monotonic or the server's sample clock). - Definitions and server resources (
clausters.defs): the UGen graph (ugens+SynthDef) andFaustDef(fromsignals, fromboxes, or from Faust source), plus theNode/Bus/Bufferhandles. TheServerowns the communication interface and emits — swap its interface to retarget the same code from a live RT take to an offline NRT score. - Sequencing (
clausters.seq):Event, the value patterns (Pseq,Pwhite,Pseries, ...) andPbind, andEventStreamPlayer, with yield-exact timing. - Ergonomic defaults without global state (
clausters.Session): bundles aServerand a clock; several sessions (e.g. an offline one for plots next to a live one) coexist in one script.
The RT / NRT / embed seam
The key design property is a single seam: the Server holds a communication interface, and which interface it holds decides where the bytes go — a live UDP/TCP server, shared memory, or an in-process embedded engine that renders a score to WAV offline. The clock, the routines and the patterns do not change. The same script can drive a real-time take and render an offline score, with no global build context in between.
How to read this book
- New here? Start with Getting started: install the package and make a sound, line by line in a REPL.
- Want the mental model? Read The client, layer by layer:
base,seq,defsand the seam. - Want the ergonomic entry point? Sessions shows how one handle drives a live take or an offline render.
- Driving the clock yourself? Routines and clocks writes routines by hand, and Timing models covers the
TempoClock's timing modes — wall-clock, sample-locked, shared transport — and how to observe each. - Looking for runnable code? See Examples.
- Looking for a symbol? The API reference is generated from the package docstrings.
License
GPL-3.0-or-later.
Getting started
Install
The package is pure Python at runtime, but it reaches Rust through artifacts that cargo builds: two cdylibs — libclausters_ffi (the numeric/timing core) and libclausters (built with embed,realtime for the in-process server and offline render) — and the standalone server binary. The packaging bundles all of them inside one wheel, so an installed package is self-contained: the in-process embedded server and the offline renderer work out of the box, and the standalone server is on your PATH as the clausters command. No target/ directory and no build step at import time.
The wheel also bundles Faust — libfaust and the libLLVM it JIT-compiles with — so a FaustDef compiles on a machine with neither installed, exactly like a SynthDef. Both def families are on by default in the server, and the client treats them as peers; the price is a heavy wheel (~50 MB packed, most of it LLVM, since the Faust compiler is LLVM). Nothing to enable and nothing to install: this is the default install.
The simplest setup, in a fresh virtualenv, run from the repository so the build hook can find the cargo workspace:
python -m venv .venv && . .venv/bin/activate
pip install -e ./clients/python --group ./clients/python/pyproject.toml:dev
# (editable + the pytest dev group; pip's --group reads ./pyproject.toml unless
# given a path, and this repo's lives in clients/python/)
# or a plain install:
pip install ./clients/python
pip install triggers setup.py, which runs cargo build for both cdylibs and stages them in clausters/_libs/ before packaging. To build a redistributable wheel explicitly:
python -m build --wheel clients/python # -> clients/python/dist/*.whl
pip install clients/python/dist/clausters-*.whl # self-contained, no cargo
In a plain source checkout (no install), the loaders fall back to the workspace target/{release,debug}/, so the historic build-and-run workflow still works:
cargo build -p clausters-ffi
cargo build --features embed,realtime
The visual server (GUI)
The GUI host — the visual server that opens windows and renders widgets — is bundled in the same wheel as the audio server, built from the independent clients/gui cargo workspace and stripped. Nothing extra to install: with the package in place, Session.live / Session.gui launch the audio and visual servers for you, and clausters-gui is also on your PATH.
Building it adds a wgpu compile (a minute or two the first time). For a lighter, server-only wheel, set CLAUSTERS_SKIP_GUI_BUILD when installing; a source-checkout binary built under clients/gui/target (cargo build --release --bin clausters-gui, from clients/gui) is still used at runtime if present, and CLAUSTERS_GUI_BIN overrides the lookup.
Runtime requirement: PipeWire (Linux)
The bundled artifacts that touch an audio device — the in-process embedded server and the standalone clausters binary — are built with the project's default features, which include the PipeWire audio backend. They therefore hard-link libpipewire and expect PipeWire present at runtime (the standard on current Linux systems); on a host without it the library and the binary will not load. The offline renderer and the numeric core need no audio device, so Session.nrt() and clausters._native work anywhere. For an audio build that does not depend on PipeWire, build the server from source with plain ALSA — see the server guide's getting started (cargo build --no-default-features --features synth,realtime).
Environment knobs (all optional)
CLAUSTERS_WORKSPACE— path to the cargo workspace, if it can't be found by searching upward.CLAUSTERS_CARGO_FEATURES— features for the embed library (defaultembed,realtime).CLAUSTERS_SKIP_NATIVE_BUILD— package the libs already staged inclausters/_libs/without rebuilding.CLAUSTERS_FFI_LIB/CLAUSTERS_LIB— at runtime, point a loader directly at a cdylib (overrides the bundled copy and the workspacetarget/).CLAUSTERS_SKIP_GUI_BUILD— at build time, skip building/bundling theclausters-guibinary (a lighter, server-only wheel).CLAUSTERS_GUI_BIN— at runtime, point the launcher directly at aclausters-guihost binary (overrides the bundled copy and the workspacetarget/).
Your first sound, line by line
The client is meant to be driven interactively — from a REPL, or cell by cell in an editor. Nothing below is a script: type a line, hear the result, type the next one. There is no global state to set up and no build context to open, so every line is complete on its own.
Start a session. Session.live() attaches to a running server, or starts one for you if none is up:
from clausters import Session
session = Session.live(latency=0.1) # latency: schedule a hair ahead, so events land on time
server = session.server # the handle you send commands to
An instrument is a def — a named graph the server compiles once and instantiates many times. Build one as a UGen graph and send it:
from clausters.defs import SynthDef, control, sin_osc, out
sdef = SynthDef("beep", out(0.0, sin_osc(control("freq", 440.0)) * control("amp", 0.2)))
server.add_synthdef(sdef) # sends /d_recv and waits for the server's /done
Now play it. synth starts a node — it sounds until you free it — and set changes its controls while it sounds:
node = server.synth("beep", {"freq": 330.0}) # you hear it now
server.set(node, {"freq": 550.0}) # ...and now it is higher
server.free(node) # silence
The other def format is a peer, not a fallback: the same instrument as a FaustDef, which the server JIT-compiles. Write it as a signal graph, as a box graph reusing the Faust libraries, or as Faust source — three ways of saying the same thing. Nothing to install: the package bundles Faust (see What the server must support).
from clausters.defs import signals as S, boxes as box, FaustDef
freq = S.hslider("freq", 440.0, 20.0, 20000.0, 0.01)
phase = S.rec(lambda p: (p + freq / S.sr()) % 1.0) # a one-sample feedback phasor
server.add_faustdef(FaustDef.from_signals("fbeep", S.sin(phase * S.TAU) * 0.2))
# the same voice, point-free, borrowing the oscillator from Faust's library:
server.add_faustdef(FaustDef.from_box(
"bbeep", box.faust("os.osc")(box.hslider("freq", 440.0, 20.0, 20000.0, 0.01)) * 0.2))
node = server.synth("bbeep", {"freq": 220.0})
server.free(node)
Both defs are now installed on the server and play the same way — a Synth is a Synth, whichever family compiled it.
Instead of starting nodes by hand, hand a pattern to the session. It plays on the session's clock, which runs in its own thread, so the REPL stays yours:
from clausters.seq import Pbind, Pseq, Pwhite
session.play(Pbind(instrument="beep",
freq=Pseq([330, 440, 550, 660], repeats=8),
dur=0.25,
amp=Pwhite(0.1, 0.2)))
session.start() # the clock runs; keep typing while it plays
session.stop() # ...and stop it whenever
When you are done, close the session. Everything it started — the server it booted, a GUI it opened — stops with it:
session.close()
That is the whole loop: a session, a def, nodes or patterns. From here, Sessions explains the offline and embedded flavours (the same code, a different factory), Defining instruments is the full def-building vocabulary, and Routines and clocks drives the clock by hand.
Play a sound
The examples below are the same ideas as scripts, so they can run unattended: they wrap the code in functions and drive the clock for a fixed time. Three paths, the same code shape — they differ only in the session factory. Embedded runs the whole server inside the Python process (the bundled library); nothing to start:
python clients/python/examples/embedded.py
Offline (no server, no audio device) renders a score to a WAV through the bundled renderer:
python clients/python/examples/offline_render.py out.wav
Live sends the same pattern over the network (TCP by default; UDP probes for the server first) to a separate server. The wheel ships that server too, as the clausters command:
clausters # start the standalone server (its own process)
python clients/python/examples/live_udp.py # in another shell
See Sessions for when to pick which, Examples for what these do, and The client, layer by layer for the architecture behind them.
Run the tests
cd clients/python
python -m pytest # or: python tests/test_smoke.py
Boundary rule (project-wide): only flat data crosses any binding — Python floats/ints in, array('f')/bytes out.
Configuration
The client reads the same TOML configuration file the server and the GUI read, so all three agree on one place for defaults. The full schema, the file locations and the precedence rules are documented once, in the server documentation's Configuration chapter; this page covers what the Python client takes from it.
The client only reads the file — it never writes it. A value passed explicitly in code always wins over the file, which in turn wins over the built-in default.
What the client reads
Two sections feed the client's defaults:
-
[client]— connection defaults forSession.live()andServer:[client] host = "127.0.0.1" port = 57110 # one number serves UDP and TCP alike latency = 0.0 # transport = "tcp" # the command carrier: "tcp" (default), "udp" or "ws"With these set,
Session.live()(no arguments) connects to127.0.0.1:57110with the configured latency. Passinghost,portorlatencyexplicitly overrides the file:Session.live() # uses [client] from the config Session.live(host="otherhost") # host explicit, port/latency from the config -
[server]— the defaults forServerOptions(which sizes the client's bus allocators and emits matching launch flags):[server] audio_buses = 128 control_buses = 1024 sample_rate = 48000 # Boot-time hardware channels and pre-allocated pools: outputs = 2 # omit to follow the device default inputs = 0 # >0 opens the input device; In reads it max_nodes = 1024 max_buffers = 1024 max_graph_children = 256 max_ugen_inputs = 32ServerOptions()then reads those values; a field passed to the constructor still wins.ServerOptions.args()turns them intoclaustersCLI flags (--outputsonly when set), so a server launched from the object matches it;Server.query_info()reads the same fields back from a running server (includinginput_channelsand the pool sizes) as aServerInfo.
Reading it yourself
clausters.config.load_config() returns the merged configuration as a nested
dict (cached after the first call; pass refresh=True to re-read). It is the
same loader the Server defaults use, so you can inspect exactly what they will
see.
Python version
The loader uses the standard-library tomllib, so the client requires Python
3.11 or newer.
The client, layer by layer
The package is three layers plus a thin convenience wrapper. The split is deliberate: the value/timing work knows nothing about the server, and the server side is one swappable seam.
clausters.base — server-agnostic timing and values
builtins— scalar and list math, computed asf32through the native core, so a value the client computes equals the one the server's UGens would compute.absobject— operator overloading, the base for composing values and signals.stream—Routine/Stream, theyieldcoroutine layer. A routine must never block the clock thread.clock—TempoClock, timing only: it schedules and paces, it does not talk to the server.timebase— monotonic, or anchored to the server's sample clock (/sched) for drift-free timing.netaddr,main— addressing and a thread-local execution context. No global state that would block running RT and NRT in one script.
See Routines and clocks for driving these directly — writing a routine by hand — and Timing models for the clock's timing modes (wall-clock, sample-locked, shared transport) and how to observe each.
clausters.seq — sequencing
Event— a note plays a synth and frees it after its sustain.- The value patterns (
Pseq,Pwhite,Pseries, ...) andPbind. The random patterns (Pwhite,Prand) draw from the random context — the running routine's generator, derived at creation from the context that created it, withmain.seed(n)seeding the root (sclang's model: no per-pattern seeds). One root seed reproduces a whole script, and the generator lives in the shared native core, so the same seed replays the same music in every Clausters client language. The context is also exposed directly asclausters.next_f64()/uniform(lo, hi)/next_below(n)/choice(items). EventStreamPlayer—Pbind(...).play(clock, server)runs live or builds an NRT score depending on which interface theServerholds, with yield-exact timing (monotonic pacing, wall-clock timetags).
clausters.defs — the server side
ugens— lowercase callables producingUgen/Control, andSynthDef(sent with/d_recv): the server's UGens wired into a graph.FaustDef(sent with/d_faust), its peer: DSP the server JIT-compiles, built fromsignals(Faust's Signal API as lowercase callables), fromboxes(its Box API — point-free, withboxes.faustopening the Faust libraries), or from Faust source. The three forms are equals; so are the two def families.- Both families are built instance-based, with no global build context, and both instantiate as ordinary nodes in the same tree.
Node/Bus/Bufferhandles and their allocators.clocksync— models the server's sample clock over UDP (Server.sample_clock()) for drift-free/schedtiming without shared memory.Server— owns the communication interface and emits. Swapping its interface retargets a routine from a live RT server to an NRT score without touching the clock or the routine. Interfaces includeOscUdpInterface,OscTcpInterface(length-prefixed OSC; start the server with--tcp), andOscWsInterface(OSC over WebSocket, the browser-reachable transport; start the server with--ws), all drop-in.
See Defining instruments: FaustDef and SynthDef for the full def-building vocabulary — every signals / ugens callable, how the two def kinds differ, and how a def is sent behind the /sync barrier.
clausters.Session — ergonomic defaults, no globals
Session bundles a Server and a clock, with Session.nrt() / Session.live() factories and .play(pattern) / .render() / .run(s). Several sessions coexist — an offline NRT one for plots next to a live RT one — in the same script. See Sessions for the full picture.
The seam, restated
Everything above the Server (clock, routines, patterns, defs) is written once. The Server's interface is the only thing that decides whether the OSC reaches a live device, a shared-memory transport, or an in-process renderer producing a WAV. That is what lets a single script do a live take and an offline render side by side.
Where the server contract lives
The wire formats this client emits — the OSC command set, the SynthDef JSON and Faust def formats, the node-tree and bus/buffer semantics, and the C ABI behind the native core — are specified in the Clausters server book, not here. When in doubt about what a byte means on the wire, that is the source of truth; this client is one consumer of it.
Defining instruments: FaustDef and SynthDef
An instrument is a def — a named processing graph the server compiles once and then instantiates many times as nodes. The client builds two kinds, both living in clausters.defs, and they are peers: neither is the "real" one, and a piece routinely uses both.
FaustDef— a Faust definition, sent with/d_faustand JIT-compiled by the server into a node. Its graph is the full Faust language, so it reaches below the unit: per-sample recursion, tables, foreign constants, and the whole Faust library ecosystem. Build it three ways — the Signal API (signals), the Box API (boxes), or Faust source — all equal citizens (see Building one). Like the SynthDef family, it works out of the box: the wheel bundles Faust (see What the server must support).SynthDef— a UGen graph, sent with/d_recv. It wires the server's UGens (oscillator, noise, impulse, bus I/O, buffer playback, feedback, FFT chains) and the full unary/binary maths — the arithmetic operators plus%,min/max, comparisons,.sin(),.midicps(),.distort()… — which compose the generic operator UGens (see Maths on a UGen graph). The graph is an assembly of ready-made units: it composes what the server already implements, needs no JIT, and works on any server build, since the SynthDef family is on by default.
Rule of thumb, not a hierarchy: reach for a SynthDef when the units you need already exist (and always for routing, buses and buffer playback); reach for a FaustDef when you want DSP the unit set does not cover, or want to write the DSP itself. Both run as ordinary nodes in the same tree and address each other through buses.
Both are built the same way: lowercase callables that compose with ordinary Python operators into a JSON tree. Both are instance-based — there is no thread-global "current graph" as in sclang, so the tree is the composed objects and several defs build concurrently. And both are sent asynchronously, behind the /sync barrier (see Sending a def).
This page is the conceptual map and the catalog of what each callable does. The exact wire format — the JSON node shapes, the UGen registry, the /d_faust / /d_recv signatures and their /done / /fail replies — is specified in the Clausters server book (the schemas / OSC reference chapter); this client is one consumer of it. The generated API reference carries the per-symbol signatures.
The shared shape
Every graph node is an AbstractObject, the operator-overloading base shared with the value layer. Composing two nodes — or a node and a plain number — returns a new node rather than a computed value:
from clausters.defs import signals as S
freq = S.hslider("freq", 220.0, 20.0, 20000.0, 0.01) # a Signal (a control)
detuned = freq * 1.5 # another Signal, not a float
A plain number that meets a node becomes a constant in the graph. The operators map on both def kinds: a FaustDef accepts the full set below, and a SynthDef accepts the same unary/binary maths (+ - * / map to dedicated kinds, everything else to the generic operator UGens — see Maths on a UGen graph). Only a selector with no server op raises TypeError.
FaustDef
Building one
Three constructors, one per payload the server's /d_faust accepts. They are three ways of writing Faust, not a main road and two detours — the server compiles all three into the same kind of node, and the same def can be expressed in any of them:
| Constructor | Payload | Use it for |
|---|---|---|
FaustDef.from_signals(name, *outputs) | a signal tree built with clausters.defs.signals | graphs assembled one output at a time from arithmetic, feedback and controls (see the signal API) |
FaustDef.from_box(name, box) | a box tree — a Box built with clausters.defs.boxes, or a raw dict | point-free graphs of composed processors, with the Faust libraries available as boxes (see the box API); the dict form for machine-generated trees |
FaustDef.from_source(name, src) | a Faust source string | writing Faust directly — the whole language, imports included |
From signals. Each argument to from_signals is one output (a Signal or a number): one argument is mono, two is stereo, and so on.
from clausters.defs import signals as S, FaustDef
freq = S.hslider("freq", 220.0, 20.0, 20000.0, 0.01)
phase = S.rec(lambda s: (s + freq / S.sr()) % 1.0) # one-sample feedback phasor
sine = S.sin(phase * S.TAU) * 0.2
fdef = FaustDef.from_signals("fsine", sine) # one output -> mono
From boxes. The same def as composed processors, with the Faust libraries in reach (box.faust compiles any Faust expression into a Box):
from clausters.defs import boxes as box, FaustDef
freq = box.hslider("freq", 220.0, 20.0, 20000.0, 0.01)
fdef = FaustDef.from_box("bsine", box.faust("os.osc")(freq) * 0.2)
From source. When you would rather write the Faust itself:
FaustDef.from_source("organ", 'import("stdfaust.lib"); process = os.osc(220) * 0.2;')
Which one to pick is a question of how you are thinking about the graph — one output at a time, blocks plugged into blocks, or the language — and is discussed under Choosing a form. Whichever you use, the def is sent, instantiated and controlled identically.
The signal API
clausters.defs.signals (imported as S by convention) is the Signal API as lowercase callables. Arithmetic, comparison and bitwise operators compose nodes directly; the rest are functions or methods.
Maths via operators and methods. These compose a Signal:
| Group | How |
|---|---|
| Arithmetic | + - * /, % (modulo), ** (power), unary - |
| Comparison | < <= > >= — return a 0/1 signal, for gating with select2 / select3 |
| Bitwise | & | ^ << >> |
| Unary methods | .abs() .floor() .ceil() .sin() .cos() .tan() .asin() .acos() .atan() .exp() .log() .log10() .sqrt() .as_int() .as_float() |
| Binary methods | .min(b) .max(b) .atan2(b) .pow(b) .mod(b) |
The same unary/binary maths is also available as module functions (so you can write S.sin(x) as well as x.sin()), plus a few that have no method form:
| Kind | Functions |
|---|---|
| Unary | sin cos tan asin acos atan exp exp10 log log10 sqrt abs floor ceil rint |
| Binary | min max pow atan2 fmod rem |
Sources and structure:
| Callable | Builds |
|---|---|
input(index=0) | audio input channel index |
delay(x, n) | x delayed by n samples |
delay1(x) | x delayed by one sample (Faust ') |
recursion(body) | single-sample feedback; body may reference self_() |
self_() | the one-sample-delayed output of the enclosing recursion |
rec(fn) | Pythonic feedback sugar: fn(s) builds the body from its own delayed output s |
select2(sel, a, b) | picks a or b by a 0/1 selector |
select3(sel, a, b, c) | picks a, b or c by a 0/1/2 selector |
signal(x) | coerces a number (or Signal) into a Signal |
rec is the everyday way to write feedback; recursion/self_ are the explicit form it desugars to. The phasor above (S.rec(lambda s: (s + freq / S.sr()) % 1.0)) is the canonical example — one sample of delay, exactly as in Faust's ~. Filters are built the same way (a biquad is rec plus a delay1 per tap).
Sample rate and constants. These two look alike but resolve differently, and the distinction matters:
| Callable | What it is |
|---|---|
sr() | the engine's sample rate, a foreign constant resolved at def-compile time (Faust's ma.SR, with its [1, 192000] clamp) |
PI, TAU | plain Python float literals (TAU = 2*PI) |
fconst(ctype, name, file="") | a foreign scalar resolved once at compile time — the building block of sr |
fvar(ctype, name, file="") | like fconst but re-read each block |
Use sr() — never a baked-in SR constant — wherever the maths depends on the rate (freq / S.sr(), filter coefficients): the def then stays in tune at whatever rate the live engine or the NRT renderer runs. PI / TAU are literals because they are literals in Faust too, so they involve no server round-trip and become constant signals as soon as they meet a Signal.
Integer vs real constants — 2 is not 2.0. Faust distinguishes an integer constant from a real one, and so does this graph. JSON has only one "number" type, so it is tempting to assume 2 and 2.0 are interchangeable, but they are not: the distinction rides on the literal's form, which survives the whole way through. The client takes the constant straight from your Python value (it does not coerce it), so a Python int serializes as 2 and a float as 2.0; the server then reads an integral token as an integer constant and a token with a decimal point as a real one — a 2.0 is stored as a float and is not folded back to an integer. This matters wherever the operation is integer-typed — the bitwise and shift ops (& | ^ << >>), rem, as_int(), table indices — while ordinary + - * / promote an int constant to real and so are unaffected. When it matters, write the literal the way you want it read: 2 for an integer, 2.0 for a real. (A SynthDef offers no such choice — it coerces every constant to float, since UGens compute only in f32.)
Controls. A control callable's label becomes the control name — the parameter you later set with /s_new / /n_set:
| Callable | Control kind |
|---|---|
hslider(label, init, lo, hi, step) | horizontal slider |
vslider(label, init, lo, hi, step) | vertical slider |
nentry(label, init, lo, hi, step) | number entry |
button(label) | momentary button |
checkbox(label) | toggle |
Tables:
| Callable | Builds |
|---|---|
waveform(values) | a constant table from a list of floats |
rdtable(size, init, ridx) | a read-only table |
rwtable(size, init, widx, wsig, ridx) | a read/write table |
The box API: reusing the Faust libraries
clausters.defs.boxes (imported as box by convention) is the same lowercase-callable pattern over Faust's Box API: the point-free algebra where whole processors compose by their input/output arities (seq / par / split / merge / rec). It is the counterpart of signals and a complete def-building API in its own right — where signals describes one output at a time referentially (input(n)), boxes describe multi-channel blocks that plug into each other, the natural shape for routing, chains, and anything conceived as units with inputs and outputs.
On top of the algebra, one addition opens the Faust libraries to it:
from clausters.defs import boxes as box, FaustDef
lp = box.faust("fi.lowpass", 3) # any Faust expression, stdlib included
box.faust compiles a Faust expression into a Box that is indistinguishable from a primitive — so the Faust libraries (os., fi., re., pm., ...) join the same algebra without transcribing them, composed with sliders and arithmetic built in Python.
Two stages of application, kept apart in the syntax. Faust applies in two different stages, and the module never mixes them in one argument list:
- Arguments to
box.faust(src, *eval_args)are evaluation-stage: spliced into the source text as Faust application.box.faust("fi.lowpass", 3)compilesfi.lowpass(3). Structural parameters — a filter order, a table size, a list of coefficients — must live here; they cannot travel as signals. Formatting:int/floatas literals, a list/tuple as a Faust list(a, b, c), a string verbatim (to pass an expression or a library function as an argument).defs=prepends helper definitions to the generated program. - Arguments to calling a
Boxare composition-stage: boxes wired to the box's signal inputs, sugar forseq(par(args), box). The call must cover all the inputs — usebox.wire()for the ones left open:
cutoff = box.hslider("cutoff", 800.0, 20.0, 8000.0, 0.1)
y = box.faust("fi.lowpass", 3)(cutoff, box.wire()) # fi.lowpass(3, cutoff, _)
Which inputs survive the partial application (here: fi.lowpass(3) expects (fc, x)) is the library function's business — read its documentation; a mismatch is reported by the Faust compiler itself, verbatim, in the /fail reply.
The wire rule — the big difference from signals: there is no input(n) here; each box.wire() is a distinct input. Two wires in two positions are two bus channels. Reusing the same wire object twice is almost always a mistake, and from_box rejects it with an error; route explicitly with box.split, or write that stretch inside the fragment ("_ <: ..."). Every other box value reuses freely — a repeated subexpression (an oscillator used by two branches) is computed once; the server shares identical subtrees.
Channel selection. The JSON carries no arities; the client computes them from the composition rules, except for fragments — only the Faust compiler knows those, so declare outs= (and ins= if useful) when you need to split channels:
st = box.faust("re.stereo_freeverb", 0.8, 0.7, 0.5, 23, outs=2)(dry, dry)
left, right = st.outs() # or st[0], st[1]
The composition surface mirrors the server's box schema one to one: seq / par / split / merge (n-ary, folded left), rec(a, b) (point-free ~; for the rec(lambda s: ...) style use signals or a fragment), wire / cut, delay / delay1, select2 / select3, the same controls, groups (hgroup / vgroup), foreign values (fconst / fvar / sr()) and tables (waveform / rdtable / rwtable) as the signal API, and the same operators on Box (with % mapping to Faust's fmod; the box schema has no shifts). See the API reference for signatures.
Choosing a form
The three forms are interchangeable on the wire, so the choice is about which one says what you mean — and they mix freely inside one piece (and even inside one def, since box.faust embeds source into a box tree).
| Write it as | When the graph is |
|---|---|
source (from_source) | a fixed chain read top to bottom (os.osc(freq) : fi.lowpass(3, 800) <: re.stereo_freeverb(...)), or a regular bank — "N copies with index-dependent parameters", which Faust iterates at compile time (par(i, N, ...), labels with %i, ba.take(i+1, list)). Parametrize it from Python with an f-string that splices N and the lists. |
boxes (from_box) | conceived as composed processors — point-free routing built in Python; its structure decided by Python data (a different shape per element, conditionals over analysis or configuration); library DSP mixed with Python-built pieces (the box.faust fragments); or machine-generated (the raw-dict form). |
signals (from_signals) | described one output at a time, referentially — arithmetic, comparisons, feedback (rec) and tables written as expressions over named values, the shape of a filter or an oscillator you are deriving rather than wiring. |
Two graphs, side by side — a fixed chain (left) belongs in source; a data-driven bank (right) belongs in boxes:
# fixed chain: write Faust # irregular bank: build with boxes
FaustDef.from_source("soft", """ def voice(f, kind):
import("stdfaust.lib"); src = ("os.osc" if kind == "sine"
freq = hslider("freq",220,20,2000,.1); else "os.sawtooth")
process = os.osc(freq) * 0.2 return box.faust(src)(f) * 0.2
: fi.lowpass(3, 800) mix = sum(voice(f, k) for f, k in spec)
<: re.stereo_freeverb(.8,.7,.5,23); fdef = FaustDef.from_box("bank", mix)
""")
Controls and reserved ports
fdef.control_names() lists the control names the def declares, in tree order — the UI labels, deduplicated. On top of those, every Faust synth also accepts the two reserved bus-selecting controls the server adds (fdef.reserved == ("out", "in")): set them at /s_new time ("in" bus "out" bus) to choose the input and output buses. They are not declared in the graph.
fdef.dump_def() returns the wire payload (JSON for a signal/box tree, the string itself for a source).
SynthDef
Building one
A SynthDef takes a name and one or more output UGens — the things that actually write to a bus. A def with no output UGen is silent on the server.
from clausters.defs import SynthDef, control, sin_osc, out
freq = control("freq", 440.0)
amp = control("amp", 0.2)
sig = sin_osc(freq) * amp
sdef = SynthDef("beep", out(0.0, sig), out(1.0, sig)) # two outputs -> stereo
control(name, default) declares a parameter; the def gathers every control its graph references, in first-seen order. Reusing the same name with a conflicting definition — a different default, type or lag — is an error, caught when the spec is built.
Control types and rates
A control can carry a type (and, for a smoothed control, a lag time), mirroring the server's control types. control takes them as keyword arguments:
| Argument | Meaning |
|---|---|
control(name, default) | a plain kr control: one value per block, read every block (the default) |
control(name, default, rate="tr") | a trigger: a /n_set holds for one block, then the server resets it to 0 — each set re-fires an env_gen gate, a sample-and-hold, a Trig |
control(name, default, rate="ir") | a scalar: read once at synth init and frozen; a later /n_set is ignored. As an ir value it may feed an ir input (rand, buffer-info UGens) |
control(name, default, lag=t) | a lagged kr control: changes are smoothed by an implicit one-pole Lag over t seconds (the server inserts the UGen) |
control(name, default, lag=up, lag_down=down) | separate rise (up) and fall (down) smoothing times (VarLag) |
An unknown rate, or a lag_down without a lag, raises a ValueError at build. Audio-rate controls are not a control type here — read an audio signal off a bus with in_ / in_ctl and map it with /n_mapa.
Each UGen output also carries a calculation rate — ir (init), kr (control), ar (audio), dr (demand). It defaults per kind (signal UGens are ar, rand / sample_rate are ir, the demand sources are dr); set it explicitly with Ugen.at_rate, e.g. sin_osc(5.0).at_rate("kr") for a control-rate LFO. The full rate model and its coercion rules live in the Clausters server book (schemas / OSC reference).
The UGen set
clausters.defs.ugens exposes the UGen callables the client implements today — a subset of the server's registry. The set is small for now:
| Group | Callable | Does |
|---|---|---|
| Sources | sin_osc(freq=440.0) | sine by f64 phase accumulation, starting at phase 0 |
impulse(freq=1.0) | one-sample 1.0 every freq Hz (freq 0 = one impulse then silence) | |
white_noise() | uniform white noise in ±1 | |
| Bus input | in_(bus=0.0) | reads an audio bus (sampled per block) |
in_ctl(bus=0.0) | reads a control bus (constant over the block) | |
| Bus output | out(bus, signal) | sums signal into an audio bus |
replace_out(bus, signal) | overwrites an audio bus instead of summing | |
| Buffers | play_buf(bufnum, chan=0.0, rate=1.0, loop=0.0) | mono buffer player, linear interpolation; rate in frames per output sample |
buf_rd(bufnum, chan, phase, loop=0.0) | reads a buffer at a phase signal in frames | |
| Feedback | local_in(channel=0.0) | reads a synth-private feedback channel |
local_out(channel, signal) | writes a feedback channel, and passes signal through | |
| Smoothers | lag(signal, time=0.1) | one-pole smoother (symmetric); the same UGen a lagged control inserts, usable on any signal |
var_lag(signal, up=0.1, down=0.1) | one-pole smoother with separate rise / fall times | |
Scalar (ir) | sample_rate() | the engine sample rate in Hz, computed once at init |
rand(lo=0.0, hi=1.0) | one uniform random value in [lo, hi), drawn once at init and held for the node's life | |
Demand (dr) | dseq(values, repeats=0.0) | a demand-rate sequence source (repeats 0 loops forever); only valid as a demand source |
demand(trig, reset, source) | pulls the next value from a demand source on each rising edge of trig, holding it between triggers | |
| Fused | mul_add(a, b, c) | a*b + c in one UGen (the multiply-accumulate the server fuses) |
sum3(a, b, c) / sum4(a, b, c, d) | three / four-operand sums in one UGen | |
| Side-effect | send_trig(trig, id, value) | on each trigger, sends /tr nodeID id value; output is silence |
send_reply(trig, *values, cmd="/reply", reply_id=-1) | sends a custom OSC message with an arbitrary value list | |
poll(trig, signal, label="poll", trig_id=-1) | posts signal to the server console (and a /tr when trig_id >= 0); passes signal through | |
| Spectral | fft(source, active=1.0, *, fft_size=1024, hop=0.5, wintype=0) | opens a spectral chain (windows and transforms source per hop) |
pv_mag_above(chain, threshold) / pv_mag_below(chain, threshold) | pass bins above / below a magnitude threshold | |
pv_brick_wall(chain, wipe) | zero a band (wipe > 0 low pass, < 0 high pass) | |
ifft(chain) | closes a spectral chain (resynthesises audio by overlap-add) |
Like Faust synths, a SynthDef also accepts the reserved in / out bus-selecting controls the server adds at /s_new time.
The side-effect UGens exist for a reply or a console post rather than audio, so a def may consist of them alone with no out — pass them as SynthDef roots (see Building one). The spectral UGens form a frequency-domain chain — see The frequency-domain chain below.
Maths on a UGen graph
Beyond the four arithmetic operators, the full unary/binary maths works on a SynthDef graph: %, min/max, the comparisons, .sin(), .midicps(), .distort(), .clip2(x) and the rest compose the server's generic BinaryOpUGen/UnaryOpUGen, each carrying the operator by name. It is the same operation set the value side (clausters.base.builtins) computes — one shared clausters-core implementation — so a value you compute ahead of time and the UGen on the audio thread agree bit-for-bit for the native ops.
from clausters.defs import SynthDef, control, sin_osc, out
note = control("note", 60.0)
freq = note.midicps() # UnaryOpUGen (midicps)
sig = sin_osc(freq).distort() * 0.3 # UnaryOpUGen (distort), then Mul
lfo = (sin_osc(5.0) * 0.5 + 0.5).clip2(0.8) # % min max > .fold/.clip … all compose
sdef = SynthDef("lead", out(0.0, sig * lfo))
The + - * / operators keep their dedicated Add/Sub/Mul/Div kinds (so existing defs are byte-identical); every other operator becomes an op UGen. Operators and math methods come from the shared AbstractObject, so the same expression composes a Faust graph, a UGen graph, or concrete numbers depending on what it is applied to. Only a selector with no server op raises TypeError. A FaustDef is still the tool for genuinely custom per-sample DSP (recursion, tables, sample-accurate feedback); a SynthDef now covers ordinary maths as well as wiring.
Feedback within a block uses the LocalIn / LocalOut pair. LocalIn must be emitted before its LocalOut; the topological walk guarantees that as long as the output graph reaches the local_in before the local_out. Make the local_out one of the def's outputs so its write stays in the graph:
from clausters.defs import SynthDef, sin_osc, local_in, local_out, out
fb = local_in(0.0) # private channel 0
sig = sin_osc(440.0) * 0.2 + fb * 0.5
echo = local_out(0.0, sig) # writes channel 0, passes sig through
sdef = SynthDef("fb", out(0.0, sig), echo) # echo as an output keeps the write
The frequency-domain chain
Spectral processing wires an fft to an ifft with any number of pv_* filters between them: fft windows an audio signal and transforms it to a spectral frame once per hop, the pv_* filters mutate the frame, and ifft resynthesises audio by overlap-add. Wire them in order — the output of one is the input of the next:
from clausters.defs import SynthDef, white_noise, fft, pv_brick_wall, ifft, out
chain = fft(white_noise() * 0.25, fft_size=1024, hop=0.5, wintype=0)
chain = pv_brick_wall(chain, 0.75) # keep the bottom 25% of bins (a low pass)
sdef = SynthDef("spec_lp", out(0.0, ifft(chain)))
The FFT frame is synth-private scratch on the server (SuperCollider's LocalBuf model) — no buffer is allocated and none is required. Only fft names the window size, hop and window type (they size the transform); the server propagates them to the rest of the chain. A bare fft → ifft reconstructs the signal at unity gain, delayed by one window.
The window type is also settable live with Server.u_cmd, which addresses one UGen instance inside a running synth:
from clausters._native import Window
server.u_cmd(synth, fft_index, "window", Window.BLACKMAN) # swap the FFT window
where fft_index is the FFT's position in the serialized graph. The smoothing windows themselves are shared with the server through the native core — clausters._native.window(Window.HANN, n) returns the same coefficients the server's FFT applies, so a client that pre-windows audio matches the server bit for bit.
Spec, dump and controls
The graph is serialized by a plain post-order walk of the output UGens, so a UGen is always emitted after its inputs (the ugens list is topologically ordered) and a shared sub-graph is emitted once (deduplicated by object identity).
sdef.spec()— the{"name", "controls", "ugens"}dict the server compiles.sdef.dump_def()— that spec as JSON text, the/d_recvpayload.sdef.control_names()— the control names in spec order (parallelsFaustDef.control_names()).
Inspecting the built graph
Before sending a def you can look at exactly what you composed: both FaustDef and SynthDef expose dump_def(), the JSON string that goes on the wire (the /d_faust / /d_recv argument). Printing it shows the resulting graph — handy to confirm a tree built the way you intended.
import json
print(sdef.dump_def()) # the raw wire string
print(json.dumps(sdef.spec(), indent=2)) # SynthDef: spec() is already a dict — pretty-print it
print(json.dumps(json.loads(fdef.dump_def()), indent=2)) # FaustDef signal/box tree, pretty-printed
SynthDef.spec() returns the {"name", "controls", "ugens"} dict directly, so it is the most convenient handle for a SynthDef. For a FaustDef built from_signals / from_box, dump_def() is JSON you can json.loads; one built from_source returns the Faust source string verbatim, not JSON. Either way, control_names() gives just the declared control names when that is all you need.
Choosing between the two
| FaustDef | SynthDef | |
|---|---|---|
| Sent with | /d_faust | /d_recv |
| Built from | clausters.defs.signals / clausters.defs.boxes / Faust source | clausters.defs.ugens |
| Compiled | JIT on the server (libfaust/LLVM) | assembled from the server's UGen registry |
| Maths | full (trig, exp/log, comparisons, tables) | full (the same operator set, as generic op UGens) |
| Feedback | rec / self_ (one sample) | local_in / local_out (one block) |
| Granularity | per sample — you write the DSP | per unit — you wire DSP the server implements |
| Reuse | the Faust libraries (os., fi., re., pm., …) | the UGen set (see the table above) |
| Server build | the faust feature — default, and bundled in the wheel | the synth feature — default |
Neither is the fallback of the other. The units a SynthDef wires are the fastest way to say "an oscillator into a bus", they need no compiler on the server, and they are the only way to reach the server-side machinery that has no Faust equivalent (buffer playback, bus I/O, send_reply/poll, the FFT chain). A FaustDef is how you write DSP that is not in the unit set — a filter you derived, a physical model, an algorithm — either from Python (signals / boxes) or in Faust itself, with its libraries at hand.
The common pattern is to combine them: a FaustDef for the voice, a SynthDef to route it, play back buffers or analyse the result. Both run as ordinary nodes in the same tree, on the same buses, and are controlled by the same /n_set.
What the server must support
Both def families are on by default on the server (synth and faust are Cargo features, and both are in the default set), and everything the wheel ships is built that way: the standalone clausters binary, the in-process embedded server and the offline renderer all take a SynthDef and a FaustDef. The package even bundles libfaust and the libLLVM it JITs with, so Faust works on a machine with neither installed — nothing to enable, nothing to build.
The one case where a FaustDef can fail is a server someone built without the feature (--no-default-features, e.g. a minimal SynthDef-only build). It answers /d_faust with a /fail — "server built without faust support" — which the client raises as a CommandError. If that happens against a server you did not build, that is what to check.
Sending a def
Sending a def is asynchronous: /d_faust JIT-compiles on the server's network thread, answered later by /done or /fail. The Server mirrors scsynth:
server.add_faustdef(fdef) # RT: BLOCKS until /done (raises CommandError on /fail, ReplyTimeout on silence)
server.add_synthdef(sdef, wait=False) # fire-and-forget: only sends
server.sync() # barrier: /sync -> /synced, waits for ALL earlier async work
server.synth("fsine", {"freq": 330.0}) # safe now — the def is installed
wait=True(the default) blocks on/done;wait=Falseonly sends, after whichsync()is the barrier before the/s_newthat needs the def.- In NRT (a score interface)
add_*always scores the def at time 0 — the renderer compiles it before time advances — sowaitdoes not apply. server.free_def(*names)removes defs (/d_free).
The same shape applies to add_synthdef. There is one rule that overrides the convenience of the blocking default: inside a routine, never block the clock thread. Send the def wait=False and yield enough beats before the dependent /s_new, rather than calling a blocking add_* or sync(). See Routines and clocks for why, and Getting started and the Examples for end-to-end defs that play.
Sessions
A Session is the client's ergonomic entry point: one object that owns a Server and a TempoClock together and drives them as a unit. It exists so that the everyday case — "set up a place to make sound, play a pattern, hear it or render it" — is a couple of lines, without giving up the design that makes this client flexible.
Why it exists
SuperCollider's sclang is convenient largely because of globals: a default Server, a default clock, an implicit "current environment". You type Synth("x") and it just goes somewhere. That convenience has a cost — there is only ever one of each, so you cannot, say, run a live take and an offline render in the same script without them fighting over the same global state.
This client deliberately has none of those globals. The clock does timing and nothing else, a Server is an ordinary object you construct, and routines find their clock through thread-local context rather than a global. That keeps real-time and offline work independent, but it means the convenient one-liner is gone: you would otherwise wire a server, a clock and a timebase together by hand every time.
Session gives the convenience back explicitly. It is just an object that holds a Server and a TempoClock and offers play / render / run, plus two factories that pick sensible defaults. Because it is a plain object and not a global, you can have as many as you like.
Kinds of session
You almost always build a session with one of the factories rather than the constructor. They differ only in where the bytes go — offline into a score, over the network to a separate server, or by function call to a server running inside this process — and otherwise behave identically.
Session.nrt() is an offline (non-real-time) session. Its server accumulates a timetagged score instead of sending anything, and render() turns that score into samples through the renderer bundled with the package. No server process and no audio device are involved.
from clausters import Session, main
from clausters.seq import Pbind, Pseq, Pwhite
main.seed(1) # one root seed reproduces every random draw in the script
session = Session.nrt(tempo=2.0)
session.play(Pbind(
degree=Pseq([0, 2, 4, 7, 4, 2], repeats=2),
dur=0.25,
amp=Pwhite(0.1, 0.2),
))
samples, frames = session.render(sample_rate=48000.0, channels=2)
print(f"{frames} frames; peak {max(abs(s) for s in samples):.3f}")
Randomness (Pwhite, Prand, clausters.uniform/choice/…) always draws
from one seedable context — the running routine's generator, derived from
the context that created it — never from per-pattern seeds, so main.seed(n)
makes a whole piece reproducible end to end (see
Routines and clocks).
Session.live() is a real-time session that sounds on a device over the network. By default it ensures a server the way nrt() ensures a renderer: if one already answers it attaches to it, and if none does it launches a separate clausters process — choosing a shared-memory segment for you — and connects to that. So the everyday live case is one line, whether or not a server is already up; a server the session started is stopped when the session is closed or the interpreter exits, and one it merely attached to is left alone.
The two protocols have two roles: UDP finds the server, TCP talks to it. The boot-or-attach probe rides UDP (discovery stays zero-config, and any scsynth-style tool can do the same), and the session's command interface then connects over TCP by default — reliable, ordered, and not bounded by the ~64 KB UDP datagram, so a large def, a whole GuiDef tree or a megabyte buffer read travels as one frame (the ceiling is the server's --max-frame, advertised in /server_info). Pass transport="udp" (or set [client].transport in the config) for a datagram-only setup — e.g. a server started with --no-tcp — or transport="ws" for a --ws server. Timing is unaffected either way: it rides on bundle timetags and /sched, never on arrival time.
from clausters import Session
from clausters.seq import Pbind, Pseq, Pwhite
with Session.live(tempo=2.0, latency=0.1) as session: # attaches, or boots one
session.play(Pbind(
instrument="default",
degree=Pseq([0, 2, 4, 7, 4, 2], repeats=2),
dur=0.25,
amp=Pwhite(0.1, 0.2),
))
session.run(3.5) # advance the clock in real time, then stop
Arguments worth knowing on live():
boot— whether to start a server when none is up (defaultTrue). Passboot=Falsefor plain attach-only behavior: connect to a server you launched yourself (possibly remote), never starting a process. When booting,options(aServerOptionsthat sizes the launched server and this client's allocators),shm("auto", a path, orNone), andverbose/data_dir/server_argsshape the launched process.latency— seconds added to each event's timetag so it arrives a touch ahead of its play time and the server sounds it on time rather than late.0.0means "as soon as possible"; a small value such as0.1is typical for a live take.timebase— the clock's pacing source. The default paces in wall-clock seconds (monotonic); passing aSampleClockTimebaseanchors timing to the server's own sample clock, for drift-free, sample-accurate scheduling. See The client, layer by layer for the timebase distinction.
Session.embed() is a real-time session whose server runs inside this process. It opens the whole engine — audio device and all — through the native library bundled with the package, and OSC is delivered by function call rather than over a socket. There is no separate process to start and no port to connect to, yet it is real-time: it sounds on a device just like live().
from clausters import Session
from clausters.seq import Pbind, Pseq, Pwhite
with Session.embed(tempo=2.0, latency=0.1) as session:
session.play(Pbind(
instrument="default",
degree=Pseq([0, 2, 4, 7, 4, 2], repeats=2),
dur=0.25,
amp=Pwhite(0.1, 0.2),
))
session.run(3.5)
It takes the same latency and timebase as live(), plus workers (engine threads for parallel node processing) and an optional server= to reuse an existing clausters.Clausters handle instead of opening a fresh one. Because the server lives in this process, you can read its sample clock and control buses directly through session.server.interface.server (the Clausters handle), with no OSC round trip.
There is deliberately no separate "spawn" factory: launching a server is not a different kind of session, just live()'s default behavior, so the option lives on live rather than multiplying constructors. See Launching the server and the GUI below for the details and the object-level Server.boot / GuiHost.boot.
Which factory to reach for:
| Factory | Server | Use it when |
|---|---|---|
Session.nrt() | none (a score + renderer) | rendering offline — a plot, an analysis, a .wav; no device. |
Session.embed() | in-process (bundled library) | making sound from one script, no setup — but the engine shares this process. |
Session.live() | a separate process (booted if needed) | the everyday real-time / live-coding case — a real, separate server a GUI or another client can also talk to. Boots one if none is up; boot=False attaches only. |
Driving a session
Once you have a session, a small set of methods drives it. Some are offline-only, some live-only — the table makes the split explicit.
| Call | Kind | What it does |
|---|---|---|
play(pattern, quant=None) | all | Plays an event pattern (e.g. a Pbind) on this session's clock and server. Returns the EventStreamPlayer. quant is the beat grid to start on; None starts immediately. |
render(sample_rate, channels) | offline | Drains the clock logically (no waiting), then renders the score. Returns (samples, frames) — interleaved float32 in a stdlib array('f'), and the frame count. |
run(seconds) | real-time | Starts the clock, advances it in real time for seconds, then stops. Returns self. (live and embed.) |
start() / stop() | real-time | Start or stop the real-time clock yourself when run (which does both) is not enough. Both return self. |
close() | all | Closes the underlying Server and its interface (for embed, shuts the in-process server down). |
play is the one call shared by both kinds, and that is the whole point — see the next section.
Because close() releases the server, the idiomatic shape for a live session is a context manager, which closes it for you even if the block raises:
with Session.live(tempo=2.0) as session:
session.play(my_pattern)
session.run(4.0)
# server closed here
An offline session holds no socket and renders synchronously, so the context manager is optional there — though harmless, and tidy if you mix both.
One rule carries over from the rest of the client: a routine must never block the clock thread. render() and run() are driver calls you make from your own (main) thread, not from inside a routine — they advance the clock, so calling them from a routine the clock is running would deadlock it.
The same code, live or offline
The reason a session draws no line between "play" and "render" is the client's central design property, the seam: a Server holds one communication interface, and which interface it holds — not your pattern, not your clock — decides where the bytes go. A live session's server holds a network interface (TCP by default); an offline session's server holds a score-accumulating one. Everything above the server is identical.
So the only thing that changes between a live take and an offline render is which factory you called. You can write the pattern once and run it both ways:
from clausters import Session
from clausters.seq import Pbind, Pseq, Pwhite
def phrase():
return Pbind(
instrument="default",
degree=Pseq([0, 2, 4, 7, 4, 2], repeats=2),
dur=0.25,
amp=Pwhite(0.1, 0.2),
)
# Offline: capture it to samples.
offline = Session.nrt(tempo=2.0)
offline.play(phrase())
samples, frames = offline.render()
# Live: hear the very same phrase.
with Session.live(tempo=2.0, latency=0.1) as live:
live.play(phrase())
live.run(3.5)
This is exactly what the two shipped examples do — offline_render.py and live_udp.py share their pattern and differ only in the session factory. See Examples.
Several sessions at once
Because a session is an ordinary object rather than a global, more than one can be live at the same time. The common case is rendering a score offline (for a plot, an analysis or a .wav) right next to a live session you are listening to, in a single script:
live = Session.live(tempo=2.0, latency=0.1)
plot = Session.nrt(tempo=2.0)
live.play(phrase())
plot.play(phrase())
live.run(2.0) # heard in real time
samples, frames = plot.render() # captured offline, no audio device
live.close()
The two never interfere: each has its own server, its own clock and its own interface. With globals this is impossible; here it is the default.
Launching the server and the GUI
Live coding wants the whole system reachable from one interpreter: a separate audio server (so it survives a client restart, is shared, and keeps the audio thread out of Python) and, often, the visual server beside it — without opening three terminals or spelling out a shared-memory path. Session.live and Session.gui do exactly that, and everything they start is torn down when the session is closed or the interpreter exits — a normal exit, an unhandled exception, or an abandoned handle garbage-collected. Nothing is left running.
session.gui() launches the clausters-gui visual server and returns a GuiHost connected to it — the GUI parallel of live() booting a server. You never spell out an address or a segment: the host is started with its client leg pointed at this session's server and mapping the same shared-memory segment the server was booted with, so meters, scopes and playheads read the engine with no per-frame messages. The host is owned by the session and stopped on close.
from clausters import Session
from clausters.gui import window, label
session = Session.live() # attaches, or boots a server (segment auto-chosen)
gui = session.gui() # clausters-gui, wired to that server + same segment
win = gui.open(window(label(1, text="hello"), title="Panel", w=320, h=120))
gui.set(1, text="edited live") # edit the open window
gui.close(win) # close it
session.close() # stops whatever the session started; leaving the interpreter would too
GuiHost opens, edits and closes windows: open sends a window-rooted GuiDef and returns its id, set edits a live widget, and close closes it (close_all closes every window still open). Repeated session.gui() calls return the same host.
The visual server binary ships bundled in the same package as the audio server (built from the independent clients/gui workspace, stripped), so there is nothing extra to install — the launcher finds it out of the box. In a source checkout a binary built under clients/gui/target is used, and CLAUSTERS_GUI_BIN overrides the lookup. See Getting started for building a lighter, server-only install.
Without a Session: Server.boot and GuiHost.boot
If you are not using a Session, the server and the GUI host each carry their own launch and teardown, so you don't juggle a separate process object. Server.boot() starts a server process and returns a connected Server that owns it (its close() stops the process); GuiHost.boot() does the same for the visual server, returning a started GuiHost (its stop() stops the process). Both also die with the interpreter.
from clausters.defs import Server
from clausters.gui import GuiHost, window, label
server = Server.boot() # a server process starts
gui = GuiHost.boot(server=f"{server.target.host}:{server.target.port}", shm=server.shm)
gui.open(window(label(1, text="hi"), title="Panel", w=320, h=120))
# ...
gui.stop() # stops the clausters-gui process
server.close() # stops the server process
This is exactly what Session.live/gui use internally — the session just bundles them with a clock.
The raw processes
One level lower, clausters.launch exposes the processes themselves — ServerProcess and GuiProcess — for when you want to own them directly (e.g. a custom Server/GuiHost wiring). Both are context managers, both register the same exit hooks, and default_shm_path() picks a segment (None on platforms where shared memory does not apply); server_is_up() is the probe live uses to decide boot-or-attach.
from clausters import ServerProcess, GuiProcess
from clausters.gui import GuiHost
with ServerProcess() as server_proc: # clausters --shm <auto>
with GuiProcess(server=f"{server_proc.host}:{server_proc.port}",
shm=server_proc.shm) as gui_proc:
host = GuiHost(port=gui_proc.port).start()
... # both processes stop when the `with` blocks exit
When you don't need a Session
A Session is sugar over two objects you can always build yourself. When you want more control — several servers behind one clock, a clock shared across subsystems, or a custom interface — skip the factory and wire them directly:
from clausters.base import TempoClock
from clausters.defs import Server
server = Server("127.0.0.1", 57110, latency=0.1)
clock = TempoClock(tempo=2.0)
phrase().play(clock, server) # what Session.play does for you
clock.run(3.5)
server.close()
Session adds no behaviour of its own — it only bundles these two and forwards to them — so reaching for the longer form costs nothing and loses nothing.
See also
- API reference — the generated reference for
Sessionand every method. - The client, layer by layer — where the
Server, the clock and the seam fit in the whole client. - Routines and clocks — the level below a session: driving a
Routine, aTempoClockand aServeryourself. - Timing models — the ways a clock keeps time (wall-clock, sample-locked, shared transport) and how to observe each.
- Examples —
offline_render.pyandlive_udp.py, the session in runnable form.
Routines and clocks
Session and Pbind are convenience layers over three plain objects you can also drive yourself:
- a
Routine— what happens over time, written as a Python generator thatyields how long to wait; - a
TempoClock— when it happens: it schedules the routine and keeps musical time in beats; - a
Server— where the sound goes: it owns the communication interface and plays events on it.
This page works at that level: write a routine by hand and play notes from it through a Server, on the high-level API throughout — you build Events and call play, never hand-assemble OSC bundles. For the different ways a clock can keep time — the default wall clock, locked to a server's sample clock, or a shared transport — and how to observe each, see Timing models.
Logical time vs physical time
This is the idea that makes routines worth using, inherited from SuperCollider.
A routine yields numbers, and each one is a wait in beats before the clock resumes it. Those waits play out in physical time — the actual wall-clock seconds the OS sleeps — and under load they jitter. But a routine also keeps a logical time: the running sum of everything it has yielded so far, relative to when it started and the clock's tempo. The logical time has no jitter — a routine that yields 0.5 four times is at logical beats 0, 0.5, 1.0, 1.5 exactly, whatever the scheduler did in between.
The Server stamps every event from the routine's logical time, not from "now". So even though the routine is woken at slightly irregular physical instants, the timing it asks the server for is precise. That is the only way to get jitter-free rhythmic sequences in real time, and every timing model builds on it.
A routine by hand
An Event is a dict of note parameters that knows how to play itself: Event(...).play(server) creates a synth at the routine's current logical beat and schedules its release after the note's sustain. Build it with keywords or from a plain dict — an Event is a dict — and play it from inside a routine, yielding the gap to the next note:
from clausters.base import TempoClock, Routine
from clausters.seq import Event
from clausters.defs import Server
server = Server("127.0.0.1", 57110, latency=0.1) # a running server, on UDP
clock = TempoClock(tempo=2.0) # 2 beats per second
def melody():
for note in [60, 62, 64, 67, 69]: # MIDI notes
e = Event(midinote=note, amp=0.2, dur=0.5) # half-beat note
e.play(server)
yield e.delta() # advance to the next note
clock.play(Routine(melody)) # schedule it to start now
clock.run(3.0) # advance the clock 3 s, then stop
server.close()
A few things worth knowing:
- An
Eventcarries musical defaults (see the API reference):midinote(ordegree, or an explicitfreq) sets pitch,ampthe level,instrumentthe def (the server has a built-indefaultsine). Timing comes fromdur: the note'sdelta(beats to the next event) isdur, and itssustain(how long it sounds) isdur * legato(legatodefaults to0.8). As in SuperCollider, an explicitdeltaorsustainkey overrides that calculation —Event(..., dur=0.5, sustain=0.4)sounds for exactly 0.4 beats. A dict works just as well —Event({"midinote": 60, "amp": 0.2, "dur": 0.5}).play(server). clock.run(seconds)starts the real-time driver, waits, and stops it. Useclock.start()/clock.stop()to keep one clock running across several routines.- A routine optionally receives the clock as its argument (
def melody(clock):) if it needs it, but for playing events you rarely do — the Server finds the logical beat itself. - This clock paces against wall-clock OSC time, the default. To make the same routine drift-free and sample-accurate, or to phase-align several clients, lock it to the server — see Timing models.
The one rule
A routine must never block the clock thread. It runs on that thread, so a time.sleep, a blocking server.sync(), or any wait=True def send freezes every other routine and the whole timeline. Cede time with yield instead. To load a def from within a routine, send it asynchronously — server.add_synthdef(sdef, wait=False) — and yield enough time before the first note that uses it.
The random context: one seed for a whole script
Everything random — Pwhite, Prand, and the module functions
clausters.next_f64() / uniform(lo, hi) / next_below(n) / choice(items)
— draws from one seedable context, the sclang model:
main.seed(n)seeds the root generator. Called before you build and play, it makes every random value in the script reproducible, end to end.- Every routine gets its own generator when it is created, seeded from the context creating it. Same root seed + same creation order = the same music; and because each routine draws from its own stream, concurrent routines (several clocks, live RT next to an NRT render) stay reproducible per routine no matter how their wakes interleave.
- A draw always uses the generator of the routine running right now, falling back to the root outside any routine.
There are no per-pattern seeds — independent seeds would break whole-script consistency. To isolate some material, play it inside its own routine: it gets its own derived stream by construction. The generator itself lives in the shared native core, so the same seed replays the same values in every Clausters client language.
from clausters import main, uniform
from clausters.seq import Pbind, Pwhite
main.seed(2026) # the whole script is now reproducible
detune = uniform(-3.0, 3.0) # a one-off draw from the same context
pattern = Pbind(degree=Pwhite(0, 7), dur=0.25) # draws when played
Offline, with the same code
Everything here works unchanged offline. Build the Server with an OscNrtInterface, drive the clock with clock.render() instead of run(), and the routine's play calls accumulate a timed score the bundled renderer turns into samples — no server, no audio device. That swap is the client's central seam, covered in Sessions and The client, layer by layer.
See also
- Timing models — the ways a clock keeps time (wall-clock, sample-locked, shared transport) and how to observe each.
- Sessions — the ergonomic handle that bundles a clock and a server, for when you do not need this level of control.
- The client, layer by layer — where routines, clocks and the server sit in the whole client.
- API reference —
TempoClock,Routine,Eventand theServermethods used here.
Timelines and the playhead
The sequencing you have seen so far is generative: a Routine is a Python generator, a Pbind an event pattern, and a TempoClock resumes them forward in time. That way of working is open-ended and expressive, but it has one thing it fundamentally cannot do — seek. A generator's musical state lives in its local variables, so you cannot jump it to beat 100 without running through 0–100, and you cannot ask "what plays at bar 33?" without getting there.
A Timeline is the complement: a static, editable list of timed items, kept sorted by beat, with random access by time. Because it is a data structure rather than a coroutine, a Playhead can give it real transport controls — play, stop, locate (seek), loop — and a song position. This is how a DAW works: the arrangement is random-access for editing and seeking, and playback is a forward scan of it from the playhead.
This page is the static counterpart to Routines and clocks; the two ways of sequencing coexist, and you can move between them (capture a pattern into a timeline, below).
The timeline
A Timeline holds (beat, item) pairs in beat order. You edit it freely and query it by time.
from clausters.seq import Timeline, Event
tl = Timeline()
a = tl.add(0.0, Event(degree=0)) # add returns a handle...
b = tl.add(1.0, Event(degree=2))
tl.add(2.0, Event(degree=4))
tl.move(a, 4.0) # ...you pass back to move or remove
tl.remove(b)
add keeps the list sorted (a stable insert, so items added at the same beat keep their order — a note-off before a re-trigger). It returns a handle you give back to remove and move, so edits stay correct as other inserts shift positions.
The random access by time is the point:
tl.index_at(1.5) # the cursor of the first item at or after beat 1.5
tl.range(1.0, 3.0) # the (beat, item) pairs in the half-open window [1.0, 3.0)
tl.at(2.0) # the items exactly at beat 2.0
tl.duration() # the beat of the last item
index_at is the seek primitive — it is what play(at=…) and locate use to start the scan at an arbitrary point, which a forward-only routine could never do.
What an item is
An item is anything that can render itself on a destination — it has a play(destination) method. Event already is one (it plays a note on a Server for OSC, or a MidiServer for MIDI — the same double dispatch the patterns use), so a timeline of Events renders to OSC or MIDI depending only on the destination the playhead holds. For a plain editable OSC or MIDI score, OscEvent and MidiEvent wrap a raw message:
from clausters.seq import OscEvent, MidiEvent
tl.add(0.0, OscEvent("/s_new", "default", -1, 0, 0, "freq", 440.0))
tl.add(1.0, MidiEvent(b"\x90\x3c\x64")) # note on, key 60, vel 100
The playhead
A Playhead scans a timeline forward as a clock advances, rendering each item on a destination. It is built from a timeline, the clock that drives it, and the destination the items go to:
from clausters.seq import Playhead
head = Playhead(timeline, session.clock, session.server)
session.start() # the clock must be running for live playback
head.play(at=0.0, quant=4) # start on the next bar, from the top
The transport controls:
| Call | What it does |
|---|---|
play(at=0.0, quant=None) | Start (or restart) from beat at, snapping to a quant bar. Re-seeks the cursor, so it doubles as locate-and-play. |
stop() | Halt the playhead; no further items are rendered (notes already started keep their scheduled releases). |
locate(beat) | Seek to beat — random access. While playing, restarts the scan there; while stopped, sets where the next play begins. |
loop(start, end) / unloop() | Loop the half-open window [start, end); the scan wraps at end. |
position() | The current song position in beats (interpolated from the clock while playing). |
Under the hood the playhead is a thin cursor over the static structure: the random access happens at the boundaries (play, locate, loop wrap), and between them it is a forward scan — exactly how a DAW's playback engine reads its arrangement. Because it rides the clock's logical time like everything else in the client, it inherits the timing models for free: quant starts it on a bar, clock.lock_to(server) makes its events sample-exact, and clock.join_transport(server) aligns its bars with other clients (see Timing models and A DAW-style transport).
Capturing a pattern into a timeline
The two meet here: run a pattern offline and record what it plays into a timeline — "bounce a pattern to a clip" — then edit and seek the result.
from clausters.seq import Timeline, Pbind, Pseq
tl = Timeline.from_pattern(
Pbind(instrument="default", degree=Pseq([0, 2, 4, 7]), dur=0.5),
dur=2.0, # bound an open-ended pattern; None drains a finite one fully
)
tl.add(0.0, Event(instrument="default", degree=7, dur=0.5, amp=0.3)) # then edit
Offline rendering
A playhead is destination-agnostic, so rendering a timeline offline is the same code with an offline session: play it on the NRT clock and render the score.
from clausters import Session
session = Session.nrt(tempo=2.0)
Playhead(timeline, session.clock, session.server).play()
session.clock.render() # drain the playhead in logical time
samples, frames = session.server.render() # the offline render
Following a conductor
A playhead is a local transport, but it can also obey a shared one. head.follow_transport(server) binds it to the server's transport so that a conductor's transport_play / transport_stop / transport_locate rolls, halts and seeks this playhead too — several clients in lockstep. It is built on the responder layer (an OscFunc on the transport broadcast) and the shared grid:
head = Playhead(timeline, clock, server)
clock.start()
head.follow_transport(server, quant=4) # roll when the conductor presses play
Beat-aligned in plain wall-clock mode, sample-exact when the clock is also lock_to the server. See A DAW-style transport for the conductor side (Server.transport_play and friends) and transport_conductor.py in Examples.
See also
- Routines and clocks — the generative counterpart (the open-ended side you can capture from).
- A DAW-style transport — the shared beat grid clients phase-align on.
- Timing models — the timing references a playhead inherits (
quant,lock_to,join_transport). - Examples —
timeline_transport.py, the playhead live. - API reference —
Timeline,Playhead,OscEvent,MidiEvent.
Timing models
A clock can keep time in a few different ways — models — and this page is the one place to understand and try each: what it is, how to switch a clock to it, and how to watch its timing from Python. The choice of model is independent of where the OSC goes. Routines and clocks is the companion page: it builds the routines you play on these clocks.
| Model | In one line | How to select it |
|---|---|---|
| Wall-clock OSC time | the client's own clock; works everywhere, including with no server | the default — TempoClock(tempo) |
| Sample clock | locks to a server's sample counter; drift-free, sample-exact | clock.lock_to(server) |
| Shared transport | a server-hosted beat grid several clients align on | clock.join_transport(server) + quant |
All three ride logical time — the jitter-free relative timing a routine's yields define (see Routines and clocks). They differ in the reference the clock paces against and how it addresses events on the wire.
Wall-clock OSC time — the default
A plain clock paces against wall-clock OSC time (OSC timetags are NTP: absolute seconds since 1900). You get it by doing nothing special:
clock = TempoClock(tempo=2.0) # or: Session.live(host, port)
- Self-contained. It is the client's own clock; across machines you can discipline it with NTP/PTP, but nothing here depends on a Clausters server.
- Works anywhere — standalone, against another OSC program, or across a network.
- Jitter-free relative timing. Logical time is exact, so events keep their spacing even though the routine wakes at slightly irregular physical instants. The routine's start is arbitrary (wall-clock), exactly as in SuperCollider; the guarantee is no jitter between events, like MIDI.
- Absolute alignment across machines is NTP/PTP-quality, not sample-exact.
This is the timing model to assume unless you opt into another. Nothing to test beyond playing a routine — it just sounds.
Sample clock — drift-free, locked to a master
Locking the clock to a Clausters server makes it schedule on the server's own sample counter (via /sched, by absolute sample), which removes the drift between the client's clock and the audio device:
clock.lock_to(server) # or: Session.live(host, port).lock_to_server()
- The server becomes the master clock. Over UDP the client tracks the server's published
/clockanchor on its own socket; with an in-process or shared-memory server it reads the counter directly. - Drift-free and sample-coherent. Events land on exact samples, and several clients locked to the same master share one sample axis.
- Graceful. With no reachable master — an offline render, or no server running —
lock_toleaves the clock on wall-clock time instead of failing.
Watching it, from Python
The point of this model is real, drift-free timing, so it is worth seeing it. The sample-clock tracker reads the server's live position from real /clock replies; everything below is plain Python, with a server running (the installed clausters command). Build the tracker explicitly so you can read it, and hand its timebase to the clock:
sc = server.sample_clock() # a tracker on its own socket
sc.warmup(); sc.track() # seed and keep the model fresh
clock = TempoClock(tempo=2.0, timebase=sc.timebase()) # same lock as lock_to, with a handle
print("rate:", sc.rate, "Hz | drift:", f"{sc.model.drift_ppm():.1f} ppm")
before = sc.now() # the server's sample counter, now
clock.run(3.0) # play something for 3 seconds
after = sc.now()
print(f"counter advanced {after - before} samples = {(after - before) / sc.rate:.3f} s")
sc.now() is the server's real sample counter (the model is fit from live round trips, not guessed), sc.rate is its measured sample rate, and sc.model.drift_ppm() is the actual measured difference between the two clocks. To verify the lock: the advance should match the 3.0 seconds you ran the clock to within the tracker's small uncertainty, and drift_ppm should be a handful of ppm, not hundreds. (The server can also print the exact sample of each scheduled event at trace level — enable it from Python with server.request("/verbosity", "clausters::osc=trace", expect=("/done",)) and read the server's own terminal — but the client-side reading above needs nothing but Python.)
clock.lock_to(server) is the same lock in one call when you do not need the tracker handle; it falls back to wall-clock time if no master answers.
Shared transport — phase-aligning several clients
This section is that timing model in brief; A DAW-style transport is the full workflow guide — conducting, following, starting together on a bar, and following a tempo change live.
Locking to a master gives every client the same sample axis, but each routine still starts whenever you play it. To make several clients begin on the same beat, two pieces work together:
quant—clock.play(routine, quant=4)(orsession.play(pattern, quant=4)) snaps the routine's start to the next beat that is a multiple ofquant(a bar in 4/4).Noneor0starts immediately. On its own it snaps to the clock's own grid — handy for one client adding a voice cleanly on the next bar.- A shared transport —
clock.join_transport(server)(orSession.join_transport()) adopts the server's/transportgrid: its tempo and an origin every client shares. Nowquantsnaps to that grid, so every client on it hits the same bar. One client (the conductor) defines it withserver.set_transport(origin_sample, tempo); the others join.
With each client also lock_to the master, the shared bar is an exact sample, so the clients are sample-aligned; in plain wall-clock mode they are beat-aligned (drift-bounded, via the server's OSC-time anchor). Start the clock before playing a quantized routine, so quant snaps against the running grid.
Trying it
The transport_sync.py example (see Examples) sets a transport, has two independent clients join and lock, and shows them landing on the same bar. The check is that both compute the same next-bar sample — using only public state, so any client on the same transport gets the same number:
import math
def next_bar_sample(server, clock, quant=4):
origin, tempo = server.transport()
rate = clock.timebase.sample_rate
grid_beat = (clock.timebase.current_sample() - origin) * tempo / rate
target = math.ceil(grid_beat / quant) * quant
return round(origin + target * rate / tempo)
Run it for two clients sampled back-to-back and the two values match to the sample.
Reference is independent of destination
The time model is orthogonal to the destination — where the OSC actually goes (any OSC endpoint, a local or remote server). The one hard rule is that the sample clock and the transport need a Clausters master; everything else falls back to wall-clock.
| You are talking to… | Model | How |
|---|---|---|
| nothing / another OSC program | wall-clock OSC time | the default — do nothing |
| a remote server across a network | wall-clock OSC time | the default (NTP/PTP-quality sync) |
| a local / LAN Clausters server | sample clock | lock_to (drift-free, the master) |
| several clients, one server | sample clock + transport | each lock_to, then join_transport |
MIDI always rides OSC time
MIDI output never uses the sample clock. A MidiServer writing a score keeps its timeline in beats (logical/OSC time) and quantizes to ticks only when it writes the file; live MIDI output is emitted on the clock's logical time. lock_to changes only how the OSC Server schedules; it does not touch MIDI timing — MIDI is not sample-exact by design, and the client may have no sample clock at all. Live OS MIDI output is therefore best-effort; for exact MIDI timing, write a score offline (its ticks come from logical time). Tighter live-MIDI timing is a possible future refinement.
The API, at a glance
TempoClock.lock_to(server)/unlock()— switch to / off the server's sample clock.Session.lock_to_server()is the session wrapper. Blocking (it does/clockround trips); call beforestart/run, never from a routine.TempoClock.join_transport(server)/leave_transport()— adopt / drop the server's shared transport grid.Session.join_transport()is the wrapper.Server.set_transport(origin_sample, tempo)/Server.transport()— define / read the shared grid (the conductor sets it once).play(routine, quant=...)— start on the nextquant-beat boundary of the current grid.
See also
- Routines and clocks — writing the routines you play on these clocks.
- Sessions — the handle that bundles a clock and a server.
- API reference —
TempoClock.lock_to/join_transport,Session,Server. - The Clausters server book —
/clock(the master-clock anchor),/schedand/transport.
A DAW-style transport
A DAW has a transport: a shared timeline with a tempo and a bar grid. Everything you arm starts aligned to bars, and moving the tempo moves the whole arrangement with it. Clausters offers that same idea, but across independent clients: a server hosts one shared beat grid, several clients join it, and a routine on each starts on the same bar. This page is the practical guide to that workflow — being the conductor, joining as a follower, starting together on a bar, and following a tempo change live.
It builds on two other pages. Timing models explains why the alignment is beat-accurate or sample-exact (the time reference a clock paces against); this page is the how of the transport itself. Receiving OSC and MIDI is the input layer the live-change section uses.
The shared grid
The transport is deliberately small: an origin sample (the sample position of beat 0) and a tempo (beats per second). Together they are a grid — beat b is sample origin + b·rate/tempo — that the server stores under /transport and any client can read. That is the whole of it: the server hosts the grid but never plays from it. There is no server-side playhead rolling forward; each client's TempoClock is its own playhead, and the grid is the common ruler they all measure bars against.
One client is the conductor: it defines the grid once.
from clausters.defs import Server
server = Server() # UDP to 127.0.0.1:57110
server.set_transport(origin_sample=0, tempo=2.0) # beat 0 at sample 0, 2 beats/s
Every other client is a follower: it adopts that grid as its own tempo and origin.
from clausters.base import TempoClock
clock = TempoClock() # its own tempo is about to be overwritten
clock.join_transport(server) # adopt the shared tempo + origin
With a Session, both sides are one call — session.server.set_transport(...) to conduct, session.join_transport() to follow. clock.leave_transport() (or never joining) returns a clock to its own private grid.
Starting together on a bar
A DAW starts a clip on the next bar, not the instant you click. The client's equivalent is quant: the beat boundary a routine's start snaps to.
clock.start() # the playhead must be running first
clock.play(routine, quant=4) # start on the next 4-beat bar
session.play(pattern, quant=4) # the Session form
quant=4 snaps the start to the next beat that is a multiple of 4 — a bar in 4/4. quant=1 is the next beat; None or 0 starts immediately. Because every follower's quant snaps to the same shared grid, they all land on the same bar, so independent clients begin in phase. Start the clock before playing the quantized routine, so quant measures against the running grid rather than a stopped one.
quant works without a transport too — then it snaps to the clock's own elapsed beats, which is the clean way for a single client to drop a new voice in on the next bar. Joining a transport is what makes that bar the same bar across clients.
Beat-accurate or sample-exact
How tightly the clients align depends on the time reference each clock paces against — the subject of Timing models, in one paragraph here:
- Plain (wall-clock) followers align to the beat, drift-bounded: the grid's sample origin is mapped to OSC time through the server's
/clockanchor, so everyone agrees on the bar to within the wall-vs-audio drift. - Followers that also
lock_to(server)align to the sample: the grid lives on the master's sample axis, so the shared bar is one exact sample for all of them.
clock.lock_to(server) # sample-exact timing (drift-free; the master clock)
clock.join_transport(server) # ...then phase-align on the shared bar
Order does not matter much, but lock first and join second reads well: choose the reference, then align on it.
Following a tempo change live
This is where the transport behaves most like a DAW's: when the conductor changes the tempo (or origin), every follower should move with it. Setting /transport again pushes the new grid to every client registered for notifications, so followers do not have to poll. A follower reacts with an OSC responder on /transport.reply:
from clausters.base import OscReceiver
from clausters.responders import OscFunc
recv = OscReceiver().start()
recv.send(server.target.addr(), "/notify", 1) # subscribe on this socket
def follow_transport(msg, time, src):
# msg == ["/transport.reply", origin_sample, tempo, defined]
if msg[3]: # defined
clock.join_transport(server) # re-adopt the new grid
OscFunc(follow_transport, "/transport.reply", recv=recv)
Now the conductor doing server.set_transport(0, 3.0) later in the session re-tempos every follower at once — the bar grid they quantize against moves together. (Register /notify from the receiver's socket, as above, so the push lands where the responder is listening.) The shipped osc_responder.py example wires exactly this reaction; see Examples.
Rolling the transport: a conductor with play / stop / locate
The shared transport also carries a DAW-style rolling state — whether it is playing and the song position — that a conductor drives and every client's playhead obeys. The server holds the state and broadcasts each change; it still never schedules audio, so each client rolls its own playhead on the shared grid.
A conductor (any client) drives it through the Server:
server.set_transport(0, 2.0) # define the grid (stopped at position 0)
server.transport_play(0.0) # roll from beat 0 -- every follower starts
server.transport_locate(16.0) # seek the song position to beat 16
server.transport_stop() # halt every follower
A follower binds a Playhead to the transport with follow_transport, and from then on the playhead mirrors the conductor — it rolls on transport_play, halts on transport_stop, and seeks on transport_locate:
from clausters.seq import Playhead
head = Playhead(timeline, clock, server)
clock.start()
head.follow_transport(server, quant=4) # obey the transport; start on a bar
follow_transport registers /notify and an OscFunc on /transport.reply (the responder layer) so it reacts to the broadcast, then applies the current state once. Because every follower computes from the same broadcast state, they roll in lockstep: beat-aligned in plain wall-clock mode, and sample-exact when each clock is also lock_to the server. The everyone-is-symmetric design makes this simple — every client (including the one issuing the commands, if it follows too) reacts to the same broadcast identically. transport_conductor.py (Examples) shows two followers rolling together; unfollow_transport() releases it.
A worked example: two clients, one bar
transport_sync.py runs two completely independent client pairs — each its own Server and TempoClock, the state two separate programs would hold — and lands a note from each on the same bar. The check uses public state only, so any client on the same transport computes the same number:
import math
def next_bar_sample(server, clock, quant=4):
origin, tempo = server.transport()
rate = clock.timebase.sample_rate
grid_beat = (clock.timebase.current_sample() - origin) * tempo / rate
target = math.ceil(grid_beat / quant) * quant
return round(origin + target * rate / tempo)
Sampled back to back, the two clients return the same next-bar sample — that equality is the phase alignment. Each then play(..., quant=4)s a note, and the two sound together. The example is in Examples.
What it is, and what it is not
The analogy to a DAW transport is the bar grid and tempo plus a play/stop/position state — enough to lock clients to the same bars, tempo, and rolling playhead. The rest of a DAW's transport is intentionally not here:
- The server broadcasts transport control, it does not schedule audio. It holds the grid and the rolling state (playing + position) and pushes changes; the actual rolling — which note sounds when — is each client's own
Playheadon the shared grid (see Timelines and the playhead). The audio scheduling stays per-client (via/sched), so the server never becomes an audio clock. - One grid per server, last-writer-wins. There is a single shared transport; whoever calls
set_transportmost recently defines it. Several conductors are a coordination choice you make, not something the server arbitrates. (Multiple independently named transports on one server were considered and deferred.) - Tempo and origin only — no meter object. A "bar" is whatever beat multiple you pass as
quant; there is no separate time-signature the server stores. Pick aquantthat matches your meter (4 for 4/4, 3 for 3/4). - No server-side recording or arrangement. The timeline a playhead rolls lives in the client; the server holds only the shared position and tempo, not the notes.
These are the honest edges of a small, composable feature: shared bars, a shared tempo, and a shared play/stop/position that several clients phase-align on, with each client owning its own playhead and arrangement.
Cheat-sheet
| You want to… | Do this |
|---|---|
| Define the shared grid (conductor) | server.set_transport(origin_sample, tempo) |
| Read the current grid | server.transport() → (origin_sample, tempo) or None |
| Join the grid (follower) | clock.join_transport(server) / Session.join_transport() |
| Leave it | clock.leave_transport() |
| Start on the next bar | clock.play(routine, quant=4) / session.play(pattern, quant=4) |
| Align to the sample, not just the beat | clock.lock_to(server) as well (see Timing models) |
| Follow live tempo changes | an OscFunc("/transport.reply", …) that re-join_transports (see Receiving OSC and MIDI) |
| Roll a playhead from a conductor | server.transport_play() / transport_stop() / transport_locate(beat); followers playhead.follow_transport(server, quant=4) |
| Read the rolling state | server.transport_state() → {tempo, playing, position, …} |
See also
- Timing models — the time reference behind beat-accurate vs sample-exact alignment.
- Routines and clocks — the playhead (
TempoClock) and the routines you start on the bar. - Receiving OSC and MIDI — the responder layer the live-change reaction uses.
- Sessions — the handle that bundles a clock and a server, with
join_transport. - Examples —
transport_sync.py(two clients on one bar) andosc_responder.py(the live transport reaction). - The Clausters server book —
/transportand/clockon the wire.
Composition: the arrangement and the multitrack editor
A Timeline places items at beats and a Playhead plays them. That is enough to
sequence, but not enough to compose: a composition is not a flat list of events,
it is an element inside an element — a phrase inside a section inside a piece, a
take placed against a melody, a generator that has not been evaluated yet.
clausters.form is that layer — the arrangement model — and
clausters.gui.Editor puts it on screen as a multitrack view you can edit. The
point of the pair is that the graphic is not a picture of the music: dragging a
clip moves the element, and the score follows.
Elements
An element is any bounded thing that produces a unit of meaning and can be decomposed or combined — and it comes in two modes, which is the axis the whole layer turns on. An element is either generated (the rendered thing: samples in a buffer, a bounced timeline of events — data you can edit directly) or a generator (the algorithm that renders it: a def, a pattern, a routine). Evaluating a generator produces a generated element; that is the change of state, and it is what rendering does.
The difference is not merely data versus process — it is what you can do with
each. A generated element is random-access: an audio file can be read
backwards, sliced, scrubbed, edited in place. A generator is forward-only: it
can be evaluated, in order, and that is all. So the change of state is a
compositional act, not an optimization — it is what turns something you can only
produce into something you can manipulate, which is why a pattern is bounced to
be drawn and edited on a lane. An element carries two optional temporal
properties — an onset (where it starts, in beats, relative to its context) and a
duration — and delegates the actual playing to the object it wraps. The
arrangement is a thin adornment over what the client already has, not a second
implementation of it.
Which of the two properties are present gives an element its temporal character: both is a segment, an onset alone is punctual, a duration alone is relative (it has a length but no place yet), neither is abstract — pure context, which only a parent gives concrete time.
There are five kinds, and they map one to one onto objects you already use:
| Element | What it is | Wraps |
|---|---|---|
Event | parameters grouped into one action | clausters.seq.Event |
Sequence | strict order, no concrete time — only sequence | a list, or a Pattern |
Buffer | a list at constant time (samples) | clausters.defs.Buffer |
Track | mixed placement of elements — a DAW track | clausters.seq.Timeline |
Generator | a process: server DSP, or a sequence generator | a def, or a Pbind/Routine |
A Buffer is data, so it has no sound of its own: it sounds through the
instrument named to play it — a def whose buf control takes the buffer
number. That is the whole rule for an audio clip.
from clausters.form import Buffer, Group, Sequence, Track
take = Buffer(buf, duration=2.0, instrument="take") # a def that plays a buffer
Grouping: the one new structure
A Group places elements by an offset, recursively — and that recursion is the
whole idea. It comes in two kinds. A concrete group is a relation in time
between its members (a section holding clips, a melody holding notes). A
logical group is a relation of processing: the members are wired to each
other through buses, which is exactly what a GraphDef expresses, so
Group.to_graphdef() translates one into it.
song = Group([
(0.0, Group([(0.0, take), (4.0, take)], name="drums")),
(0.0, Group([(0.0, bass)], name="bass")),
(2.0, Group([(0.0, melody)], name="lead")),
], name="song")
From how its members sit in time, a group derives its temporal relation:
successive when they tile contiguously, simultaneous when they start and end
together, mixed otherwise. You do not set it; it is read from the placements.
Rendering: the change of state
Rendering a composition flattens it — a tree-walk accumulating the nested
offsets into absolute beats — into a flat Timeline, which a Playhead then
plays. A generator contained in it is bounced in the same pass: that evaluation,
the change from a process into a generated element, is the change of state.
song.render(server, clock) # live, through a playhead
song.render(nrt.server, nrt.clock) # offline: the same tree, a score
There is no second rendering path: RT and NRT are the same flattening, differing only in the destination, so the offline render is sample-identical to what you heard.
The multitrack editor
Editor draws that tree as the multitrack view and applies its edits back onto
the tree. The mapping is one rule, not a heuristic per case:
- the root group's members are the lanes;
- a lane's members are its clips;
- a
Bufferclip names its server buffer and spans its frames — the host fetches the take and decimates it to the clip's pixel width, so a long take costs nothing on the wire; - an element of events draws a piano-roll — each note placed in pitch and
time, shaded by its velocity (an explicit
velocity, else the event'samp) — and since a contained pattern is bounced to draw it, a generator lane shows the notes it is about to play. (The same notes drive the standalone, editor-gradeclausters.gui.pianorollwidget — a keyboard, an editable note grid, a velocity lane and an OSC-event lane — when you want to author them directly rather than through the multitrack.) - a nested group draws as the labeled rectangle that summarizes it, until you
expandit into lanes of its own. That collapse/expand is the arrangement's base level: the same structure, seen coarser or finer.
from clausters.gui import Editor
editor = Editor(song, sample_rate=SR, tempo=2.0, quant=0.5, follow=True)
editor.open(gui) # the arrangement, as a multitrack window
editor.render(server, clock) # play it; the playhead sweeps the clips
while editor.window is not None:
editor.poll(0.05) # a dragged clip moves the element
poll drains the host's events into the arrangement — drag a clip to move it, an
edge to resize it — and with follow=True the composition is re-scheduled from the
playhead, so you hear it where you dropped it. The semantics there are honest:
re-schedule from here, not a sample-exact splice, so a synth already sounding
keeps sounding.
The dedicated piano-roll
The multitrack draws an element of events as a clip body — the notes, but at a clip's size. To author the notes, open the element in the editor-grade view instead:
roll = Editor(melody, sample_rate=SR, tempo=2.0, quant=0.25)
roll.open_pianoroll(gui) # keyboard, note grid, velocity + OSC-event lanes
Edits flow back through poll exactly as the multitrack's do, when the
element is editable: a dragged, added or removed note rebuilds a Track's
timeline (times converted to beats, any OSC/MIDI events on the same timeline
preserved). A generator — a Pbind, a Routine — is forward-only, so its
bounced notes are shown read-only; bounce it to a Track (the change of state)
and the same view becomes an editor. OSC events are shown in their lane but not
written back: their marker carries a time and a label, not the full message.
Quantization exists on both surfaces, because the GUI also runs standalone:
q over the roll snaps the selected notes' onsets (or all of them) to the
widget's snap grid, flowing back like any other edit; on the data side,
Timeline.quantize(grid) snaps every placement to the beat grid directly.
Beats and samples
The arrangement places elements in beats; the view places clips in timeline
samples, because a clip's body is audio data and its sample 0 sits at the clip's
offset. The editor is the only converter between the two: one beat is
sample_rate / tempo timeline units, so a take placed at its own frame count sits
1:1 on the axis. Give it the engine's rate and the clock's tempo and the bridge is
closed.
The quant you pass is a musical grid (0.5 = half a beat). It becomes the
lane's drag grid, so the grid a clip is dropped on is the grid the arrangement
re-schedules on — what you see is what plays.
Automation, and the logical side
An Automation placed in the composition draws its curve as the clip's body —
the same break-points the envelope editor draws — and it is edited in place:
drag a point, Ctrl+click to add one or remove the one under the cursor. The edit
lands on the automation's Env, which is what the next render plays, so the
curve you draw is the curve you hear.
A logical group is not a timeline at all: its members relate by processing, so
it draws as a graph patch — a box per member, a node per bus, and a wire per
connection. The patch is deliberately undirected: a GraphDef knows that a control
touches a bus, and which end writes is the server's own analysis, so the view
shows the connection and leaves the direction to the engine. Dragging a port onto
a bus rewires that control (onto empty space, unwires it), and the edit rewrites
the group — so the next render sends a GraphDef wired the way the patch is
drawn.
clients/python/examples/gui_composer.py is the whole loop in one script: a take
bounced offline and loaded from disk, a melody, a pattern, all three composed,
edited on screen and heard. And to work through everything this chapter
argues — interactively, one block at a time, building that same piece — see
Composing a piece, step by step.
Composing a piece, step by step
This section builds one short piece from nothing, using the arrangement
(clausters.form) and its multitrack editor (clausters.gui.Editor), and it
builds it the way the pair is meant to be used: interactively. You keep one Python interpreter open from the first
page to the last — IPython, or an editor that evaluates # %% cells — and
every block below assumes the names defined by the blocks before it. Evaluate
a block, hear (or see) its result, move on.
Composition: the arrangement and the multitrack editor is the companion chapter: it explains why the layer is shaped the way it is. This section is the doing — each concept appears when the piece needs it, with a way to probe it on the spot. When you want the deeper reasoning behind a rule, follow the links back to that chapter.
The one loop
Everything here teaches a single working loop:
data ⟷ graphic ⟷ sound
You write elements in Python and place them in time (data). The editor draws them as tracks of clips (graphic). Playing renders them (sound). And the arrows point both ways: dragging a clip on screen edits the element — not a picture of it — so the next play is the piece as you left it, and a change you make in code redraws and replays the same way. The graphic is a view of the data; the sound is a render of the data; the data is the piece.
Concretely, the rhythm you will fall into on the editor pages is:
# make a gesture in the window (drag a clip, reshape a curve) ... then:
editor.poll() # fold the pending edits into the arrangement
editor.play() # hear the piece as it now stands
No polling loop, no dispatch table: one call drains the window's pending
events into the arrangement, and one call plays the result. (A self-contained
script form of the very same piece — transport buttons on screen, a poll
loop — is examples/gui_composer.py; the tutorial points at it at the end.)
What you will build
A four-lane piece, eight beats long:
- drums — an audio take, bounced offline and loaded from disk, placed
twice (a
Bufferelement); - bass — a
Pbindpattern: a generator the editor bounces to draw and the render bounces to play (aSequenceelement); - lead — a melody of events placed by hand on a timeline (a
Trackelement); - sweep — a frequency envelope and the voice it drives, grouped so they
are one clip (an
Automationinside aGroup).
Along the way you will play elements alone, inspect the flattened score, open the piece as a multitrack window, move clips by hand and hear the edits, reshape the automation curve in place, wire a small signal graph and rewire it on its patch, and finally bounce the piece to a file — sample-identical to what you heard.
The pages
- Setup: a session, two instruments, a take — the live session, the defs the piece needs, and a take bounced offline.
- Elements: the five primitives — what an element is, and the piece's elements built one by one.
- Grouping: placing elements in time — the
Group, placements, and the song tree. - Rendering: hearing the arrangement — flattening, the playhead, and why every play re-reads the tree.
- The multitrack editor: the arrangement on screen — the window, the mapping rules, and the unit bridge.
- Editing on screen: the loop closed — the transport,
the gesture →
poll()→play()rhythm, andfollow. - Automation: a curve as an element — the sweep, the layered clip, and editing the curve in place.
- The logical side: groups as signal graphs — the other grouping kind, and the patcher.
- Bouncing: the piece as a file — the same tree, offline.
- Glossary — every term this section uses, pinned.
Two units, stated up front
One conversion runs under the whole section, so meet it before it can
surprise you. The arrangement places elements in beats — musical,
tempo-relative time. The view places clips in timeline samples — one unit per
audio sample, so an audio take sits 1:1 on the axis. The editor is the only
converter between the two: one beat is sample_rate / tempo timeline units.
You will see both units in this section — beats whenever you touch the
arrangement, samples only inside the window — and the editor pages make the
bridge explicit.
Prerequisites
A working install (Getting started), a display and a GPU
adapter for the editor pages, and passing familiarity with defs
(Defining instruments) and with patterns and timelines
(Timelines and the playhead) — the piece uses a Pbind, a
Timeline and two small SynthDefs, all shown in full when they appear.
Setup: a session, two instruments, a take
Everything in this section runs in one live session. Open your interpreter and evaluate the blocks as you go — every later page assumes the names this one defines.
The session
from clausters import Session
TEMPO = 2.0 # beats per second (the TempoClock convention): 120 bpm
QUANT = 0.5 # the musical grid the editor will snap drags to: half a beat
# `latency` schedules each event a touch ahead (a wall-clock timetag), so the
# server plays it on time instead of "as soon as possible".
session = Session.live(tempo=TEMPO, latency=0.1)
server = session.server
Session.live attaches to a running server or boots one for you, and bundles
it with a TempoClock at TEMPO beats per second (Sessions).
Two derived numbers the editor pages will need:
SR = float(server.options.sample_rate)
BEAT = SR / TEMPO # timeline samples per beat — the unit bridge, one line
print(SR, BEAT)
Start the clock now; routines and playheads run on it, and it costs nothing while idle:
session.start()
The instrument that plays a buffer
The notes of the piece will use the server's stock default def. The audio
take needs an instrument of its own, because of a rule you will meet properly
on the next page: a buffer is data, and data sounds through the def named to
play it — a def whose buf control takes the buffer number, as a sampler's
does.
from clausters.defs import SynthDef, control, out, play_buf
def sampler(name: str = "take") -> SynthDef:
"""Plays a buffer once. The event frees the synth after its `sustain`,
which is the clip's length — so the take stops when the clip ends."""
buf = control("buf", 0.0, "ir")
amp = control("amp", 0.8, "ir")
sig = play_buf(buf, 0.0, 1.0, 0.0) * amp
return SynthDef(name, out(0.0, sig), out(1.0, sig))
server.add_synthdef(sampler()) # blocks on /done — fine at the top level
(add_synthdef blocks until the server confirms the def. That is fine here,
in the interpreter; the one place it is forbidden is inside a routine on the
clock thread — see Routines and clocks.)
The take
A real audio clip, made the way a composition really makes one: bounced offline and loaded from the file. A buffer is loaded or generated on the server, never push-filled by the client — so we render two beats of a bass note to a WAV with an offline session, then ask the live server to read it.
import struct, tempfile, wave
from pathlib import Path
from clausters.seq import Pbind, Pseq
def bounce_take(path: str, beats: float = 2.0) -> str:
"""Render a two-beat bass note offline and write it to a WAV."""
offline = Session.nrt(tempo=TEMPO)
offline.play(Pbind(midinote=Pseq([36], 1), dur=beats, legato=1.0, amp=0.3))
samples, frames = offline.render(sample_rate=SR, channels=1)
with wave.open(path, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(int(SR))
w.writeframes(b"".join(
struct.pack("<h", int(max(-1.0, min(1.0, s)) * 32767))
for s in samples))
print(f"bounced {frames} frames -> {path}")
return path
wav = bounce_take(str(Path(tempfile.mkdtemp(prefix="clausters-")) / "take.wav"))
buf = server.query_buffer(server.read_buffer(wav)) # on the server, shape known
print(buf.bufnum, buf.frames, buf.channels)
read_buffer loads the file into a server buffer and returns its number;
query_buffer asks the server for the buffer's shape (frames, channels,
sample rate), which the arrangement and the editor will both read. Note the offline
session is the same client code as the live one — only the destination
differs; the last page of this section leans on exactly that.
Hear it once
Before any model enters the picture, confirm the raw pieces work: one note of the take, played as a one-event pattern on the session's clock.
session.play(Pbind(instrument="take", buf=float(buf.bufnum),
dur=Pseq([2.0], 1), legato=1.0))
Two beats of bass. (Timing always rides a clock: an event plays itself with
play(destination) from inside a routine or a playhead running on a
TempoClock — never from the bare interpreter, where there is no logical time
to stamp it with. session.play wraps the pattern in exactly such a routine.)
That play(destination) seam — an object rendering itself onto a server — is
what the whole arrangement model delegates to, and everything after this page
builds on it.
Next: Elements: the five primitives.
Elements: the five primitives
The arrangement's unit is the element: any bounded thing that produces a unit of meaning — a note, a phrase, a take, a whole section — and can be decomposed or combined. An element is a thin adornment over an object you already use (an event, a timeline, a buffer, a pattern): it adds temporal metadata and membership in a group, and delegates the actual playing to what it wraps. It never reimplements it.
One axis organizes everything (the full argument is in the composition chapter): an element is either generated — the rendered thing, data you can access at random, edit, slice — or a generator — the algorithm that renders it, which can only be evaluated forward. Evaluating a generator into a generated element is the change of state, and it is what rendering does. You will watch it happen twice on these pages: a pattern bounced into notes to be drawn, and the same pattern bounced to be played.
Temporal character
An element carries two optional properties, both in beats and both relative
to its context: an onset (where it starts) and a duration (how long it
is). Which of the two are present gives it its temporal character — probe
the rule directly, it is pure:
from clausters.form import temporal_character
print(temporal_character(0.0, 4.0)) # 'segment' — both: a placed span
print(temporal_character(0.0, None)) # 'punctual' — an onset, no length
print(temporal_character(None, 4.0)) # 'relative' — a length, no place yet
print(temporal_character(None, None)) # 'abstract' — pure context
In practice a standalone element usually has a duration and no onset — relative: it has a length, and its concrete place will come from where a group puts it, not from the element itself.
The five kinds
| Element | Conceptual name | What it is | Wraps |
|---|---|---|---|
Event | event/clip | parameters grouped into one action | clausters.seq.Event |
Sequence | List | strict order, no concrete time — only sequence | a list, or a Pattern |
Buffer | Buffer | a list at constant time (samples) | clausters.defs.Buffer |
Track | Set | mixed placement of elements — a DAW track | clausters.seq.Timeline |
Generator | Function | a process: server DSP, or a sequence generator | a def, or a Pbind/Routine |
The class names are what you type; the conceptual names (event/clip, List, Buffer, Set, Function) say what each kind is, and the glossary keeps both. Now build the piece's three melodic elements.
The take: a Buffer
from clausters.form import Buffer
take = Buffer(buf, duration=2.0, instrument="take")
print(take.temporal_character) # 'relative' — a length, no place yet
The rule from the setup page, now in its proper home: a Buffer element is
data, so it has no sound of its own — it sounds through the
instrument named to play it, a def whose buf control takes the buffer
number. Ask it for the event it will emit:
event = take.to_event()
print(event["instrument"], event["buf"], event["dur"], event["legato"])
# take <bufnum> 2.0 1.0
legato is 1 so the take sounds its whole length — the note default of 0.8
would cut it short, and a sampled take is not a note with a gap. Leave the
instrument off and the element is still perfectly good structure: the
editor will draw its waveform and it contributes its length to the piece — it
simply emits no event. (Why the client refuses to ship a built-in sampler
instead is a deliberate decision; the composition chapter and the design
records cover it.)
The melody: a Track
A Track wraps a Timeline — free placement of items by beat, the Set of
the five. The lead line, three notes placed by hand:
from clausters.form import Track
from clausters.seq import Timeline
from clausters.seq.event import Event as SeqEvent
melody = Track(Timeline([
(0.0, SeqEvent(midinote=72, dur=1.0)),
(1.0, SeqEvent(midinote=76, dur=1.0)),
(2.0, SeqEvent(midinote=79, dur=2.0)),
]))
The events name no instrument, so they will play the server's stock
default def.
The bass: a Sequence wrapping a pattern
from clausters.form import Sequence
from clausters.seq import Pbind, Pseq
bass = Sequence(Pbind(midinote=Pseq([48, 48, 55, 53], 2),
dur=1.0, amp=0.15))
This one is a generator: eight beats of bass that exist only as an algorithm until something evaluates them. The editor will bounce it to draw the notes it is about to play, and rendering will bounce it to play them — the change of state, both times, from the same element.
The other two kinds, briefly
You will meet them later in the piece, so just their shape for now. An
Event element wraps one clausters.seq.Event and defaults its duration
from the event's dur:
from clausters.form import Event
hit = Event(SeqEvent(midinote=60, dur=1.0))
print(hit.duration, hit.temporal_character) # 1.0 'relative'
A Generator wraps server DSP (a def, or its name) or a sequence generator;
its moment comes on the logical page, where generators wired
through buses become a signal graph.
Hear one element alone
An element renders itself with render(destination, clock) — the full
mechanics are two pages away, but nothing stops you from playing one right
now:
playhead = melody.render(server, session.clock)
Three notes. What came back is a Playhead — a transport; it stopped by
itself at the end of the melody, and you could have stopped it early:
playhead.stop() # idempotent here: the melody already ended
The take would play the same way (take.render(server, session.clock)), and
so would the bass — pattern and all. Three elements, one verb.
Next: Grouping: placing elements in time.
Grouping: placing elements in time
The five primitives adorn things the client already had. The Group is
the one genuinely new structure of the arrangement: it places elements by an
offset, recursively — a group is itself an element, so a phrase sits inside a
section inside a piece, and the same operations work at every level.
The song tree
Each lane of the piece is a group placing one element (the drums place the take twice), and the song is a group placing the lanes:
from clausters.form import Group
drums = Group([(0.0, take), (4.0, take)], name="drums")
bass_lane = Group([(0.0, bass)], name="bass")
lead_lane = Group([(0.0, melody)], name="lead")
song = Group([
(0.0, drums),
(0.0, bass_lane),
(2.0, lead_lane), # the lead enters two beats in
], name="song")
A child is seeded as a (offset, element) pair, a bare Element (placed at
offset 0), or a (offset, dur, element) triple — the third form also fixes a
placement length, which you will meet below. Every offset is in beats,
relative to the group: the melody sits at 0.0 inside its lane, and the lane
at 2.0 inside the song, so the melody starts at absolute beat 2.0. Nothing in
the tree is absolute until rendering adds the offsets up.
Placements are edits: the member handle
A group is not frozen. add places an element and returns a handle — a
stable identity you can move or remove later, however much else has
changed around it:
member = drums.handles[1] # the second take (the handles of the seeds)
drums.move(member, 6.0) # push it two beats later
print(drums.members) # [(0.0, None, <Buffer>), (6.0, None, <Buffer>)]
drums.move(member, 4.0) # and back
members reads the placements as (offset, dur, element) triples; handles
returns the stable member objects that move/remove take. This handle is
exactly what the multitrack editor will hold on to per clip — a dragged clip
becomes a move on its handle, nothing more.
Note the placement dur is None above: the take is its own length (its
duration, 2.0). The two lengths are different things, and the difference is
the next rule.
A placement's length is what you hear of it
A placement dur trims what the element plays — the DAW rule: pull in a
clip's edge and you hear less of it, but the element underneath is untouched.
Watch it on the data directly:
from clausters.form import flatten
drums.move(member, 4.0, 1.0) # same place, but only 1 beat of it
print(flatten(drums)) # the second event now has dur=1.0
print(take.to_event()["dur"]) # 2.0 — the element is untouched
drums.move(member, 4.0, 2.0) # restore the full take
The trim never rewrites the element: the shortened event is a copy made at flatten time, and lengthening the placement again restores everything, because the source was never touched. Events that fall entirely past a placement's end are dropped the same way — shorten a melody's placement and its last notes simply do not play.
The derived temporal relation
From how its members sit in time, a group derives its temporal relation — you never set it, you read it:
print(drums.temporal_relation()) # 'mixed' — a gap between the two takes
print(Group([(0.0, 2.0, take), (2.0, 2.0, take)]).temporal_relation())
# 'successive' — they tile contiguously
print(song.temporal_relation()) # 'mixed'
successive means the members tile — each begins exactly where the previous
ends; simultaneous means they all start and end together; anything else is
mixed. The interesting one is simultaneous: members that start and end
together are one thing on the timeline, which is how an envelope gets attached
to the voice it shapes — the automation page uses it.
(There is a second grouping kind besides the default concrete: a
logical group, whose members relate by processing rather than by time. It
gets its own page.)
Inspecting the whole piece: flatten
Rendering will flatten the tree — accumulate every nested offset into absolute beats. You can run that walk yourself, any time, as a pure inspection:
for beat, item in flatten(song):
print(f"{beat:4} {item.get('instrument', 'default'):8}"
f" midinote={item.get('midinote')} dur={item.get('dur')}")
Every event of the piece, in absolute beats, sorted: the takes at 0 and 4, the
bass — bounced from its pattern in this very walk, the change of state
before your eyes — on every beat from 0 to 7, the melody from beat 2. The bass
is the longest lane: its last note starts at beat 7, so the piece sounds
until beat 7.8 — a note's sounding time is its dur scaled by legato
(default 0.8), and a length here is what you hear, a rule you will meet
again. to_timeline(song) builds the same thing as a Timeline, which is
precisely what a playhead plays — and that is the next page.
Next: Rendering: hearing the arrangement.
Rendering: hearing the arrangement
Rendering is the change of state from the arrangement to sound. For a
concrete element it is exactly the walk you ran on the last page, plus a
transport: flatten the tree into a timeline of absolute beats, hand that
timeline to a Playhead on a clock, play. Nothing else — no second sequencer
is added; the one the client already has is reused
(Timelines and the playhead).
Play the piece
playhead = song.render(server, session.clock)
Eight beats: the take at 0 and 4, the bass walking under everything, the
melody entering at beat 2. What render did, in order:
to_timeline(song)— flatten to absolute beats. Contained generators (the bassPbind) are bounced in this pass: the pattern runs offline on a throwaway clock and its events are recorded at their logical beats. ASequencethat wraps a plain list of elements is laid out successively, each after the previous one's duration; an abstract element contributes context and no event.Playhead(timeline, clock, destination)— a transport over that timeline.playhead.play(at=0.0)— the playhead scans forward on the clock, playing each item onto the server at its exact logical beat.
The returned playhead is the transport, and you can drive it directly:
playhead.stop() # halt the scan
playhead = song.render(server, session.clock, at=4.0) # from beat 4
print(playhead.position()) # the song position, in beats
stop halts the scheduling — anything already sounding finishes its own
release. Stopping a playhead is not a panic button, and nothing here needs
one: every voice in this piece is an event with a length, so it ends when its
event does.
Let it run out, or stop it:
playhead.stop()
Rendering always re-reads the tree
There is no cached score. Every render re-flattens the tree as it now
stands, so an edit in code is heard on the very next play:
member = drums.handles[1]
drums.move(member, 5.0) # push the second take late
playhead = song.render(server, session.clock) # ... and it plays at beat 5
playhead.stop()
drums.move(member, 4.0) # back where it was
This is the property the whole editor rests on. A dragged clip will do exactly
what that move did — write a placement — and pressing play will do exactly
what render does — re-read the composition. The GUI adds no path of its own.
The same verb, offline
render takes a destination, and everything about it is
destination-agnostic: give it an offline session's server and clock and the
identical flattening accumulates a score instead of sounding —
sample-identical to the live playback, because it is the same walk feeding the
same engine. The bounce page closes the tutorial with it.
One more dispatch hides in the same verb: a logical group (the other
grouping kind) does not flatten at all — render sends it to the server as a
signal-graph definition instead. That is the logical page; the
piece does not need it yet.
You now have the whole loop in code: build elements, place them, play them, edit, play again. Time to put it on screen.
Next: The multitrack editor: the arrangement on screen.
The multitrack editor: the arrangement on screen
Editor draws the song tree as a multitrack window — one lane per member of
the root group, each holding its members as clips on one shared time axis —
and, on the next page, applies the window's edits back onto that tree. It is
the one module that knows both worlds: clausters.form never imports the GUI.
Open the piece
from clausters.gui import Editor
gui = session.gui() # boot (once) the visual server, wired to this session
editor = Editor(song, sample_rate=SR, tempo=TEMPO, quant=QUANT,
title="Composer")
win = editor.open(gui)
print(f"opened window {win}")
Three lanes appear — drums, bass, lead — with a beats ruler under the bottom one. What each argument fixed:
sample_rateandtempoclose the unit bridge (below);quantis the musical drag grid, in beats —0.5means clips will snap to half-beats, both on screen and in the data;- the window is
titled, and the editor allocates its widget ids frombase_id(default 10 000) up, clear of anything you open yourself.
Read the view
The mapping from the tree to the window is one rule, not a heuristic per case:
- the root group's members are the lanes — top to bottom, in order;
- a lane's members are its clips, at their placements on the shared axis;
- a
Bufferclip draws its take: the clip names the server buffer, and the host fetches it and decimates it to the clip's pixel width — a long take costs nothing on the wire; - an element of events draws a piano-roll: each note a bar, high pitches up. The bass lane shows its notes too — the pattern was bounced to draw it, the same change of state that rendering performs, now on screen: a generator lane shows the notes it is about to play;
- a group nested inside a lane draws as a labeled rectangle — its summary — until you expand it. (The root's members are already lanes; the rule is about the level below them.)
That last rule is the arrangement's base level — the zoom that summarizes a group or resolves it — and it is an editor call, not a protocol. The piece has no nested group yet, so make one — a two-note fill dropped into the lead lane — and watch each step in the window:
from clausters.form import Event, Group
from clausters.seq.event import Event as SeqEvent
fill = Group([(0.0, Event(SeqEvent(midinote=84, dur=0.5))),
(0.5, Event(SeqEvent(midinote=88, dur=0.5)))], name="fill")
handle = lead_lane.add(fill, offset=6.0)
editor.update() # a structural change: push the re-rendered tree
The fill appears on the lead lane as one labeled rectangle — the summary of the level below it. Resolve it, then summarize it back:
editor.expand(fill); editor.update() # the fill as a lane of its own
editor.collapse(fill); editor.update() # one rectangle again
Same structure, seen coarser or finer — the data never changed, only the base level. The fill was a demonstration, so put the piece back:
lead_lane.remove(handle)
editor.update()
update() is a whole-tree redefine — the honest way to show a structural
change (an element added or removed, a group expanded). You will use it again
on the automation page when the piece grows a lane. A mere placement change
needs no redefine: when you drag a clip, the host already moved it.
The unit bridge: beats on one side, samples on the other
The arrangement places elements in beats. The window places clips in
timeline samples — one unit per audio sample, so an audio take sits 1:1 on the axis
and sample 0 of its body is exactly at the clip's left edge. The editor is the
only converter between the two, and you gave it everything it needs: one
beat is sample_rate / tempo timeline units.
print(editor.units_per_beat) # == BEAT == SR / TEMPO
print(editor.beats_to_units(2.0)) # where beat 2 sits on the axis
print(editor.units_to_beats(editor.beats_to_units(2.0))) # 2.0 — round trip
The quant you passed is a musical grid; the editor converts it once and
hands it to the lanes as their drag grid in samples. So the grid a clip snaps
to on screen is the grid the arrangement re-schedules on — what you see is
what plays.
The piece's length is read from the arrangement
print(editor.extent()) # 7.8 — the end of the last sounding event, in beats
extent() is not a constant: it walks the tree — and it measures what you
hear. The last bass note starts at beat 7 and sounds for 0.8 of a beat (its
dur scaled by the default legato), so the piece is 7.8 beats long, not 8.
Drag a clip past the end (or move one there in code) and the piece is longer
— which is exactly what a transport must ask, and what the playhead on the
next page will respect.
Navigate
All lanes share one time axis, and they navigate as one:
- mouse wheel — zoom, toward the cursor;
- Shift + drag — pan;
r— reset the view to the whole piece;- Escape — close the window.
Zoom into the drums take: the waveform re-resolves as you go (the host keeps a peak pyramid per take and never resolves finer than the screen). Zoom far out: there is empty time to zoom out into, and the axis spans the longest clip end over every lane.
The window is open and truthful; now make it writable.
Next: Editing on screen: the loop closed.
Editing on screen: the loop closed
This page is the working rhythm the whole layer was built for. It has three
beats: gesture in the window, editor.poll() to fold the gesture into
the arrangement, editor.play() to hear the piece as it now stands.
Everything else here is detail on those three.
The transport, from code
The editor owns a transport over the piece. Give it a destination and a clock once, and drive it from the interpreter:
editor.locate(0.0) # the cursor waits at the top
editor.play(server, session.clock) # render and play from the cursor
The piece plays, and a line sweeps the clips — the playhead, anchored to the engine's own sample clock, so it tracks the audio rather than a guess. Pause and resume:
editor.pause() # halt here; the position stays
editor.play() # resume from where we paused (destination remembered)
play is always a fresh render from the transport's position — the tree
is re-flattened, so it plays the composition as it now stands. pause
stops the scheduling and keeps the position; what is already sounding finishes
by itself (a transport is not a panic button — every voice in this piece ends
with its clip). And:
editor.stop() # pause + back to the top
editor.locate(4.0) # seek: put the transport at beat 4
locate while stopped just moves the cursor — the thin static line the
lanes draw; while playing, it re-renders from the new position (so a seek
also picks up any pending edit). Clicking a lane's ruler, or its empty
space, is the same locate — try it: click somewhere in the empty part of a
lane and watch the cursor move. That click reaches the data the same way
every other gesture does, which is the next section.
Two playheads, to be precise, and telling them apart matters: the sweeping
line is anchored to the engine clock and moves with the audio; the cursor
is where a stopped transport sits (where the next play starts). You never
set either directly — the transport calls do.
The rhythm: gesture → poll() → play()
The window accumulates your gestures as events; nothing touches the
arrangement until you ask. editor.poll() drains everything pending and applies it — one
call, no loop. Run the whole cycle once: drag the second drums clip
somewhere later in its lane (it snaps to the half-beat grid, your quant),
then:
editor.poll() # -> True: the composition changed
print(drums.members) # the second take's offset is where you dropped it
The drag became a Group.move on the member handle — the same edit you made
in code two pages ago, arriving through the window. Hear it:
editor.play(at=0.0)
Resize works the same way: drag a clip's edge (the outer few pixels) and
its placement gets a dur — the trim rule from the grouping page, by hand.
Pull the second take's right edge in to one beat, then:
editor.poll()
print(drums.members) # (offset, 1.0, <Buffer>) — trimmed
print(take.to_event()["dur"]) # 2.0 — the element, untouched as ever
editor.play(at=0.0) # you hear one beat of it
That is the entire loop: the graphic edits the data, the sound plays the data, and the three never disagree. A few mechanical notes:
poll()returns whether the composition changed, and it is safe to call any time — unknown messages are ignored, a closed window just marks itself.- Call it from the interpreter (or your own loop), never from the clock thread — the same golden rule as every routine.
- The edit-back arrives in timeline samples; the editor converts to beats and
snaps to
quant— the same grid the lane snapped the drag to, so the round trip is exact. A drag that moved less than half a sample is not an edit. - Only what actually changed is written: a plain drag carries the clip's length along unchanged, and the editor is careful not to re-snap that — snapping a length that was never touched would silently shorten the element.
dirty, and the follow variant
An edit does not interrupt what is sounding. It changes the arrangement and marks the editor:
print(editor.dirty) # True after an applied edit, until the next render
The next transport action — a play, a resume, a seek — re-reads the composition, because rendering always re-flattens. If you want the piece to re-schedule itself on every edit instead, turn on follow:
editor.follow = True # the live editor: poll() now re-renders on each edit
With follow on, the same rhythm loses its third step: gesture, poll(),
and it already plays from the playhead's position. The semantics are honest —
re-schedule from here, not a sample-exact splice: a synth already sounding
keeps sounding, and what changes is what has not been scheduled yet.
editor.follow = False # back to the explicit rhythm for the next pages
The piece ends where the arrangement says
Let the piece play to the end: the playhead reaches editor.extent() — read
from the tree, remember — and the scan simply runs out of events. Drag a clip
past the old end, poll(), play(): the piece is longer now, and the
playhead sweeps to the new end. Nothing was configured; the length is not a
setting, it is a fact about the elements.
Undo, by the way, is a placement again: you watched every edit land as one
(drums.members), so putting one back is a move — from code or by dragging
it home.
Next: Automation: a curve as an element.
Automation: a curve as an element
The piece gets its fourth lane: a frequency sweep — a break-point curve
driving a voice — placed in the composition like everything else. This page
carries three ideas: an Automation is a curve rendered as a control
signal; a curve and the voice it shapes can be grouped into one clip; and
the curve is edited in place, on the lane, with the same
gesture → poll() → play() rhythm as a clip.
The voice: an instrument that reads a bus
The sweep drives a drone's frequency. The def reads its frequency straight from a control bus — the one the automation will write — and is gated, so the event's length is the voice's length:
from clausters.defs import (DoneAction, Env, SynthDef, control, env_gen,
in_ctl, out, sin_osc)
def drone(name: str = "drone") -> SynthDef:
"""A sine whose frequency is read from a control bus, held by a gate and
freed on release — its life is the event's."""
bus = control("freq_bus", 0.0, "ir")
amp = control("amp", 0.12, "kr")
gate = control("gate", 1.0, "kr")
shape = env_gen(Env.asr(attack=0.05, release=0.4), gate=gate,
done_action=DoneAction.FREE_SELF)
sig = sin_osc(in_ctl(bus)) * shape * amp
return SynthDef(name, out(0.0, sig), out(1.0, sig))
server.add_synthdef(drone())
The curve: an Automation
from clausters.seq import Automation
SWEEP = 4.0 # the sweep's length in beats (the curve's, and its voice's)
sweep = Automation.from_points(
[(0.0, 200.0, 1, 0.0), # 200 Hz ...
(2.0, 900.0, 2, 0.0), # ... up to 900 (segment shape 2: exponential)
(4.0, 300.0, 1, 0.0)], # ... back down (shape 1: linear)
target=None, name="freq")
sweep.prepare(server) # allocate + fill its buffer and bus, once
print(sweep.duration(), sweep.bus.index)
Break-points are (time, value, shape, curve) — times in beats, values in the
control's real units (Hertz here); the stored curve is an Env, the same
object the envelope editor round-trips. How it is rendered is worth knowing,
because it is all server machinery you already have: the curve is discretized
into a control buffer on the server (/b_gen "env", evaluated through the
same envelope math the EnvGen UGen plays — what is drawn is what is heard),
and at play time a small internal synth reads that buffer onto a control
bus over the curve's duration. A target node would follow the bus via
/n_map; our drone simply reads the bus itself, so target=None — the
automation just writes its bus.
The two-phase shape is deliberate: prepare(server) blocks (it allocates
and fills the buffer), so it runs here, at the top level, once. Playing —
which happens on the clock thread — only schedules, and never blocks. The
same golden rule as everywhere else in the client.
One clip: the envelope attached to the voice it shapes
The voice is an event in the composition — not a synth held by your session — reading the automation's bus:
from clausters.form import Element, Event, Group
from clausters.seq.event import Event as SeqEvent
voice = Event(SeqEvent(instrument="drone", freq_bus=sweep.bus.index,
dur=SWEEP, legato=1.0, amp=0.12, has_gate=True))
sweep_clip = Group([(0.0, voice), (0.0, Element(sweep, duration=SWEEP))],
name="sweep")
print(sweep_clip.temporal_relation()) # 'simultaneous'
Look at what was just said in model terms: two members that start and end together — the group's derived relation is simultaneous, and the grouping page promised this moment. A simultaneous group is one thing on the timeline, so the editor draws it as one clip with layered bodies: the curve over the note, dragging as one. The voice cannot outlive its envelope, and the envelope cannot be left behind.
(The bare Element(sweep, duration=SWEEP) is the escape hatch: an
Automation already knows how to play(destination), so a plain Element
adorning it with a duration is enough — no sixth primitive needed.)
Add the lane and show it:
song.add(Group([(0.0, sweep_clip)], name="sweep"), offset=0.0)
editor.update() # a structural change: the window grows a fourth lane
editor.play(server, session.clock, at=0.0)
Four beats of drone slide 200 → 900 → 300 Hz under the piece. Note what the
clip's picture means: a curve's floor is its parameter's minimum, nothing
more — this envelope touching the bottom of its clip is the lowest
frequency, not silence. Draw a curve over amp instead and the bottom would
be silence, and that silence part of the clip's length. Same clip, same
curve; what it means is the control it drives.
And because the voice is an event with a length, placed in the tree: seek
past the clip (editor.locate(6.0) and play) and there is simply no drone. A
synth still humming over empty timeline is not a drone, it is a leak — this
piece cannot leak.
Edit the curve in place
The curve's break-points are live on the lane:
- drag a point to move it (a point wins over the clip body — the clip still drags by its empty space);
- Ctrl+click on empty curve to add a point;
- Ctrl+click on a point to remove it.
Pull the middle point up toward 1500 Hz, then the rhythm you know:
editor.poll() # the points land on the automation's Env
print(sweep.to_points()) # the curve, as (t, v, shape, curve) — as drawn
sweep.prepare(server) # re-fill the control buffer from the edited Env
editor.play(at=0.0)
The edit went back onto sweep.env itself — the Env is the single source of
truth shared by the picture and the data. One step is still yours today: the
server-side control buffer was filled from the Env when you first
prepared it, and rendering schedules the lane synth over that buffer
without re-filling it — so after a curve edit, call prepare(server) again
(it re-runs the /b_gen on the buffer it already owns; cheap, and safe at the
top level). Skip it and the next play sounds the curve as it was at the last
prepare, not as drawn.
The concrete side of the piece is now complete: four lanes, three element kinds, one automation, all editable from either side of the loop. One grouping kind remains.
Next: The logical side: groups as signal graphs.
The logical side: groups as signal graphs
Every group so far has been concrete: a relation in time between
contents placed in time. The other kind is logical: the members relate by
processing — a signal chain wired through buses — and time has nothing to do
with it. Same Group, different kind, and a different render: a logical
group does not flatten into a timeline, it translates into a GraphDef — the
server's own notion of a named configuration of nodes wired by buses.
Two defs that wire
A member of a signal chain reads and writes buses through its own controls —
by convention named in and out:
from clausters.defs import SynthDef, control, in_, out, sin_osc
freq, out_bus = control("freq", 440.0), control("out", 0.0)
tone = SynthDef("tone", out(out_bus, sin_osc(freq) * 0.15))
in_bus, level = control("in", 0.0), control("level", 0.4)
gain = SynthDef("gain", out(0.0, in_(in_bus) * level),
out(1.0, in_(in_bus) * level))
server.add_synthdef(tone)
server.add_synthdef(gain)
tone writes a sine onto whatever bus its out control names; gain reads
its in bus, scales it, and puts it on the hardware outputs. Neither def
knows the other exists — the wiring is the group's business.
The logical group
from clausters.form import Generator, Group
chain = Group(kind="logical", name="chain", buses=["mix"])
chain.add(Generator("tone", controls={"out": "mix", "freq": 220.0}))
chain.add(Generator("tone", controls={"out": "mix", "freq": 331.0}))
chain.add(Generator("gain", controls={"in": "mix"}))
The members are Generator elements — the Function kind, wrapping a def by
name. A control's value is a number (set at creation), the name of one of the
group's buses ("mix", a private internal audio bus each instance
allocates for itself), or the reserved "OUT" (the hardware). Placement
offsets exist but are ignored here: a logical group is a signal graph, not
a timeline.
The translation is pure — inspect it before it goes anywhere:
import json
print(json.dumps(chain.to_graphdef().spec(), indent=2))
Two tones summed on mix, one gain stage reading it: the 1:1 mapping of the
group onto a GraphDef. Realize it — for a logical group that means
send and instance, not flatten and play:
inst = chain.render(server) # /d_graph, then /graph_new — it sounds now
A fifth and its gain stage, sounding continuously — a graph instance is a running configuration, not a scheduled event. It lives until you free it:
server.free(inst) # the instance group and its private buses
One boundary, stated plainly: a logical group is rendered on its own. It
is not placed inside the concrete song and flattened with it — the two
kinds answer different questions (what sounds when versus what is wired to
what), and render routes each to its own path.
The patcher
A logical group has a view too, and it is not a lane — its shape is not time. Open an editor on the chain itself:
patcher = Editor(chain, sample_rate=SR, tempo=TEMPO, title="chain")
pwin = patcher.open(gui)
A patch: the member boxes down one side (each with its wirable controls as
ports), the bus nodes down the other (mix, and OUT if a member names it),
and one wire per (member, control) ↔ bus connection.
Notice the patch is undirected — no arrows. That is deliberate honesty: a
GraphDef records that a control names a bus; which end writes and which
reads is the server's own analysis of the running graph, not something the
data knows. The view shows the connection and leaves the direction to the
engine, so it can never lie about signal flow.
Rewire it
The wires are live, and the rhythm is the one you know:
- drag a port onto a bus — that control now names that bus;
- drag a port onto empty space — unwired.
Re-instance the chain so you can hear the difference, then unplug the gain
stage's input on screen (drag its in port to empty space), and:
inst = chain.render(server)
patcher.poll() # the edit lands on the group
print(chain.members[2][2].controls) # {} — 'in' no longer names a bus
The edit rewrote the member Generator's controls — the data again, nothing
else. And exactly as with a moved clip, what is running does not rewire
itself; the next render sends the graph as drawn:
server.free(inst)
inst = chain.render(server) # silent: the gain stage reads nothing
Wire in back to mix on screen, then:
patcher.poll()
server.free(inst)
inst = chain.render(server) # and it sounds again, wired as drawn
Clean up the demo:
server.free(inst)
patcher.poll() # drain anything left
gui.close(pwin)
The piece itself never needed the logical side — but a real composition grows
one the moment two nodes share a bus: a send, a master chain, a layered
instrument. It is the same Group, the same five primitives, and the same
loop: build in code, see it drawn, edit either side, render.
Next: Bouncing: the piece as a file.
Bouncing: the piece as a file
The last change of state: the whole piece, rendered offline. The promise made
on the rendering page comes due here — RT and NRT are the same flattening,
differing only in the destination, so the render is sample-identical to what
you heard. There is no export path; there is render, pointed at an offline
session.
The offline session
offline = Session.nrt(tempo=TEMPO)
offline.server.add_synthdef(sampler())
offline.server.add_synthdef(drone())
offline.server.read_buffer(wav) # the take, on the offline server
The offline server needs everything the live one had: the defs, and the take loaded from its file (in NRT these are scored at time 0 — the renderer compiles and loads before time advances).
Server-bound state moves explicitly
One category of thing does not retarget itself: resources that live on a server — the take's buffer number, and the automation's control buffer and bus. The model is destination-agnostic; those numbers are not. Two of them matter here:
- the take was the live server's first buffer, and the offline server's
read_bufferabove allocated its first — the numbers line up because both allocators started fresh; - the sweep was
prepared on the live server, and the drone's voice captured the bus index when you built its event. Release the live resources, prepare against the offline server (scored at time 0), and re-point the voice at the bus the offline pass allocated — then render, the same verb, the same tree, an offline destination:
sweep.free(server) # return the live buffer and bus
sweep.prepare(offline.server) # score its buffer + bus at time 0
voice.wraps["freq_bus"] = sweep.bus.index # the voice reads the offline bus
playhead = song.render(offline.server, offline.clock)
Render and write
samples, frames = offline.render(sample_rate=SR, channels=2)
print(f"rendered {frames} frames ({frames / SR:.2f} s)")
import struct, wave
out_path = str(Path(tempfile.mkdtemp(prefix="clausters-")) / "piece.wav")
with wave.open(out_path, "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(int(SR))
w.writeframes(b"".join(
struct.pack("<h", int(max(-1.0, min(1.0, s)) * 32767))
for s in samples))
print(f"wrote {out_path}")
Listen to it (ffplay -autoexit, or any player): the piece, with every edit
you made on screen — the moved clips, the trimmed take, the redrawn sweep —
because the offline pass flattened the same model your window was editing.
Both renders converge on the same score; the engine that plays it is
the same engine that played it.
To keep working live afterwards, give the sweep its live resources back the
same way (sweep.free(offline.server) is not needed — the offline session is
done — just sweep.prepare(server) and re-point voice.wraps["freq_bus"]).
And when you are done for the day:
session.close() # stops the clock, the GUI host and a server it booted
Where to go from here
- The finished, self-contained script form of this piece — on-screen
transport buttons, a poll loop, the same four lanes — is
clients/python/examples/gui_composer.py. Read it next to this section and you will recognize every line. - Composition: the arrangement and the multitrack editor — the concepts, argued rather than exercised.
- The glossary — every term this section used, pinned in one place.
- The API reference for
clausters.form,clausters.guiandclausters.seq; the wire protocol the editor speaks is in the Clausters server book.
Glossary
Every term of art this section uses, pinned. The vocabulary is the code's own — these are the words the docstrings, the design records and the tutorial all share. Each entry links to the page that develops it.
arrangement — the client-side layer that places elements in
time, groups them recursively and renders them: clausters.form. Pure and
transport-agnostic; the server knows nothing of it. Its DAW-style view is the
multitrack editor. Called the arrangement (or the
arrangement model when naming the layer as such) — never "the model" bare,
which reads as the node tree or a def.
automation / automation lane — a break-point curve placed on the timeline
that drives controls: clausters.seq.Automation. Stored as an
Env; rendered as a control vector read onto a
control bus. (Automation)
base level — the zoom at which a nested group is summarized (one labeled
rectangle) or resolved (lanes of its own): Editor.expand / collapse. The
same structure, seen coarser or finer — a view state, not data.
(The editor)
beats — the arrangement's unit of time: musical, tempo-relative. Everything
in clausters.form — onsets, durations, placements, extent — is in beats.
Compare timeline samples. (Overview)
bounce — evaluating a generator offline into a generated element: a pattern run on a throwaway clock into a timeline of events (at flatten time), or a whole piece rendered to audio (Bouncing). A bounce is a change of state.
bus (internal) — a private audio/control bus a logical group
declares (buses=["mix"]); members wire to it by naming it in their
controls. Each graph instance allocates its own. The reserved name OUT is
the hardware output. (The logical side)
change of state — evaluating a generator into a generated element: a pattern bounced to events, a piece rendered to a file. The compositional act rendering performs — it turns something you can only produce into something you can manipulate. (Elements)
clip — the editor's graphic unit: a placed rectangle spanning
[offset, offset + dur] on the shared axis — its length is its duration.
Draws the body (or layered bodies) its element calls for: a take, a
piano-roll, a curve. (The editor)
concrete (group kind) — see group: a group whose members relate in time (a section, a lane), as opposed to a logical one, whose members relate by processing. It is the default kind, and the one the multitrack draws as lanes of clips. (Grouping)
control bus — a server bus carrying control-rate values. What an automation
renders onto: a small internal synth reads the
control vector onto the bus, and targets follow it (via
/n_map, or by reading the bus directly). (Automation)
control vector — an automation curve discretized into a server control
buffer (/b_gen "env", the same envelope math EnvGen plays — what is drawn
is what is heard). (Automation)
cursor — the static transport line: where a located, stopped transport
sits, and where the next play starts. Set by locate or a ruler click.
Compare playhead. (Editing)
dirty — Editor.dirty: the arrangement changed since the last render. An
edit never interrupts what is sounding; the next transport action re-reads the
composition. (Editing)
duration — an element's own length in beats (Element.duration),
optional. Distinct from a placement's dur, which overrides and
trims it. (Elements)
edit-back — the GUI-to-data direction of the loop: the window's gestures
("clip" move/resize, "points" curve edits, "wire" rewiring, "locate")
applied onto the arrangement by Editor.apply / Editor.poll.
(Editing)
element — the arrangement's unit: any bounded thing that produces a unit of
meaning and can be decomposed or combined — in one of the two modes,
generated or generator, carrying an optional onset
and duration, delegating its playing to the object it wraps.
(Elements)
Env — the client's envelope object (levels, segment times, per-segment
shapes): the stored form of an automation curve, round-tripped to and from
break-points by env_to_points / points_to_env — the picture, the data and
the server buffer all read the same object. (Automation)
event / clip (element) — the Event primitive: parameters grouped into
one action, internally simultaneous. Wraps clausters.seq.Event.
(Elements)
extent — the composition's length in beats, read from the arrangement (the
end of its last placed element): Editor.extent(). Not a constant — move a clip
past the end and the piece is longer. (The editor)
five primitives — the five element kinds, each a thin adornment over an
object the client already has, with their conceptual names: Event
(event/clip), Sequence (List), Buffer (Buffer), Track (Set),
Generator (Function). (Elements)
flatten — the tree-walk that accumulates nested placement offsets into
absolute beats, producing a flat timeline of playable items; contained
patterns are bounced in the same pass. clausters.form.flatten /
to_timeline — also available as pure inspection. (Grouping)
follow — Editor.follow: re-render on every applied edit (the live
editor). Off, an edit marks dirty and waits for the next transport
action. (Editing)
generated / generator — the two modes of an element, the axis the layer turns on. Generated: the rendered thing — random-access data you can edit, slice, read backwards (a buffer, a timeline of events). Generator: the algorithm that renders it — forward-only, it can just be evaluated (a pattern, a def). Between them, the change of state. (Elements)
group — the arrangement's one new structure: a composite element placing members recursively by offset. Two kinds: concrete (the members relate in time — a section, a lane) and logical (the members relate by processing — wired through buses, rendered as a GraphDef). (Grouping, The logical side)
GraphDef — the server's named configuration of member nodes wired by
buses, sent with /d_graph and instanced with /graph_new. What a logical
group renders to: Group.to_graphdef() maps one onto it, 1:1.
(The logical side)
handle (member) — the stable object Group.add returns (also
Group.handles), identifying one placement across edits — what move and
remove take, and what the editor holds per clip. Group.members reads the
placements as (offset, dur, element) triples. (Grouping)
instrument — the def named to play a Buffer element (its buf control
takes the buffer number). A buffer is data: without an instrument it is
structure only — it draws and contributes extent, but emits no event.
(Elements)
lane — one track row of the multitrack window. The root group's members
are the lanes; a lane's members are its clips.
(The editor)
locate — seek: put the transport at a beat. Stopped, it moves the cursor; playing, it re-renders from there. A click on a lane's ruler or empty space is the same locate. (Editing)
logical (group kind) — see group: a group whose members relate by processing — wired to each other through buses — rather than in time. It does not flatten; it renders to a GraphDef, and it draws as a patch. (The logical side)
multitrack editor — the arrangement's DAW-style view and driver:
clausters.gui.Editor plus the track/clip/graph widgets. Draws the
tree, applies edits back onto it, owns the transport, and is the only
converter between beats and timeline samples.
(The editor)
onset — where an element starts, in beats, relative to its context; optional. Usually supplied by a placement rather than the element itself. (Elements)
patch / patcher — the view of a logical group: member boxes,
bus nodes, one wire per (member, control) ↔ bus connection. Deliberately
undirected — the data knows the connection; the direction is the server's
analysis. (The logical side)
piano-roll — the clip body an element of events draws: one bar per note, high pitches up. A pattern's roll is bounced to be drawn — a generator lane shows the notes it is about to play. (The editor)
placement — one member's position in a group: an offset (beats, relative
to the group) and an optional dur. An element's concrete place comes from
its placement, not from itself. (Grouping)
placement length (trim) — a placement's dur overrides the element's own
duration and trims what plays: events past the end are dropped, a final
event is shortened — on a copy; the element is never rewritten. The DAW rule:
a clip's length is what you hear of it. (Grouping)
playhead (sweeping) — the moving transport line, anchored to the engine's
sample clock so it tracks the audio (Editor.anchor). Also the
clausters.seq.Playhead object itself: what a render returns. Compare
cursor. (Rendering, Editing)
poll — Editor.poll(): drain the window's pending events into the
arrangement (apply each), returning whether the composition changed. One call,
no loop; never from the clock thread. (Editing)
quant — the one musical grid, in beats: the editor converts it once and hands it to the lanes as their drag grid, and snaps edit-backs to it — so the grid a clip is dropped on is the grid the arrangement re-schedules on. (The editor)
render / rendering — the change of state to sound, Element.render. What
it does depends on the group kind. For a concrete group (a
relation in time): flatten to a timeline and play it through a Playhead —
and every render re-reads the tree. For a logical group (a relation of
processing): translate to a GraphDef, send it, instance it. RT or
NRT purely by destination. (Rendering)
rerender — Editor.rerender(): re-schedule the (edited) composition from
the playhead's position, reusing the destination and clock the last render
remembered — stop, re-flatten, play. Honest semantics: re-schedule from here,
not a sample-exact splice; a synth already sounding keeps sounding.
(Editing)
RT / NRT — real-time (a live server, timetagged bundles) versus
non-real-time (an offline score, Session.nrt + render). The same client
code and the same flattening either way — which is why a bounce is
sample-identical to what you heard. (Bouncing)
take — an audio clip: a Buffer element's recorded/bounced content, drawn
from the server buffer itself (fetched and decimated host-side).
(Setup, The editor)
temporal character — what a single element's onset/duration presence
makes it: segment (both), punctual (onset only), relative
(duration only), abstract (neither — pure context).
(Elements)
temporal relation — what a group's members' placements make the group: successive (they tile contiguously), simultaneous (they start and end together — one thing on the timeline, drawn as one layered clip), mixed (anything else). Derived, never declared. (Grouping)
timeline samples — the view's unit: one unit per audio sample, so a take
sits 1:1 on the axis. Clips, rulers and edit-backs speak it; the editor
converts (one beat = sample_rate / tempo units) and nothing else does.
(The editor)
unit bridge — the single conversion between beats and
timeline samples, owned entirely by the editor
(units_per_beat, beats_to_units, units_to_beats), through the core's own
time arithmetic. (The editor)
wire — one (member, control) ↔ bus connection of a patch. Rewiring on
screen rewrites the member Generator's controls; the next render sends the
graph as drawn. (The logical side)
Receiving OSC and MIDI (responders)
Everything so far has the client sending — building OSC or MIDI and pushing it to the server. Responders add the other direction: the client receives OSC and MIDI from any application, matches each message, and dispatches it to a callback. That callback can do anything, including emit OSC or MIDI onward — so the client becomes a hub: a MIDI keyboard or a controller app on one side, the Clausters server (or another program) on the other.
The two responder classes mirror SuperCollider's OSCFunc and MIDIFunc:
OscFunc(func, path)firesfuncon incoming OSC messages whose address matchespath.MidiFunc(func, midi_msg)firesfuncon incoming MIDI of a given type ("note_on",["note_on", "note_off"], …).
Both are enabled the moment you create them, and you call free() (or disable()) when done.
Receivers: the transport under a responder
A responder registers with a receiver — the object that owns the listening socket (or MIDI port) and a background thread that decodes and demuxes incoming messages:
OscReceiverbinds a UDP socket and decodes each datagram (bundles unwrapped) through the same single decode door the server uses.MidiReceiveropens a virtual MIDI input port (through theclausters-midicrate'slivefeature) that other apps and devices route into, and parses each message into a dict.
You can pass a receiver explicitly, or let the responder use a lazily-created module default. The default OSC receiver binds an ephemeral port; the default MIDI receiver opens a virtual input port the first time a MidiFunc needs it. They are the one bit of process-wide state in this layer, in the spirit of main.default_clock — explicit receivers are always available when you want full control (a fixed port, a shared clock).
from clausters.base import OscReceiver
from clausters.responders import OscFunc
# A receiver on a fixed port external apps can target.
recv = OscReceiver(port=57121).start()
@OscFunc # not how you'd normally call it -- see the decorator form below
def _(): ...
OSC: matching and the callback
An OscFunc callback is called func(msg, time, src):
msgis the message as a list —[addr, arg1, arg2, …].timeis the bundle's Unix time, orNonefor an immediate or bare message.srcis the(host, port)of the sender.
You can narrow what matches with src=(host, port) (respond only to one sender) and arg_template (match arguments by position — a literal compares equal, a callable is a predicate, None matches anything):
from clausters.base import OscReceiver
from clausters.responders import OscFunc, oscfunc
recv = OscReceiver(port=57121).start()
# Relay an incoming /note <midinote> <dur> to the server as a synth.
def play(msg, time, src):
freq = 440.0 * 2 ** ((msg[1] - 69) / 12)
server.synth("default", {"freq": freq})
OscFunc(play, "/note", recv=recv)
# The decorator form reads the same and returns the OscFunc.
@oscfunc("/ctl", arg_template=[7, None], recv=recv)
def on_cc(msg, time, src):
print("controller 7 ->", msg[2])
OscFunc(...).one_shot() frees the responder after its first match, for a one-time wait.
MIDI: message dicts
A MidiReceiver decodes raw channel-voice bytes into a dict with a type and the type's fields — {"type": "note_on", "channel": 0, "note": 60, "velocity": 100}, {"type": "control_change", "channel": 0, "control": 7, "value": 127}, and so on (pitchwheel carries a single 14-bit pitch). A MidiFunc callback is called func(message, src) with that dict and the port name, and you match on the type, an optional chan, and an arg_template over the fields:
from clausters.base import MidiReceiver
from clausters.responders import MidiFunc
recv = MidiReceiver(port="clausters-in").start()
voices = {}
def note_on(m, src):
if m["velocity"] == 0:
return note_off(m, src)
freq = 440.0 * 2 ** ((m["note"] - 69) / 12)
voices[m["note"]] = server.synth("default", {"freq": freq, "amp": m["velocity"] / 127 * 0.3})
def note_off(m, src):
synth = voices.pop(m["note"], None)
if synth is not None:
server.free(synth)
MidiFunc(note_on, "note_on", recv=recv)
MidiFunc(note_off, "note_off", recv=recv)
That virtual input port is a loose cable until you wire a MIDI source into it (pw-link, qpwgraph, or aconnect). This is the client-side mirror of the server's own direct MIDI input: a Clausters server can be played by MIDI it receives itself, or by a client that listens to MIDI and forwards /s_new. Both coexist.
Threading: the golden rule
A responder's callback runs on the receiver's background thread (or, if the receiver was given a clock, on the clock thread). The golden rule holds: never block that thread. Keep callbacks quick. To sequence in response to an incoming message — play a phrase, not a single note — schedule a routine on a clock, which returns immediately:
from clausters.base import Routine
def start_phrase(msg, time, src):
def phrase():
for degree in (0, 2, 4):
Event(instrument="default", degree=degree).play(server)
yield 0.25
clock.play(Routine(phrase)) # non-blocking; the clock thread runs it
OscFunc(start_phrase, "/go", recv=recv)
Reacting to the shared transport
Setting the server's shared transport (/transport, the beat grid several clients phase-align on — see Timing models) pushes the new grid to every client registered with /notify. A responder on /transport.reply re-aligns this client the moment a conductor changes the tempo or origin, with no polling. Register /notify from the receiver's own socket so the push lands where the responder listens:
recv = OscReceiver(port=57121).start()
recv.send(server.target.addr(), "/notify", 1) # subscribe on this socket
def on_transport(msg, time, src):
origin, tempo, defined = msg[1], msg[2], msg[3]
if defined:
clock.join_transport(server) # adopt the new grid
OscFunc(on_transport, "/transport.reply", recv=recv)
Examples
osc_responder.py— the client as an OSC hub: relay incoming/noteto the server, and re-align on a/transport.replypush. Runs against a live server, self-feeding a few messages to demonstrate.midi_responder.py— aMidiFuncturning a MIDI keyboard into synths on the server.
See Examples.
Examples
The installed-package examples import clausters from the installed package — no sys.path shim, no target/ directory, no separately built binary for the offline one. Run them after installing the package (see Getting started):
python -m venv .venv && . .venv/bin/activate
pip install ./clients/python # builds + bundles the native libs
-
embedded.py— the same pattern, live from a server running inside the process (Session.embed). Nothing to start: it opens the bundled engine in-process and plays. The batteries-included path. No server process, no socket.python clients/python/examples/embedded.py -
offline_render.py— fully self-contained: renders a short arpeggio to a WAV through the bundled renderer. No server, no audio device.python clients/python/examples/offline_render.py out.wav -
typed_controls.py— also fully self-contained (renders a WAV, no server): aSynthDefwhose controls show the three control types — a laggedfreq(portamento glide), agatetrigger (rate="tr", re-plucking a percussive envelope each set), and adetunedrawn once withrand(anirscalar). ARoutinesetsfreq/gateper note through timetagged bundles. See Building defs.python clients/python/examples/typed_controls.py out.wav -
graph_maths.py— also self-contained (renders a WAV, no server): aSynthDefthat does real per-sample maths —.midicps()for pitch,.distort()for timbre, a comparison-and-.clip2()tremolo — all composing the server's generic operator UGens (S3), bit-identical to the same maths computed off the RT path. See Maths on a UGen graph.python clients/python/examples/graph_maths.py out.wav -
spectral.py— also self-contained (renders a WAV, no server): a frequency-domainfft→pv_brick_wall→ifftchain (S8) that low-passes white noise in the spectral domain, rendered next to the raw noise so the effect is audible. No buffer is allocated; the frame is synth-private scratch on the server. See The frequency-domain chain.python clients/python/examples/spectral.py out.wav -
live_udp.py— the same pattern, live over UDP to a separate, running server. The wheel ships that server as theclausterscommand (or usecargo run --release).clausters # the standalone server, in its own process python clients/python/examples/live_udp.py # in another shell -
transport_sync.py— two independent clients lock to a running server's sample clock and join its shared transport, so aquant-ed routine on each lands on the same bar (sample-aligned). Prints the matching next-bar sample and plays a note on each. See Timing models.python clients/python/examples/transport_sync.py -
osc_responder.py— the client as an OSC hub: anOscFuncrelays incoming/notemessages to a running server as synths, and anOscFuncon/transport.replyre-aligns when a conductor changes the shared transport. Self-feeds a few messages to demonstrate. See Receiving OSC and MIDI.python clients/python/examples/osc_responder.py -
midi_responder.py— aMidiFuncturning a MIDI keyboard into synths on a running server: note-on starts a synth, note-off frees it. Opens a virtual MIDI input port (clausters-in) to wire a source into. Needs thelivecdylib (cargo build --release -p clausters-midi --features live).python clients/python/examples/midi_responder.py -
timeline_transport.py— DAW-style transport over a staticTimeline: captures a pattern into a timeline, edits it, then drives it live with aPlayhead—play,locate(seek),loop, and a songposition. Needs a running server. See Timelines and the playhead.python clients/python/examples/timeline_transport.py -
transport_conductor.py— a conductor's play/stop/locate driving several clients' playheads in lockstep: two followersfollow_transporta running server's shared transport and roll together (sample-exact, since they alsolock_to). Prints their matching song positions. See A DAW-style transport.python clients/python/examples/transport_conductor.py
The first three share their pattern code and differ only in the Server interface — embedded, offline or live — the seam from The client, layer by layer in practice.
The broader catalog of examples (the low-level transports and the raw OSC helpers) lives in the repository-root examples/; those use a sys.path shim so they run straight from a source checkout without an install.
The GUI examples (gui_*.py) drive the clausters GUI host — a separate process (or a browser tab) this client talks to over the same widget protocol. The host itself, including the browser quick-start (the wasm bundle, --ws, and the streamed-meters and fetched-bulk data paths), is documented in the Clausters server book's clients chapter. Two of them close the composition loop: gui_multitrack.py lays out tracks of clips on one shared time axis, and gui_composer.py drives that view from a composition — the arrangement drawn, dragged and re-rendered (see Composition: the arrangement and the multitrack editor, and the step-by-step tutorial Composing a piece, which builds the same piece interactively).
API reference
- clausters.session
- clausters.responders
- clausters.ipc
- clausters.errors
- clausters.base.stream
- clausters.base.clock
- clausters.base.timebase
- clausters.base.main
- clausters.seq.event
- clausters.seq.pattern
- clausters.seq.timeline
- clausters.defs.signals
- clausters.defs.boxes
- clausters.defs.ugens
- Ugen
- Control
- control
- sin_osc
- impulse
- white_noise
- in_
- in_ctl
- out_ctl
- out
- replace_out
- send_trig
- send_reply
- poll
- fft
- ifft
- pv_mag_above
- pv_mag_below
- pv_brick_wall
- play_buf
- buf_rd
- local_in
- local_out
- mul_add
- sum3
- sum4
- lag
- var_lag
- sample_rate
- buf_frames
- rand
- dseq
- demand
- DoneAction
- NONE
- PAUSE_SELF
- FREE_SELF
- FREE_SELF_AND_PREV
- FREE_SELF_AND_NEXT
- FREE_SELF_AND_FREE_ALL_IN_PREV
- FREE_SELF_AND_FREE_ALL_IN_NEXT
- FREE_SELF_TO_HEAD
- FREE_SELF_TO_TAIL
- FREE_SELF_PAUSE_PREV
- FREE_SELF_PAUSE_NEXT
- FREE_SELF_AND_DEEP_FREE_PREV
- FREE_SELF_AND_DEEP_FREE_NEXT
- FREE_ALL_IN_GROUP
- FREE_GROUP
- FREE_SELF_RESUME_NEXT
- Env
- env_gen
- env_to_points
- points_to_env
- clausters.defs.faustdef
- clausters.defs.synthdef
- clausters.defs.graphdef
- clausters.defs.server
- ServerOptions
- ServerInfo
- Server
- boot
- shm
- send_msg
- send_bundle
- play_event
- request
- query_info
- query_tree
- node_query
- dump_graph
- add_faustdef
- add_synthdef
- add_graphdef
- graph
- graph_voice
- u_cmd
- run
- pause
- resume
- stream_buses
- tap
- stream_taps
- alloc_buffer
- gen_buffer
- read_buffer
- read_into
- write_buffer
- zero_buffer
- query_buffer
- get_samples
- render
- sync
- sample_clock
- transport
- set_transport
- transport_state
- transport_play
- transport_stop
- transport_locate
- close
- clausters.defs.node
- clausters.defs.bus
- clausters.defs.clocksync
- clausters.form.element
- clausters.form.group
- clausters.form.render
- clausters.gui.guidef
- clausters.gui.editor
- clausters.gui.host
clausters.session
Session: ergonomic defaults without global state.
The client deliberately avoids global state, but sc3's ease of use came largely
from its globals (Server.default, the default clock). A Session gives that
ergonomics back explicitly: it bundles a Server
and a TempoClock into one handle with play /
render / run, and the nrt / live factories pick sensible
defaults. Because it is an ordinary object, several sessions coexist — e.g.
one offline NRT session for plotting next to a live RT one — in the same script,
which globals make impossible.
s = Session.nrt(tempo=2.0)
s.play(Pbind(instrument="default", freq=Pseq([440, 550, 660]), dur=0.5))
samples, frames = s.render() # drains the clock, renders the score
Session
class Session()
One Server plus one TempoClock, bundled into a single handle.
This is the client's ergonomic entry point. Rather than wiring a server, a
clock and (optionally) a timebase together yourself, you take a Session
that owns both and drives them as a unit -- play a pattern on it,
render it offline, or run it for some seconds live.
Prefer the factories to the constructor: nrt builds an offline,
score-accumulating session and live a real-time one over UDP, each with
sensible defaults. The constructor is for the uncommon case of supplying
your own Server and clock.
Which factory you call is the only thing that differs between an offline
render and a live take: that difference lives in the Server's
communication interface, not in the pattern or the clock. So the same
play drives either kind, and an offline and a live session can run side
by side in one script.
Arguments:
-
server- theServerto drive -- a live one, or one holding anOscNrtInterfacefor offline rendering. -
clock- theTempoClockthat sequences it; a fresh one at tempo 1.0 is created when omitted.Closing the session closes its server, so the common shape is a context manager:
with Session.live(tempo=2.0, latency=0.1) as s:
s.play(Pbind(instrument="default", degree=Pseq([0, 2, 4]), dur=0.5))
s.run(3.0)
Session.nrt
@classmethod
def nrt(cls, tempo: float = 1.0) -> "Session"
Build an offline (non-real-time) session.
Its Server holds an OscNrtInterface, so playing a pattern
accumulates a timetagged score instead of sending anything; render
then turns that score into samples through the bundled embedded
renderer. No server process and no audio device are involved.
Arguments:
tempo- the clock's tempo, in beats per second.
Returns:
A Session whose render produces the audio.
Session.live
@classmethod
def live(cls,
host: "str | None" = None,
port: "int | None" = None,
*,
tempo: float = 1.0,
latency: "float | None" = None,
timebase=None,
boot: bool = True,
options=None,
shm="auto",
transport: "str | None" = None,
verbose: int = 0,
data_dir=None,
server_args=(),
ready_timeout: float = 10.0) -> "Session"
Build a real-time session, starting a server if none is up.
The probe (and the boot handshake) ride UDP — discovery stays
zero-config — and the session's command interface then connects over
TCP by default (transport="udp"/"ws" opt across), so defs
and bulk reads are not bounded by a datagram.
This is the everyday live-coding entry point. By default (boot=True)
it ensures a server the way nrt ensures a renderer: if one already
answers at the target address it attaches to it, and if none does it
launches a separate clausters process — choosing a shared-memory
segment for you — and connects to that. Either way you get a session you
drive the same. A server the session started is stopped when the session
is closed or the interpreter exits, so a REPL or script leaves nothing
running; a server it merely attached to is left alone.
Pass boot=False for the plain attach-only behavior (never start a
process): connect to a server you launched yourself, possibly remote.
Arguments:
host- the server's host;Nonetakes the config file's[client].host(default127.0.0.1). Booting is local.port- the server's UDP port;Nonetakes[client].port(the Clausters default is 57110).tempo- the clock's tempo, in beats per second.latency- seconds added to each event's timetag so it reaches the server slightly ahead of its play time and sounds on time instead of late; a small value such as 0.1 is typical for a live take.Nonetakes the config file's[client].latency.timebase- the clock's pacing source. The default (monotonic) paces in wall-clock seconds; aSampleClockTimebaseanchors timing to the server's sample clock for drift-free scheduling.boot- start a server if none is already answering (default).Falseattaches only, never launching a process.options- aclausters.defs.ServerOptionssizing a launched server and this client's allocators alike;Noneuses the defaults.shm- the shared-memory segment for a launched server —"auto"picks one, a path forces it,Nonelaunches without one. The path is remembered soguimaps the same segment.transport- the command carrier —"tcp"(default),"udp"or"ws";Nonetakes[client].transportfrom the config.verbose- launched-server log verbosity (1/2/3->-v/-vv/-vvv; negative ->-q).data_dir- a launched server's--data-dir;Noneuses default.server_args- extra CLI tokens for a launched server (e.g.["--tcp"]).ready_timeout- seconds to wait for a launched server to answer.
Returns:
A Session you drive with run (or start / stop).
Session.embed
@classmethod
def embed(cls,
tempo: float = 1.0,
latency: "float | None" = None,
workers: int = 0,
timebase=None,
server=None) -> "Session"
Build a real-time session backed by an in-process embedded server.
The whole server — audio device and engine — runs in this process
through the bundled native library; there is no socket and no separate
server process. Otherwise it is identical to live: the same routines,
patterns and defs drive it, because only the Server's communication
interface differs (an OscEmbedInterface instead of UDP). So an
embedded, a live and an offline session can run side by side in one
script.
Arguments:
tempo- the clock's tempo, in beats per second.latency- seconds added to each event's timetag so it lands a touch in the future and sounds on time; a small value such as 0.05 is typical even in-process.workers- engine worker threads for parallel node processing (0 lets the server choose).timebase- the clock's pacing source (default monotonic wall clock).server- an existingclausters.Claustershandle to reuse; when omitted the session opens and owns a fresh embedded server and closes it onclose.
Returns:
A Session you drive with run (or start / stop), exactly like
live.
Session.gui
def gui(*,
port: "int | None" = None,
transport: str = "tcp",
verbose: int = 0,
data_dir=None,
extra_args=(),
ready_timeout: float = 10.0)
Launch (once) a clausters-gui visual server wired to this session's
server, and return a clausters.gui.GuiHost connected to it.
The GUI parallel of live booting a server: one call and the visual
server is up, its client leg pointed at this session's server and — when
that server was launched with a shared-memory segment — mapping the same
segment, so meters, scopes and playheads read the engine with no
per-frame messages. You never spell out an address or a segment path:
they come from the session. The host is owned by the session and stopped
on close (or interpreter exit).
Idempotent: repeated calls return the same GuiHost (the port and
other options of the first call stand).
Arguments:
port- the GUI host's own port (script -> host, UDP and TCP alike);Noneuses the host default (57210).transport- the carrier this session talks to the host over —"tcp"(default; a/gui_deftree is not bounded by a datagram) or"udp".verbose- host log verbosity (1/2/3->-v/-vv/-vvv).data_dir- the host's--data-dirfor its GuiDef store.extra_args- extra host CLI tokens.ready_timeout- seconds to wait for the host to answer.
Returns:
A started clausters.gui.GuiHost. Use clausters.gui.GuiHost.open
to open a window, set to edit it and clausters.gui.GuiHost.close
to close it.
Session.play
def play(pattern, quant=None)
Play an event pattern on this session's clock and server.
Arguments:
pattern- an event pattern, e.g. aPbind.quant- optional quantization handed to the player -- the beat grid the routine starts on;Nonestarts immediately.
Returns:
The EventStreamPlayer driving the pattern.
Session.render
def render(sample_rate: float = 48_000.0, channels: int = 2)
Drain the clock and render the accumulated score (offline only).
Advances the clock logically with no real-time waiting, so every scheduled event lands in the score, then renders that score through the embedded renderer.
Arguments:
sample_rate- render sample rate, in Hz.channels- number of interleaved output channels.
Returns:
(samples, frames) -- interleaved float32 in a stdlib
array('f') and the frame count. Schedule a closing event (e.g.
freeing the root group) so the render has a defined duration.
Session.lock_to_server
def lock_to_server()
Lock this session's clock to its server's sample clock — the
sample-accurate, drift-free timebase, with the server as the master
clock. Returns self, so it chains after a factory:
Session.live(...).lock_to_server().
Safe when the server is not a reachable master (offline, or no server
running): the clock simply stays on wall-clock OSC time. See
TempoClock.lock_to.
Session.join_transport
def join_transport()
Join this session's server's shared transport, so a quant-ed
pattern starts on the same beat as every other client on it (see
TempoClock.join_transport). Returns self for chaining:
Session.live(...).lock_to_server().join_transport(). No-op if the
server has no transport defined.
Session.run
def run(seconds: float)
Run the clock in real time for seconds, then stop (live only).
Arguments:
seconds- how long to advance the clock, in wall-clock seconds.
Returns:
self, so calls chain.
Session.start
def start()
Start the clock so scheduled events fire in real time; returns
self. Pair with stop, or use run to start, wait and stop in one
call.
Session.stop
def stop()
Stop the clock; returns self. Events scheduled past the stop
point do not fire.
Session.close
def close()
Close the underlying Server and release the clock's master-clock
tracker (from lock_to_server), if any. Also stops the GUI host (gui)
and, if live launched a server, that process too — so nothing is left
running. Done automatically when the session is used as a context manager
and, for launched processes, on interpreter exit.
clausters.responders
Responders: OscFunc / MidiFunc (port of sc3/base/responders.py).
The input half of the client. Until now the client was output-only — it
built OSC/MIDI and sent it to the server. Responders add the receive path and
the client's role as a general MIDI/OSC hub, mirroring sclang's
OSCFunc/MIDIFunc: receive OSC and MIDI from any application, match and
dispatch to a callback, and let that callback emit OSC/MIDI onward — to the
Clausters server or to other apps.
A responder registers a self-filtering handler with a receiver (the
transport + demux thread): clausters.base.OscReceiver for OSC,
clausters.base.MidiReceiver for MIDI. Pass one explicitly, or rely on the
lazily-created module defaults (default_osc_receiver / default_midi_receiver
— opt-in convenience, the one bit of process-wide state here, in the spirit of
main.default_clock).
Callbacks run on the receiver's thread (or, if the receiver has a clock, on the clock thread): keep them quick and non-blocking — the golden rule. To sequence in response to an event, schedule a routine on a clock (non-blocking) instead of looping inside the callback.
from clausters.responders import OscFunc, MidiFunc
# Relay an incoming /play to the server as a /s_new.
OscFunc(lambda msg, t, src: server.synth("default", {"freq": msg[1]}), "/play")
# Drive the server from a MIDI keyboard: note on -> /s_new, note off -> free.
notes = {}
def on(m, src):
notes[m["note"]] = server.synth("default", {"freq": 440 * 2 ** ((m["note"] - 69) / 12)})
MidiFunc(on, "note_on")
default_osc_receiver
def default_osc_receiver() -> OscReceiver
The lazily-created, started default OscReceiver (ephemeral UDP port).
Created on first use so importing this module opens no socket. Override it
with set_default_osc_receiver (e.g. to bind a fixed port external apps can
target, or to attach a clock).
set_default_osc_receiver
def set_default_osc_receiver(receiver: OscReceiver) -> OscReceiver
Install receiver as the module default returned by
default_osc_receiver. Start it yourself first.
default_midi_receiver
def default_midi_receiver() -> MidiReceiver
The lazily-created, started default MidiReceiver (a virtual input
port). Created on first use, so importing this module opens no MIDI port and
needs no live-built clausters-midi. Override with
set_default_midi_receiver.
set_default_midi_receiver
def set_default_midi_receiver(receiver: MidiReceiver) -> MidiReceiver
Install receiver as the module default returned by
default_midi_receiver. Start it yourself first.
OscFunc
class OscFunc()
Responder for incoming OSC messages.
Registers func to fire when a message matching path arrives. The
callback is called func(msg, time, src) — msg the message as a list
[addr, arg1, …], time the bundle's Unix time (or None for an
immediate / bare message), src the (host, port) of the sender.
Arguments:
-
func- the callbackfunc(msg, time, src). -
path- the OSC address to match (a leading/is added if missing). -
src- optional(host, port)— respond only to that sender. A port ofNonematches any port from that host. -
arg_template- optional list matched against the message arguments by position; an entry is a literal (compared equal), a predicate callable, orNone(matches anything). Shorter than the message is fine — only the listed positions are checked. -
recv- theclausters.base.OscReceiverto register with; defaults todefault_osc_receiver.The responder is enabled on creation. Call
free(ordisable) when done.
OscFunc.enable
def enable()
Start responding (registers the handler with the receiver).
OscFunc.disable
def disable()
Stop responding without discarding the object (re-enable-able).
OscFunc.free
def free()
Disable permanently; call when finished with this responder.
OscFunc.one_shot
def one_shot()
Free the responder after its first match — a one-time action.
MidiFunc
class MidiFunc()
Responder for incoming MIDI messages.
Registers func to fire on channel-voice messages of a given type. The
callback is called func(message, src) — message a dict
({'type', 'channel', …}, see clausters.base._midiinterface.parse_midi)
and src the port name.
Arguments:
-
func- the callbackfunc(message, src). -
midi_msg- a message type ('note_on') or a list of types (['note_on', 'note_off']). -
chan- optional channel (0..15) to restrict to. -
arg_template- optional{field: matcher}dict matched against the message fields; a matcher is a literal, a predicate callable, orNone(matches anything). -
recv- theclausters.base.MidiReceiverto register with; defaults todefault_midi_receiver.Enabled on creation;
free(ordisable) when done.
MidiFunc.enable
def enable()
Start responding (registers the handler with the receiver).
MidiFunc.disable
def disable()
Stop responding without discarding the object (re-enable-able).
MidiFunc.free
def free()
Disable permanently; call when finished with this responder.
MidiFunc.one_shot
def one_shot()
Free the responder after its first match — a one-time action.
oscfunc
def oscfunc(path, **kwargs)
Decorator building an OscFunc over a callback.
@oscfunc("/play")
def resp(msg, time, src):
print(msg, time, src)
midifunc
def midifunc(midi_msg, **kwargs)
Decorator building a MidiFunc over a callback.
@midifunc(["note_on", "note_off"])
def resp(message, src):
print(message, src)
clausters.ipc
Clausters Python bindings. Standard library only, by design.
Two ways in, both speaking ordinary OSC bytes (build them however you like;
examples/json_client.py has stdlib helpers):
Clausters— the embedded server: loads the cdylib (build it withcargo build --release --features embed,realtime) and runs the whole server in-process. Commands are function calls, no network anywhere.ShmClient— attaches to a separate server started withclausters --shm <path>: commands/replies travel through a shared-memory ring, and the data plane (sample clock, control buses) is read and written directly in mapped memory.
Plus render — the synchronous "scientific" call: hand it a binary
score, get the interleaved float32 samples back, no server running at all.
Boundary rule (project-wide): only flat data crosses — bytes in,
array('f')/floats/ints out. A numpy user can wrap the results without
copying (numpy.frombuffer), but nothing here imports anything heavy.
Caveats of the pure-Python shm path: Python has no atomics, so cursor reads and writes rely on x86-TSO-style ordering of aligned 32-bit accesses — fine on the supported platforms, documented in docs/ipc.md.
ShmClient
class ShmClient()
Client of a clausters --shm <path> server (same machine).
ShmClient.clock
@property
def clock() -> int
The engine's sample counter, mirrored every block (64 samples).
ShmClient.ctl_set
def ctl_set(index: int, value: float)
Writes the very atomic the engine's InCtl reads next block.
ShmClient.request
def request(packet: bytes, timeout: float = 2.0) -> bytes
The synchronous facade: send, then block (the client blocks, never the server) until a reply arrives.
render
def render(score: bytes,
sample_rate: float = 48000.0,
channels: int = 2,
workers: int = 0,
lib_path: str | None = None) -> tuple[array, int]
Synchronous offline render: binary score in, (samples, frames)
out — interleaved float32 in a stdlib array('f'). The whole call
blocks the caller and nothing else; there is no server involved.
Clausters
class Clausters()
The embedded live server: audio device + engine in this process.
Clausters.request
def request(packet: bytes, timeout: float = 2.0) -> bytes
Synchronous facade: blocks this thread until the reply arrives.
Clausters.clock
@property
def clock() -> int
Block-accurate sample counter, straight from shared memory.
clausters.errors
Library-specific exception types.
A small hierarchy so callers can catch what went wrong concretely instead of
guessing from a stray AttributeError or a generic OSError. Everything
derives from ClaustersError, and each leaf also derives from the
builtin it used to be raised as (OSError, RuntimeError,
BufferError, TimeoutError, ValueError) — so existing
except OSError: / except RuntimeError: code (and the test-suite skips)
keep working unchanged while new code can be precise:
try:
samples, frames = clausters.render(score)
except clausters.LibraryFeatureError as e:
print(e.symbol, "needs", e.feature) # build it with the right feature
except clausters.ClaustersError:
... # any other library failure
ClaustersError
class ClaustersError(Exception)
Base class for every error this library raises on purpose.
LibraryError
class LibraryError(ClaustersError)
A problem with the native libclausters shared library itself.
LibraryNotFoundError
class LibraryNotFoundError(LibraryError, OSError)
The libclausters cdylib could not be located on disk.
LibraryFeatureError
class LibraryFeatureError(LibraryError, OSError)
The cdylib loaded but a required FFI symbol is missing — it was built without the Cargo feature that exports it.
The concrete cause behind the otherwise cryptic undefined symbol /
AttributeError: a plain cargo build produces a libclausters.so
with the FFI surface compiled out. symbol and feature say
exactly what is missing and what to rebuild with.
AbiMismatchError
class AbiMismatchError(LibraryError, OSError)
The cdylib's ABI version does not match this binding's.
RenderError
class RenderError(ClaustersError, RuntimeError)
The offline render call failed (the core reported an error).
ServerError
class ServerError(ClaustersError, RuntimeError)
The embedded server could not be opened (no audio device, etc.).
CommandError
class CommandError(ClaustersError, RuntimeError)
A server command was answered with a /fail reply.
SegmentError
class SegmentError(ClaustersError, ValueError)
The shared-memory segment is not a valid clausters segment.
CommandRingFull
class CommandRingFull(ClaustersError, BufferError)
The command ring is full; the command was not sent (retry later).
ReplyTimeout
class ReplyTimeout(ClaustersError, TimeoutError)
No reply arrived within the timeout.
clausters.base.stream
Streams and routines (port of sc3/base/stream.py).
The coroutine layer. A Routine wraps a Python generator function;
driving it resumes the generator, and the value it yields is a time to
wait (in beats) before the next resumption. The thing that resumes routines on
a schedule is the clock (clausters.base.clock); here we only define the
protocol. This is the part that stays in the host language — yield is
Python control flow and never moves to Rust.
StopStream
class StopStream(StopIteration)
Raised (and caught) to end a stream normally.
YieldAndReset
class YieldAndReset(Exception)
Raise from inside a routine to yield value and then reset the routine
to its initial state, so its next resumption restarts the generator.
Stream
class Stream()
A lazy sequence: implements the iterator protocol over next.
Concrete streams carry their own random generator (rng), derived from the
creating context at construction (see clausters.base.rand): random values
drawn while a stream runs come from its stream, so one root seed
(main.seed) reproduces a whole script and concurrent routines stay
reproducible per routine.
rng
the stream's random generator; None falls back to the root context.
Stream.next
def next(inval=None)
Produce the next value, optionally fed inval; raise StopStream
to end. Subclasses implement this -- the base raises
NotImplementedError.
Stream.reset
def reset()
Return the stream to its initial state so iteration restarts. A no-op on the base; stateful subclasses override it.
FunctionStream
class FunctionStream(Stream)
Wraps a plain callable: each next calls it with inval.
Routine
class Routine(Stream)
Wraps a generator function into a resumable timeline.
The generator may take zero or one positional argument (the initial
inval). Each next resumes it; a yielded number is the delay
before the routine should be resumed again.
The generator must never block the thread — that is the user's
responsibility. A routine runs on the clock thread (RT) or inside the
render loop (NRT); blocking it (time.sleep, a blocking
Server.sync/wait=True def send, any synchronous wait-for-reply)
stalls every other routine and the whole timeline. Cede time with yield
instead. In particular, to create a def from inside a routine use the
asynchronous form -- server.add_faustdef(fdef, wait=False) (or
add_synthdef(..., wait=False)) -- which only sends; do not call the
blocking server.sync() here. A non-blocking, notification-driven
barrier you can yield (OSCFunc) is future work; until then, send
the def async and yield enough time before the /s_new that depends
on it.
Routine.run
@classmethod
def run(cls, func)
Decorator/constructor sugar: @Routine.run over a genfunc.
Routine.reset
def reset()
Discard the running generator and return to the init state, so
the next next or play starts the generator function afresh.
Routine.next
def next(inval=None)
Resume the generator once (sending it inval) and return the value
it yields -- a delay in beats -- or raise StopStream when it finishes.
The clock calls this on each wake; you rarely call it directly.
Routine.play
def play(clock=None, quant=None)
Schedule this routine to start on clock; returns self.
Arguments:
clock- theTempoClockto run on; whenNoneit falls back tomain.default_clockand raises if that is unset.quant- start quantization, forwarded to the clock (seeTempoClock.play; not yet implemented).
clausters.base.clock
TempoClock (port of sc3/base/clock.py, native-backed).
The seam between the native core and the host language. The clock owns the
scheduling queue and the beat/second arithmetic — the latter delegated to
clausters-core through clausters._native, so timing matches the
server's sample clock. The queue holds routines (and one-shot callables);
resuming a routine (the yield driver) stays in Python.
One clock, two drives:
run/start— real time: a background thread sleeps between events using a monotonic pacing clock; the logical beat still advances only by the routines'yields, so inter-event timing is exact and the OSC timetags (stamped from a separate wall clock) carry that exactness.render— non-real time: drain the queue in beat order with no sleeping, advancing a logical clock; used to build a score.
The clock does not talk to the server: it only schedules and exposes the
current time (beats, beats2secs, start_time). Sending
events belongs to clausters.defs.server.Server, which owns the
destination/communication interface and reads the time from the clock of the
routine being resumed (the clock sets routine.clock and
main.current_tt around each wake). Swapping that interface (RT/NRT/MIDI) is
the seam — and it lives on the Server, not here.
TempoClock
class TempoClock()
A scheduler that keeps musical time in beats and resumes routines on it.
A clock has a tempo (beats per second) and a queue of scheduled items --
routines and one-shot callables. Two drives share that queue:
- real time (
start/run): a background thread sleeps between items, pacing against thetimebase, and fires them live. - non-real time (
render): the queue is drained in beat order with no sleeping, advancing a logical clock as fast as possible -- used to build a score offline.
The defining property is that the logical beat advances only by the
routines' yields, never by wall-clock drift: a routine that yields
0.25 is resumed exactly a quarter-beat later, whichever drive is running
and whatever the OS scheduler does. That is what makes inter-event timing
exact -- and, with a SampleClockTimebase, sample-accurate.
The clock does not talk to the server. It only schedules and reports time
(beats, beats2secs, start_time); a Server reads the clock of the
routine it is resuming and emits from there. Choosing where events go (real
time, offline, MIDI) is the Server's job, not the clock's.
Arguments:
tempo- beats per second.timebase- the pacing source -- the default monotonic clock, or aSampleClockTimebaseto anchor pacing and scheduling to the server's own sample clock.
TempoClock.beats2secs
def beats2secs(beats: float) -> float
Convert a beat position to seconds under the current tempo (computed in the native core, so it matches the server's own arithmetic).
TempoClock.secs2beats
def secs2beats(secs: float) -> float
Convert seconds to a beat position under the current tempo (native core, server-matching).
TempoClock.beats
def beats() -> float
The clock's current beat: the yield-driven logical beat while rendering or being woken, else the monotonic-paced elapsed beat in RT (used for scheduling relative to "now").
TempoClock.start_time
@property
def start_time()
Wall-clock origin (Unix seconds) while running in real time, else
None. The Server uses it to turn a logical beat into a wall-clock
OSC timetag — the wall clock, kept separate from the monotonic
pacing source so timetags stay valid Unix time.
TempoClock.pacing_origin
@property
def pacing_origin()
The timebase value (seconds) captured at start. For a
sample-clock timebase this is sample_origin / sample_rate, which the
Server turns into the absolute sample for /sched.
TempoClock.set_tempo
def set_tempo(tempo: float)
Change tempo, pinning the current instant (no discontinuity).
TempoClock.bar
def bar(quant: float, beats: float | None = None) -> float
The bar index the clock's current beat (or an explicit beats
position) falls in on a grid of quant beats per bar (0-based;
quant <= 0 -> 0). The read complement of the quant argument
play takes — computed in the native core, so a GUI ruler in beats
shows the same bar:beat this returns.
TempoClock.beat_in_bar
def beat_in_bar(quant: float, beats: float | None = None) -> float
The beat within its bar for the clock's current beat (or an
explicit beats position) on a grid of quant beats per bar
(0-based, in [0, quant); quant <= 0 returns the position).
TempoClock.lock_to
def lock_to(server, warmup: bool = True, timeout: float = 2.0)
Lock this clock to a master server's sample clock, so events
schedule on the server's own sample axis (drift-free) instead of a
wall-clock OSC timetag.
Opt-in: a plain clock paces against wall-clock OSC time, which works
standalone, against another program, or across a network. lock_to
switches it to the server's sample clock — over UDP it tracks the
server's published /clock anchor on its own socket. The switch is
graceful: an offline (score) server, or a master that does not
answer, leaves the clock on wall-clock time, so a client with no
Clausters server keeps working. Returns self.
Blocking — call it before start/run, never from inside a
routine (it does /clock round trips). Release it with unlock or
close.
TempoClock.unlock
def unlock()
Undo a lock_to: close the tracker and return to wall-clock OSC
time. Returns self.
TempoClock.close
def close()
Stop the clock and release a lock_to tracker, if any.
TempoClock.join_transport
def join_transport(server)
Adopt a master server's shared /transport beat grid as this
clock's tempo and grid, so a quant-ed routine starts on the same
beat as every other client joined to it.
Reads the transport once; if the server has none defined, the clock
keeps its own grid (no-op). A sample-locked clock (lock_to) aligns
sample-exactly; a plain wall-clock clock aligns to beats through the
server's OSC-time anchor (drift-bounded). Returns self.
Blocking — call it before start/run, never from a routine.
TempoClock.leave_transport
def leave_transport()
Stop following a joined transport; quant returns to the clock's own
grid. Returns self.
TempoClock.sched
def sched(delay_beats: float, item)
Schedule item to run delay_beats from the current beat.
item is a Routine (or any Stream), or a plain callable for a
one-shot. When resumed, a routine is rescheduled by whatever delay it
yields; a callable that returns a number is rescheduled by that number,
and one returning None runs once. Safe to call from another thread
or from inside a running routine.
TempoClock.sched_abs
def sched_abs(beat: float, item)
Schedule item at an absolute beat, rather than relative to
the current beat as sched does.
TempoClock.play
def play(routine, quant=None)
Schedule a routine (or callable), snapping its start to a beat grid.
Arguments:
routine- aRoutine, anyStream, or a one-shot callable.quant- start quantization -- the routine starts on the next beat that is a multiple ofquant(e.g.4= the next bar in 4/4).Noneor0starts immediately. The grid is the clock's own elapsed beats, or a shared one when the clock has joined a transport (join_transport); for multi-client alignment start the clock before playing the quantized routine.
TempoClock.clear
def clear()
Drop every item currently in the schedule queue.
TempoClock.unsched
def unsched(item)
Remove a specific scheduled item from the queue (by identity),
leaving the rest in order. Used to cancel one routine — e.g. a
clausters.seq.timeline.Playhead stopping or seeking — without clearing
everything else clear would drop.
TempoClock.render
def render(until_beat: float | None = None)
NRT drive: process the queue in beat order without sleeping.
Returns when the queue is empty (or the next event is past
until_beat). Whatever the routines emit (through a Server) lands in
that Server's interface — here we only advance time and resume them.
TempoClock.start
def start()
Begin the real-time driver on a background thread.
TempoClock.stop
def stop()
Stop the real-time driver and join its background thread; returns
self. Schedules built up by run/start end here.
TempoClock.run
def run(seconds: float)
Convenience: run the RT driver for seconds then stop.
clausters.base.timebase
Selectable pacing timebase for the clock.
A TempoClock's logical beat advances only by the routines' yields;
the timebase is the monotonic-ish source the clock paces its sleeps against
(and, in real time, anchors its OSC timetags to). Two choices:
MonotonicTimebase(default) — the OS monotonic clock. Events are sent as NTP-timetagged bundles; simple, drift between the client and server clocks is small but real.SampleClockTimebase— seconds derived from the server's sample counter (sample() / sample_rate). The client paces against the server's own clock, and the Server emits via/sched <absolute_sample>instead of a wall-clock timetag, so there is no inter-clock drift and timing is exact at the sample.sampleis any callable returning the current sample count (e.g.Clausters.clockorShmClient.clock).
A timebase is callable (tb() == tb.now()) so it also works as the plain
timebase callable the clock accepts.
SampleClockTimebase
class SampleClockTimebase(Timebase)
Seconds from the server's sample clock: now = sample() / sample_rate.
SampleClockTimebase.sample_at
def sample_at(seconds: float) -> int
The absolute sample for a time in this timebase's seconds (the core's seconds->samples conversion, ties to even).
clausters.base.main
Execution context (port of sc3/base/main.py).
A small singleton holding what routines and clocks need to find each other. The
client deliberately avoids global state: the server and clock are passed
explicitly, so RT and NRT can coexist in one script. The one piece of ambient
context that must exist — "which routine is running right now" — is
thread-local, so several TempoClock threads (and a live RT clock next to
an offline NRT render) never clobber each other's current thread.
The optional default_clock is convenience-only sugar (None by default,
never required). Anything that can be passed explicitly, is.
Main
class Main()
Main.current_tt
@property
def current_tt()
The routine being resumed on this thread (thread-local), set by
the clock around each wake, so Server can read the running routine's
exact logical beat. None outside a routine.
Main.seed
def seed(value=None)
Seeds the context RNG (None reseeds from entropy). Returns the seed actually used so a session can be reproduced.
Main.rng
@property
def rng()
The context value stream (clausters._native.RngStream, the shared
core generator — reproducible across client languages). Created lazily,
seeded from entropy unless seed was called.
clausters.seq.event
Events (port of sc3/seq/event.py, adapted to Clausters).
An Event is a dict of parameters with sensible defaults that knows how
to play itself against a Server. The default
'note' event creates a synth and schedules its release. Timing is the
clock's job: an event emits at the running routine's exact logical beat (via
server.send_bundle), and the player advances by the event's delta.
By default a note frees its synth after sustain (/n_free) rather
than closing a gate — unless has_gate is set, in which case it sends
gate 0 (for defs whose env_gen envelope has a release node and a
doneAction that frees the synth once the release finishes).
DEFAULTS
Default parameters merged into every Event. type selects behaviour
(note or rest); instrument is the def name; dur is the beats
to the next event, scaled by legato/stretch into the sounding time;
amp is linear amplitude; add_action/target place the synth in the
node tree; has_gate picks release-by-free vs gate 0; and
octave/root/scale define the pitch space that degree indexes.
Event
class Event(dict)
A note event: a dict of parameters that knows how to play itself.
Built from DEFAULTS overlaid with whatever you pass, exactly like a dict
(Event(freq=440, amp=0.2)), so unknown keys are simply stored. The keys
split in two: a fixed reserved set drives timing and structure (dur,
legato, stretch, add_action/target, the pitch keys, ...) and
is never sent to the synth; every other numeric key is forwarded as a synth
control.
The derived quantities compute the values actually used: midinote and
freq resolve pitch (an explicit freq wins, else midinote, else
degree within octave/root/scale), delta is the beats to
the next event and sustain the beats the synth sounds. play renders the
event on a destination -- a Server or a MIDI destination.
Event.midinote
def midinote() -> float
The MIDI note number this event sounds (the value freq derives
from). An explicit freq (Hz) is inverted via cpsmidi; otherwise it
comes from midinote, or degree/octave/root/scale.
Event.freq
def freq() -> float
The frequency in Hz this event sounds: an explicit freq if given,
otherwise midinote converted through the native midicps.
Event.delta
def delta() -> float
Beats until the next event: an explicit delta key if given,
otherwise dur * stretch. As in SuperCollider, the key overrides the
calculation when it is present.
Event.sustain
def sustain() -> float
Beats the synth sounds: an explicit sustain key if given,
otherwise dur * legato * stretch. As in SuperCollider, the key
overrides the calculation when it is present.
Event.play
def play(destination)
Realize this event on destination (double dispatch): the OSC
Server turns it into /s_new + release,
a MIDI destination into note on/off — without the clock or routine
knowing which. Returns whatever the destination's play_event does
(the synth node id for OSC, None for a rest or MIDI).
rest
def rest(dur: float = 1.0) -> Event
A silent Event that sounds nothing but still advances time by dur
beats -- a rest in the sequence.
clausters.seq.pattern
Patterns (port of sc3/seq/pattern.py + patterns/).
A Pattern is a reusable, lazy description of a value sequence; iterating
it yields the values (a fresh stream each time). Value patterns (Pseq,
Pwhite, …) feed Pbind, which combines per-key value patterns into a
stream of Event objects. An event pattern is
played on a clock with Pattern.play (see
EventStreamPlayer).
Patterns are plain Python generators under the hood, so nesting and composition are natural; a sub-pattern used as a value is embedded (iterated) in place.
Pattern
class Pattern()
Pattern.stream
def stream()
A Stream over this pattern.
Pattern.play
def play(clock, server, quant=None)
Play this (event) pattern on clock, sending to server.
Pconst
class Pconst(Pattern)
A constant value, length times (infinite by default).
Pseq
class Pseq(Pattern)
The items in order, repeats times (sub-patterns are embedded).
Pser
class Pser(Pattern)
The items in order, yielding exactly length values (cycling).
Prand
class Prand(Pattern)
Random items, length values, drawn from the random context (the
running routine's generator, or the root outside one — see
clausters.base.rand): main.seed(n) reproduces the choices along with
everything else in the script. There is no per-pattern seed — independent
seeds would break whole-script consistency.
Pwhite
class Pwhite(Pattern)
Uniform random numbers in [lo, hi), length values, drawn from
the random context (the running routine's generator, or the root
outside one — see clausters.base.rand): main.seed(n) reproduces the
sequence along with everything else in the script. There is no per-pattern
seed — independent seeds would break whole-script consistency.
Pseries
class Pseries(Pattern)
Arithmetic series start, start+step, … (length values).
Pgeom
class Pgeom(Pattern)
Geometric series start, start*grow, … (length values).
Pfunc
class Pfunc(Pattern)
Calls func() for each value (length values).
Pn
class Pn(Pattern)
Repeats pattern n times.
Pbind
class Pbind(Pattern)
Binds keys to value patterns; yields an Event per step, stopping
when any key's stream stops. Constant values are held; sub-patterns advance
one value per event.
clausters.seq.timeline
Static timelines and a playhead (random-access sequencing).
The counterpart to the generative layer (clausters.base.stream.Routine,
clausters.seq.pattern.Pbind). A Routine is a forward-only generator: its
musical state lives in the generator's locals, so it cannot be seeked. A
Timeline is the opposite — a static, editable list of timed items kept
sorted by beat, with random access by time (index_at, range). That is what
makes DAW-style transport controls possible: a Playhead scans the timeline
forward as the clock advances, and play / stop / locate / loop re-seek the
cursor by time at the boundaries.
An item is anything that can render itself on a destination — it has a
play(destination) method. clausters.seq.event.Event already is one (it plays
a note on a Server or a MidiServer — the same double dispatch the patterns
use), so a timeline of Events renders to OSC or MIDI by which destination
the playhead holds, exactly like the rest of the client. OscEvent and
MidiEvent wrap a raw OSC message or MIDI bytes, so a timeline can also be a
plain, editable OSC/MIDI score.
This layer is client-side: each playhead has its own local transport over
its own timeline, and several clients phase-align through quant and the shared
/transport grid (see the timing docs). A server-broadcast transport (one
conductor's play/stop/locate driving every client) can layer on top later
without changing this.
OscEvent
class OscEvent()
A raw OSC message (addr, *args) as a timeline item: rendering it sends
the message at the playhead's current logical beat through a Server.
MidiEvent
class MidiEvent()
Raw MIDI bytes as a timeline item: rendering it emits the message at the
playhead's current logical beat through a MidiServer.
Timeline
class Timeline()
A static, editable sequence of (beat, item) kept sorted by beat, with
random access by time.
The structure a Playhead plays and a transport seeks. Items are kept in
beat order (a stable insert preserves the order of items added at the same
beat, e.g. a note-off before a re-trigger). Edit it freely — add,
remove, move, clear — and read ranges of it by time — index_at,
range, at.
add returns a handle (an opaque entry) you pass back to remove/move, so
edits stay correct as other inserts shift indices.
Arguments:
items- optional iterable of(beat, item)to seed the timeline.
Timeline.add
def add(beat, item)
Insert item at beat (kept sorted); returns an entry handle.
Timeline.remove
def remove(entry)
Remove an entry returned by add (by identity).
Timeline.move
def move(entry, new_beat)
Move an entry to new_beat, keeping the timeline sorted.
Timeline.clear
def clear()
Drop every item.
Timeline.quantize
def quantize(grid)
Snap every placement to the nearest multiple of grid (beats):
each entry's beat moves to the grid line, durations untouched. The
data-side counterpart of the piano-roll's q gesture (which quantizes
in the view when the GUI runs standalone). A zero/negative grid is a
no-op. Returns the timeline.
Timeline.index_at
def index_at(beat) -> int
The cursor (index) of the first item at or after beat — the seek
primitive a playhead uses to start or locate.
Timeline.range
def range(t0, t1) -> list
The (beat, item) pairs in the half-open beat window [t0, t1).
Timeline.at
def at(beat) -> list
The items exactly at beat.
Timeline.duration
def duration() -> float
The beat of the last item (0.0 when empty) — the timeline's length.
Timeline.from_pattern
@classmethod
def from_pattern(cls, pattern, dur=None, tempo: float = 1.0) -> "Timeline"
Bounce an event pattern (a Pbind) into a static timeline by running
it offline and recording each event at its logical beat. dur bounds
an open-ended pattern (beats); None drains a finite one fully.
Playhead
class Playhead()
A transport over a Timeline: play / stop / locate / loop, and a song
position.
The playhead scans the timeline forward as a clock advances, rendering each
item on a destination (a Server for OSC, a MidiServer for MIDI — the
same seam as the rest of the client). The forward scan is what play runs;
the random access lives at the boundaries — play(at=…) and locate(beat)
re-seek the cursor by time, which a forward-only routine could never do.
Timing rides the clock's logical time like everything else, so a playhead
inherits quant (start on a bar), lock_to (sample-exact) and
join_transport (the shared grid) for free.
Arguments:
timeline- theTimelineto play.clock- theTempoClockthat drives it (start it for live playback;renderit for offline).destination- where items go — aServeror aMidiServer.
Playhead.play
def play(at: float = 0.0, quant=None)
Start (or restart) playback from beat at, snapping the start to a
quant beat boundary of the clock's grid (a bar). Re-seeks the cursor
to at by time, so it works as a locate-and-play.
Playhead.stop
def stop()
Halt the playhead. Items already rendered keep sounding (their releases are scheduled); no further items are played.
Playhead.locate
def locate(beat: float)
Seek the playhead to beat. While playing, restarts the scan from
there (random access); while stopped, just sets where the next play
begins.
Playhead.loop
def loop(start: float, end: float)
Loop the half-open beat window [start, end): when the scan reaches
end it wraps back to start. Set before or during play.
Playhead.unloop
def unloop()
Stop looping; the scan plays through to the end.
Playhead.position
def position() -> float
The current song position, in beats. Interpolated from the clock between items while playing; the start/last-seek beat while stopped.
Playhead.follow_transport
def follow_transport(server, recv=None, quant=None)
Make this playhead obey a server's shared transport: when a
conductor calls transport_play / transport_stop /
transport_locate on the server, the server broadcasts the new state and
this playhead rolls / halts / seeks to match — so several clients run in
lockstep on the shared grid.
It registers /notify (so the server's /transport.reply pushes
arrive) and an clausters.responders.OscFunc on /transport.reply
that drives this playhead, then applies the current state once. Pass a
started clausters.base.OscReceiver as recv (it must be subscribable
on its own socket); one is created if omitted. quant snaps each
rolling start to a beat boundary of the shared grid, so all followers
land together. Release with unfollow_transport. Returns self.
Beat-aligned in plain wall-clock mode; sample-exact when the clock is
also lock_to the server (see the timing docs).
Playhead.unfollow_transport
def unfollow_transport()
Stop following a server transport (see follow_transport): frees the
responder and closes the receiver it created. Returns self.
clausters.defs.signals
Faust Signal API as composable, lowercase callables.
The user-facing way to build a FaustDef. Each function here is a small
lowercase callable (a design choice that keeps graph-building fluent in
Python) that returns a Signal; composing signals with Python operators
or these functions builds the JSON signal tree the server's /d_faust
consumes ({"signals": [ <node>, … ]}, one node per output — see the
server's faust::signals). The same lowercase pattern will return UGen-graph
nodes for SynthDefs later.
A Signal is an AbstractObject, so
hslider("freq", …).sin() * 0.2 and sin(x) * 0.2 both compose the graph.
Plain numbers are constants (Faust int/real); explicit feedback uses
recursion/self_ (one sample of delay), and input(n) reads audio
input n.
Reserved controls in and out (set with /s_new … "in" b "out" b)
choose the input/output buses; they are added by the server, not declared here.
Signal
class Signal(AbstractObject)
One node of a Faust signal graph (one output). Wrap a number to make a constant; compose with operators or the module functions.
signal
def signal(x) -> Signal
Coerces a number or Signal into a Signal.
input
def input(index: int = 0) -> Signal
Audio input index (Faust CsigInput).
self_
def self_() -> Signal
The one-sample-delayed output of the enclosing recursion.
recursion
def recursion(body) -> Signal
Single feedback: body is a signal that may reference self_.
rec
def rec(fn) -> Signal
Pythonic feedback: fn(s) builds the body from its own delayed
output s (sugar over recursion/self_).
delay
def delay(x, n) -> Signal
x delayed by n samples (Faust CsigDelay).
fconst
def fconst(ctype, name, file="") -> Signal
A foreign constant: a scalar the server resolves once, at def-compile
time, from its runtime (Faust CsigFConst). ctype is "int" or
"real", name the runtime symbol, file the include that declares
it. The building block of sr -- prefer that helper for sample rate.
fvar
def fvar(ctype, name, file="") -> Signal
A foreign variable: like fconst but re-read each block
(Faust CsigFVar).
sr
def sr() -> Signal
The engine's sample rate as a Signal, read from the server at
def-compile time -- the port of Faust's ma.SR.
Use this instead of baking a Python SR constant: a def built with
sr is correct at whatever rate the server (or NRT renderer) actually
runs, e.g. when normalizing a frequency (freq / sr()) or cooking filter
coefficients. It reproduces ma.SR exactly, including the stdlib's
[1, 192000] clamp around the raw fSamplingFreq constant.
abs
noqa: A001 — Signal API name, by design
min
noqa: A001
max
noqa: A001
pow
noqa: A001
TAU
2*PI; Faust has no ma.TAU, this is just the literal
clausters.defs.boxes
Faust Box API as composable, lowercase callables.
The box counterpart of clausters.defs.signals, and a complete def-building
API in its own right: each function returns a Box and composing boxes
builds the JSON box tree the server's /d_faust consumes (see the
server's faust::boxes for the schema). Boxes are Faust's point-free
algebra — seq/par/split/merge/rec compose whole
processors by their input/output arities. Where signals describes
one output at a time referentially (input(n)), boxes describe
multi-channel blocks that plug into each other — the natural shape for
routing, chains, and anything conceived as units with inputs and outputs.
On top of the algebra, faust compiles any Faust expression into a
Box that composes like a primitive. That addition puts the whole Faust
library ecosystem (os.osc, fi.lowpass, re., pm., ...) inside
the same algebra without transcribing anything: library functions become
boxes among boxes.
Choosing a form: a fixed processing chain written top to bottom often reads
best as plain Faust (FaustDef.from_source); graphs assembled one output
at a time from arithmetic and feedback suit clausters.defs.signals.
Regular banks ("N copies with index-dependent parameters") are best written
in Faust itself — par(i, N, ...), widget labels with %i, ba.take
— and parametrized from Python by splicing N and lists through faust's
eval arguments. Boxes shine when the graph is conceived as composed
processors, when its structure is decided by Python data, and whenever
library DSP has to mix with Python-built pieces.
Two stages of application, kept separate on purpose:
faust("fi.lowpass", 3)— arguments tofaustare evaluation-stage: spliced into the Faust source text (fi.lowpass(3)), where structural parameters like a filter order must live.fi_lp(cutoff, wire())— arguments to aBoxcall are composition-stage: boxes wired as the box's signal inputs, sugar forseq(par(cutoff, wire()), fi_lp).
The wire rule (the big difference from signals): each wire is a
distinct input. There is no referential input(n) here — two wires in
two positions are two input channels. Reusing the same wire() (or
cut()) object in more than one position is almost always a mistake, and
FaustDef.from_box rejects it; route explicitly with split, or write that
stretch inside a faust fragment (_ <: ...). Every other box value can
be reused freely: a repeated subexpression is computed once (the server
shares identical subtrees).
Reserved controls in and out (set with /s_new ... "in" b "out" b)
choose the input/output buses; they are added by the server, not declared
here.
Box
class Box(AbstractObject)
One node of a Faust box expression. Wrap a number to make a constant; compose with operators, the module functions, or by calling the box.
num_inputs/num_outputs are the box's signal arity as computed on the
client from the composition rules; None when unknown (a faust
fragment without ins=/outs=). The server does not read them — a
real mismatch is reported by Faust itself when the def compiles.
Box.__call__
def __call__(*args) -> "Box"
Applies boxes to this box's inputs: f(a, b) is
seq(par(a, b), f) (with one argument, seq(a, f)) — Faust's
partial-application style written as a call. The arguments must cover
all the box's inputs; use wire for the ones left open.
Box.__getitem__
def __getitem__(index) -> "Box"
Selects one output channel: st[0] is seq(st, par(wire, cut, ...)). Needs a known num_outputs (pass outs= to faust for
fragments). The selected fragment is shared, not recomputed, when
several channels of the same box value are used.
Box.outs
def outs() -> tuple
All output channels as a tuple: l, r = st.outs().
box
def box(x) -> Box
Coerces a number or Box into a Box (numbers are constants:
Python int -> Faust int, float -> real).
wire
def wire() -> Box
The identity box _: one open signal input. Every call is a new,
distinct input — reusing one wire object in two positions is an error
(see the module docs for the rule and the escapes).
cut
def cut() -> Box
The ! box: swallows one signal. Like wire, each call is a new,
distinct position.
seq
def seq(*items) -> Box
Sequential composition a : b : ... (needs at least 2).
par
def par(*items) -> Box
Parallel composition a , b , ... (needs at least 2).
split
def split(*items) -> Box
Split composition a <: b (needs at least 2).
merge
def merge(*items) -> Box
Merge composition a :> b — excess outputs are summed (needs at
least 2).
rec
def rec(a, b) -> Box
Recursive composition a ~ b: b feeds a's first inputs back
from a's first outputs, with one implicit sample of delay. Point-free —
for the rec(lambda s: ...) style, build the loop in a faust
fragment or with clausters.defs.signals instead.
faust
def faust(src: str,
*eval_args,
defs: str = "",
ins: int = None,
outs: int = None) -> Box
A Faust expression compiled into a box — the door to the Faust
libraries (stdfaust.lib is imported for you). The resulting box is
indistinguishable from a primitive: compose it, call it, do arithmetic
on it.
eval_args are evaluation-stage arguments, spliced into the source
text as Faust application — faust("fi.lowpass", 3) compiles
fi.lowpass(3). That is where structural parameters (a filter order,
a table size, a list of coefficients) must go; they cannot travel as
signals. Formatting: int/float as literals, a list/tuple as a
Faust list (a, b, c), a string verbatim (for expressions or library
functions passed as arguments). Signal inputs are then wired by calling
the box:
lp = faust("fi.lowpass", 3) # fi.lowpass(3): inputs (fc, x)
y = lp(cutoff, wire())
defs prepends auxiliary Faust definitions (helper functions, pattern
matching) to the generated program. ins/outs declare the
fragment's signal arity — only the Faust compiler knows it, so pass
outs= when you need channel selection (st[0] / .outs()); a
wrong declaration is caught by Faust when the def compiles.
Each distinct generated source is compiled (and cached) separately on the server; reusing one fragment value many times compiles and computes it once.
delay
def delay(x, n) -> Box
x delayed by n samples (Faust @).
delay1
def delay1(x) -> Box
One sample of delay (Faust '), sugar for delay(x, 1).
fconst
def fconst(ctype, name, file="") -> Box
A foreign constant: a scalar the server resolves once, at
def-compile time, from its runtime. ctype is "int" or "real".
The building block of sr -- prefer that helper for sample rate.
fvar
def fvar(ctype, name, file="") -> Box
A foreign variable: like fconst but re-read each block.
sr
def sr() -> Box
The engine's sample rate as a Box, read from the server at
def-compile time -- the port of Faust's ma.SR, with the stdlib's
[1, 192000] clamp. Use this instead of baking a Python constant so
the def is correct at whatever rate the engine or NRT renderer runs.
abs
noqa: A001 — box schema name, by design
round
noqa: A001
min
noqa: A001
max
noqa: A001
pow
noqa: A001
TAU
2*PI
waveform
def waveform(values) -> Box
A fixed table; outputs the (size, content) pair, ready to stand in
for rdtable/rwtable's leading (size, init) boxes.
rdtable
def rdtable(*args) -> Box
rdtable(size, init, ridx) — or rdtable(wf, ridx) with a
waveform standing in for (size, init).
rwtable
def rwtable(*args) -> Box
rwtable(size, init, widx, wsig, ridx) — or the 4-argument form
with a waveform up front.
check_wires
def check_wires(node)
Rejects a tree where the same wire/cut object appears in more
than one position. Each wire is a distinct input in the box algebra;
reusing one object almost always means the graph silently reads more bus
channels than intended. Duplicating any other box value is fine (shared
subtrees are computed once).
clausters.defs.ugens
UGen graph as composable, lowercase callables (port of the UGen side of
sc3/synth, adapted to Clausters' server format).
The UGen-graph counterpart of clausters.defs.signals: each function here
is a small lowercase callable that returns a Ugen node (one
output); composing nodes with Python operators or these functions builds the
graph a SynthDef serializes into the JSON
SynthDefSpec the server's /d_recv consumes ({"controls": […], "ugens": […]} — see the server's synthdef module).
Instance-based, no global build context. Unlike sclang — where SynthDef
build relies on a thread-global "current graph" that every UGen.new mutates
(UGen.buildSynthDef) — the graph here is the tree of composed objects: a
Ugen's inputs hold its operands directly, and the SynthDef walks
that tree to emit the spec. Nothing is global, so several defs can be built
concurrently.
The server's UGen set is deliberately focused: oscillators/sources
(sin_osc, impulse, white_noise, rand), bus and buffer I/O
(in_/in_ctl, out/replace_out, play_buf/buf_rd),
feedback (local_in/local_out), the env_gen envelope, the lag/
var_lag smoothers, the demand pair (dseq/demand), and the fused
mul_add/sum3/sum4. Maths works: + - * / map to the
Add/Sub/Mul/Div kinds and every other operator or method
(%, min/max, comparisons, .sin(), .midicps(),
.distort() …) composes a generic BinaryOpUGen/UnaryOpUGen carrying
the operator name — the same op the value side computes, so the two agree
bit-for-bit. Reach for a Faust def (clausters.defs.signals) only for genuinely
custom per-sample DSP (recursion, tables, sample-accurate feedback).
Each UGen output carries a rate (ir/kr/ar/dr); it defaults
per kind and can be set with Ugen.at_rate. Controls carry a type and an
optional lag — see control/Control.
Envelopes are the Env breakpoint builder plus the env_gen callable, which
serialize to the EnvGen UGen's flat input list.
Reserved controls in and out (the input/output buses, set with
/s_new … "in" b "out" b) are added by the server, not declared here.
Ugen
class Ugen(_Node)
One UGen node (one output). kind is a server UGen name; inputs
is a list of operands, each a Ugen, a Control, or a plain
number (a constant). Build them with the lowercase callables below rather
than directly.
rate is the optional output calculation rate ("ir"/"kr"/
"ar"/"dr"); None lets the server pick the kind's default (ar
for signal UGens). Set it fluently with at_rate. op is the operator
name carried by the generic BinaryOpUGen/UnaryOpUGen (S3), e.g.
"mul" / "midicps"; None for every other kind. label is the
string tag the side-effect UGens carry — send_reply's command name and
poll's label; None for every other kind. static is a dict of any
other non-signal fields (the spectral UGens' fft_size/hop/
wintype); it merges verbatim into the serialized UGen spec.
Ugen.at_rate
def at_rate(rate: str) -> "Ugen"
Set this UGen's output rate ("ir"/"kr"/"ar"/"dr") and
return it, e.g. sin_osc(5.0).at_rate("kr") for a control-rate LFO.
Control
class Control(_Node)
A named control (a /s_new//n_set parameter) with a default and an
optional type and lag (S2), mirroring the server's control types:
rate="tr"— a trigger: a/n_setholds for one block, then the server resets it to 0 (drives anenv_gengate, a sample-and-hold).rate="ir"— a scalar: read once at init and frozen; a later/n_setis ignored. Asirit may feed anirinput (rand, buffer-info UGens).lag(seconds) — smooth akrcontrol's changes with an implicit one-pole (alag/var_lagUGen the server inserts);lag_downgives a separate downward time.
Used as a UGen input it serializes to a {"control": index} reference;
the SynthDef gathers the controls a graph references, in first-seen
order.
control
def control(name, default=0.0, rate=None, lag=None, lag_down=None) -> Control
A named control (/s_new//n_set parameter). rate is its type
("tr" trigger, "ir" scalar, or the default kr); lag (with an
optional lag_down) smooths a kr control. See Control.
sin_osc
def sin_osc(freq=440.0) -> Ugen
Sine by f64 phase accumulation, starting at phase 0.
impulse
def impulse(freq=1.0) -> Ugen
A single-sample 1.0 every freq Hz, 0.0 between (freq 0 =
one impulse then silence). The first sample is always an impulse.
white_noise
def white_noise() -> Ugen
Uniform white noise in ±1.
in_
def in_(bus=0.0) -> Ugen
Reads an audio bus (sampled per block).
in_ctl
def in_ctl(bus=0.0) -> Ugen
Reads a control-bus value, constant over the block.
out_ctl
def out_ctl(bus, signal) -> Ugen
Writes signal's latest per-block value to a control bus — the
write side of in_ctl, so a node reading that bus (via /n_map or
in_ctl) tracks it. Passes signal through as its output.
out
def out(bus, signal) -> Ugen
Sums signal into the audio bus (output happens only here).
replace_out
def replace_out(bus, signal) -> Ugen
Overwrites the audio bus with signal instead of summing.
send_trig
def send_trig(trig, id=0, value=0.0) -> Ugen
On each trigger of trig, sends /tr nodeID id value to /notify
clients. Output is silence; pass it as a SynthDef root.
send_reply
def send_reply(trig, *values, cmd="/reply", reply_id=-1) -> Ugen
On each trigger of trig, sends the OSC message cmd nodeID reply_id value… to /notify clients (cmd defaults to /reply). values
is the arbitrary-arity payload. Output is silence; pass it as a SynthDef
root.
poll
def poll(trig, signal, label="poll", trig_id=-1) -> Ugen
On each trigger of trig, posts label: value (the signal
value) to the server console and, when trig_id >= 0, also sends /tr nodeID trig_id value. signal passes through the output, so poll
can sit mid-chain.
fft
def fft(source, active=1.0, *, fft_size=1024, hop=0.5, wintype=0) -> Ugen
Opens a spectral chain: windows source (an audio signal) and
transforms it to a spectral frame once per hop. active > 0 runs the
transform, <= 0 holds. fft_size is the window size (a power of two:
256/512/1024/2048/4096), hop the fraction of the window between frames,
wintype the window (a clausters._native.Window: 0 Hann, 1 sine, …).
These size the transform, so they are static fields given only here — the
server propagates them to the rest of the chain. The window is also settable
live with Server.u_cmd. Feed the result to a pv_* filter or ifft.
ifft
def ifft(chain) -> Ugen
Closes a spectral chain: inverse-transforms each fresh frame and
overlap-adds it back to audio (window-normalized, so a bare fft->ifft
reconstructs at unity gain, delayed by one window). chain is the output
of an fft or a pv_* filter.
pv_mag_above
def pv_mag_above(chain, threshold) -> Ugen
Passes only the bins whose magnitude is above threshold, zeroing
the rest. chain comes from fft or another pv_*.
pv_mag_below
def pv_mag_below(chain, threshold) -> Ugen
Passes only the bins whose magnitude is below threshold.
pv_brick_wall
def pv_brick_wall(chain, wipe) -> Ugen
Brick-wall band limit: wipe > 0 zeroes the top fraction of bins (a low
pass), wipe < 0 the bottom (a high pass), 0 passes everything
(wipe in -1..1).
play_buf
def play_buf(bufnum, chan=0.0, rate=1.0, loop=0.0) -> Ugen
Mono buffer player with linear interpolation; rate is frames per
output sample (1.0 = server rate).
buf_rd
def buf_rd(bufnum, chan, phase, loop=0.0) -> Ugen
Reads a buffer at a phase signal in frames (linear interpolation).
local_in
def local_in(channel=0.0) -> Ugen
Reads synth-private feedback channel channel (a constant); pairs with
local_out for one-block feedback. LocalIn must precede its
LocalOut — the SynthDef's topological order does that as long
as the output graph reaches the local_in before the local_out.
local_out
def local_out(channel, signal) -> Ugen
Writes signal into synth-private feedback channel channel (a
constant); also passes signal through as its output (so it can be a
SynthDef output to keep the write in the graph).
mul_add
def mul_add(a, b, c) -> Ugen
a*b + c in one UGen (the multiply-accumulate the server fuses). The
plain expression a * b + c builds the same value with two op UGens; this
is the fused equivalent.
sum3
def sum3(a, b, c) -> Ugen
a + b + c in one UGen.
sum4
def sum4(a, b, c, d) -> Ugen
a + b + c + d in one UGen.
lag
def lag(signal, time=0.1) -> Ugen
One-pole smoother: signal lagged over time seconds (symmetric);
time 0 passes through. The same UGen the server inserts for a lagged
control -- use it directly to smooth any signal.
var_lag
def var_lag(signal, up=0.1, down=0.1) -> Ugen
One-pole smoother with separate rise (up) and fall (down) times.
sample_rate
def sample_rate() -> Ugen
The engine sample rate in Hz, computed once at init (ir).
buf_frames
def buf_frames(bufnum) -> Ugen
The number of frames in a buffer, block-constant (kr).
rand
def rand(lo=0.0, hi=1.0) -> Ugen
One uniform random value in [lo, hi), drawn once at synth init and
held for the node's life (ir); lo/hi must be constants or ir.
dseq
def dseq(values, repeats=0.0) -> Ugen
A demand-rate sequence source: yields values in order, repeats
times (0 loops forever), then signals end-of-stream. Only valid as a
demand source.
demand
def demand(trig, reset, source) -> Ugen
Demand driver: pulls the next value from a demand source (a dseq)
on each rising edge of trig and holds it between triggers; a rising
reset restarts the source.
DoneAction
class DoneAction()
The action env_gen takes when its envelope finishes — scsynth's full
done-action set (0-15). Pass one as done_action. The relative actions
(3-13, 15) act on the synth's neighbours in its group; a paused node is
resumed with Server.run (/n_run).
NONE
Do nothing; the envelope just holds its final level.
PAUSE_SELF
Pause the synth (stops processing; it stays in the tree). Resume with
Server.run.
FREE_SELF
Free the synth — the usual choice for a one-shot or a released note.
FREE_SELF_AND_PREV
Free the synth and the preceding node.
FREE_SELF_AND_NEXT
Free the synth and the following node.
FREE_SELF_AND_FREE_ALL_IN_PREV
Free the synth; if the preceding node is a group, free all its children.
FREE_SELF_AND_FREE_ALL_IN_NEXT
Free the synth; if the following node is a group, free all its children.
FREE_SELF_TO_HEAD
Free the synth and every preceding node in its group.
FREE_SELF_TO_TAIL
Free the synth and every following node in its group.
FREE_SELF_PAUSE_PREV
Free the synth and pause the preceding node.
FREE_SELF_PAUSE_NEXT
Free the synth and pause the following node.
FREE_SELF_AND_DEEP_FREE_PREV
Free the synth; if the preceding node is a group, deep-free it.
FREE_SELF_AND_DEEP_FREE_NEXT
Free the synth; if the following node is a group, deep-free it.
FREE_ALL_IN_GROUP
Free the synth and every other node in its group.
FREE_GROUP
Free the synth's whole enclosing group.
FREE_SELF_RESUME_NEXT
Free the synth and resume (unpause) the following node.
Env
class Env()
A breakpoint envelope: levels (one more than times), the segment
times in seconds, and a curve per segment (a shape name, a numeric
curvature, or a list of either, one per segment).
release_node is the index into levels where the envelope sustains while
the gate is held (None = no sustain, plays straight through). Feed it to
env_gen. Modelled on SuperCollider's Env; the shapes match the
server's EnvGen.
Env.perc
@classmethod
def perc(cls, attack=0.01, release=1.0, level=1.0, curve=-4.0)
A fixed-duration percussive hit: 0 -> level -> 0. No sustain, so a
rising gate triggers the whole thing.
Env.adsr
@classmethod
def adsr(cls,
attack=0.01,
decay=0.3,
sustain=0.5,
release=1.0,
peak=1.0,
curve=-4.0)
The classic attack/decay/sustain/release. Sustains at peak * sustain (the release node) until the gate falls.
Env.asr
@classmethod
def asr(cls, attack=0.01, sustain=1.0, release=1.0, curve=-4.0)
Attack to sustain, hold there until release, then fall to 0.
Env.step
@classmethod
def step(cls, levels, times, release_node=None, loop_node=None)
A step sequence: each value held for its duration — levels
and times have the same length, unlike the raw constructor
(Env.step([0, 1], [0.5, 0.5]) holds 0 for 0.5, then 1 for 0.5).
This is the conceptual interface of a value-with-duration sequence;
like SuperCollider's Env.step, it is realized over the raw
initial-level + (target, duration) form by prepending the first level
with the "step" shape (which jumps to each segment's target at its
start).
Env.to_inputs
def to_inputs()
The envelope as the flat number list env_gen appends after its
fixed inputs: initLevel, numSegments, releaseNode, loopNode then
target, duration, shape, curve per segment.
env_gen
def env_gen(env: Env,
gate=1.0,
level_scale=1.0,
level_bias=0.0,
time_scale=1.0,
done_action=DoneAction.NONE) -> Ugen
Plays an Env. A rising gate (re)triggers from the start; while the
gate is held the envelope sustains at the env's release node; when the gate
falls it plays the release segments. level_scale/level_bias affine the
output, time_scale stretches every segment. done_action is taken when
the envelope finishes (see DoneAction).
env_to_points
def env_to_points(env, *, time_at: float = 0.0) -> list
An Env (levels / segment times / curves) as the flat bpf breakpoint
list [t, v, shape, curve, ...], with absolute times starting at
time_at. The last point carries a linear placeholder (no segment leaves
it). Feed the result to the bpf widget or to a live points set.
points_to_env
def points_to_env(points, *, time_at: float = 0.0, **env_kwargs)
A bpf breakpoint list — the flat t v shape curve ... quads a
"points" event carries — as an Env: absolute times become segment
durations and each segment keeps its shape (the numeric curvature for the
custom shape, the shape name otherwise).
A first breakpoint later than time_at (default 0.0) is a drawn
initial delay, realized as a leading hold segment (the first level held
for that duration) so what was drawn and what plays stay identical. Extra
keywords (release_node, loop_node) pass through to Env.
clausters.defs.faustdef
FaustDef: a named Faust definition ready for /d_faust.
Wraps a graph built with clausters.defs.signals (the signal tree
form), one built with clausters.defs.boxes (the box tree form — a Box,
or a raw dict for machine-generated trees), or a Faust source string: the
three payloads the server's /d_faust accepts, on equal footing (it sniffs
which by the first byte; see the server's faust module). They are three ways
of writing Faust, not a main road and two detours — pick the one that says what
you mean. Sending and instantiating is the
Server's job; this only builds the payload and
exposes the declared control names (UI labels), plus the reserved in/out
bus controls the server adds.
FaustDef
class FaustDef()
FaustDef.from_signals
@classmethod
def from_signals(cls, name: str, *outputs) -> "FaustDef"
One output per argument (Signal or number).
FaustDef.from_box
@classmethod
def from_box(cls, name: str, box) -> "FaustDef"
From a clausters.defs.boxes.Box (or a raw box-tree dict, kept
for machine-generated graphs). A Box is checked for the one silent
mistake the box algebra allows: reusing the same wire()/cut()
object in two positions (each wire is a distinct input).
FaustDef.dump_def
def dump_def() -> str
The def serialized to text -- the /d_faust <name> <payload> wire
payload: a JSON signal/box tree, or the Faust source string verbatim.
Useful to inspect the built graph before sending it.
FaustDef.control_names
def control_names() -> list[str]
The control names this def declares (UI labels), in tree order.
The reserved in/out bus controls (added by the server) are not
included; see reserved.
reserved
bus-selecting controls every Faust synth also accepts.
clausters.defs.synthdef
SynthDef: a named UGen graph ready for /d_recv (port of the SynthDef
side of sc3/synth, adapted to Clausters' JSON SynthDefSpec).
The UGen-graph counterpart of FaustDef: it
wraps one or more output Ugen nodes (built with
the lowercase callables in clausters.defs.ugens), walks the graph and
serializes the {"name", "controls", "ugens"} JSON the server compiles.
from clausters.defs import SynthDef, control, sin_osc, out
freq = control("freq", 440.0)
amp = control("amp", 0.2)
sig = sin_osc(freq) * amp
sdef = SynthDef("beep", out(0.0, sig), out(1.0, sig)) # stereo
server.add_synthdef(sdef) # /d_recv
Instance-based build (no globals). The walk is a plain post-order traversal
of the output nodes: a UGen is emitted only after its inputs, so the ugens
list is topologically ordered (every {"ugen": w} reference points at an
earlier node, as the server requires) and shared sub-graphs are emitted once
(dedup by object identity). Controls are gathered in first-seen order; reusing
the same name with a different default is an error. No thread-global build
context is touched, so defs build concurrently.
SynthDef
class SynthDef()
A named UGen graph. Pass the graph's root UGens — normally the
outputs (out(...) / replace_out(...), and any local_out(...) to
keep feedback writes in the graph), but a root can equally be a side-effect
UGen with no audio output (send_trig(...) / send_reply(...) /
poll(...)): a def may consist only of those and no out at all. Every
root must be a UGen; a def needs at least one (the server rejects an empty
graph). A def with no output UGen is simply silent on the server.
SynthDef.spec
def spec() -> dict
The SynthDefSpec dict the server's /d_recv compiles.
SynthDef.dump_def
def dump_def() -> str
The def serialized to text -- the /d_recv wire payload, the JSON
SynthDefSpec (see spec). Useful to inspect the built graph before
sending it.
SynthDef.control_names
def control_names() -> list[str]
The control names this def declares, in spec order (parallels
FaustDef.control_names).
clausters.defs.graphdef
GraphDef: a named node-graph "program" ready for /d_graph.
Where SynthDef and
FaustDef each describe a single synthesis
node, a GraphDef describes a whole configuration of member nodes
wired by buses — an effect chain, a mixer, a layered instrument — that the
server stores and instantiates as one unit. It exposes a named parameter
surface: ports that map to inner member controls, so the running instance is
driven through the port names, never the private member node ids (the same
encapsulation a composite SynthDef would give).
It is a thin JSON builder, like the other two def kinds: it composes a spec and
hands it to server.add_graphdef (/d_graph). The server resolves the
member def names (SynthDef or FaustDef, identically), allocates the
instance's private buses and wires them.
from clausters.defs import GraphDef
g = GraphDef("chain")
mix = g.bus("mix") # a private internal audio bus
src = g.add("gsrc", out=mix, level=1.0) # a member; `out` control -> the bus
g.add("gsink", {"in": mix, "out": "OUT"}) # `in` reads `mix`, `out` -> hardware
g.port("gain", src["level"], default=0.5) # surface port -> the source's level
server.add_graphdef(g) # /d_graph (blocks on /done in RT)
inst = server.graph("chain", {"gain": 0.8}) # /graph_new
server.set(inst, {"gain": 0.3}) # resolves against the surface
server.free(inst) # frees the group + its private buses
The reserved control name "OUT" wires a member's output to hardware bus 0;
any other string value of a member control is the name of an internal bus.
GraphBusRef
class GraphBusRef()
A reference to an internal GraphDef bus, returned by GraphDef.bus.
Used as a member control value (it serializes to the bus name).
_Target
class _Target()
One inner target of a surface port: a member's control with optional
linear scaling (mul·v + add).
_Target.scaled
def scaled(mul: float = 1.0, add: float = 0.0) -> "_Target"
A copy of this target with linear scaling applied to incoming
values, e.g. filt["cutoff"].scaled(7800, 200) maps a 0..1 port to
200..8000 Hz.
MemberRef
class MemberRef()
A handle to a member added with GraphDef.add. Index a control
name (member["cutoff"] or member.cutoff) to get a surface
_Target.
GraphDef
class GraphDef()
A named node graph. Build it with bus, add and
port, then send it with server.add_graphdef.
GraphDef.bus
def bus(name: str, *, rate: str = "audio", channels: int = 1) -> GraphBusRef
Declares a private internal bus (rate "audio" or
"control"). Each instance allocates its own, so two instances never
collide.
GraphDef.add
def add(defname: str,
controls: dict | None = None,
*,
maps: dict | None = None,
voice: bool = False,
**control_kw) -> MemberRef
Adds a member: an instance of the SynthDef/FaustDef defname.
Control values may be numbers, a GraphBusRef (to wire the
control to an internal bus), or "OUT" (hardware bus 0). maps
binds controls to internal control buses via /n_map. Pass
controls as a dict (needed for reserved names like in) and/or as
keywords. voice=True marks a per-voice member: instantiated once
per Server.graph_voice (or MIDI note) instead of at
instantiation — the per-note part of a polyphonic instrument.
GraphDef.port
def port(name: str, *targets: _Target, default: float | None = None)
Defines a surface port mapping name to one or more member
controls (each a _Target, optionally .scaled(...)).
default is applied at instantiation unless overridden.
GraphDef.spec
def spec() -> dict
The GraphDefSpec dict the server's /d_graph validates.
GraphDef.dump_def
def dump_def() -> str
The def serialized to text -- the /d_graph wire payload, the JSON
GraphDefSpec (see spec). Useful to inspect the composition before
sending it.
clausters.defs.server
Server facade: the running Clausters server, its resources and comms.
This is the server-application side of the client: it owns the
communication interface (RT over UDP by default; an OscNrtInterface for
offline; shared-memory/embed would be further interfaces), the client-side
resource allocators (node/bus/buffer), builds the OSC and
handles the async replies (/done / /fail) and notifications.
Timing is not here and not in the clock-as-sender: the clock
(clausters.base.clock.TempoClock) only schedules and tells time; the
Server emits, reading the logical time from the clock of the routine in flight.
A routine sequences events by calling send_bundle; swapping the
Server's interface retargets every routine from live RT to an NRT score without
touching clock or routine.
ServerOptions
@dataclass
class ServerOptions()
Client-owned server configuration, the way SuperCollider's
ServerOptions works: it both sizes the client's bus allocators and
emits the CLI flags to launch a matching server (args), so the
two agree by construction. Verify a running server with
Server.query_info.
outputs
Hardware output channels; None follows the device default (no flag).
inputs
Hardware input channels; 0 opens no input device.
max_nodes
Node slab capacity, root included.
max_buffers
Buffer pool size.
max_graph_children
Per-group child capacity.
max_ugen_inputs
Accepted inputs per UGen (clamped to 32 by the server).
taps
Audio-tap rings for oscilloscopes (0 disables the tap region).
tap_frames
Per-tap ring capacity in samples (rounded up to a power of two).
ServerOptions.args
def args() -> list[str]
The clausters CLI flags that launch a server matching these
options (pass to subprocess after the binary path). outputs is
emitted only when set (otherwise the server follows the device); the
pre-allocated pool sizes are always emitted so the launched server and
this object agree by construction.
ServerInfo
@dataclass
class ServerInfo()
The static configuration a running server reports over /server_info
(read-only; the result of Server.query_info).
The first six fields are the stable original set; the rest are the
boot-time capacities the server appends. channels is the hardware
output channel count, input_channels the live input count (0 when
the server was launched without --inputs). Against a pre-S7 server that
reports only six fields, the appended ones fall back to the compiled
defaults.
taps
Audio-tap region shape; 0/0 when the server has no segment
(or predates taps).
max_frame
The stream-transport frame ceiling in bytes (--max-frame): the
largest OSC frame a TCP/WebSocket client may send or receive, what
bulk requests (Server.get_samples chunks) are sized from. Falls back
to the UDP datagram cap against a server too old to report it.
Server
class Server()
Server.boot
@classmethod
def boot(cls,
options: "ServerOptions | None" = None,
*,
shm="auto",
transport: "str | None" = None,
verbose: int = 0,
data_dir=None,
server_args=(),
latency: "float | None" = None,
ready_timeout: float = 10.0) -> "Server"
Start a separate clausters server process and return a Server
connected to and owning it.
The launcher's ergonomic non-Session entry point: it spawns the
standalone server (choosing a shared-memory segment), waits until it
answers, and hands back a Server whose close also stops the process
(and interpreter exit stops it too). Pair it with
clausters.gui.GuiHost.boot for the GUI, or use clausters.Session.live
for the bundled, clock-included path.
Arguments:
options- aServerOptionssizing the launched server and this handle's allocators alike;Noneuses the server's defaults.shm- the shared-memory segment —"auto"picks one, a path forces it,Nonelaunches without one. Remembered for a GUI to map.transport- the carrier this handle talks over —"tcp"(default),"udp"or"ws"(a--wsserver). The boot-or-attach probe itself always rides UDP.verbose- server log verbosity (1/2/3->-v/-vv/-vvv; negative ->-q).data_dir- the server's--data-dir;Noneuses its default.server_args- extra server CLI tokens (e.g.["--tcp"]).latency- seconds added to RT timetags (see the constructor).ready_timeout- seconds to wait for the server to answer.
Returns:
A booted Server; server.shm is the segment path (or None).
Server.shm
@property
def shm() -> "str | None"
The shared-memory segment path of the server this handle boot-ed, or
None (attached, or booted without a segment). A GUI maps this.
Server.send_msg
def send_msg(addr, *args)
Send one message immediately.
Server.send_bundle
def send_bundle(*messages, delay_beats: float = 0.0, clock=None)
Emit a timetagged bundle of (addr, *args) messages at the running
routine's exact logical beat (+ optional lookahead). Call it from a
routine playing on a clock (found via main.current_tt) or pass
clock=. The timetag comes from the yield-accumulated beat, not from
wall-clock now, so inter-event timing is exact; the interface decides
the wire time (wall clock for RT, seconds-from-start for NRT).
Server.play_event
def play_event(event)
Realize a note Event as OSC: /s_new
at the routine's logical beat, then /n_free (or gate 0) after the
sustain. The OSC side of the double dispatch — a MIDI destination
renders the same event as note on/off. Returns the synth node id (or
None for a rest).
Server.request
def request(addr, *args, timeout: float = 5.0, expect=None)
Sends a message and returns the first matching reply (addr, args)
(RT only; the interface must reply). expect filters reply addresses.
Server.query_info
def query_info(timeout: float = 5.0) -> ServerInfo
Asks the running server for its static configuration (RT only): bus
counts, output/input channels, block size, sample rate and the
boot-time pool sizes. Use it to size or check allocators against a
server you did not launch; compare the result with options. The
appended capacity fields degrade to the defaults against a server too
old to report them.
Server.query_tree
def query_tree(group=ROOT_NODE_ID,
*,
controls: bool = True,
timeout: float = 5.0) -> dict
The node tree from group down (scsynth /g_queryTree), as a
nested dict: a group is {"id", "children": [...]}, a synth is
{"id", "def", "controls": {name: value}} (controls only when
controls=True). This is the structured way to read the tree —
never scrape the server's logs.
Server.node_query
def node_query(node, timeout: float = 5.0) -> dict
Per-node detail (/n_query -> /n_info): id, parent,
prev/next siblings, is_group; for a group head/tail;
for a synth def, controls, maps (/n_map bindings) and the
inferred reads/writes bus lists.
Server.dump_graph
def dump_graph(group=ROOT_NODE_ID, timeout: float = 5.0) -> str
The inferred bus graph of group as a human-readable string
(/g_dumpGraph): what each child reads/writes and the current order.
A debugging aid; for machine use prefer query_tree.
Server.add_faustdef
def add_faustdef(fdef: FaustDef,
*,
wait: bool = True,
timeout: float = 10.0) -> str
Sends a FaustDef via /d_faust.
/d_faust JIT-compiles asynchronously on the server's network
thread (answered later by /done//fail). In RT, wait=True
(the default) blocks until that reply -- raising CommandError
on /fail or ReplyTimeout if it never lands; wait=False
returns immediately (fire-and-forget), so use sync as a barrier
before relying on the def (e.g. yield it from a routine, never block
in one). In NRT it always scores /d_faust at time 0 -- the
renderer compiles it before time advances -- so wait does not
apply.
Server.add_synthdef
def add_synthdef(sdef, *, wait: bool = True, timeout: float = 10.0) -> str
Sends a UGen SynthDef via
/d_recv. Like add_faustdef: wait=True (default) blocks
in RT until /done//fail; wait=False is fire-and-forget
(pair with sync). In NRT it scores /d_recv at time 0 so the
renderer compiles it before time advances.
Server.add_graphdef
def add_graphdef(gdef, *, wait: bool = True, timeout: float = 10.0) -> str
Sends a GraphDef via /d_graph.
Like add_synthdef/add_faustdef: wait=True
(default) blocks in RT until /done//fail; wait=False is
fire-and-forget (pair with sync). In NRT it scores /d_graph
at time 0. Loading a GraphDef is cheap on the server (no JIT — it only
validates and references the member defs), but it is still asynchronous,
so the same barrier discipline applies.
Server.graph
def graph(defname,
ports=None,
*,
target=ROOT_NODE_ID,
action=AddAction.TAIL) -> Group
Instantiates a GraphDef (/graph_new) as a wired group, with
ports (a {name: value} dict) overriding the def defaults. The
returned Group is the instance: drive it
through the surface with set (/n_set resolves names against
the surface, not the private members) and tear it down with
free (which also reclaims its private buses).
Server.graph_voice
def graph_voice(instance, ports=None) -> Group
Spawns a per-voice sub-graph (/graph_voice) inside a running
GraphDef instance (a Group from
graph), wired to its shared private buses. ports overrides
the voice-port defaults. The returned group is the voice: drive it
through its surface with set and free it with free.
Server.u_cmd
def u_cmd(node, ugen_index: int, name: str, *args)
Sends a typed command to one UGen instance inside a synth
(/u_cmd nodeID ugenIndex name args…). The server hashes name to a
stable selector and routes the numeric args to that UGen on the audio
thread. The FFT chain uses it to swap a window live, e.g.
server.u_cmd(synth, fft_index, "window", 4) for a Blackman window
(a clausters._native.Window value); an unrecognized name is a
no-op on the server.
Server.run
def run(node, flag: bool = True)
Pauses (flag=False) or resumes (flag=True) a node — a synth
or a whole group — with /n_run. A paused node stays in the tree and
keeps its state but is skipped (silent); this is what resumes a synth
parked by DoneAction.PAUSE_SELF.
Server.pause
def pause(node)
Pauses a node (/n_run … 0). See run.
Server.resume
def resume(node)
Resumes a paused node (/n_run … 1). See run.
Server.stream_buses
def stream_buses(period_ms: int, *buses, timeout: float = 5.0)
Subscribes this client to a periodic /c_set snapshot of the
given control buses (/c_stream): the server sends one snapshot
immediately and then one every period_ms (floor 10 ms, at most 128
buses) with no further requests -- the network counterpart of reading
the shared-memory segment, e.g. for meters over WebSocket. One
subscription per client, replaced on each call; period_ms <= 0 (or
no buses) cancels it. Receive the snapshots with an OscFunc on
/c_set. Blocks on the /done ack.
Server.tap
def tap(tap: int, bus)
Routes audio bus into the server's audio-tap ring tap
(/tap): from the next block on, the engine appends that bus's
samples to the ring, where a GUI oscilloscope reads them out of shared
memory with zero messages (or this client streams them with
stream_taps). bus = -1 stops the tap. No ack, like /n_map
(failures reply /fail); sequence with sync when needed. The
server must have a tap region (--taps > 0, the default) -- check
query_info.
Server.stream_taps
def stream_taps(period_ms: int, frames: int, *taps, timeout: float = 5.0)
Subscribes this client to a periodic /tap_data snapshot of the
given audio taps (/tap_stream): every period_ms (floor 10 ms)
the server sends, per tap, the newest frames samples of its
ring as /tap_data tap endPosition blob -- the tap index, the tap's
stream position (total samples written) at the window's end, and the
window as raw little-endian float32. The network counterpart of
reading the tap rings out of shared memory, e.g. for a browser
oscilloscope or headless capture. frames is clamped to 8192 and to
half the ring; at most 8 taps per subscription; one subscription per
client, replaced on each call; period_ms <= 0 (or no taps)
cancels. Receive the snapshots with an OscFunc on /tap_data.
Blocks on the /done ack.
Server.alloc_buffer
def alloc_buffer(frames: int,
channels: int = 1,
*,
wait: bool = True,
timeout: float = 5.0) -> Buffer
Allocates a zeroed buffer. In NRT it scores /b_alloc at time 0
(so the renderer installs it before time advances); in RT wait=True
(default) blocks on /done, wait=False is fire-and-forget.
Server.gen_buffer
def gen_buffer(buf, cmd: str, *args, wait: bool = True, timeout: float = 5.0)
Fills a buffer through /b_gen (the wavetable/generator commands:
"env", "sine1"/"sine2"/"sine3", "cheby", "copy").
Like alloc_buffer: NRT scores at time 0; RT wait=True blocks on
/done, wait=False is fire-and-forget.
Server.read_buffer
def read_buffer(path,
*,
file_start: int = 0,
num_frames: int = 0,
wait: bool = True,
timeout: float = 5.0) -> Buffer
Allocate a buffer and read a sound file into it (/b_allocRead): the
shape and sample rate come from the file (num_frames 0 = the whole
file, from file_start). Decoding is by content (WAV, FLAC, OGG, MP3,
…). In NRT it scores at time 0; in RT wait=True blocks on /done.
The returned Buffer's frames/channels are unknown client-side
until query_buffer.
Server.read_into
def read_into(buf,
path,
*,
file_start: int = 0,
num_frames: int = -1,
buf_start: int = 0,
wait: bool = True,
timeout: float = 5.0)
Read a sound file into an existing buffer (/b_read), keeping its
shape. NRT scores at time 0; RT wait=True blocks on /done.
Server.write_buffer
def write_buffer(buf,
path,
*,
sample_format: str = "int16",
num_frames: int = -1,
buf_start: int = 0,
wait: bool = True,
timeout: float = 5.0)
Write a buffer to a WAV file (/b_write); sample_format is
"int16", "int24" or "float". NRT scores at time 0; RT
wait=True blocks on /done.
Server.zero_buffer
def zero_buffer(buf, *, wait: bool = True, timeout: float = 5.0)
Zero a buffer (/b_zero). NRT scores at time 0; RT wait=True
blocks on /done.
Server.query_buffer
def query_buffer(buf, timeout: float = 5.0) -> Buffer
Ask the running server for a buffer's shape (/b_query → /b_info bufnum frames channels sampleRate) and fill it into the Buffer
handle. RT only (it needs a reply).
Server.get_samples
def get_samples(buf,
start: int = 0,
count: int = -1,
*,
chunk: "int | None" = None,
timeout: float = 5.0)
Fetch interleaved samples from a buffer (/b_getn → /b_setn),
in chunks, as a stdlib array('f'). count -1 = to the end (the
shape is queried first). RT only (it needs replies); for display the GUI
host fetches buffers itself.
chunk (samples per round-trip) defaults to the transport's bound:
over a stream transport (TCP/WebSocket) it is sized from the frame
ceiling the server advertises in /server_info — megabytes per
reply — while over UDP each reply must fit a datagram, so it stays at
1024. Pass an explicit chunk to override either.
Server.render
def render(sample_rate: float = 48_000.0, channels: int = 2)
Renders the accumulated score (the interface must be an
OscNrtInterface). Schedule a closing bundle (e.g. /n_free 0)
so the render has a defined duration.
Server.sync
def sync(timeout: float = 5.0) -> int
The async barrier (scsynth /sync): sends /sync id and blocks
until the server answers /synced id, which it does only once every
async command sent earlier -- Faust/SynthDef compiles, buffer jobs --
has completed. Use it after a wait=False add_faustdef /
add_synthdef / buffer alloc. RT only (in NRT the renderer
already serializes async work at time 0). Returns the id used.
Blocking — never call from a routine. This (and any wait=True)
blocks the calling thread on a reply: fine on your own thread, but it
would freeze the clock thread if called from inside a routine generator
(see Routine). It also polls the socket
synchronously; a non-blocking, notification-driven barrier you can
yield from a routine is future work (OSCFunc).
Server.sample_clock
def sample_clock(window: int = 64, timeout: float = 2.0)
A UdpSampleClock tracking this
server's sample clock over UDP. Pass its .timebase() to a
TempoClock to anchor timing to the server and schedule by /sched.
Server.transport
def transport(timeout: float = 5.0)
The server's shared transport grid (/transport) as
(origin_sample, tempo), or None if none is set. The grid lets
several clients phase-align on the master clock; join it from a clock
with clausters.base.clock.TempoClock.join_transport. RT only.
Server.set_transport
def set_transport(origin_sample: int, tempo: float, timeout: float = 5.0)
Define the server's shared transport grid (/transport): beat 0 at
origin_sample on the sample clock, advancing at tempo beats per
second. One client (the conductor) sets it; the others
join_transport. Last writer wins. Defining the grid resets the rolling
state to stopped at position 0.
Server.transport_state
def transport_state(timeout: float = 5.0)
The full shared transport state as a dict {origin_sample, tempo, playing, position}, or None if no grid is defined. playing is
whether the transport is rolling and position the song-position beat
(where play starts, or where a stopped transport sits). A
clausters.seq.timeline.Playhead follows this with follow_transport.
RT only.
Server.transport_play
def transport_play(position: "float | None" = None, timeout: float = 5.0)
Start the shared transport rolling (/transport_play). With
position playback starts from that song-position beat; without it,
from where it last stopped or located. The server broadcasts the change
to every /notify client, so all playheads following the transport roll
together. Needs a grid defined (set_transport).
Server.transport_stop
def transport_stop(timeout: float = 5.0)
Stop the shared transport (/transport_stop); every following
playhead halts. Broadcast to /notify clients.
Server.transport_locate
def transport_locate(position: float, timeout: float = 5.0)
Set the shared transport's song position (/transport_locate) —
where play starts, or where it seeks to while playing. Every following
playhead locates to it. Broadcast to /notify clients.
Server.close
def close()
Close the communication interface and, if this handle boot-ed a
server process, stop it too.
clausters.defs.node
Nodes (synths and groups) and client-side id allocation.
The server's node tree (node): the root group is id 0; clients allocate
positive ids. Add actions match the server: head/tail of a group, before/after
a node, or replace. Synth and Group are flat handles holding
an id; the Server does the OSC.
NodeIdAllocator
class NodeIdAllocator()
Hands out node ids from start (scsynth clients use 1000+).
clausters.defs.bus
Audio and control buses, with client-side allocation.
Mirrors the server's bus model (dsp): audio buses (0..channels are the
hardware outputs) and single-float control buses. Like scsynth, the client owns
allocation; the server just indexes. A Bus is a flat
(index, channels, rate) — only flat data ever leaves for the wire.
The allocators carry no default size of their own: how many buses exist is a
property of the server, not the bus module. The Server
sizes them from its ServerOptions (which also emits
the matching --audio-buses/--control-buses launch flags), and the live
counts can be read back with query_info.
AudioBusAllocator
class AudioBusAllocator(_Allocator)
Allocates audio buses above the hardware outputs (reserved). size
is the server's audio-bus count (from ServerOptions/query_info).
ControlBusAllocator
class ControlBusAllocator(_Allocator)
size is the server's control-bus count (from
ServerOptions/query_info).
clausters.defs.clocksync
Track the server's sample clock over UDP.
To use a SampleClockTimebase over UDP — where
the client can't read the sample counter directly (as shm/embed can) — it
queries the server's /clock and models
sample(t_local) = a + b * t_local
from (local monotonic time, counter) anchor pairs, a least-squares line over
a sliding window (JACK-DLL / Ableton-Link in spirit; same model as the server's
examples/sample_clock.py). The TempoClock then paces against this and the
Server schedules every event by absolute sample with /sched — drift-free.
Query latency does not accumulate: an anchor is paired with the midpoint of its round trip, whose half-width is a bounded uncertainty that only shifts the whole grid by a constant. Relative timing stays sample-exact by construction.
Why this is drift-free, and what can fail. Each event's target is an absolute
sample recomputed from the routine's absolute logical beat — round((origin + beat / tempo) * sample_rate) — not stepped from the previous event, so error
never accumulates: at 48 kHz the target stays integer-exact within float64
for hours. The fitted line above only sizes the lead (how far ahead the
/sched is queued), never the event time, which the server's own counter
resolves exactly. So the only failure mode is binary, not cumulative: a
/sched that arrives after its target sample (lead < worst-case client +
network + model jitter) lands late; one that arrives in time lands exact. A long
run at perfect spacing just means the lead was never violated.
Surviving suspend. The server's counter counts samples actually emitted, so it
freezes when the audio device suspends (system sleep, or the sink going idle)
and resumes in place — it is not a wall clock. A /sched keyed to an absolute
sample simply waits in the server's queue and fires when the counter reaches it,
so the audio grid stays sample-exact across the gap (consecutive events keep
their exact spacing; the freeze just drops out of the timeline). The tracker
rides this automatically: while the counter is stalled its anchors fit a flat
slope, so the predictor stops running ahead and the lead/backlog stays bounded;
on resume the slope recovers. Only the wall-clock phase shifts, by the suspend
duration — relative sample spacing is preserved.
SampleClockModel
class SampleClockModel()
sample(t) = a + b·t, least-squares over a sliding anchor window.
The fit itself lives in the native core (clausters._native.ClockSyncModel
over clausters_core::clocksync), so every client predicts the same
sample from the same anchors; this class only adds the local-time
convenience (now reads the monotonic clock).
SampleClockModel.a
@property
def a() -> float
Fitted intercept (samples at local time 0).
SampleClockModel.b
@property
def b() -> float
Fitted slope (samples per local second).
SampleClockModel.now
def now() -> int
Predicted current value of the server's sample counter.
SampleClockModel.local_time_of
def local_time_of(sample: int) -> float
Inverse: the monotonic time the counter reaches sample.
UdpSampleClock
class UdpSampleClock()
Tracks a server's sample clock over UDP and yields a timebase.
Uses its own socket (so /clock round trips never contend with the
Server's command socket). Build one with server.sample_clock().
UdpSampleClock.anchor
def anchor() -> float
One /clock round trip; returns the anchor's uncertainty (s).
UdpSampleClock.warmup
def warmup(n: int = 5, gap: float = 0.05) -> float
A few anchors to seed the model; returns the worst uncertainty.
UdpSampleClock.track
def track(interval: float = 0.5)
Re-anchor in the background forever (keeps the slope fresh).
UdpSampleClock.timebase
def timebase() -> SampleClockTimebase
A SampleClockTimebase reading this tracker's model.
clausters.form.element
The arrangement — elements and their temporal character.
The client-side layer under a multitrack editor of recursive granularity: it
places elements in time, groups them recursively and renders them. An Element
is an arbitrarily delimited entity that produces a unit of meaning and can be
decomposed or combined — generated (the rendered thing, editable and
random-access) or a generator (the algorithm that renders it, forward-only),
with the change of state between them. It is a thin adornment over the objects the client
already has (clausters.seq.Event, clausters.seq.Timeline, a Buffer, a
Pattern, a def): it carries the temporal metadata (onset, duration, and the
derived temporal character) and belongs to a Group, while it delegates
playing to the wrapped item's play(destination) — the double-dispatch
seam every leaf item in the client already shares. The arrangement does not
reimplement or subclass those objects.
The five primitives map one-to-one onto what the client already has:
Event— event/clip: parameters grouped into one action (internally simultaneous), with its own onset/duration. Wrapsclausters.seq.Event.Sequence— List: strict order with no concrete time, only sequence. Wraps a Python list or aPattern.Buffer— Buffer: a list at constant time (audio or control samples). Wrapsclausters.defs.Buffer.Track— Set: mixed placement of elements, a DAW track. Wrapsclausters.seq.Timeline.Generator— Function: a generator element — server DSP (a def) or a sequence generator (Pbind/Routine).
Grouping and rendering live in clausters.form.group and
clausters.form.render. This module is pure and transport-agnostic (factorable
into clausters-core in a future port).
SEGMENT
The temporal character of an element, derived from which of onset and
duration are present. segment has both; punctual has an
onset but no duration; relative has a duration but no onset; abstract
has neither (a pure context/container that only a parent gives concrete time).
temporal_character
def temporal_character(onset, duration) -> str
The temporal character for a given onset/duration pair (the pure
rule behind Element.temporal_character).
Element
class Element()
Base of the arrangement: temporal metadata over a wrapped item.
An element carries an optional onset and duration (in beats, relative
to its context) and wraps an underlying client object it delegates to. The
concrete onset of an element typically comes from its placement inside a
clausters.form.group.Group, not from the element itself, so a standalone
leaf commonly has a duration but no onset (a relative character).
Arguments:
wraps- the underlying object playing delegates to (orNonefor a pure container like aGroup).onset- start in beats relative to the context, orNone.duration- length in beats, orNone.
Element.temporal_character
@property
def temporal_character() -> str
This element's character (SEGMENT/PUNCTUAL/RELATIVE/ABSTRACT),
derived from the presence of onset and duration.
Element.play
def play(destination)
Delegate playing to the wrapped item's play(destination) — the
double-dispatch seam shared by clausters.seq.Event,
clausters.seq.timeline.OscEvent/MidiEvent and
clausters.seq.Automation.
Container and pattern-backed elements (Group, Track, a Sequence
wrapping a Pattern) are not directly playable this way — they are
rendered by render(). Delegating here requires the wrapped
object to follow the play(destination) protocol.
Element.to_timeline
def to_timeline(base: float = 0.0)
Flatten this element to a flat clausters.seq.Timeline in absolute
beats (accumulating nested placement offsets). See
clausters.form.render.
Element.render
def render(destination,
clock=None,
*,
at: float = 0.0,
quant=None,
ports=None)
Render this element onto destination — the change of state to
sound. A concrete element flattens and plays through a
clausters.seq.Playhead over clock (returns the playhead); a logical
Group sends and instances a GraphDef on the server (returns the
instance). See clausters.form.render.render.
Event
class Event(Element)
event/clip: parameters grouped into one action, internally simultaneous.
Wraps a clausters.seq.Event (or a plain dict of parameters). Its
duration defaults to the event's dur when not given explicitly; its
onset usually comes from its placement in a Group.
Sequence
class Sequence(Element)
List: strict order with no concrete time — only sequence.
Wraps a Python list or a clausters.seq.pattern.Pattern. The items can be
numbers, events, notes or whole elements; the structure fixes only their
successive order. Rendering bounces a pattern-backed sequence; a list is
interpreted by its content.
Buffer
class Buffer(Element)
Buffer: a list at constant time — audio or control samples.
Wraps a clausters.defs.Buffer. An automation sampled at a constant interval
is a control buffer (the List/Buffer duality of the arrangement).
A buffer is data, so rendering it as an audio clip needs an instrument:
the def that plays it, named by instrument (a synth whose buf control
takes the buffer number, as a sampler's does). Rendering then emits one
event playing that def — to_event. Without an instrument the element is
still perfectly good structure (and the editor draws its take), it simply has
no sound of its own.
Arguments:
buffer- theclausters.defs.Bufferon the server.instrument- the def that plays it (itsbufcontrol gets the buffer number), orNonefor a buffer that is data only.controls- extra event parameters passed to that def (amp,rate…).onset- start in beats relative to the context, orNone.duration- length in beats — how long the clip sounds. Give it for a take placed in time (an event's default length is used otherwise).
Buffer.to_event
def to_event()
The event that plays this buffer: the instrument def with the buffer
number in its buf control, sounding for the element's duration.
legato is 1 so the take sounds its whole length (the note default of
0.8 would cut it short — a sampled take is not a note with a gap).
Track
class Track(Element)
Set: mixed placement of elements — a DAW track.
Wraps a clausters.seq.Timeline (free placement of items by beat). A fresh
empty Timeline is created when none is given.
Generator
class Generator(Element)
Function: a generator element.
Wraps either server DSP (a SynthDef/FaustDef/GraphDef, or a def name)
or a sequence generator (a Pbind/Routine). Its change of state —
evaluating the generator into a generated element — happens at rendering: a
contained event pattern is bounced to a timeline; a def member of a
logical Group becomes a wired GraphDef member.
Arguments:
generator- the wrapped def (name or object) or sequence generator.controls- control values for a logical-graph member — numbers, an internal-bus name (astrmatching aGroupbus), or"OUT"(hardware). Used byGroup.to_graphdef.maps- control-bus bindings for a logical-graph member (/n_map),- ```{control` - bus_name}``.
Generator.def_name
@property
def def_name() -> str
The member def name — the wrapped string itself, or the def object's
name.
clausters.form.group
The arrangement — grouping, and the derived temporal relation.
A Group is the one genuinely new structure of the arrangement: the recursive
placement of elements with an offset, and the temporal relation derived from
how the members sit in time. Everything else (the five primitives) already
exists and is merely adorned by clausters.form.element.Element.
Two kinds of grouping:
- concrete — the members relate in time (a section holding clips, a melody holding note-events), with no processing relation.
- logical — the members relate by processing or generation logic (a bus-wired signal chain on the server, or a generative dependency on the client).
Rendering lives in clausters.form.render; this module is pure structure plus the
temporal-relation derivation (a pure function over the members' placements).
CONCRETE
The kind of a Group.
SUCCESSIVE
The temporal relation between a group's members, derived from their placements
successive — duration-only, tiling contiguously; simultaneous
— all starting and ending together (a container that can be reinterpreted,
enabling recursion); mixed — any other combination.
_Member
class _Member()
One placed member of a Group. A stable object so it can be removed
or moved by identity after other edits shift things.
offset is the member's start in beats relative to the group's context;
dur is an explicit placement length that overrides the element's own
duration when set.
_Member.length
@property
def length()
The effective length of this member: the placement dur if given,
else the element's own duration (may be None).
Group
class Group(Element)
A composite element: a set of placed members with a grouping kind.
Members are placed by an offset (beats relative to the group's context)
and an optional placement dur. Edit freely — add, remove, move; a
handle returned by add stays valid across other edits (like
clausters.seq.Timeline).
A LOGICAL group additionally names the composition and may declare internal
buses; to_graphdef translates it into a clausters.defs.GraphDef (the
bus-wired configuration the server already expresses).
Arguments:
children- optional iterable seeding the group. Each item is a(offset, element)pair, a(offset, dur, element)triple, or a bareElement(placed at offset 0).kind-CONCRETE(default) orLOGICAL.name- the composition's name — the GraphDef name for a logical group.buses- internal buses for a logical group — each aname(audio, 1 channel) or a(name, rate)/(name, rate, channels)tuple.onset- the group's own onset in its parent context, orNone.duration- the group's own duration, orNone.
Group.add
def add(element, offset=0.0, dur=None)
Place element at offset (beats), optionally overriding its
length with dur. Returns a member handle for remove/move.
Group.remove
def remove(member)
Remove a member returned by add (by identity).
Group.move
def move(member, offset, dur=None)
Reposition member to offset (and optionally set dur).
Group.clear
def clear()
Drop every member.
Group.members
@property
def members() -> list
The members as (offset, dur, element) triples, insertion order.
Group.handles
@property
def handles() -> list
The member handles (the objects add returns), insertion order —
the stable identities remove and move take. Reading a placement is
members; holding on to one across edits (as an editor keying its
widgets by member does) needs these.
Group.temporal_relation
def temporal_relation()
Derive this group's temporal relation (SUCCESSIVE/SIMULTANEOUS/
MIXED) from its members' placements, or None when empty.
SIMULTANEOUS: every member starts and ends together (a single member trivially qualifies).SUCCESSIVE: members tile contiguously in time — sorted by start, each member begins exactly where the previous ends (requires known lengths).MIXED: anything else.
Group.to_graphdef
def to_graphdef(name=None)
Translate this logical group into a clausters.defs.GraphDef — the
1:1 mapping of the arrangement's logical grouping (nodes wired by sender/
receiver buses) onto the configuration the server already expresses.
Each member must be a clausters.form.element.Generator (its
def_name is the member def; its controls — numbers, an internal
bus name, or "OUT" — and maps wire it). The group's buses become
the private internal buses. Placement offsets are ignored (a logical group
is a signal graph, not a timeline). Returns the GraphDef; sending and
instancing it is clausters.form.render.
clausters.form.render
Rendering — the change of state from the arrangement to sound.
A concrete Group is rendered by flattening it: a tree-walk that
accumulates the nested placement offsets into absolute beats, producing a flat
clausters.seq.Timeline of items that each know how to play(destination). That
timeline is then played by a clausters.seq.Playhead — RT (timetagged bundles)
or NRT (a score for Session.render) purely by which destination and clock it
holds, sample-identical, with no scheduling path of its own. This mirrors
Timeline.from_pattern: the arrangement reuses the sequencing layer rather than
duplicating it.
Scope of this phase (the concrete path):
Group{concrete}— flattened recursively; each member'soffset(and any nested group's) accumulates into the child's absolute beat.Track— itsTimeline's items are shifted by the placement beat.Event— placed as a single item at its beat.Sequence/Generatorwrapping an event pattern (aPbind) — bounced in the same pass (its change of state); aSequenceof elements is laid out successively by their durations.- An abstract element (no onset/duration, no content) contributes context, not an event.
A Buffer is data: it sounds through the instrument that plays it (a def
whose buf control takes the buffer number), so a Buffer with an
instrument emits one event playing it — the audio clip — and one without
contributes structure only. A Group{logical} takes the other path entirely (it
becomes a GraphDef); instancing a bare def still needs an instrument of its own
and raises a clear NotImplementedError here.
flatten
def flatten(element, base: float = 0.0) -> list
Flatten element into (absolute_beat, item) pairs, sorted by beat,
accumulating nested placement offsets onto base. The items are playable
(they follow the play(destination) protocol).
to_timeline
def to_timeline(element, base: float = 0.0)
Flatten element into a flat clausters.seq.Timeline in absolute
beats — the structure a Playhead plays and a transport seeks.
render
def render(element,
destination,
clock=None,
*,
at: float = 0.0,
quant=None,
ports=None)
Render element onto destination.
A concrete element (a Group, Track, Event, …) is flattened to
a timeline and played through a Playhead over clock — RT (start/run the
clock) or NRT (clock.render() then destination.render(), or
Session.render), sample-identical; returns the Playhead.
A logical Group is translated to a clausters.defs.GraphDef, sent
(/d_graph) and instanced (/graph_new, with ports overriding the
surface defaults) on the Server destination; returns the instance
group. The seam is the destination, not the element.
render_logical
def render_logical(group, server, *, ports=None)
Send a logical group's GraphDef (Group.to_graphdef) and instance it on
server. Returns the instance group (server.graph's handle).
clausters.gui.guidef
Building GuiDefs the way defs are built.
A GuiDef is the GUI analogue of a SynthDef/GraphDef: a tree of
{id, type, ...props, children} nodes serialized to JSON and carried inside
one OSC argument. These helpers compose that tree as plain dicts — they are
host-agnostic, just like building a SynthDef is server-agnostic; only
clausters.gui.host.GuiHost knows how to send one. The root node carries no
id (it comes from the /gui_def <id> argument); every child carries its
own client-allocated integer id.
The int/float distinction is the user's to make and is preserved end to end:
write 480 for an integer property and 480.0 for a float — json.dumps
keeps them apart in the JSON text and the host's serde parse keeps them apart on
the wire (ids stay integers, control values stay floats).
node
def node(type: str, *, id: int | None = None, children=None, **props) -> dict
A generic widget node {id?, type, ...props, children?}.
The building block every other helper wraps. Pass id for any non-root
widget, children as an iterable of nodes for a container, and any other
keyword as a property (kept verbatim, so its int/float type is preserved).
window
def window(*children,
title: str | None = None,
w: int | None = None,
h: int | None = None,
layout: str | None = None,
**props) -> dict
A top-level window container (a GuiDef root). It takes no id.
panel
def panel(id: int, *children, layout: str | None = None, **props) -> dict
A nestable panel container; layout is row/col/grid/free.
label
def label(id: int, text: str, **props) -> dict
Static label text.
knob
def knob(id: int,
*,
label: str | None = None,
min: float | None = None,
max: float | None = None,
value: float | None = None,
**props) -> dict
A rotary knob over a continuous range.
slider
def slider(id: int,
*,
label: str | None = None,
min: float | None = None,
max: float | None = None,
value: float | None = None,
vertical: bool = False,
**props) -> dict
A continuous slider over a range. vertical=True lays it out along
the y axis (min at the bottom, max at the top) instead of horizontally.
number
def number(id: int,
*,
label: str | None = None,
min: float | None = None,
max: float | None = None,
value: float | None = None,
**props) -> dict
A draggable numeric read-out over a range.
button
def button(id: int, *, label: str | None = None, **props) -> dict
A momentary push button (emits 1 on press, 0 on release).
toggle
def toggle(id: int,
*,
label: str | None = None,
value: bool | None = None,
**props) -> dict
A boolean toggle. value is sent as 1/0 (OSC has no bool).
text
def text(id: int,
*,
value: str | None = None,
label: str | None = None,
**props) -> dict
A text field showing value (script-driven via /gui_set).
menu
def menu(id: int,
options,
*,
index: int | None = None,
label: str | None = None,
**props) -> dict
A menu selector over options (a list of strings); a click cycles
to the next and emits the chosen index.
waveform
def waveform(id: int,
*,
data=None,
blob: int | None = None,
buffer: int | None = None,
path: str | None = None,
cache: str | None = None,
channels: int | None = None,
base_bucket: int | None = None,
overlay: bool | None = None,
ruler: str | None = None,
ruler_y: str | None = None,
bit_depth: int | None = None,
sample_rate: float | None = None,
tempo: float | None = None,
beat_at: float | None = None,
quant: float | None = None,
sel_start: float | None = None,
sel_len: float | None = None,
playhead_at: float | None = None,
y_start: float | None = None,
y_len: float | None = None,
link: int | None = None,
**props) -> dict
The heavy waveform view, fed its samples one of several ways (in the
host's precedence order):
cache— a path to a prebuilt peak-pyramid file (seepeaks_cache_file) the host memory-maps and renders directly; the raw samples are never loaded. The most compact bulk path: nothing rides OSC. A cache built withchannels > 1holds every channel in the one file.path— a path to a file of raw little-endianf32samples (seesamples_to_file, or the server's/b_export) the host memory-maps; a multi-megabyte buffer renders with no OSC and no re-send.buffer— a server buffer number; the host fetches its samples from the audio server over OSC (it must be started with--server). The async fallback when a shared file is not available.data— a small list of floats embedded inline in the JSON;blob— the index of a binary blob carried beside the JSON in the same/gui_defmessage (seesamples_to_blobandGuiHost.define).
channels is the interleaved channel count of path/data/blob
(default 1): every channel is kept and drawn — stacked lanes sharing the
time axis by default, or per-color overlaid traces with overlay=True.
base_bucket sets the peak-pyramid bucket size (default 256); for path
it also keys the sibling cache the host writes beside the file.
The rulers (each in its own strip beside the view, each independently
switchable off, all live via GuiHost.set — so a menu or button in the
same GUI can retune them): ruler labels the time axis — "time"
(the default; clock time, using sample_rate or the rate the source
brings), "samples", "beats" (musical time: tempo in beats per
second — pass clock.tempo — beat_at the beat position of sample 0,
quant the beats per bar, labels bar:beat), or "off".
ruler_y labels the amplitude axis — "norm" (the default;
normalized [-1, 1]), "db" (dBFS), "bits" (integer sample values at
the bit_depth resolution, default 16), "percent" (0-100% of full
scale), or "off".
The rest of the editor chrome: sel_start/sel_len set the selection
in samples (dragging on the view updates it and emits
/gui_event id "selection" start len; Shift+drag pans, the wheel zooms).
playhead_at draws a playhead tracking the engine sample clock: pass the
/clock sample value that corresponds to buffer position 0 (negative or
omitted = no playhead). y_start/y_len set the vertical view
window — the visible slice of the amplitude axis, in normalized display
units where 0, 1 (the default) is the full axis: the wheel over the
y-ruler strip zooms it, dragging the strip pans it, and every change is
reported as /gui_event id "view_y" y_start y_len (a non-positive
y_len resets to the full axis).
link puts the view in a shared navigation group: every timeline
view (waveform or spectrogram, in any window) declaring the same link
id shares one horizontal view, selection and playhead — a zoom, pan or
drag-selection on any member moves all of them, and setting
view_start/view_len (samples; a non-positive view_len resets
to the whole timeline), sel_start/sel_len or playhead_at via
GuiHost.set on any member applies group-wide. Events still emit once,
with the interacted member's id. Membership is live: set link to
another group id to move the view, or to a negative value to unlink it
(it keeps the view it had). Only the vertical window y_start/y_len
stays per-view.
spectrogram
def spectrogram(id: int,
*,
data=None,
blob: int | None = None,
buffer: int | None = None,
path: str | None = None,
cache: str | None = None,
channels: int | None = None,
window_size: int | None = None,
hop: int | None = None,
sample_rate: float | None = None,
db_floor: float | None = None,
db_ceil: float | None = None,
freq_scale: str | None = None,
log_freq: bool | None = None,
colormap: int | None = None,
ruler: str | None = None,
ruler_y: str | None = None,
tempo: float | None = None,
beat_at: float | None = None,
quant: float | None = None,
sel_start: float | None = None,
sel_len: float | None = None,
playhead_at: float | None = None,
y_start: float | None = None,
y_len: float | None = None,
link: int | None = None,
**props) -> dict
The heavy spectrogram (STFT time-frequency) view, fed like the
waveform: a mapped path of raw little-endian f32, a server
buffer, inline data/blob, or a prebuilt single-channel STFT
cache file. channels de-interleaves the source (default 1); each
channel gets its own analysis, drawn as stacked lanes sharing the time axis.
The analysis: window_size is the FFT size (a power of two, default
1024) and hop the frame advance (default window_size // 2; the host
raises it as needed so a long file fits the GPU texture). sample_rate
places the frequency axis for path/inline sources (a fetched buffer
brings its own rate). The display is live (GuiHost.set): the dB window
[db_floor, db_ceil] (default -90/0) controls contrast,
freq_scale picks the frequency axis — "log" (the default),
"linear", "mel" or "bark" (log_freq is the legacy boolean
alias for the first two) — and colormap picks 0 viridis / 1 magma /
2 grayscale.
The rulers ride their own strips beside the view: ruler_y ("hz",
the default, or "off") draws the frequency ruler, its tick positions
following freq_scale; ruler labels the time axis exactly as on the
waveform ("time"/"samples"/"beats" with
tempo/beat_at/quant, or "off"). The rest of the editor
chrome (sel_start/sel_len, playhead_at, drag-to-select /
Shift+drag pan / wheel zoom) also works exactly as on the waveform —
including the vertical view window y_start/y_len, which here
slices the frequency display axis (normalized, 0, 1 = the full
axis, whatever the freq_scale): wheel over the Hz-ruler strip zooms,
dragging it pans, changes emit /gui_event id "view_y" y_start y_len.
link joins a shared navigation group exactly as on the waveform —
the classic composition is a waveform lane and a spectrogram lane of the
same render under one link, scrolling and selecting in lockstep.
meter
def meter(id: int,
bus: int,
*,
min: float | None = None,
max: float | None = None,
label: str | None = None,
**props) -> dict
A level meter reading control bus straight from the audio server's
shared-memory segment each frame (zero OSC messages). The host must be started
with --shm pointing at the server's segment. min/max scale the bar
(default 0/1).
scope
def scope(id: int,
bus: int = 0,
*,
tap: int | None = None,
window_ms: float | None = None,
trigger: float | None = None,
hold: bool | None = None,
min: float | None = None,
max: float | None = None,
label: str | None = None,
**props) -> dict
A time-domain scope, in one of two rates. By default (control rate)
it plots the recent history of control bus, read from shared memory
each frame (needs --shm like meter). Passing tap makes it an
audio-rate oscilloscope over that audio-tap ring of the server (route
a bus into it first with Server.tap): a window_ms display window
(default 20 ms), re-read every frame and aligned on a rising crossing of
trigger (default 0.0, with hysteresis; free-running when the
signal never crosses), so a periodic signal draws a stable trace.
hold freezes the trace. Natively the host reads the tap out of the
--shm segment with zero messages; in the browser it subscribes
/tap_stream over the server leg. min/max set the vertical
range (default the bipolar -1/1).
phasescope
def phasescope(id: int,
tap: int,
tap2: int | None = None,
*,
window_ms: float | None = None,
hold: bool | None = None,
label: str | None = None,
**props) -> dict
A phasescope (goniometer): the two audio taps tap (left) and
tap2 (right, default tap + 1) drawn as the 45°-rotated Lissajous
figure — vertical is the mid (L + R)/√2, horizontal the side
(L - R)/√2, the audio-engineering convention where mono reads as a
vertical line, anti-phase as horizontal and a wide field fills the lozenge.
An age-faded persistence trail spans the last window_ms of pairs (default
30 ms) and a correlation read-out (Pearson's r over the window) sits
under the field. Route each channel's bus into its tap first with
Server.tap; hold freezes the trace. Reads the segment natively
(zero messages) and /tap_stream in the browser, like the oscilloscope.
spectrum
def spectrum(id: int,
tap: int,
*,
fft_size: int | None = None,
db_floor: float | None = None,
db_ceil: float | None = None,
log_freq: bool | None = None,
averaging: float | None = None,
peak_hold: bool | None = None,
label: str | None = None,
**props) -> dict
A live spectrum (spectroscope): one forward FFT per frame over the
newest window of audio tap tap, drawn as a magnitude curve. fft_size
is a power of two (256..4096, default 2048); the vertical axis is dB over
[db_floor, db_ceil] (default -100/0); log_freq (default true)
selects a log frequency axis. Raw per-frame FFTs flicker, so averaging
(0..1, default 0.5) exponentially smooths each bin and peak_hold (default
false) overlays a slowly decaying peak trace. Route a bus into the tap first
with Server.tap; the analysis uses the shared-core FFT and Hann window,
so it agrees with the spectrogram. Native reads the segment; the browser
subscribes /tap_stream.
nodetree
def nodetree(id: int,
*,
group: int = 0,
controls: bool | None = None,
label: str | None = None,
**props) -> dict
A live nodetree view of the audio server's node tree rooted at group
(default the root group 0). The host mirrors the server's tree over its
client leg (it must be started with --server), refreshing on node
creation/removal and a low-rate poll, so group/synth changes and /n_set
edits show live. controls (default true) shows each synth's control
name/value pairs. A read-only view.
bpf
def bpf(id: int,
*,
points=None,
min: float | None = None,
max: float | None = None,
duration: float | None = None,
exp: bool | None = None,
label: str | None = None,
**props) -> dict
A drawable bpf break-point function — the envelope editor.
Breakpoints (time, value) plus a per-segment shape using the server's
own envelope shape numbers, evaluated host-side through the same shared
math the server's EnvGen plays — what you draw is what you hear.
points accepts either the flat quad list [t, v, shape, curve, ...]
(the wire form: shapes int, everything else float) or a list of tuples
(time, value) / (time, value, shape) where shape is an
Env-style curve spec — a name ("lin", "exp", "sin",
"step", "hold", ...) or a numeric curvature. Omitting points
draws a flat, immediately editable line. See env_to_points /
points_to_env for the round trip with clausters.defs.Env.
The widget is general on purpose (the automation-lane shape): values live in
[min, max] — unipolar (the 0/1 default), bipolar, or any
parameter span; an on/off lane is the "hold" shape over 0/1
(each point's value held until the next point — "step", per the
SuperCollider semantics, instead jumps to the target level at segment
start, so a step segment shows the next point's value);
exp=True gives frequency-like ranges a geometric display scale
(requires 0 < min < max). Times span [0, duration] (omitting
duration fits the last point).
Editing (drag a point — times stay monotonic; drag a segment vertically to
bend its curvature; Ctrl+click adds a point, Ctrl+click on one removes it)
flows back per the edit-back pattern:
/gui_event <id> "points" <t v shape curve ...> to the script — or, when
the widget is bound (GuiHost.bind or an inline bind), the flat list
is forwarded straight to the audio server after the binding's prefix.
Setting is live too: GuiHost.set(id, points=json.dumps(flat)) replaces
the whole list (a /gui_set value is a scalar, so the array rides as its
JSON string).
plot
def plot(id: int,
*,
data=None,
blob: int | None = None,
path: str | None = None,
channels: int | None = None,
min: float | None = None,
max: float | None = None,
label: str | None = None,
**props) -> dict
A simple static plot of a signal over [min, max] (default the
bipolar -1/1) — a line when the data fits the width, a min/max envelope
when it does not. Unlike the heavy waveform, it does not zoom or pan; it is
the catalog's "plot of an NRT-generated signal/file". Its samples come from:
path— a file of raw little-endianf32(seesamples_to_file, or an NRT render written out) the host memory-maps; the bulk path, no OSC.channelsde-interleaves channel 0 (default 1).data— a small list of floats inline in the JSON;blob— the index of a binary blob carried beside the JSON (seesamples_to_blobandGuiHost.define).
track
def track(id: int,
*clips,
label: str | None = None,
height: float | None = None,
snap: float | None = None,
ruler: str | None = None,
sample_rate: float | None = None,
tempo: float | None = None,
beat_at: float | None = None,
quant: float | None = None,
playhead_at: float | None = None,
**props) -> dict
A multitrack track lane holding clip children placed on a shared
time axis — the DAW-style track editor's lane. label names it in a left
header; height is its lane weight when several tracks stack under one
window (a col layout). The window's tracks share one time axis, so a
clip at a given offset lines up across lanes. snap is the drag grid in
timeline samples a clip's move/resize rounds to (omitted / 0 = snap to
whole samples).
The lanes of a window navigate as one: they share a time axis you can
zoom (wheel) and pan (Shift+drag), spanning the composition (the longest clip
end over every lane, so dragging a clip past the end lengthens it). That is
the same navigation group the heavy views use, so link joins or splits it
— pass a shared id to align lanes across windows, or a distinct one to give
a lane an axis of its own. Scripted navigation is GuiHost.set(track_id, view_start=…, view_len=…), and it applies group-wide.
A lane carries the same time chrome as the heavy editor views:
ruler— a time ruler under the lane ("time","samples","beats", or the default"off": a lane reserves no ruler strip unless asked).sample_ratelabels real time, andtempo/beat_at/quantlabel beats. One ruler under the bottom lane is the usual layout.playhead_at— the engine sample-clock value at timeline position 0, so the playhead sweeps the clips as the composition plays (the same anchor thewaveformuses; read the clock withServer.request("/clock")). Set it live withGuiHost.set(track_id, playhead_at=clock); a negative value (the default) draws no playhead.
Pass the clips positionally::
track(1, clip(10, offset=0, dur=4, data=take_a),
clip(11, offset=4, dur=2, data=take_b), label="drums")
clip
def clip(id: int,
*,
offset: float = 0.0,
dur: float,
data=None,
blob: int | None = None,
buffer: int | None = None,
path: str | None = None,
cache: str | None = None,
channels: int | None = None,
base_bucket: int | None = None,
notes=None,
points=None,
exp: bool | None = None,
min: float | None = None,
max: float | None = None,
label: str | None = None,
**props) -> dict
One clip on a track: a placed rectangle spanning [offset, offset + dur] in timeline sample units (the graphic unit — length = duration). Its
body is one of three:
- a waveform — the take, drawn decimated to the clip's pixel width;
- a piano-roll —
notes, an iterable of(start, dur, pitch)(or(start, dur, pitch, velocity, channel)) events (times relative to the clip, in samples; pitch mapped over[min, max]), drawn as note bars — the events-track view. The dedicated editor-gradepianorollwidget draws the same notes with a keyboard and editing; or - an automation curve —
points, break-points over the clip's span (thebpfeditor's break-points and shape math, placed on a lane): times relative to the clip in samples, values over[min, max](exp=Truegives a frequency-like range a geometric display scale). It is editable in place — drag a point, Ctrl+click to add one or remove the one under the cursor — and an edit flows back as the same flat"points"event thebpfview sends, so anclausters.seq.Automationconsumes it either way.
A real take is minutes long, so it never rides the wire as JSON. The
waveform body reaches the clip exactly the ways the heavy waveform view's
samples do, in the same precedence order:
cache— a prebuilt peak-pyramid file the host maps (seepeaks_cache_file); the most compact bulk path, raw samples never loaded.path— a file of raw little-endianf32the host maps (seesamples_to_file);channelsde-interleaves it,base_bucketsizes the pyramid built (and cached) on load. No OSC.buffer— a server buffer, fetched over the host's client leg.data/blob— a short body inline (a float list, or the index of a blob carried beside the JSON — seesamples_to_blob); it must fit the datagram, so keep it to a sketch.
Whichever the source, the body is summarized to fit the clip rectangle through the take's peak pyramid — the same "never resolve finer than the screen" rule the editor views follow.
Other keywords:
offset— the clip's start on the shared timeline (samples;>= 0).dur— its duration (samples); a clip with no duration draws nothing. For an audio take placed 1:1, that is the take's frame count.min/max— the waveform value range, or the low/high pitch of a piano-roll (default the bipolar-1/1).
Dragging a clip (move) or its edge (resize) flows back as a "clip"
event carrying the new offset/dur — the edit-back path — so a driver
can update the arrangement and re-render.
pianoroll
def pianoroll(id: int,
*,
notes=None,
osc=None,
min: float | None = None,
max: float | None = None,
snap: float | None = None,
velocity: bool | None = None,
osc_lane: bool | None = None,
midi_in: bool | None = None,
link: int | None = None,
ruler: str | None = None,
sample_rate: float | None = None,
tempo: float | None = None,
beat_at: float | None = None,
quant: float | None = None,
sel_start: float | None = None,
sel_len: float | None = None,
playhead_at: float | None = None,
playhead: float | None = None,
y_start: float | None = None,
y_len: float | None = None,
label: str | None = None,
**props) -> dict
The dedicated editor-grade pianoroll view: a piano keyboard gutter, a
note grid, an optional velocity lane and an OSC-event lane — the timeline
sibling of the compact clip piano-roll body, drawing the same notes with
the same geometry (they share the host's pianoroll primitives), plus
editing, rulers and navigation.
Content:
notes— an iterable of(start, dur, pitch)or(start, dur, pitch, velocity, channel)MIDI notes: times in timeline samples,pitcha MIDI note number drawn over the[min, max]window (default the 88-key range 21–108),velocity0..127(default 100),channel0..15. The notes are the MIDI messages the roll represents.osc— an iterable of(time, label)(or baretime) OSC events, drawn as flags in a lane below the grid — the OSC messages the roll carries alongside the notes.
Editing (native gestures; the browser keeps display + /gui_set parity):
drag a note to move it in time/pitch, drag an edge to resize it, Ctrl+click to
add a note or remove the one under the cursor; drag in the velocity lane to
set a note's velocity; Ctrl+click the OSC lane to add/remove an event, drag
one to move it. snap is the drag grid in timeline samples (0 = whole
samples). An edit flows back as a flat "notes" event (start dur pitch velocity channel …) or "osc" event (time label …) — the edit-back
pattern — so a driver updates the arrangement and re-renders.
Navigation and chrome mirror the heavy editor views: it is a timeline widget,
so link joins/splits its navigation group (zoom with the wheel over the
grid, pan with Shift+drag, all group-wide); ruler places a time ruler
("time"/"samples"/"beats", default "time") with
sample_rate/tempo/beat_at/quant labelling it; sel_start/
sel_len mark a time selection; playhead_at sweeps a playhead from the
engine clock (playhead sets a static cursor); y_start/y_len are the
vertical pitch window (normalized 0..1 over [min, max]) for pitch
zoom/pan. velocity=False hides the velocity lane; osc_lane=True opens
the OSC lane even with no events (to author them). midi_in=True arms
live MIDI painting in the native host: it opens a virtual MIDI input
port ("clausters-gui") and paints incoming notes into this roll — at the
running playhead, or step-entering on the snap grid when the transport
is stopped — flowing back as the usual "notes" events (the standalone
host's live input; a script can equally paint via a clausters.responders. MidiFunc and /gui_set).
graph
def graph(id: int,
*,
members=None,
buses=None,
wires=None,
label: str | None = None,
**props) -> dict
A graph patcher: a bus-wired node graph (a clausters.defs.GraphDef)
drawn as member boxes, bus nodes, and a wire per connection — the logical
side of a composition, where materials relate by processing rather than by
time.
The view is deliberately bipartite, because that is what a GraphDef knows: a member's control touches a bus. Which end writes and which reads is the server's own analysis (it sorts the graph), so the patch shows the connection and leaves the direction to the engine.
members— the nodes, each(def_name, [control, …]): the def and the controls that are wired (each drawn as a port on its box).buses— the internal bus names, plus"OUT"(the hardware) when used.wires— the connections, each(member_index, control, bus).
Dragging a port onto a bus rewires that control; dropping it on empty
space unwires it. Either way the edit flows back as /gui_event <id> "wire" <member> <control> <bus> (an empty bus = unwired), so a driver updates the
logical group and re-renders it — the same edit-back pattern the clips use.
canvas
def canvas(id: int,
shader: str | None = None,
*,
params=None,
buses=None,
label: str | None = None,
**props) -> dict
A canvas running a script-supplied WGSL shader over the widget area --
custom visuals (ShaderToy-style).
shader is the body of a shade function the host wraps and runs::
fn shade(uv: vec2<f32>, frag: vec4<f32>) -> vec4<f32> { ... }
Inside it, the host exposes u.resolution (the viewport size in px),
u.time (seconds), and u.params (a vec4<f32> of four values). The
four params are driven two ways, which is the point of the widget:
- from the script --
GuiHost.set(id, param0=...)sends an OSC value that lands inu.params.x(param0..param3->.x...w); - from a control bus, read straight from the audio server's shared memory
each frame (zero messages) --
buses=[busA, busB, ...]maps each control bus onto the param of the same index; a-1(or absent) slot stays script-driven. Needs the host started with--shm(likemeter).
So a shader can animate from OSC parameters and from live server audio at
once. Omitting shader uses a default moving color field. params is an
optional initial list of floats.
to_json
def to_json(tree: dict) -> str
Serializes a GuiDef tree to the JSON string carried in /gui_def.
samples_to_blob
def samples_to_blob(samples) -> bytes
Packs an iterable of floats into a little-endian f32 blob, the bulk
form a waveform reads via blob. Flat bytes at the boundary — the same
rule the rest of the client follows.
samples_to_file
def samples_to_file(samples, path: str) -> str
Writes samples to path as raw little-endian f32 — the local
shared resource a waveform(path=...) maps. Unlike samples_to_blob
(which rides the /gui_def message and so must fit a datagram), a file has
no size limit: this is how a multi-megabyte buffer reaches the host without
OSC. Returns path.
peaks_cache_file
def peaks_cache_file(samples,
path: str,
base_bucket: int = 256,
channels: int = 1) -> str
Builds the peak-pyramid cache for samples (via the shared native core,
so it is byte-identical to the host's own) and writes it to path — the most
compact bulk path, mapped by a waveform(cache=...). The host renders the
overview without ever loading the raw samples. With channels > 1 the
samples are interleaved frames and the file is the multichannel cache
(one resource, a pyramid per channel — the editor-grade stacked lanes).
Returns path.
correlation
def correlation(left, right) -> float | None
The stereo correlation (Pearson's r) of two equal-length channels,
in [-1, 1] — +1 mono/in-phase, 0 decorrelated, -1 anti-phase
— via the shared native core, so a headless capture reads the identical
number the GUI phasescope draws. None when it is undefined (empty input
or a constant channel: silence/DC). Pair it with Server.stream_taps to
measure a live stereo signal without the GUI.
lissajous
def lissajous(left, right) -> list
The Lissajous / goniometer coordinates of stereo pairs (left, right): each maps to (x, y) with x the side (L - R)/√2 and
y the mid (L + R)/√2 — the rotated stereo plane a goniometer draws.
The geometry lives once in the shared native core (the phasescope draws the
same points); useful for plotting or driving a stereo image in
electroacoustic work. Returns a list of (x, y) tuples.
clausters.gui.editor
Editor: the bridge between the arrangement and the multitrack GUI.
The driver of the DAW-style view. It draws a clausters.form tree as a
multitrack GuiDef (tracks of clips on one shared time axis), applies the clip
edit-backs the host sends straight onto the arrangement, and re-renders it — the
loop data ↔ graphic ↔ sound, which is what makes the composition editable at
any granularity rather than merely displayable.
Three things are worth knowing about how it is built.
The dependency arrow points this way. clausters.form stays pure and
transport-agnostic; the editor imports the arrangement, never the reverse. This
module is the only one that knows both worlds.
Beats meet samples here. The arrangement places elements in beats; the
multitrack view places clips in timeline samples, because a clip's body is
audio data and its sample 0 sits at the clip's offset. The editor is the only
converter: one beat is sample_rate / tempo timeline units, so an audio take
placed at its own length sits 1:1 on the axis. A musical quant becomes the
lane's 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_secs →
secs_to_samples), not a second implementation.
One mapping rule, not a heuristic per case. The root Group's members are
the lanes; a lane's members are its clips; a Buffer clip draws its take, a
element of events draws a piano-roll, and a nested Group draws as a labeled
rectangle — its summary — until it is expanded into lanes of its own. That
collapse/expand is the arrangement's base level (the zoom that summarizes a
group or resolves it), so it needs no protocol of its own.
DEFAULT_PITCH
The pitch range a piano-roll lane falls back to when its notes give none (C3..C6 — the span a melodic line usually lives in).
PITCH_PAD
Semitones of headroom above and below the notes of a piano-roll clip.
Editor
class Editor()
A composition on screen: the arrangement tree drawn as a multitrack view, editable back into the tree.
Arguments:
-
element- the composition — aclausters.form.group.Group(its members become the lanes) or any singleElement(one lane). -
sample_rate- the engine's sample rate; withtempoit fixes the beats↔timeline-samples conversion. -
tempo- the clock's tempo in beats per second (theTempoClockconvention — 2.0 is 120 bpm). -
quant- the musical drag grid in beats (0.25= a sixteenth);0snaps to whole samples. -
follow- re-render on every edit (the live editor). -
extra- extra GuiDef nodes to place under the lanes (a transport panel, say). Their events are not the editor's:applyignores them, so a script can handle them itself. -
title- the window title. -
base_id- the first widget id the editor allocates. The default sits well above the idsclausters.gui.host.GuiHostassigns to windows it opens (from 1000), so the two never collide.Usage::
editor = Editor(song, sample_rate=server.sample_rate, tempo=clock.tempo, quant=0.25) editor.open(gui) # draw and open the window editor.apply(*gui.poll()) # a dragged clip moves the element editor.render(server, clock) # play the edited composition
Editor.units_per_beat
@property
def units_per_beat() -> float
Timeline samples per beat — the whole of the data↔view unit bridge. One timeline unit is one audio sample, so a take placed at its own frame count sits 1:1 on the axis.
Editor.beats_to_units
def beats_to_units(beats: float) -> float
Beats → timeline samples, through the core's own time arithmetic (the seconds→samples rounding every client shares).
Editor.units_to_beats
def units_to_beats(units: float) -> float
Timeline samples → beats: the inverse the edit-back path takes to turn a dragged clip back into a placement.
Editor.expand
def expand(element) -> "Editor"
Resolve a nested Group into lanes of its own (instead of the labeled
rectangle that summarizes it). The arrangement's base level, made an edit.
Editor.collapse
def collapse(element) -> "Editor"
Summarize a nested Group back into one labeled rectangle.
Editor.draw
def draw() -> dict
The composition as a window-rooted GuiDef: one track lane per
member of the root group, each holding its members as clips on the shared
time axis — and a graph patch for every logical group, whose
members relate by processing rather than by time (so a lane would be the
wrong shape for it). Pure — it builds the tree and the id registry, and
sends nothing.
Editor.open
def open(host, id: int | None = None) -> int
draw the composition and open it on host (a
clausters.gui.host.GuiHost). Returns the window id.
Editor.open_pianoroll
def open_pianoroll(host, element=None, id: int | None = None) -> int
draw a single events element as a dedicated piano-roll window
and open it on host — the editor-grade note view (a keyboard, an
editable note grid, a velocity lane, an OSC-event lane) of one MIDI/OSC
element, as opposed to open, where the same notes are only a clip body.
Edits write back through poll exactly as the multitrack does, when the
element is editable — a clausters.form.Track (a
clausters.seq.Timeline): a dragged, added or removed note is rebuilt onto
its timeline. A generator (a Pbind/Routine) is forward-only, so its
bounced notes are shown read-only (bounce it to a Track to edit). OSC
events are shown but not edited back yet (a marker carries only its time
and address, not the full message). Returns the window id.
Editor.extent
def extent(element=None) -> float
The composition's length in beats, read from the arrangement — the end of its last placed element. It is not a constant: move a clip past the end and the piece gets longer, which is exactly what a transport must ask (a hard-coded length would cut the playback short at the old end).
Editor.playhead
@property
def playhead()
The clausters.seq.Playhead playing the composition, or None before
the first render — what a transport (play/pause/stop/locate) drives.
Editor.window
@property
def window()
The open window's id, or None once it is closed (a /gui_closed
seen by apply/poll) — what a script's loop checks to stop.
Editor.update
def update()
Push the current arrangement back to the open window — a whole-tree
redefine (GuiHost.define), the honest way to show a structural edit (an
element added, a group expanded). A mere placement change needs no redefine: the
host already moved the clip that was dragged.
Editor.apply
def apply(addr: str, args) -> bool
Apply one message from the host to the arrangement. Returns whether the composition changed.
The clip edit-back (/gui_event <id> "clip" <offset> <dur>, the payload
a drag or a resize sends) is resolved through the widget registry to the
placement it came from and written with Group.move. The clip's offset is
absolute on the shared axis while a placement is relative to its
group, so the position converts back through the base the clip was drawn
at; and only what actually moved is written — a drag carries the clip's
unchanged dur along, and snapping that to the grid would silently
shorten the element. /gui_closed drops the window (its own — the
payload names the window id); anything else is ignored, so a whole poll
loop can be fed straight in — even one shared with a second editor
(a dedicated piano-roll beside the multitrack, say): every route resolves
through this editor's own registries, so another window's events fall
through untouched.
Editor.poll
def poll(timeout: float = 0.0) -> bool
Drain the host's pending messages into the arrangement (apply each).
Returns whether the composition changed. Call it from the script's loop —
never from the clock thread, which a routine must never block.
Editor.render
def render(destination, clock=None, *, at: float = 0.0, quant=None)
Render the composition onto destination — RT (a Server and a
running clock) or NRT (a score) — and anchor the lanes' playhead so the
line sweeps the clips as it plays. Returns the clausters.seq.Playhead.
This is the arrangement's own render (flatten to absolute beats, play
through a playhead): the editor adds no rendering path of its own, it only
remembers the destination so rerender can re-schedule after an edit.
Editor.rerender
def rerender(*, at: float | None = None)
Re-schedule the (edited) composition from the playhead's current position: stop, re-flatten, play again.
The honest semantics are re-schedule from here, not a sample-exact splice — a synth already sounding keeps sounding, and what changes is what has not been scheduled yet. In NRT there is no "already", so it is simply a fresh score.
Editor.position
@property
def position() -> float
The transport's position in beats: where the playhead is while it plays,
and where the next play starts when it does not.
Editor.play
def play(destination=None, clock=None, *, at: float | None = None)
Play (or resume) from the transport's position — a fresh render, so
it plays the composition as it now stands (moved clips, new lengths,
redrawn curves). Reuses the destination and clock of the last render
when they are not given.
Editor.pause
def pause()
Halt where we are: the playhead stops scheduling and the position stays,
so a play resumes from here. What is already sounding keeps sounding —
stopping a playhead is not a panic button (the script owns its voices).
Editor.stop
def stop()
Halt and return to the top.
Editor.locate
def locate(beat: float)
Seek: put the transport at beat. Playing, it re-renders from there
(so a seek also picks up any edit); stopped, it just moves the cursor the
lanes draw. This is what a click on a lane's ruler does.
Editor.anchor
def anchor(server, *, at: float = 0.0) -> bool
Anchor every lane's playhead to the engine clock, so the line starts at
beat at of the timeline and sweeps on with the audio. Returns whether
it could (a destination with no clock — an NRT score — has no playhead).
playhead_at is the sample-clock value at timeline position 0, which is
now minus the beats already played. The anchor is a query: it asks
the server for its clock, and a server that does not answer leaves the
lanes without a line — so the failure is reported, not swallowed (a
playhead that silently never appears is the worst of both).
Editor.unanchor
def unanchor()
Take the sweeping playhead line off the lanes (the transport's cursor, if any, stays). The host's anchored playhead tracks the engine clock, so a line left anchored keeps sweeping after the music stopped.
clausters.gui.host
GuiHost: the client object that drives a clausters-gui host.
The GUI host is a sibling OSC front of the audio server: it speaks the same
OSC encoding over the same transports, only the vocabulary is /gui_* instead
of the audio commands. So GuiHost reuses the existing OSC interfaces
(clausters.base.OscTcpInterface by default — the host listens on TCP at the
same port, so a /gui_def tree is not bounded by a UDP datagram —
clausters.base.OscUdpInterface with transport="udp") pointed at the
host's port rather than the server's, and builds messages with the existing
encoder — there is no parallel wire code here. Keep the split: building the
GuiDef tree (see clausters.gui.guidef) is host-agnostic; only this object
talks to the host.
This is the request/reply face used at the skeleton milestone: define sends
a whole tree, set/free mutate it, and query round-trips a widget's
state back through /gui_info. Event streams (/gui_event//gui_closed)
flow through the responder model (clausters.responders.OscFunc) and are wired
up as the interactive widgets land.
DEFAULT_PORT
The GUI host's default port, UDP and TCP alike (the host's
transport::DEFAULT_PORT), clear of the audio server's family
(UDP/TCP 57110, WebSocket 57120).
GuiHost
class GuiHost()
A connection to a running clausters-gui host.
transport picks the carrier: "tcp" (default — reliable, and a
/gui_def tree with its blobs can be as large as the host's frame
ceiling) or "udp" (each message must fit a datagram; for constrained
setups or a host started with --no-tcp).
GuiHost.boot
@classmethod
def boot(cls,
server: "str | None" = None,
*,
shm: "str | None" = None,
port: "int | None" = None,
transport: str = "tcp",
verbose: int = 0,
data_dir=None,
extra_args=(),
ready_timeout: float = 10.0) -> "GuiHost"
Start a clausters-gui visual-server process and return a GuiHost
connected to and owning it.
The launcher's ergonomic non-Session entry point for the GUI: it spawns
the host binary (its client leg pointed at server and, when given,
mapping the audio server's shm segment), waits until it answers, and
hands back a started GuiHost whose stop also stops the process (as
does interpreter exit). Pass a clausters.defs.Server's address and its
shm, or let clausters.Session.gui wire those for you.
Arguments:
server- the audio server address as"host:port", orNonefor a host with no client leg.shm- the audio server's shared-memory segment path to map (Unix only), orNoneto skip it.port- the GUI host's own port (UDP and TCP alike);Noneuses the default (57210).transport- the carrier thisGuiHosttalks over —"tcp"(default) or"udp".verbose- host log verbosity, likeclausters.defs.Server.boot.data_dir- the host's--data-dirfor its GuiDef store.extra_args- extra host CLI tokens.ready_timeout- seconds to wait for the host to answer.
Returns:
A started, process-owning GuiHost.
GuiHost.stop
def stop()
Close the connection and, if this host boot-ed a clausters-gui
process, stop it too.
GuiHost.open
def open(tree: dict, *blobs: bytes, id: "int | None" = None) -> int
Open a window from a window-rooted GuiDef and return its id.
A thin, id-managing wrapper over define: with id=None an id is
assigned for you (and remembered so close / close_all can free it);
pass an explicit id to name the root yourself (e.g. to set its
children by their own ids later). Editing the open window is set;
closing it is close. Any trailing blobs ride along exactly as in
define.
GuiHost.close
def close(id: int)
Close a window opened with open (or any widget subtree): /gui_free
destroys the subtree and, for a window root, its OS window. The
counterpart to open; set edits a window in between.
GuiHost.close_all
def close_all()
Close every window still open through open. Handy at the end of a
live session before dropping the host.
GuiHost.define
def define(id: int, tree: dict, *blobs: bytes)
/gui_def <id> <json> [blob…] — build a whole widget tree in one
message. Any trailing blobs (e.g. waveform samples from
clausters.gui.guidef.samples_to_blob) ride alongside the JSON and are
referenced by index from a widget's blob property.
GuiHost.set
def set(id: int, **props)
/gui_set <id> <k> <v> ... — update one live widget. Property types
are preserved: a Python int rides as an OSC int, a float as an
OSC float.
GuiHost.free
def free(id: int)
/gui_free <id> — destroy a widget and its subtree.
GuiHost.bind
def bind(id: int, address: str, *prefix)
/gui_bind <id> "server" <address> <prefix…> — forward this widget's
value straight to the audio server, bypassing this script.
On every change the host sends address (an OSC path like /n_set
or /c_set) with the fixed prefix arguments followed by the
widget's value — e.g. bind(10, "/n_set", node_id, "freq") makes knob
10 send /n_set <node_id> freq <value> to the server itself, so the
control responds with no round-trip through Python (the low-latency
path). A bound widget stops emitting /gui_event; unbind restores it.
The host must have been started with --server for the value to reach
the audio server. prefix items keep their type (an int rides as an
OSC int, a str as a string).
GuiHost.unbind
def unbind(id: int)
/gui_bind <id> (no target) — remove a widget's binding, so its value
flows back to this script as /gui_event again.
GuiHost.query
def query(id: int, timeout: float = 1.0)
/gui_query <id> -> the /gui_info reply as (type, props).
Returns None on timeout. An empty type ("") means the host
has no such widget — it still answers, the way the server replies even on
a miss.
GuiHost.poll
def poll(timeout: float = 0.0)
One inbound message as (addr, args), or None within timeout.
The receive side of the protocol: the host pushes /gui_event (a widget
was interacted with) and /gui_closed (a window was closed) back to the
script that built the window. Drive an interactive panel by polling this
in a loop, or wrap it with a clausters.responders.OscFunc-style dispatch.
GuiHost.listen
def listen(duration: float, handler)
Polls events for duration seconds, calling handler(addr, args)
for each. A small convenience for scripts and demos; for anything richer,
use poll with your own loop or the responder model.