Tzopilotl
Docs
GitHub

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:

  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:

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:

KeysEvaluates
Cmd+Enterthe selection, or — with nothing selected — the block around the cursor (the surrounding run of non-blank lines)
Shift+Enterthe current line
Cmd+Shift+Enterthe 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.

Module imports The recipes below begin with the 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.

Two compile paths 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).

Tip Compiling a synthdef invokes 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:

FunctionMeaning
engineStart()Open the audio device and begin processing.
engineStop()Stop processing and close the device.
masterGain(g Float)Set the master output gain.
isAudioRunning() BoolIs 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:

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 NRTFrom inside a siloMeaning
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:

DefSoundnoteOn paramsControls (index · name)Out
smpPercPercussive sample player: the resolved sample plays once, at the pitch-shifted rate, and stops at its end. Note-off is ignored.[pitch, vel]stereo
smpLoopTailSample 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
smpLoopEnvSample player: keeps looping through sustain and release; an exponential envelope of release seconds shapes the tail.[pitch, vel]0 releasestereo
wtLeadBand-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 fsustainmono
resonBankHarmonic 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 decaymono
ksPluckKarplus-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 dampmono

Two conventions to know:

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

FunctionMeaning
attachVM(silo Int) IntCreate 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) IntDestroy 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.

Live coding Once a silo is started its sequence keeps running while the app is open. You can keep evaluating code to change things live — recompile a synthdef, re-run 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:

FunctionMeaning
setTempo(clock Int, bpm Float)Set a clock's tempo.
clockBeats(clock Int) FloatThe clock's current beat.
clockTempo(clock Int) FloatThe 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.

FunctionMeaning
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) FloatThe engine slot's current beat.
sched([clock,] deltaBeats Float, fn() Float) IntRun 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>) IntDrive 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
});
Two 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

Higher-level option — the music libraries (chapter 11) generate event streams declaratively and play them for you; this chapter shows the underlying hand-rolled technique, which remains the right tool for open-ended reactive processes.

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.

Coroutine form There is an older equivalent: a 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

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

FunctionMeaning
renderNRT(path String, setup fn() Void) IntOpen-ended render; returns a handle.
renderNRT(path String, dur Float, setup fn() Void) IntRender 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.

Runnable examplesintegration-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

FunctionModuleRuns at
engineStart / engineStop / masterGainaudio_engineNRT
begin / go(silo) / sched(silo) / sched(silo,clock,beat)audio_engineNRT
newNode / connect / freeNodeaudio_engineNRT
noteOn / noteOffaudio_engineNRT
playNote / releaseNoteaudio_enginesilo
setControl / setInputaudio_engineNRT / silo
setTempo(clock,bpm) / clockBeats / clockTempoaudio_engineNRT
attachVM / siloLoad / siloStartAt / detachVMaudio_engineNRT
defSynthX / defSynth / listSynthDefssynthc.compile / synthdef / audio_engineNRT
loadBuffer / fillBuffer / resizeBufferaudio_engineNRT
loadSampleBank / sampleZoneaudio_engineNRT
oscTables / sawTables / squareTables / triTableswavetablesNRT
smpPerc / smpLoopTail / smpLoopEnv / wtLead / resonBank / ksPluck (def factories)instrumentsNRT
sched / after / at / go / delayBeats / delayReal / setTempo / getBeats / cancel (scheduling forms take an optional clock slot as first argument)clockNRT
bind / merge / takeDur / perform / realize / patEventsmusic.*NRT / silo
play / stop / stopAll / stopFirst / stopLast / replace / enqueue / voiceBundle / rendermusic.playNRT
scorePlayermusic.playsilo
renderNRT / renderDone / stopRender / endRenderaudio_engineNRT
spawn / send / receive / register / sendByName(builtin)NRT / silo
runActors / serveActors(builtin)NRT
siloSend / siloSendAt / runActorServeractorsNRT
siloPostsilo_actorssilo
natsBridgeActornatsNRT
siloOutboxaudio_enginesilo
encode / decode / isMessagemessageNRT / silo
await delay(beats) / awaitAll(builtin) / futuresNRT / 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

TopicReference
Language syntax, async/await, pattern matchingTzopilotl By Example
All built-in functionsBuiltin Functions
The music libraries: events, tunings, and the four dialectsMusic Libraries Reference
Embedding, FFI, OSC, NATS, Clock/Tempo, Silo VM, NRT render, binary messagesFFI Guide
Engine FFI surface (every node/connection/note call)FFI Guide — Audio Engine
Engine design: silos, nodes, commands, voices, topo sortengine/Architecture.md, engine/CLAUDE.md
SynthDef compiler: the S graph, rates, codegensynthdef-compiler/ARCHITECTURE.md
UGen / filter / DSP librarieslang/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 formatlang/modules/actors.x, messageEncoding.x, message.x
Worked examples to copy fromintegration-tests/scripts/*.x, lang/tests/actors/*.x