Tzopilotl Music Cookbook
Task-oriented recipes for making sound and music with the Tzopilotl audio platform — synthdefs, node graphs, silos, scheduling, generative sequences, actors, and the music libraries.
This is a cookbook: each section states the concepts, points at the functions you will call (and where to read more), gives working code, and then explains how that code works. Every runnable example here was executed against the build in this repository. For language syntax see Tzopilotl By Example; for the embedding and FFI details see the FFI Guide; for the full list of built-ins see Builtin Functions.
1. Orientation
1.1 The four layers
The platform is four cooperating pieces, all reachable from Tzopilotl code:
- The language VM (
lang/) — a statically typed, real-time-safe interpreter. This is what runs your.xfile. - The synthdef compiler — turns a signal-flow graph you
write in Tzopilotl into an optimized native
.dylibplugin. - The audio engine (
engine/) — loads those plugins as node defs, builds a live audio graph of nodes, and processes audio sample-accurately. It is organized into silos (independent real-time processing units). - The bridges (
bridge/) — FFI modules that expose the engine, the compiler, the clock, OSC and NATS to Tzopilotl. You reach them withimport audio_engine.*;,import synthdef.*;,import clock.*;and friends.
your .x file ── lang VM ──┬── synthdef compiler ──> .dylib plugin ─┐
│ v
└── audio_engine FFI ──> engine: nodes, graph, silos
│
v
audio output
1.2 Three places code runs
A recurring idea in this cookbook is where a piece of code runs. There are three locations, and the same actor/message APIs work across all of them:
- NRT — the main VM, on the orchestration thread. This is where your script starts, where you build the graph, and where a "conductor" lives. "NRT" means non-real-time: it may allocate and block.
- Silos — each silo has its own VM on a real-time audio thread, driven sample-by-sample. Silo code is rt-restricted: it must not allocate from the system heap or block. This is the natural home for musical scheduling, because a silo actor can wake exactly on the beat.
- Other processes — a separate Tzopilotl process, reached over NATS. Messages are ordinary values serialized to a compact binary form, so the same message travels unchanged between any of these locations (§8.2).
1.3 Running the examples
Tzopilotl runs as a desktop application, tzpl_app: a multi-tab code
editor above an output panel. You type code in the editor and evaluate it from the
keyboard — there is no separate REPL prompt and no Run button. Three shortcuts
choose what to evaluate:
| Keys | Evaluates |
|---|---|
| Cmd+Enter | the selection, or — with nothing selected — the block around the cursor (the surrounding run of non-blank lines) |
| Shift+Enter | the current line |
| Cmd+Shift+Enter | the whole file (the active tab) |
Output from print/println, evaluation results (shown as
=> value : type), and compile or runtime errors (marked !!)
appear in the output panel below the editor. Open an existing .x file
with File → Open (Cmd+O); the editor
evaluates against the standard library and bridge modules, so the
import lines in each recipe resolve as-is.
To try a recipe, paste it into a tab, place the cursor in it, and press
Cmd+Enter (or Cmd+Shift+Enter
to run the whole tab). Audio is silent until your code calls
engineStart(); after that, sound continues for as long as the app is
open, and you keep shaping it by evaluating more code.
import lines they need. The common
ones are audio_engine (engine control), synthdef +
synthc.compile + common_ugens (writing synths),
clock (tempo scheduling), futures (async helpers),
actors + message + messageEncoding (actors and
messages).2. Writing a SynthDef
2.1 Signal graphs, the S type, and outlet
A synthdef is a function that builds a signal-flow graph and
returns it. Every audio expression has type S (an alias for
SignalExpr). You compose unit generators (UGens) with ordinary
operators and the pipe operator |>, and you mark the graph's
output with outlet. The graph is then handed to defSynthX,
which compiles it to a native plugin and registers it with the engine under a
name you choose.
Numbers auto-lift to constant signals, arrays of signals become multichannel
(stereo is just [left, right]), and functions auto-map over
channels — so [440.0, 441.0] sinosc is a two-channel detuned
pair. The UGen library lives in lang/modules/common_ugens.x
(oscillators, noise, envelopes) and lang/modules/filters.x (filters);
the compiler internals are described in
synthdef-compiler/ARCHITECTURE.md. This chapter is a quick tour;
the full guide — signal rates, element types, channel broadcasting,
delays, buffers, voicers, and a complete UGen reference — is
Writing SynthDefs.
defSynthX is the production compiler (the Tzopilotl-hosted
synthc). An older defSynth calls the legacy C++ compiler
and exists mainly as a differential oracle. Prefer defSynthX. Both
need a working clang toolchain on PATH — they emit
C++, compile it to a .dylib, and dlopen it. Both are
async: the clang step runs in the background so music that is
already playing never pauses. Write mySynth defSynthX("mySynth") await; when the next line needs the def (its
first play); when redefining a def that is already playing, drop the
await — players hot-swap to the new version when it loads.2.2 A free-running synth
The simplest kind of synth is free-running: it sounds continuously from the
moment its node is created, with no notes to trigger — the contrast with
the polyphonic voice in the next section. Here is a burbling sine, compiled and
registered as "bubbles":
import synthdef.*;
import synthc.compile.*; -- defSynthX
import common_ugens.*;
import audio_engine.*; -- listSynthDefs
-- An LFO-driven sine through a comb filter. `nnhz` converts a MIDI-style
-- note number to Hz; `|>` pipes the left value in as the last argument.
fn bubbles() S =
0.4 lfsaw * 24
+ [8, 7.23] lfsaw * 3
+ 81
|> nnhz sinosc * 0.04
|> combn(0.2, 4) outlet;
bubbles defSynthX("bubbles") await;
"defs: " print; listSynthDefs() println;
Evaluating this (Cmd+Shift+Enter) prints the compile/link commands to the output panel and then:
bubbles compiled successfully (synthc).
defs: [+, voicer, Audio In, bubbles, Audio Out, *, sinosc]
How it works. defSynthX walks the returned
S graph, generates SIMD C++ for it, compiles and links a
.dylib, and registers it as a node def named "bubbles".
listSynthDefs() now shows it alongside the engine's built-in defs
(voicer, sinosc, +, *, and the
hardware endpoints Audio In/Audio Out). The synth is now
ready to instantiate as a node (next section).
2.3 A polyphonic voice
For a playable instrument you wrap a single voice in
voicer(maxVoices, voiceFn) and sum the voices. A voice reads its
per-note inputs with noteParam(name, spec) and its note on/off
trigger with gate() (gate is voice input 0, injected automatically).
import synthdef.*;
import synthc.compile.*;
import common_ugens.*;
import audio_engine.*;
-- One voice: pitch + amp note params, an ADSR envelope driven by the gate.
fn sineVoice() S {
let pch = noteParam("pitch", ControlSpec { lo: 0.0, hi: 127.0, init: 60.0, warp: ControlWarp.linear });
let amp = noteParam("amp", ControlSpec { lo: 0.0, hi: 1.0, init: 0.5, warp: ControlWarp.linear });
let env = gate() adsr(0.01, 0.1, 0.7, 0.3);
pch nnhz sinosc * env * amp
}
-- 16-voice polyphonic synth; `sum` mixes the voices, `outlet` is the sink.
fn sineSynth() S = voicer(16, sineVoice) sum outlet;
sineSynth defSynthX("sine_voice") await;
How it works. voicer(16, sineVoice) instantiates the
voice graph 16 times with independent note state; sum reduces them to
one stereo signal. noteParam values arrive in the order they are
declared (here index 1 = pitch, index 2 = amp; index 0 is always the gate), which
is the layout you pass to noteOn when you play the synth in
section 4. The adsr envelope opens when the
gate goes high (note on) and releases when it goes low (note off).
clang to build a .dylib. To
skip that while prototyping note logic, use the engine's built-in
"voicer" node def (used throughout the silo recipes below) — it
needs no compilation. Its note params are
[pitch, amp, drive, pan, attack, release].2.4 A band-limited wavetable oscillator
The lf* oscillators alias at high frequencies, and
blip only makes impulse spectra. For an arbitrary waveform that is
alias-free at any pitch, use osc: a wavetable oscillator
that reads from a bank of 30 tables, one per 1/3 octave, each holding a
progressively band-limited copy of the wave (after SAPF's Osc).
The synth declares a bufferVar() slot; the bank itself is computed
on the engine side by the wavetables module and pushed in with
fillBuffer:
import synthdef.*;
import synthc.compile.*;
import common_ugens.*;
import wavetables.*;
import audio_engine.*;
-- a saw that never aliases: table choice follows the frequency
fn wtsaw() S {
let b = bufferVar();
let f = control("freq", ControlSpec { lo: 20.0, hi: 8000.0, init: 110.0, warp: ControlWarp.exponential });
b osc(f) * 0.2 |> outlet
}
wtsaw defSynthX("wtsaw") await;
engineStart();
begin();
newNode("wtsaw", 102);
fillBuffer(102, 0, 1, sawTables()); -- compute the bank, swap it in
connect(102, 0, 0, 0);
sched(0);
Sweep the freq control up and down: unlike lfsaw, the
top of the spectrum stays clean because osc re-selects (and
crossfades between) tables as the frequency moves, keeping every partial below
Nyquist.
How it works. sawTables() builds each table by
writing a band-limited slice of the 1/i harmonic series into a spectrum and
running it through the ifft builtin (any partial recipe works:
squareTables(), triTables(), or your own amplitudes and
phases via oscTables(amps, phases, smooth)). The smooth
argument counters the Gibbs phenomenon: a hard truncation of the harmonic series
(smooth = 0) overshoots and rings at the waveform's sharp edges, and
smooth > 0 fades the top of the series out with a
cos^smooth rolloff instead — less ripple, slightly mellower
top end (see Writing SynthDefs
§15.13 for the details).
fillBuffer hands the finished [Float] to the engine
inside the bundle, so the swap lands sample-accurately on the RT thread. With a
constant frequency the table choice folds to a one-time computation, like
SAPF picking its Osc implementation; a moving frequency (as here,
since freq is a control) takes the crossfading path. Details and the
phase-modulation forms are in
Writing SynthDefs §15.13.
3. Starting the engine & building a node graph
3.1 Engine lifecycle
The engine is started once and produces sound until stopped. The core controls
(all in audio_engine) are:
| Function | Meaning |
|---|---|
engineStart() | Open the audio device and begin processing. |
engineStop() | Stop processing and close the device. |
masterGain(g Float) | Set the master output gain. |
isAudioRunning() Bool | Is the audio callback running? |
safetyLimiter(on Bool) | Toggle the output safety limiter. |
3.2 Command bundles
Every change to the graph — creating nodes, connecting them, setting
inputs — is a command. Commands are gathered into an
atomic bundle with begin() and dispatched with one of:
go(silo)orsched(silo)— apply the bundle now.sched(silo Int, clock Int, beat Float)— apply it sample-accurately when tempoclockreachesbeat.
Bundling means a whole patch change lands on the audio thread together, between two samples — never half-applied. The dispatch call takes the silo index the bundle targets (silo 0 is the audio-callback thread; see section 5). The bundle is validated as a whole at dispatch: if any command in it is invalid, the entire bundle is discarded and the dispatch call returns the error.
3.3 Recipe: instantiate a synth and hear it
Nodes are created with newNode(defName, nodeId) and wired with
connect(srcNode, srcPort, dstNode, dstPort). Two node ids are
predefined: node 0 is the audio output and node 1 is the
audio input. Connecting your node's output port 0 to node 0's input port 0 routes
it to the speakers.
import audio_engine.*;
engineStart();
masterGain(0.2);
begin(); -- open a bundle on silo 0
newNode("bubbles", 101); -- instantiate the synth from section 2 as node 101
connect(101, 0, 0, 0); -- node 101 out 0 -> output (node 0) in 0
sched(0); -- dispatch the bundle now
And to stop it, free the node:
begin();
freeNode(101); -- remove the bubbles node -- silence
sched(0);
Evaluate it and you hear the bubbles synth — the engine keeps
playing for as long as the app is open. (You can use the engine's built-in
"sinosc" def the same way without compiling anything.)
How it works. newNode asks the engine to allocate an
instance of the named def; connect redirects node 101's output buffer
pointer into the output node's input (a zero-copy patch — no samples are
copied during processing). The engine re-runs a topological sort whenever
connections change, so the graph is always processed in dependency order. To take
a node down later, call freeNode(nodeId) inside a bundle; for a smooth
transition use the crossfading variants (connectX,
replaceNode). The full surface is in the
FFI Guide's Audio Engine section and
engine/Architecture.md.
4. Playing notes
4.1 Note commands
A voicer node (your sine_voice from section 2, the
engine's built-in "voicer", or a ready-made instrument from
§4.2) holds a pool of polyphonic voices. You
trigger and release voices by a note id you choose:
| From NRT | From inside a silo | Meaning |
|---|---|---|
noteOn(node, noteId, params) | playNote(node, noteId, params) | Start a voice. |
noteOff(node, noteId) | releaseNote(node, noteId) | Release that voice. |
The two columns differ in more than where they run. noteOn /
noteOff are bundle commands: they must appear between
begin() and sched()/go() (outside a bundle
they return an error), and they do nothing until the bundle is dispatched —
which is what lets them be beat-scheduled along with any other graph changes.
playNote / releaseNote bypass bundling entirely: called
from a task running inside a silo, they act on the voicer node
immediately, at the silo's current sample time, and they only work there (there is
no bundle and no scheduling — the running task is the schedule).
params is a [Float] of the voice's
noteParams, in declaration order. For your sine_voice it
is [pitch, amp]. The engine's built-in "voicer" takes
[pitch, amp, drive, pan, attack, release].
import audio_engine.*;
engineStart();
masterGain(0.2);
begin();
newNode("sine_voice", 200); -- the polyphonic synth from section 2.3
connect(200, 0, 0, 0);
sched(0);
-- A C-major chord: three voices with distinct note ids.
begin();
noteOn(200, 0, [60.0, 0.4]);
noteOn(200, 1, [64.0, 0.4]);
noteOn(200, 2, [67.0, 0.4]);
sched(0);
The chord holds until released — stop it with:
begin();
allNotesOff(200); -- release all held voices on the synth
sched(0);
How it works. Each noteOn claims a free voice in the
node's pool, keyed by the note id, and feeds it the param array (filling any
trailing params from the voice's declared defaults). A later
noteOff(200, 0) drops the gate on the voice with that id, so its
release stage plays out. Reusing a note id while it is still sounding retriggers
that same voice. Like all graph changes, note commands are bundled and can be
beat-scheduled with sched(silo, clock, beat) — which is exactly what
the generative and actor recipes below do.
4.2 Ready-made instruments: the instruments library
lang/modules/instruments.x is a library of note-playing
instruments, so you can play something real without writing a synthdef first.
It has two layers: voice-building UGen functions (a Karplus-Strong string
ksString, a ringing-filter bank ringBank, sample-bank
playback helpers, standard note parameters) for composing your own voices, and
six ready-made defs, each a polyphonic voicer wrapped around one synthesis
model:
| Def | Sound | noteOn params | Controls (index · name) | Out |
|---|---|---|---|---|
smpPerc | Percussive sample player: the resolved sample plays once, at the pitch-shifted rate, and stops at its end. Note-off is ignored. | [pitch, vel] | — | stereo |
smpLoopTail | Sample player: loops the sample's sustain loop while the note is held; on release the playhead runs on through the sample's own recorded decay. | [pitch, vel] | — | stereo |
smpLoopEnv | Sample player: keeps looping through sustain and release; an exponential envelope of release seconds shapes the tail. | [pitch, vel] | 0 release | stereo |
wtLead | Band-limited wavetable oscillator (§2.4) through a resonant lowpass; separate ADSR envelopes for amplitude and filter, the latter sweeping the cutoff up to envOct octaves. | [freq, amp] | 0 attack, 1 decay, 2 sustain, 3 release, 4 cutoff, 5 envOct, 6 res, 7 fattack, 8 fdecay, 9 fsustain | mono |
resonBank | Harmonic ringing-filter bank excited by gated noise: hold the note for a bowed, breathy tone; release it and the modes ring out over decay seconds. | [freq, amp] | 0 attack, 1 release, 2 decay | mono |
ksPluck | Karplus-Strong plucked string: a noise burst of pluck seconds excites a tuned string that rings for decay seconds; damp darkens it. Note-off is ignored. | [freq, amp] | 0 pluck, 1 decay, 2 damp | mono |
Two conventions to know:
- Each def is a factory.
ksPluckis not the graph function —ksPluck()returns it, with an optional voice count (default 8):ksPluck() defSynthX("ksPluck")compiles the standard def,ksPluck(16) defSynthX("ksPluck16")a 16-voice variant. Running the fileexamples/note_synthdefs.xregisters all six at the default voice count. - Note params are only what genuinely varies per note
—
[pitch, vel](MIDI-style, 0–127) for the sample players,[freq, amp](Hz, 0–1) for the synthesis models. Everything panel-like — envelope segments, cutoff, damping — is a node-level control, shared by all voices like the panel of a hardware synth: set it withsetControl(node, index, value)(a bundle command), using the indexes in the table.
The plucked string, end to end — compile, instantiate, play a chord, then turn the panel:
import synthdef.*;
import synthc.compile.*;
import instruments.*;
import audio_engine.*;
ksPluck() defSynthX("ksPluck") await;
engineStart();
masterGain(0.2);
begin();
newNode("ksPluck", 210);
connect(210, 0, 0, 0);
sched(0);
-- an open A-minor chord; ksPluck ignores note-off -- the strings ring out
begin();
noteOn(210, 0, [110.0, 0.8]);
noteOn(210, 1, [164.8, 0.5]);
noteOn(210, 2, [220.0, 0.45]);
noteOn(210, 3, [261.6, 0.4]);
noteOn(210, 4, [329.6, 0.35]);
sched(0);
-- turn the panel: darker, shorter strings -- then re-run the chord block
begin();
setControl(210, 2, 0.8); -- damp: roll the highs off the string loop
setControl(210, 1, 1.0); -- decay: ring time down to 1 s
sched(0);
And to stop:
begin();
freeNode(210); -- remove the instrument -- silence
sched(0);
How it works. Each def wraps
voicer(maxVoices, voiceFn) exactly as you did by hand in
§2.3 — the library just ships the
voice. noteOn params land in declaration order (the gate is
implicit, always input 0), and omitted trailing params take their spec
defaults, so noteOn(210, 5, [440.0]) plucks at the
default amplitude. Controls are read by the voices as shared values, so a
control change moves every sounding voice at once —
decay and damp sit inside the string's feedback loop
and re-shape strings that are already ringing. And because the synthesis
models declare [freq, amp], they are exactly the layout
music.play's freqVoice expects
(§11.6): play(tune, freqVoice(210)) plays a chapter 11 stream on the plucked
string.
4.3 Instruments with per-node data: wavetables and sample banks
Three of the defs read data you load into the node right after
newNode, in the same bundle. wtLead reads a wavetable
bank (buffer 0) built by the wavetables module, as in
§2.4:
import synthdef.*;
import synthc.compile.*;
import instruments.*;
import wavetables.*;
import audio_engine.*;
wtLead() defSynthX("wtLead") await;
engineStart();
masterGain(0.2);
begin();
newNode("wtLead", 211);
fillBuffer(211, 0, 1, sawTables(1.0)); -- the bank the oscillator reads
connect(211, 0, 0, 0);
sched(0);
-- hold a fifth; each note's filter envelope sweeps its own cutoff
begin();
noteOn(211, 0, [110.0, 0.5]);
noteOn(211, 1, [165.0, 0.4]);
sched(0);
-- play the panel while they hold: open the filter, sharpen the resonance
begin();
setControl(211, 4, 3000.0); -- cutoff up
setControl(211, 6, 0.15); -- res is 1/Q -- smaller is more resonant
sched(0);
These notes are sustained, so release them to stop:
begin();
allNotesOff(211); -- both notes play out their release stage
sched(0);
The smp* players resolve every note against sample
bank 0 of their node: an array of zones, each a sound
file mapped to a key range, velocity range, and root key. Substitute a couple
of files of your own:
-- zones: sampleZone(path, loKey, hiKey, loVel, hiVel, rootKey)
begin();
newNode("smpLoopEnv", 212);
loadSampleBank(212, 0, [
sampleZone("samples/epiano_c3.wav", 0, 59, 0, 127, 48),
sampleZone("samples/epiano_c4.wav", 60, 127, 0, 127, 72)
]);
connect(212, 0, 0, 0);
sched(0);
-- middle C, forte -- the sample players take [pitch, vel], MIDI-style
begin();
noteOn(212, 0, [60.0, 96.0]);
sched(0);
Stop it the same way — begin(); allNotesOff(212); sched(0);.
How it works. At note-on the voice looks its
[pitch, vel] up in the bank and latches the matching zone; the
playback rate comes from the semitone distance to the zone's root key,
corrected for the file's sample rate, so every sample sounds at the requested
pitch. Loop points come from the file itself when it carries them (the WAV
smpl / AIFF INST chunks most samplers write), or from
explicit loopStart/loopEnd frame arguments to
sampleZone; smpPerc ignores loops entirely. Choose the
player by what your files contain: smpLoopTail when the audio
after the loop is a recorded decay worth keeping, smpLoopEnv when
it isn't (its release control shapes the tail instead), and
smpPerc for one-shots like drums. Like fillBuffer,
loadSampleBank is a bundle command: the files are read and the
zones validated up front on the calling thread — a missing file or bad
loop spec returns an error instead of ever reaching the audio thread —
and the finished bank lands on the RT thread sample-accurately.
5. Loading scripts into silos
A silo is an independent real-time processing unit: its own audio node graph, its own Tzopilotl VM with a private memory pool, and a per-block tick that advances time against the silo's tempo clock. Silo 0 runs on the audio callback thread; silos 1..N run on dedicated worker threads. Putting parts on different silos is how you get true CPU parallelism. Because a silo VM runs on the audio thread it is rt-restricted: no system allocation, no blocking, only real-time-safe builtins.
You give a silo code with four calls (all in audio_engine):
| Function | Meaning |
|---|---|
attachVM(silo Int) Int | Create and attach a fresh VM to the silo. |
siloLoad(silo Int, code String) Future<String> | Compile + install a module on the silo; await it (resolves to "" or an error). |
siloStartAt(beat Float, silos [Int]) | Call each silo module's start() at a shared beat. |
detachVM(silo Int) Int | Destroy the silo's VM. |
The silo module is just Tzopilotl source, most conveniently written as a
triple-quoted multi-line string. A conventional start() entry point
kicks off the music; siloStartAt calls it on the grid.
import audio_engine.*;
import std.futures.*;
-- The silo module: an actor that plays a six-note scale, advancing time with
-- `await delay`. (Actors are covered in section 8; here just note that the silo
-- runs ordinary async code.) start() spawns it.
let taskCode = """
import audio_engine.*;
import std.message.*;
let scale = [60.0, 63.0, 65.0, 67.0, 70.0, 72.0];
async fn melody(self Actor<Msg>, init Msg) Void {
var i = 0;
while (true) {
playNote(101, i % 16, [scale[i % 6], 0.6, 4.7, 0.0, 0.01, 0.2]);
await delay(0.4);
releaseNote(101, i % 16);
await delay(0.1);
i = i + 1;
}
}
fn start() Void { spawn(melody, Msg.int(0)); }
""";
engineStart();
masterGain(0.3);
setTempo(0, 120.0);
-- A built-in `voicer` node on silo 0 (the melody triggers notes on node 101).
begin();
newNode("voicer", 101);
connect(101, 0, 0, 0);
sched(0);
attachVM(0);
let err = await siloLoad(0, taskCode);
"load err=[" print; err print; "]" println;
siloStartAt(clockBeats(0), [0]); -- begin now (current beat)
To stop it, tear down the silo's VM (which ends the melody task) and release anything still sounding:
detachVM(0); -- stop the silo's melody task (tears down the silo VM)
begin();
allNotesOff(101); -- release anything still sounding
sched(0);
How it works. siloLoad compiles the module on the
calling thread against the silo's rt-restricted target, then hands the installed
code to the silo's RT thread; the returned Future<String> lets you
await completion and check for errors before starting. The
start() entry spawns the actor; siloStartAt schedules
the start() call (on tempo clock 0) at the given beat, so several
silos can be launched together on a downbeat. From then on the silo's per-block
tick drives the actor, and await delay(beats) resolves on the audio
beat — the note timing is sample-accurate.
The task module above is an inline triple-quoted string, which is convenient for
short parts. For anything substantial, keep the task in its own .x file
and load it with siloLoadFile(silo, path) (from the silo
module) — the file equivalent of siloLoad:
import silo.*;
attachVM(0);
let err = await siloLoadFile(0, "parts/melody.x"); -- read + compile + load the file
It is just siloLoad(silo, readFile(path)) — readFile
returns the file's text. The file is an ordinary module with its own
imports and a start(); you can edit it in another editor
tab and re-load to iterate on a part without retyping it as a string.
siloLoad with new task code, or send the silo new messages
(§8.3) — without stopping the audio.6. Scheduling on the beat
There are two complementary scheduling layers.
Engine tempo clocks (sample-accurate, musical)
The engine has numbered tempo clocks. Bundles and note commands can be tied to a clock and beat so they fire sample-accurately:
| Function | Meaning |
|---|---|
setTempo(clock Int, bpm Float) | Set a clock's tempo. |
clockBeats(clock Int) Float | The clock's current beat. |
clockTempo(clock Int) Float | The clock's current BPM. |
sched(silo Int, clock Int, beat Float) | Dispatch the current bundle to silo at that beat. |
schedTempoChange(clock, atBeat, targetBPM, rampBeats) | Ramp tempo over rampBeats. |
import audio_engine.*;
-- Schedule a note exactly two beats from now on clock 0.
let now = clockBeats(0);
begin();
noteOn(200, 0, [60.0, 0.4]);
sched(0, 0, now + 2.0); -- silo 0; fires when clock 0 reaches now+2
The note has no scheduled release — stop it with:
begin();
allNotesOff(200); -- release the scheduled note
sched(0);
The NRT clock module (timed callbacks on the main thread)
For orchestration logic that runs on the main VM, import clock.*;
gives a tempo-aware callback scheduler. While audio runs it is slaved to the
engine's TempoClock slots — the same clocks the silos schedule
against — so a handler fires (latency early) as the actual engine slot
reaches the beat, whichever way the tempo was changed. Every scheduling
function also takes an optional clock slot as its FIRST argument; the slotless
forms mean slot 0.
| Function | Meaning |
|---|---|
setTempo(bpm Float) / getTempo(clock = 0) | Set slot 0's tempo / read a slot's tempo. (Other slots: audio_engine.setTempo(clock, bpm).) |
getBeats(clock = 0) Float | The engine slot's current beat. |
sched([clock,] deltaBeats Float, fn() Float) Int | Run a handler after deltaBeats; if it returns a positive number, reschedule that many beats later (SuperCollider Routine style). |
after([clock,] deltaBeats Float, fn() Void) / at([clock,] beat, fn() Void) | One-shot, relative / absolute. |
go([clock,] c Coroutine<Float>) Int | Drive a coroutine on the clock: each yielded Float is a beat delta. |
await delayBeats([clock,] beats) | Awaitable form of a one-shot handler: park an async fn (or the top level) until the engine slot reaches now + beats. |
await delayReal(seconds) | Awaitable wall-clock wait, independent of tempo. |
cancel(id Int) | Cancel a scheduled handler. |
import audio_engine.*;
import clock.*;
engineStart();
setTempo(120.0);
-- A repeating quarter-note tick. State lives in a Ref, since a handler captures
-- by value -- mutate through the Ref, not an outer `var`.
let n = &0;
sched(0.0, fn() Float {
n <- (*n + 1);
"tick " print; (*n) println;
if (*n < 4) { 0.5 } else { 0.0 - 1.0 } -- reschedule 0.5 beats later, or stop
});
setTempo/sched
clock and audio_engine both export setTempo
and sched with different signatures. The rule: a clock
scheduling call always ends in a handler (it runs code on the beat),
while audio_engine.sched takes no handler (it submits the pending
command bundle); clock.setTempo(bpm) is one argument,
audio_engine.setTempo(clock, bpm) is two. Both drive the same
engine clocks: tempo changed through either module — or from silo code
— moves clock callbacks and engine-scheduled bundles
together, since the engine's TempoClock slots are the single timeline.7. Generative sequences
The idiomatic way to generate notes or control values over time is an
async fn that loops, doing work and then await delay(beats)
to advance musical time. Inside a silo the delay resolves on the audio beat, so the
sequence is sample-accurate and naturally polyphonic with other silo sequences. The
event loop is single-threaded per VM, so thousands of these can interleave
cheaply.
import audio_engine.*;
import std.futures.*;
-- A silo module: a generative arpeggio that walks a chord and drifts upward.
let arpCode = """
import audio_engine.*;
import std.message.*;
async fn arp(self Actor<Msg>, init Msg) Void {
let chord = [0.0, 4.0, 7.0, 11.0]; -- semitone offsets
var root = 48.0;
var i = 0;
while (true) {
let pitch = root + chord[i % 4];
playNote(101, i % 16, [pitch, 0.3, 4.5, 0.0, 0.005, 0.2]);
await delay(0.25);
releaseNote(101, i % 16);
i = i + 1;
if (i % 8 == 0) { root = root + 2.0; } -- drift up a step every 8 notes
}
}
fn start() Void { spawn(arp, Msg.int(0)); }
""";
engineStart();
masterGain(0.2);
setTempo(0, 120.0);
begin();
newNode("voicer", 101);
connect(101, 0, 0, 0);
sched(0);
attachVM(0);
let err = await siloLoad(0, arpCode);
"load err=[" print; err print; "]" println;
siloStartAt(clockBeats(0), [0]); -- audible now
And to stop it:
detachVM(0); -- stop the arpeggio task
begin();
allNotesOff(101);
sched(0);
A module like this can also live in its own .x file and load with
await siloLoadFile(0, "parts/arp.x"). The same shape generates
control automation rather than notes — sweep a filter cutoff by
calling setControl(node, ctl, value) (or setInput) each
step instead of playNote.
coro fn ... yield beats coroutine,
launched with spawn(clock, coro()) (the audio_engine.spawn
overload). It yields beat-deltas the silo tick uses to reschedule it. New code
should prefer async fn + await delay, which composes with
the actor and Future machinery.How it works. Each silo VM has an event loop with a timer queue.
await delay(b) parks the coroutine on a timer for beat now+b;
the silo's per-block tick advances its clock to the audio beat, fires due timers,
and resumes the parked coroutines — all within the audio block's budget, so a
runaway sequence cannot overrun the block. Because everything is one cooperative
loop per silo, ordering is deterministic and there are no data races.
8. Actors & messaging
Actors are lightweight concurrent objects built on the same async event loop.
An actor is an async fn(self Actor<M>, init M) Void that loops,
awaiting messages of type M from its mailbox. Many
actors interleave on one VM; they run at NRT, in silos, and — over NATS
— in other processes, all with the same API. The full design is in
lang/modules/actors.x and the actor tests under
lang/tests/actors/.
8.1 Actor basics
| Builtin | Meaning |
|---|---|
spawn(behavior, init) Actor<M> | Create an actor and run it to its first receive. |
send(to Actor<M>, msg M) | Deliver a message (wakes a parked receiver, else queues). |
receive(self Actor<M>) Future<M> | await it to take the next message. |
register(a Actor<M>, name Symbol|String) Actor<M> | Name an actor in this VM (the spawner names it); returns the actor, so it chains off spawn. |
sendByName(name Symbol|String, msg M) | Deliver to a locally-registered name. |
runActors() | Drive the event loop until every actor is parked (NRT, blocking). |
serveActors() | Like runActors but parks when idle, woken by cross-thread delivery. |
Two actors bouncing a counter (from lang/tests/actors/pingpong.x):
import std.message.*;
-- Seed a Ref so `ping` can refer to `pong`, which is spawned after it.
let dummy = spawn(async fn(s Actor<Msg>, i Msg) Void {}, Msg.int(0));
let pongRef = &dummy;
let ping = spawn(async fn(self Actor<Msg>, init Msg) Void {
while (true) {
let m = await receive(self);
match (m) {
Msg.int(n): {
println("ping " $ (n toString));
if (n < 6) { send(*pongRef, Msg.int(n + 1)); }
}
_: {}
}
}
}, Msg.int(0));
let pong = spawn(async fn(self Actor<Msg>, init Msg) Void {
while (true) {
let m = await receive(self);
match (m) {
Msg.int(n): { println("pong " $ (n toString)); send(ping, Msg.int(n + 1)); }
_: {}
}
}
}, Msg.int(0));
pongRef <- pong;
send(ping, Msg.int(0));
runActors();
This prints ping 0, pong 1, …, pong 5,
ping 6. How it works. receive returns a
resolved Future if a message is queued, otherwise a pending one the
actor parks on; send resolves a parked receiver and re-queues it as
runnable. runActors turns that crank until everyone is parked again.
8.2 Msg messages
Messages are Msg values — a small universal tree
(bool, int, float, symbol,
string, vec) from std.message. The
same type serializes to a compact binary form (TZB) via
std.messageEncoding (encode / decode,
documented in FFI Guide §15), which is what makes a message
location-transparent: the identical value can be delivered locally, to a silo, or
across a process boundary.
import std.message.*;
import std.messageEncoding.*;
let m = Msg.vec([Msg.int(60), Msg.float(0.5), Msg.symbol('hit)]);
m toString println; -- [60, 0.5, hit]
let b = encode(m); -- Bytes (TZB binary form)
decode(b) toString println; -- [60, 0.5, hit] (round-trips)
Construct a chord-note message, match on it in the receiver with
match (m) { Msg.float(p): … }, and so on. The wire format and a
zero-copy reader are documented in the Binary Messages section of the
FFI Guide.
8.3 Commanding silo actors by name
A musical "conductor" at NRT can command an actor living in a silo. Whoever
spawns the silo actor gives it a name with register; the conductor
then addresses it with siloSend (immediate) or siloSendAt
(on a beat). Both are in actors.x:
-- actors.x
fn siloSend(silo Int, name Symbol, msg Msg) Void;
fn siloSendAt(silo Int, clock Int, beat Float, name Symbol, msg Msg) Void;
The silo side (adapted from
integration-tests/scripts/silo_actor_scheduled.x) plays each pitch it
receives; the module names it 'voice at the spawn site — the
behavior itself stays name-agnostic:
import audio_engine.*;
import std.futures.*;
-- Load the voice module into silo 0; the actor spawns and registers at load.
let voiceModule = """
import audio_engine.*;
import std.message.*;
async fn voice(self Actor<Msg>, init Msg) Void {
var id = 0;
while (true) {
let m = await receive(self);
match (m) {
Msg.float(pitch): { playNote(101, id % 16, [pitch, 0.6, 4.7, 0.0, 0.01, 0.2]); id = id + 1; }
_: {}
}
}
}
voice spawn(Msg.int(0)) register('voice); -- spawn, then name it
""";
engineStart();
masterGain(0.2);
begin();
newNode("voicer", 101);
connect(101, 0, 0, 0);
sched(0);
attachVM(0);
let err = await siloLoad(0, voiceModule);
"load err=[" print; err print; "]" println;
The NRT conductor, after siloLoading that module, schedules a rising
line one note per beat:
import actors.*; -- siloSendAt
import audio_engine.*; -- clockBeats
import std.message.*;
let now = clockBeats(0);
siloSendAt(0, 0, now + 1.0, 'voice, Msg.float(60.0));
siloSendAt(0, 0, now + 2.0, 'voice, Msg.float(64.0));
siloSendAt(0, 0, now + 3.0, 'voice, Msg.float(67.0));
siloSendAt(0, 0, now + 4.0, 'voice, Msg.float(72.0));
And to stop:
detachVM(0); -- stop the silo (the voice actor lives there)
begin();
allNotesOff(101);
sched(0);
How it works. The message is encoded to bytes on the
NRT side and carried across the silo's command FIFO; the silo decodes it and
sendByNames it into the actor's mailbox. siloSendAt uses
the engine's beat scheduler, so each note lands in the mailbox sample-accurately
when clock 0 reaches the beat — this is how you command silo voices on the
musical grid from outside.
8.4 Silo↔silo and silo→NRT
Silos can message each other, and message NRT actors, without you touching the
transport. From inside a silo actor, call siloPost(target, name, msg)
(from silo_actors) — target is the destination silo,
or -1 for an NRT actor. It encodes the Msg and pushes it
into a lock-free outbox (the audio thread cannot allocate a transport), returning
an error code if the message exceeds the outbox cap. It is the silo-side
counterpart to siloSend; under the hood it wraps the rt-safe
siloOutbox primitive. A router on the main thread,
runActorServer(), drains the outboxes and forwards each message:
-- actors.x: the main-thread router. Run it instead of runActors when silos route.
fn runActorServer() Void {
while (true) {
pumpSiloOutboxes(); -- forward silo->silo; stash silo->NRT
var k = nrtActorMsgCount();
while (k > 0) {
let nm = nrtActorMsgName();
let b = nrtActorMsgTake();
sendByName(nm, decode(b)); -- deliver to the NRT actor
k = k - 1;
}
runActors(); -- drive NRT actors that got mail
sleepMs(2);
}
}
A silo sender targeting an NRT conductor (from
integration-tests/scripts/silo_to_nrt.x):
-- inside the silo module
import silo_actors.*; -- siloPost
async fn sender(self Actor<Msg>, init Msg) Void {
let scale = [60.0, 64.0, 67.0, 72.0];
var i = 0;
while (i < 4) {
await delay(1.0);
siloPost(-1, 'conductor, Msg.float(scale[i % 4]));
i = i + 1;
}
}
The NRT side spawns a conductor actor (registered as
"conductor") and calls runActorServer(). This recipe is
fully deterministic — its harness
(integration-tests/scripts/silo_to_nrt.sh) confirms all four messages
arrive without relying on audible sound. For silo↔silo, use a positive
target silo index instead of -1; the router forwards it to that
silo's named actor.
8.5 Across processes (NATS)
Cross-process messaging is the same actor, addressed over NATS. A standing
subscription bridges a subject to a local actor, and publishing encodes a message
onto that subject (from
integration-tests/scripts/actor_nats_loopback.x):
import nats.*;
import std.messageEncoding.*; -- encode (for publishing)
import std.message.*;
-- a worker actor, named 'w by its spawner
async fn worker(self Actor<Msg>, init Msg) Void {
while (true) {
let m = await receive(self);
println("got " $ (m toString));
}
}
worker spawn(Msg.int(0)) register('w);
-- bridge subject "actors.w" -> the local actor named 'w. natsBridgeActor wraps
-- the onMessageMsg + isMessage + decode + sendByName pattern.
natsBridgeActor("actors.w", 'w);
natsPubMsg("actors.w", encode(Msg.string("hello over nats")));
serveActors(); -- park until a NATS delivery wakes the actor
How it works. The NATS handler runs on the client thread,
decodes the bytes, and enqueues into the named actor's mailbox; that enqueue wakes
serveActors, which drives the actor. Use serveActors (not
runActors) for a long-lived process so the VM parks when idle instead
of returning. See the OSC/NATS sections of the
FFI Guide for connection setup and subject naming.
9. Offline (NRT) rendering
To render audio to a file faster (or slower) than real time, use
renderNRT. It owns a private engine and tempo scheduler on a render
thread, so it runs entirely offline, independent of the live audio device. You
build the graph in a setup callback and await renderDone(handle) for
completion.
| Function | Meaning |
|---|---|
renderNRT(path String, setup fn() Void) Int | Open-ended render; returns a handle. |
renderNRT(path String, dur Float, setup fn() Void) Int | Render a fixed number of seconds. |
renderDone(handle Int) Future<Void> | Awaitable completion. |
isRenderDone(handle Int) Bool / onRenderDone(handle, fn() Void) | Poll / callback. |
stopRender(handle) / endRender() | Stop early (from outside / from inside the render). |
import synthdef.*;
import synthc.compile.*;
import common_ugens.*;
import audio_engine.*;
import std.futures.*;
fn beep() S = [330.0, 331.0] sinosc * (1.0 lfimp decay(0.25)) * 0.2 |> outlet;
beep defSynthX("beep") await;
-- Render two seconds of beeps to a WAV file.
let h = renderNRT("out.wav", 2.0, fn() Void {
setTempo(0, 120.0);
begin();
newNode("beep", 101);
connect(101, 0, 0, 0);
sched(0);
});
await renderDone(h);
"render done" println;
How it works. The setup callback runs with the render's engine
installed as the current context, so the ordinary begin/newNode/
connect/sched and clock calls target it. The
render thread drives its own tempo scheduler, writes the WAV, and resolves the
renderDone future. Because there is no audio device involved, this path
is fully deterministic — the example above produces a stereo
out.wav every run. You can also schedule notes and run generative
sequences inside the setup, exactly as in the live recipes.
10. Putting it together: a multi-silo ensemble
The pieces combine into a small ensemble: each part is an actor on its own silo,
all loaded in parallel and started together on a downbeat. This mirrors
integration-tests/scripts/silo_scenario1_audible.x.
import audio_engine.*;
import std.futures.*;
import silo.*; -- quantizeUp
let bass = """
import audio_engine.*;
import std.message.*;
let notes = [36.0, 36.0, 43.0, 41.0];
async fn play(self Actor<Msg>, init Msg) Void {
var i = 0;
while (true) {
playNote(101, i % 8, [notes[i % 4], 0.7, 4.7, 0.0, 0.005, 0.15]);
await delay(1.0); releaseNote(101, i % 8); await delay(0.0); i = i + 1;
}
}
fn start() Void { spawn(play, Msg.int(0)); }
""";
let arp = """
import audio_engine.*;
import std.message.*;
let p = [72.0, 76.0, 79.0, 84.0];
async fn play(self Actor<Msg>, init Msg) Void {
var i = 0;
while (true) {
playNote(301, i % 16, [p[i % 4], 0.25, 4.5, 0.0, 0.005, 0.1]);
await delay(0.25); releaseNote(301, i % 16); i = i + 1;
}
}
fn start() Void { spawn(play, Msg.int(0)); }
""";
-- Set up one part on a silo: a voicer node + the loaded module.
fn preparePart(silo Int, node Int, code String) Future<String> {
begin();
newNode("voicer", node);
connect(node, 0, 0, 0);
sched(silo);
attachVM(silo);
siloLoad(silo, code)
}
async fn startSession() Void {
await awaitAll([
preparePart(0, 101, bass),
preparePart(1, 301, arp)
]);
siloStartAt(quantizeUp(clockBeats(0), 4.0), [0, 1]); -- both parts on the next bar
}
engineStart();
masterGain(0.22);
setTempo(0, 110.0);
await startSession();
"ensemble running" println;
And to stop both parts:
detachVM(0); detachVM(1); -- stop both parts
begin(); allNotesOff(101); sched(0);
begin(); allNotesOff(301); sched(1);
How it works. Each part builds its own voicer node on its own
silo and loads an actor that sequences notes with await delay.
awaitAll (from futures.x) waits for both
siloLoads to finish; quantizeUp(clockBeats(0), 4.0) finds
the next bar line, and siloStartAt starts both silos exactly there so
they are phase-locked. The two parts then run on separate threads, each
self-timed against the shared tempo clock 0.
11. The music libraries
Everything so far has generated notes by hand — loops that call
playNote and wait. The music.* libraries
(lang/modules/music/) raise the level: four composition dialects, each
inspired by a different tradition of computer-music system design, all producing the
same List<Event> so they combine freely and share one player. The
full API is in the Music Libraries Reference;
this chapter is the tour.
11.1 One event stream, one tuning step
An Event is a note with an absolute onset in beats, a note value
(dur), a sounding time (sustain), an amplitude, named synth
params — and a symbolic pitch. A Pitch is a frequency, a
tuning step, a scale degree with an optional accidental (degree(4),
degree(4, 1.0) — a whole degree plus an alteration in tuning
steps), a ratio, cents, or a rest; it only becomes Hz at play time, through a
Tuning and Scale. Tunings are real-numbered all the way
down: any count of equal divisions (including fractional counts) of any period
ratio, or a cents table for unequal tunings, with real-valued steps interpolating
in between. To move a whole performance up a fifth, re-anchor the tuning
(transposeRoot(t, 7.0)) — degrees keep their spelling.
import music.core.*;
let t = bp() root(220.0, 0.0); -- 13 equal divisions of the tritave, 220 Hz root
stepHz(t, 6.5); -- halfway up the tritave, a legal step
degreeStep(major, -1) -- degrees wrap below the root too
Because the events stay symbolic, the same phrase plays in 12ED2, in a tritave tuning, or in a just-intonation table by changing one argument to the player.
11.2 Streams: music.pat
Inspired by SuperCollider's Patterns library and by Sound as Pure Form (SAPF).
Patterns are ordinary lazy lists — usually infinite — so the whole list
vocabulary applies, plus musical generators (series,
browns, wpicks, xpicks, shufs,
lace, stutter…) and bind, which zips
one stream per event field:
import music.pat.*;
randSeed(2026); -- reproducible randomness
let melody = bind(
wpicks([0, 1, 2, 4], [4.0, 2.0, 3.0, 1.0]) degree,
List(0.5, 0.25, 0.25) cyc,
rands(0.2, 0.6));
let bass = bind(List(-7, -5) cyc degree, List(2.0) cyc);
let tune = merge(melody, bass) takeDur(32.0); -- polyphony is a lazy merge
tune length println;
tune take(3) showEvents; -- (t, dur, sustain, amp, pitch)
11.3 An algebra of pieces: music.media
Inspired by Paul Hudak's Haskore and the temporal-media algebra of the
Haskell School of Music. A Music<T> is a value built
from notes, rests, sequential ($) and parallel (|, as in
the pi calculus) composition, and control annotations (tempo, trans,
dyn, inst) — so a piece can be analyzed
(mdur), transformed (retro, mmap), and only
then performed into events:
import music.media.*;
fn deg(d Int, dur Float) Music<Pitch> = note(dur, degree(d));
let theme = line([deg(0, 1.0), deg(1, 1.0), deg(2, 0.5), deg(4, 1.5)]);
let canon = theme | (mrest(2.0) $ (theme dyn(0.7) trans(-4)));
let canonEvents = canon perform; -- List<Event>, same as bind produces
canon mdur println; -- 6.0 beats end to end
canonEvents showEvents; -- the second voice enters at beat 2
11.4 Shapes and forms: music.shape / music.job
Inspired by HMSL (the Hierarchical Music Specification Language). A
Shape is a sequence of frames over named dimensions; morphological
operators (retrograde, invertDim,
transposeDim, warpDim…) act one dimension at a
time, and any extra dimension becomes a per-note synth param. Shapes arrange into a
hierarchy (Coll) whose selection behaviors roll at realize time
— each realize is a fresh reading of the form:
import music.job.*;
let theme = shape([[0.5, 0.0, 0.5], [0.25, 1.0, 0.4], [0.5, 3.0, 0.5]]);
let riffs = [Coll.leaf(theme),
Coll.leaf(theme retrograde),
Coll.leaf(theme invertDim('degree, 2.0))];
let form = Coll.rpt((4, Coll.sel((Behavior.shuffled, riffs))));
let reading = realize(form); -- a different reading every call
reading take(3) showEvents; -- run it again for another reading
11.5 Patterns as functions of time: music.spans
Inspired by Tidal Cycles. A Pattern<T> is a callable value:
query a time span (in cycles) and get back the value fragments active in it.
Structure is computed, never stored, so transformations like fast,
rev, every, iterp, and euclid
compose freely; continuous signals (sinePat, sawPat) ride
along and are sampled with segment. patEvents renders
cycles to the shared event list:
import music.spans.*;
fn d(x Int) Pattern<Pitch> = pure(degree(x));
let spanTune = stack([
d(-14) euclid(3, 8) events(0.8, 0.6),
fastcat([d(0), d(4), d(5), d(7)])
iterp(4)
every(4, fn(p Pattern<Pitch>) Pattern<Pitch> { p rev })
events(0.5, 0.9)
withAmp(sinePat() slow(4.0) range(0.25, 0.6) segment(4)),
]);
let spanEvents = patEvents(spanTune, 16.0, 2.0); -- 16 cycles, 2 beats per cycle
spanEvents length println;
spanEvents take(4) showEvents;
11.6 Playing and rendering: music.play
Whichever dialect produced the events, one player takes them from there.
score (pure) lowers events to timestamped note commands;
play groups simultaneous commands into single command bundles
(section 3.2) scheduled at absolute beats on the engine's TempoClock —
chords atomic, timing sample-accurate. The returned Player is a live
handle: replace swaps what's playing, enqueue queues what
follows, stop releases everything, and stopAll() stops
every player started so far — the recovery when the p binding
was overwritten by re-running the play block (stopFirst() and
stopLast() peel off one player at a time, oldest or newest first). The first block defines the
section 2.3 sine voice inline, so this chapter sounds on its own.
pitchVoice(node) matches defs whose note params are
[pitch, amp]; freqVoice(node) is the same for defs
taking Hz directly, like the instruments-library models of
§4.2.
import music.play.*;
import audio_engine.*;
import synthdef.*;
import synthc.compile.*;
import common_ugens.*;
-- the polyphonic sine voice from section 2.3, inline so this cell sounds on its own
fn sineVoice() S {
let pch = noteParam("pitch", ControlSpec { lo: 0.0, hi: 127.0, init: 60.0, warp: ControlWarp.linear });
let amp = noteParam("amp", ControlSpec { lo: 0.0, hi: 1.0, init: 0.5, warp: ControlWarp.linear });
let env = gate() adsr(0.01, 0.1, 0.7, 0.3);
pch nnhz sinosc * env * amp
}
fn sineSynth() S = voicer(16, sineVoice) sum outlet;
sineSynth defSynthX("sine_voice") await;
engineStart();
masterGain(0.2);
let v = pitchVoice(101); -- a voice: node, param order, pool size
voiceBundle(v, "sine_voice") go(0); -- build the node graph
let p = play(tune, v, et12, pentMinor); -- the 11.2 stream -- audible now
Live-swap and queue while it plays:
p replace(realize(form)); -- live-swap in an 11.4 reading
p enqueue(canon perform); -- the 11.3 canon follows when it ends
And stop, releasing every note the voices hold:
stopAll(); -- stop every player (however many times the play block ran)
The same events also render offline, through any tuning:
-- the same events, offline, in a tritave tuning:
let h = render(tune, v, "sine_voice", "/tmp/take.wav", 20.0,
bp() root(220.0, 0.0), chromatic(13));
await renderDone(h);
"wrote /tmp/take.wav" println;
For code running inside a silo (section 5), use
scorePlayer with spawn instead — it walks the same
score with immediate playNote/releaseNote on the silo's
audio thread.
integration-tests/scripts/pat_audible.x,
media_audible.x, shape_audible.x,
spans_audible.x (live, one per dialect), and
pat_nrt.x (offline render in a tritave tuning). Each header has the
run command. The pure layers are golden-tested in lang/tests/music/.12. Live coding with proxies
The live module (lang/modules/live/) is for changing a
running patch while it sounds — in the spirit of SuperCollider's JITLib. A
proxy is a stable connection point you hold as an ordinary value: you give
it a signal function, listen to it, hand it to other proxies as an input, and
replace its definition mid-performance. Every replacement crossfades; all node
management, wiring, and cleanup happen behind the scenes.
12.1 Define, listen, redefine
Create proxies once, then assign signal functions with <-. The
first assignment starts silent sound; every later assignment crossfades the old
sound into the new over fadeTime seconds. Because a re-run of
ndef() makes a new proxy, keep creation in a cell you run
once and put the assignments you edit in their own cell:
import live.*;
import synthdef.*;
import common_ugens.*;
import audio_engine.*;
engineStart();
-- run this cell once:
let drone = ndef(2); -- a silent 2-channel proxy
-- edit and re-run this cell freely:
drone <- fn() S {
let f = control("freq", espec(40.0, 800.0, 110.0));
sinosc([1.0, 1.003] * f) * 0.2
};
drone play; -- fade onto the speakers
fadeTime(4.0); -- slow morphs from here on
drone <- fn() S {
(sinosc([1.0, 1.5, 2.01, 3.0] * 110.0) sum(2)) * 0.12
};
play and stop only control what you hear:
drone amp(0.5) ramps the listening level without affecting any proxy
that reads drone as an input. A definition that fails to compile
prints its error and leaves the running sound untouched.
-- stop cell:
drone stop;
12.2 Proxies reading proxies
Inside a signal function, calling a proxy — drone(), or
equivalently drone sig — feeds that proxy's signal in. The
connection point is stable: redefining drone later never disturbs
the proxies listening to it.
import effects.*;
let verb = ndef(2);
verb <- fn() S { drone() reverb(3.5, 0.4, 0.5) };
verb play;
drone stop; -- keep only the reverberated path
A proxy may read itself (fb <- fn() S { (fb() * 0.5) + input })
— the loop closes with a one-sample delay, which is the engine's feedback
primitive. Freeing a proxy that others still read is safe: their input simply
goes silent until you redefine them. References must appear at the top level of
the signal function, not inside voicer/if_ bodies.
-- stop cell:
verb stop;
12.3 Parameters
set targets any control declared in the current
definition, by name. Values are remembered on the proxy and re-applied after
every redefinition whose definition still declares that control (a value whose
control disappeared is kept and simply waits):
drone set("freq", 165.0);
drone <- ["freq": 220.0]; -- map form: several at once
12.4 Notes into proxies
A proxy whose definition uses voicer and noteParam is
a note target. play(proxy, events) derives the parameter list from
the definition itself (each noteParam's name and default, in
declaration order) and schedules the events sample-accurately; redefining the
proxy mid-pattern retargets the notes to the new sound at the crossfade:
import music.pat.*;
let keys = ndef(2);
keys <- fn() S {
voicer(16, fn() S {
let f = noteParam("freq", espec(20.0, 2000.0, 440.0));
let a = noteParam("amp", lspec(0.0, 1.0, 0.5));
sinosc(f) * a * (gate() adsr(0.01, 0.1, 0.7, 0.3))
}) sum
};
keys play;
let pl = play(keys, bind(
wpicks([0, 2, 4, 7], [3.0, 2.0, 2.0, 1.0]) degree,
List(0.25) cyc) takeDur(16.0));
pl replace(events) swaps the sequence, pl enqueue(events)
appends one, pl stop releases everything it holds.
-- stop cell:
pl stop;
keys stop;
12.5 Housekeeping
silence(p) fades a proxy's sound out but keeps the proxy (and its
stored parameters) ready for the next definition. free(p) fades out
and releases everything the proxy owns. quant(4.0) makes every
redefinition land on the next 4-beat boundary of the silo's clock;
quant(0.0) turns that off. dump() println lists the
live proxies. If handles were lost to a re-run creation cell, the escape hatches
are clearAll() — free every proxy now — and
endAll(fade), which fades all listening out over fade
seconds first:
-- stop cell:
endAll(2.0) await;
engineStop();
Proxies work unchanged inside an offline render — the same session code
writes a WAV under renderNRT (see
chapter 9).
12.6 Meters, reshaping, named proxies
meter(p) installs a level tap on the proxy's output — what
dependents and the listener receive, before amp — and
peak(p)/rms(p) read it from anywhere;
unmeter(p) removes it. A proxy that is neither played nor read by a
played proxy does not run at all, so its meter reads zero.
drone meter;
drone peak println; -- pre-volume output level
drone unmeter;
reshape(p, chans) changes a proxy's channel count in place: the
source, every dependent, and the monitor crossfade to the new width, so nothing
has to be rebuilt by hand. Proxies reading it keep working through the change
(the engine wraps or folds the width difference) and adopt the new width at
their next redefinition:
reshape(drone, 4); -- stereo -> quad, dependents keep running
Finally, ndef also accepts a symbol name as an opt-in identity:
ndef('drone, fn() S {…}) finds the existing 'drone
proxy and crossfades it instead of creating a new one, so a whole cell —
creation included — can be edited and re-run safely. free
releases the name. Anonymous ndef() stays available for throwaway
proxies:
-- re-run this whole cell freely; 'drone keeps its identity
let drone = ndef('drone, fn() S {
sinosc([220.0, 220.7]) * 0.2
});
drone play;
-- stop cell:
drone free;
13. Appendix
13.1 Function quick reference
| Function | Module | Runs at |
|---|---|---|
engineStart / engineStop / masterGain | audio_engine | NRT |
begin / go(silo) / sched(silo) / sched(silo,clock,beat) | audio_engine | NRT |
newNode / connect / freeNode | audio_engine | NRT |
noteOn / noteOff | audio_engine | NRT |
playNote / releaseNote | audio_engine | silo |
setControl / setInput | audio_engine | NRT / silo |
setTempo(clock,bpm) / clockBeats / clockTempo | audio_engine | NRT |
attachVM / siloLoad / siloStartAt / detachVM | audio_engine | NRT |
defSynthX / defSynth / listSynthDefs | synthc.compile / synthdef / audio_engine | NRT |
loadBuffer / fillBuffer / resizeBuffer | audio_engine | NRT |
loadSampleBank / sampleZone | audio_engine | NRT |
oscTables / sawTables / squareTables / triTables | wavetables | NRT |
smpPerc / smpLoopTail / smpLoopEnv / wtLead / resonBank / ksPluck (def factories) | instruments | NRT |
sched / after / at / go / delayBeats / delayReal / setTempo / getBeats / cancel (scheduling forms take an optional clock slot as first argument) | clock | NRT |
bind / merge / takeDur / perform / realize / patEvents | music.* | NRT / silo |
play / stop / stopAll / stopFirst / stopLast / replace / enqueue / voiceBundle / render | music.play | NRT |
scorePlayer | music.play | silo |
renderNRT / renderDone / stopRender / endRender | audio_engine | NRT |
spawn / send / receive / register / sendByName | (builtin) | NRT / silo |
runActors / serveActors | (builtin) | NRT |
siloSend / siloSendAt / runActorServer | actors | NRT |
siloPost | silo_actors | silo |
natsBridgeActor | nats | NRT |
siloOutbox | audio_engine | silo |
encode / decode / isMessage | message | NRT / silo |
await delay(beats) / awaitAll | (builtin) / futures | NRT / silo (on the NRT VM delay is logical time; for the live clock use delayBeats, for wall-clock delayReal) |
13.2 Where to read more
| Topic | Reference |
|---|---|
| Language syntax, async/await, pattern matching | Tzopilotl By Example |
| All built-in functions | Builtin Functions |
| The music libraries: events, tunings, and the four dialects | Music Libraries Reference |
| Embedding, FFI, OSC, NATS, Clock/Tempo, Silo VM, NRT render, binary messages | FFI Guide |
| Engine FFI surface (every node/connection/note call) | FFI Guide — Audio Engine |
| Engine design: silos, nodes, commands, voices, topo sort | engine/Architecture.md, engine/CLAUDE.md |
SynthDef compiler: the S graph, rates, codegen | synthdef-compiler/ARCHITECTURE.md |
| UGen / filter / DSP libraries | lang/modules/common_ugens.x, filters.x, dsp_math.x |
| Ready-made note-playing instruments (defs + voice-building UGens) | lang/modules/instruments.x, examples/note_synthdefs.x |
| Actor model + message format | lang/modules/actors.x, messageEncoding.x, message.x |
| Worked examples to copy from | integration-tests/scripts/*.x, lang/tests/actors/*.x |