A synthdef is written as a Tzopilotl function of no arguments returning S
(short for SignalExpr). The function does not process audio when it runs.
Instead, it runs once at definition time to build a graph: every operation on an
S value — +, sinosc, lag,
outlet — appends a node to the graph under construction and returns a new
S referring to it. The finished graph is handed to the synthdef compiler, which
analyzes it (rates, types, channel counts, common subexpressions, SIMD) and generates a native
.dylib plugin that the engine loads and can instantiate as nodes.
your synth fn (.x) —runs once→ SignalGraph —defSynthX→ compiled .dylib plugin
↓
engine loads it; newNode() makes instances
Two consequences of this staging model are worth internalizing:
common_ugens.x and filters.x is ordinary Tzopilotl code that
manipulates S values. Calling lpf(x, 800) inlines a biquad's
worth of nodes into your graph. There is no overhead for building abstractions out of
small functions — the compiler sees only the resulting flat graph and optimizes it
as a whole (constant folding, common-subexpression elimination, rate factoring).for loop, array comprehension, or recursive function that builds signal
expressions unrolls into the graph. Use them freely to generate banks of oscillators,
filter cascades, etc. (For a loop that must exist in the generated code, see
for_.)A minimal synthdef, defined and played:
import synthdef.*;
import synthc.compile.*; -- provides defSynthX, the production compile path
import common_ugens.*;
fn beep() S =
[330.0, 331.0] sinosc -- 2-channel sine (slightly detuned)
* (1.0 lfimp decay(0.25)) -- 1 Hz impulse -> exponential decay envelope
* 0.2
|> outlet; -- the graph's output sink
beep defSynthX("beep") await; -- compile + load into the engine
"beep" play; -- make a node and connect it to audio out
The compile itself runs in the background, so any music already playing keeps
running while clang works. The await waits until the def is loaded into
the engine — needed here because the next line plays it. When you re-evaluate a
defSynthX call for a def that is already playing you can drop the
await: players keep using the old version and switch to the new one the
moment it finishes loading.
Synthdef code leans on two pieces of Tzopilotl syntax used heavily below: the
space-separated pipeline (x sinosc sq is sq(sinosc(x))) and the
|> pipe operator, which forwards the whole expression to its left into the
function on its right. Many ugens are written with untyped parameters; they are generic over
anything convertible to a signal (see §3).