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, the Routine/yield coroutine layer, a TempoClock that 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) and FaustDef (from signals, from boxes, or from Faust source), plus the Node/Bus/Buffer handles. The Server owns 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, ...) and Pbind, and EventStreamPlayer, with yield-exact timing.
  • Ergonomic defaults without global state (clausters.Session): bundles a Server and 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, defs and 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 Faustlibfaust 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 (default embed,realtime).
  • CLAUSTERS_SKIP_NATIVE_BUILD — package the libs already staged in clausters/_libs/ without rebuilding.
  • CLAUSTERS_FFI_LIB / CLAUSTERS_LIB — at runtime, point a loader directly at a cdylib (overrides the bundled copy and the workspace target/).
  • CLAUSTERS_SKIP_GUI_BUILD — at build time, skip building/bundling the clausters-gui binary (a lighter, server-only wheel).
  • CLAUSTERS_GUI_BIN — at runtime, point the launcher directly at a clausters-gui host binary (overrides the bundled copy and the workspace target/).

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 for Session.live() and Server:

    [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 to 127.0.0.1:57110 with the configured latency. Passing host, port or latency explicitly overrides the file:

    Session.live()                 # uses [client] from the config
    Session.live(host="otherhost") # host explicit, port/latency from the config
    
  • [server] — the defaults for ServerOptions (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 = 32
    

    ServerOptions() then reads those values; a field passed to the constructor still wins. ServerOptions.args() turns them into clausters CLI flags (--outputs only when set), so a server launched from the object matches it; Server.query_info() reads the same fields back from a running server (including input_channels and the pool sizes) as a ServerInfo.

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 as f32 through 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.
  • streamRoutine/Stream, the yield coroutine layer. A routine must never block the clock thread.
  • clockTempoClock, 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, ...) and Pbind. The random patterns (Pwhite, Prand) draw from the random context — the running routine's generator, derived at creation from the context that created it, with main.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 as clausters.next_f64() / uniform(lo, hi) / next_below(n) / choice(items).
  • EventStreamPlayerPbind(...).play(clock, server) runs live or builds an NRT score depending on which interface the Server holds, with yield-exact timing (monotonic pacing, wall-clock timetags).

clausters.defs — the server side

  • ugens — lowercase callables producing Ugen/Control, and SynthDef (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 from signals (Faust's Signal API as lowercase callables), from boxes (its Box API — point-free, with boxes.faust opening 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/Buffer handles and their allocators.
  • clocksync — models the server's sample clock over UDP (Server.sample_clock()) for drift-free /sched timing without shared memory.
  • Serverowns 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 include OscUdpInterface, OscTcpInterface (length-prefixed OSC; start the server with --tcp), and OscWsInterface (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_faust and 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:

ConstructorPayloadUse it for
FaustDef.from_signals(name, *outputs)a signal tree built with clausters.defs.signalsgraphs 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 dictpoint-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 stringwriting 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:

GroupHow
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:

KindFunctions
Unarysin cos tan asin acos atan exp exp10 log log10 sqrt abs floor ceil rint
Binarymin max pow atan2 fmod rem

Sources and structure:

CallableBuilds
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:

CallableWhat 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, TAUplain 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:

CallableControl 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:

CallableBuilds
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) compiles fi.lowpass(3). Structural parameters — a filter order, a table size, a list of coefficients — must live here; they cannot travel as signals. Formatting: int/float as 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 Box are composition-stage: boxes wired to the box's signal inputs, sugar for seq(par(args), box). The call must cover all the inputs — use box.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 asWhen 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:

ArgumentMeaning
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 rateir (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:

GroupCallableDoes
Sourcessin_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 inputin_(bus=0.0)reads an audio bus (sampled per block)
in_ctl(bus=0.0)reads a control bus (constant over the block)
Bus outputout(bus, signal)sums signal into an audio bus
replace_out(bus, signal)overwrites an audio bus instead of summing
Buffersplay_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
Feedbacklocal_in(channel=0.0)reads a synth-private feedback channel
local_out(channel, signal)writes a feedback channel, and passes signal through
Smootherslag(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
Fusedmul_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-effectsend_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
Spectralfft(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 fftifft 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_recv payload.
  • sdef.control_names() — the control names in spec order (parallels FaustDef.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

FaustDefSynthDef
Sent with/d_faust/d_recv
Built fromclausters.defs.signals / clausters.defs.boxes / Faust sourceclausters.defs.ugens
CompiledJIT on the server (libfaust/LLVM)assembled from the server's UGen registry
Mathsfull (trig, exp/log, comparisons, tables)full (the same operator set, as generic op UGens)
Feedbackrec / self_ (one sample)local_in / local_out (one block)
Granularityper sample — you write the DSPper unit — you wire DSP the server implements
Reusethe Faust libraries (os., fi., re., pm., …)the UGen set (see the table above)
Server buildthe faust feature — default, and bundled in the wheelthe 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=False only sends, after which sync() is the barrier before the /s_new that needs the def.
  • In NRT (a score interface) add_* always scores the def at time 0 — the renderer compiles it before time advances — so wait does 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 (default True). Pass boot=False for plain attach-only behavior: connect to a server you launched yourself (possibly remote), never starting a process. When booting, options (a ServerOptions that sizes the launched server and this client's allocators), shm ("auto", a path, or None), and verbose/data_dir/server_args shape 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.0 means "as soon as possible"; a small value such as 0.1 is typical for a live take.
  • timebase — the clock's pacing source. The default paces in wall-clock seconds (monotonic); passing a SampleClockTimebase anchors 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:

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

CallKindWhat it does
play(pattern, quant=None)allPlays 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)offlineDrains 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-timeStarts the clock, advances it in real time for seconds, then stops. Returns self. (live and embed.)
start() / stop()real-timeStart or stop the real-time clock yourself when run (which does both) is not enough. Both return self.
close()allCloses 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 Session and 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, a TempoClock and a Server yourself.
  • Timing models — the ways a clock keeps time (wall-clock, sample-locked, shared transport) and how to observe each.
  • Examplesoffline_render.py and live_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 Routinewhat happens over time, written as a Python generator that yields how long to wait;
  • a TempoClockwhen it happens: it schedules the routine and keeps musical time in beats;
  • a Serverwhere 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 Event carries musical defaults (see the API reference): midinote (or degree, or an explicit freq) sets pitch, amp the level, instrument the def (the server has a built-in default sine). Timing comes from dur: the note's delta (beats to the next event) is dur, and its sustain (how long it sounds) is dur * legato (legato defaults to 0.8). As in SuperCollider, an explicit delta or sustain key 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. Use clock.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 referenceTempoClock, Routine, Event and the Server methods 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:

CallWhat 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).
  • Examplestimeline_transport.py, the playhead live.
  • API referenceTimeline, 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.

ModelIn one lineHow to select it
Wall-clock OSC timethe client's own clock; works everywhere, including with no serverthe default — TempoClock(tempo)
Sample clocklocks to a server's sample counter; drift-free, sample-exactclock.lock_to(server)
Shared transporta server-hosted beat grid several clients align onclock.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 /clock anchor 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_to leaves 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:

  • quantclock.play(routine, quant=4) (or session.play(pattern, quant=4)) snaps the routine's start to the next beat that is a multiple of quant (a bar in 4/4). None or 0 starts 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 transportclock.join_transport(server) (or Session.join_transport()) adopts the server's /transport grid: its tempo and an origin every client shares. Now quant snaps to that grid, so every client on it hits the same bar. One client (the conductor) defines it with server.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…ModelHow
nothing / another OSC programwall-clock OSC timethe default — do nothing
a remote server across a networkwall-clock OSC timethe default (NTP/PTP-quality sync)
a local / LAN Clausters serversample clocklock_to (drift-free, the master)
several clients, one serversample clock + transporteach 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 /clock round trips); call before start/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 next quant-beat boundary of the current grid.

See also

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 /clock anchor, 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 Playhead on 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_transport most 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 a quant that 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 gridserver.transport()(origin_sample, tempo) or None
Join the grid (follower)clock.join_transport(server) / Session.join_transport()
Leave itclock.leave_transport()
Start on the next barclock.play(routine, quant=4) / session.play(pattern, quant=4)
Align to the sample, not just the beatclock.lock_to(server) as well (see Timing models)
Follow live tempo changesan OscFunc("/transport.reply", …) that re-join_transports (see Receiving OSC and MIDI)
Roll a playhead from a conductorserver.transport_play() / transport_stop() / transport_locate(beat); followers playhead.follow_transport(server, quant=4)
Read the rolling stateserver.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.
  • Examplestransport_sync.py (two clients on one bar) and osc_responder.py (the live transport reaction).
  • The Clausters server book/transport and /clock on 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:

ElementWhat it isWraps
Eventparameters grouped into one actionclausters.seq.Event
Sequencestrict order, no concrete time — only sequencea list, or a Pattern
Buffera list at constant time (samples)clausters.defs.Buffer
Trackmixed placement of elements — a DAW trackclausters.seq.Timeline
Generatora process: server DSP, or a sequence generatora 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 Buffer clip 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's amp) — 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-grade clausters.gui.pianoroll widget — 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 expand it 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 Buffer element);
  • bass — a Pbind pattern: a generator the editor bounces to draw and the render bounces to play (a Sequence element);
  • lead — a melody of events placed by hand on a timeline (a Track element);
  • sweep — a frequency envelope and the voice it drives, grouped so they are one clip (an Automation inside a Group).

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

  1. Setup: a session, two instruments, a take — the live session, the defs the piece needs, and a take bounced offline.
  2. Elements: the five primitives — what an element is, and the piece's elements built one by one.
  3. Grouping: placing elements in time — the Group, placements, and the song tree.
  4. Rendering: hearing the arrangement — flattening, the playhead, and why every play re-reads the tree.
  5. The multitrack editor: the arrangement on screen — the window, the mapping rules, and the unit bridge.
  6. Editing on screen: the loop closed — the transport, the gesture → poll()play() rhythm, and follow.
  7. Automation: a curve as an element — the sweep, the layered clip, and editing the curve in place.
  8. The logical side: groups as signal graphs — the other grouping kind, and the patcher.
  9. Bouncing: the piece as a file — the same tree, offline.
  10. 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

ElementConceptual nameWhat it isWraps
Eventevent/clipparameters grouped into one actionclausters.seq.Event
SequenceListstrict order, no concrete time — only sequencea list, or a Pattern
BufferBuffera list at constant time (samples)clausters.defs.Buffer
TrackSetmixed placement of elements — a DAW trackclausters.seq.Timeline
GeneratorFunctiona process: server DSP, or a sequence generatora 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:

  1. to_timeline(song) — flatten to absolute beats. Contained generators (the bass Pbind) are bounced in this pass: the pattern runs offline on a throwaway clock and its events are recorded at their logical beats. A Sequence that 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.
  2. Playhead(timeline, clock, destination) — a transport over that timeline.
  3. 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_rate and tempo close the unit bridge (below);
  • quant is the musical drag grid, in beats — 0.5 means clips will snap to half-beats, both on screen and in the data;
  • the window is titled, and the editor allocates its widget ids from base_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 Buffer clip 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.

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_buffer above 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.gui and clausters.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)

dirtyEditor.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)

followEditor.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)

pollEditor.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)

rerenderEditor.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) fires func on incoming OSC messages whose address matches path.
  • MidiFunc(func, midi_msg) fires func on 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:

  • OscReceiver binds a UDP socket and decodes each datagram (bundles unwrapped) through the same single decode door the server uses.
  • MidiReceiver opens a virtual MIDI input port (through the clausters-midi crate's live feature) 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):

  • msg is the message as a list — [addr, arg1, arg2, …].
  • time is the bundle's Unix time, or None for an immediate or bare message.
  • src is 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 /note to the server, and re-align on a /transport.reply push. Runs against a live server, self-feeding a few messages to demonstrate.
  • midi_responder.py — a MidiFunc turning 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): a SynthDef whose controls show the three control types — a lagged freq (portamento glide), a gate trigger (rate="tr", re-plucking a percussive envelope each set), and a detune drawn once with rand (an ir scalar). A Routine sets freq / gate per 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): a SynthDef that 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-domain fftpv_brick_wallifft chain (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 the clausters command (or use cargo 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 a quant-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: an OscFunc relays incoming /note messages to a running server as synths, and an OscFunc on /transport.reply re-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 — a MidiFunc turning 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 the live cdylib (cargo build --release -p clausters-midi --features live).

    python clients/python/examples/midi_responder.py
    
  • timeline_transport.py — DAW-style transport over a static Timeline: captures a pattern into a timeline, edits it, then drives it live with a Playheadplay, locate (seek), loop, and a song position. 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 followers follow_transport a running server's shared transport and roll together (sample-exact, since they also lock_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

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 - the Server to drive -- a live one, or one holding an OscNrtInterface for offline rendering.

  • clock - the TempoClock that 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; None takes the config file's [client].host (default 127.0.0.1). Booting is local.
  • port - the server's UDP port; None takes [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. None takes the config file's [client].latency.
  • timebase - the clock's pacing source. The default (monotonic) paces in wall-clock seconds; a SampleClockTimebase anchors timing to the server's sample clock for drift-free scheduling.
  • boot - start a server if none is already answering (default). False attaches only, never launching a process.
  • options - a clausters.defs.ServerOptions sizing a launched server and this client's allocators alike; None uses the defaults.
  • shm - the shared-memory segment for a launched server — "auto" picks one, a path forces it, None launches without one. The path is remembered so gui maps the same segment.
  • transport - the command carrier — "tcp" (default), "udp" or "ws"; None takes [client].transport from the config.
  • verbose - launched-server log verbosity (1/2/3 -> -v/ -vv/-vvv; negative -> -q).
  • data_dir - a launched server's --data-dir; None uses 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 existing clausters.Clausters handle to reuse; when omitted the session opens and owns a fresh embedded server and closes it on close.

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); None uses the host default (57210).
  • transport - the carrier this session talks to the host over — "tcp" (default; a /gui_def tree is not bounded by a datagram) or "udp".
  • verbose - host log verbosity (1/2/3 -> -v/-vv/-vvv).
  • data_dir - the host's --data-dir for 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. a Pbind.
  • quant - optional quantization handed to the player -- the beat grid the routine starts on; None starts 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 callback func(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 of None matches 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, or None (matches anything). Shorter than the message is fine — only the listed positions are checked.

  • recv - the clausters.base.OscReceiver to register with; defaults to default_osc_receiver.

    The responder is enabled on creation. Call free (or disable) 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 callback func(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, or None (matches anything).

  • recv - the clausters.base.MidiReceiver to register with; defaults to default_midi_receiver.

    Enabled on creation; free (or disable) 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 with cargo 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 with clausters --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 - the TempoClock to run on; when None it falls back to main.default_clock and raises if that is unset.
  • quant - start quantization, forwarded to the clock (see TempoClock.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 the timebase, 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 a SampleClockTimebase to 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 - a Routine, any Stream, or a one-shot callable.
  • quant - start quantization -- the routine starts on the next beat that is a multiple of quant (e.g. 4 = the next bar in 4/4). None or 0 starts 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. sample is any callable returning the current sample count (e.g. Clausters.clock or ShmClient.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 - the Timeline to play.
  • clock - the TempoClock that drives it (start it for live playback; render it for offline).
  • destination - where items go — a Server or a MidiServer.

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 to faust are 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 a Box call are composition-stage: boxes wired as the box's signal inputs, sugar for seq(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_set holds for one block, then the server resets it to 0 (drives an env_gen gate, a sample-and-hold).
  • rate="ir" — a scalar: read once at init and frozen; a later /n_set is ignored. As ir it may feed an ir input (rand, buffer-info UGens).
  • lag (seconds) — smooth a kr control's changes with an implicit one-pole (a lag/var_lag UGen the server inserts); lag_down gives 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 durationlevels 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 - a ServerOptions sizing the launched server and this handle's allocators alike; None uses the server's defaults.
  • shm - the shared-memory segment — "auto" picks one, a path forces it, None launches without one. Remembered for a GUI to map.
  • transport - the carrier this handle talks over — "tcp" (default), "udp" or "ws" (a --ws server). 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; None uses 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:

  • Eventevent/clip: parameters grouped into one action (internally simultaneous), with its own onset/duration. Wraps clausters.seq.Event.
  • SequenceList: strict order with no concrete time, only sequence. Wraps a Python list or a Pattern.
  • BufferBuffer: a list at constant time (audio or control samples). Wraps clausters.defs.Buffer.
  • TrackSet: mixed placement of elements, a DAW track. Wraps clausters.seq.Timeline.
  • GeneratorFunction: 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 (or None for a pure container like a Group).
  • onset - start in beats relative to the context, or None.
  • duration - length in beats, or None.

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 - the clausters.defs.Buffer on the server.
  • instrument - the def that plays it (its buf control gets the buffer number), or None for a buffer that is data only.
  • controls - extra event parameters passed to that def (amp, rate…).
  • onset - start in beats relative to the context, or None.
  • 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 (a str matching a Group bus), or "OUT" (hardware). Used by Group.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 bare Element (placed at offset 0).
  • kind - CONCRETE (default) or LOGICAL.
  • name - the composition's name — the GraphDef name for a logical group.
  • buses - internal buses for a logical group — each a name (audio, 1 channel) or a (name, rate) / (name, rate, channels) tuple.
  • onset - the group's own onset in its parent context, or None.
  • duration - the group's own duration, or None.

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's offset (and any nested group's) accumulates into the child's absolute beat.
  • Track — its Timeline's items are shifted by the placement beat.
  • Event — placed as a single item at its beat.
  • Sequence/Generator wrapping an event pattern (a Pbind) — bounced in the same pass (its change of state); a Sequence of 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).

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 (see peaks_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 with channels > 1 holds every channel in the one file.
  • path — a path to a file of raw little-endian f32 samples (see samples_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_def message (see samples_to_blob and GuiHost.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.tempobeat_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-endian f32 (see samples_to_file, or an NRT render written out) the host memory-maps; the bulk path, no OSC. channels de-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 (see samples_to_blob and GuiHost.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_rate labels real time, and tempo/beat_at/ quant label 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 the waveform uses; read the clock with Server.request("/clock")). Set it live with GuiHost.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-rollnotes, 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-grade pianoroll widget draws the same notes with a keyboard and editing; or
  • an automation curvepoints, break-points over the clip's span (the bpf editor's break-points and shape math, placed on a lane): times relative to the clip in samples, values over [min, max] (exp=True gives 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 the bpf view sends, so an clausters.seq.Automation consumes 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 (see peaks_cache_file); the most compact bulk path, raw samples never loaded.
  • path — a file of raw little-endian f32 the host maps (see samples_to_file); channels de-interleaves it, base_bucket sizes 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 — see samples_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, pitch a MIDI note number drawn over the [min, max] window (default the 88-key range 21–108), velocity 0..127 (default 100), channel 0..15. The notes are the MIDI messages the roll represents.
  • osc — an iterable of (time, label) (or bare time) 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 in u.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 (like meter).

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_secssecs_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 — a clausters.form.group.Group (its members become the lanes) or any single Element (one lane).

  • sample_rate - the engine's sample rate; with tempo it fixes the beats↔timeline-samples conversion.

  • tempo - the clock's tempo in beats per second (the TempoClock convention — 2.0 is 120 bpm).

  • quant - the musical drag grid in beats (0.25 = a sixteenth); 0 snaps 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: apply ignores 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 ids clausters.gui.host.GuiHost assigns 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", or None for a host with no client leg.
  • shm - the audio server's shared-memory segment path to map (Unix only), or None to skip it.
  • port - the GUI host's own port (UDP and TCP alike); None uses the default (57210).
  • transport - the carrier this GuiHost talks over — "tcp" (default) or "udp".
  • verbose - host log verbosity, like clausters.defs.Server.boot.
  • data_dir - the host's --data-dir for 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.