Tzopilotl
Docs
GitHub

Writing SynthDefs

2. The Signal Type: Rate, Element Type, Channels

Every expression in the graph has a signal type with three components, inferred by the compiler — you rarely write any of them explicitly:

ComponentWhat it isValues
rateWhen the expression is computed constant, init, reset, event, audio
element typeThe numeric type of each channel value i32, i64, f32, f64
channelsHow many parallel values it carries per sample a power of two: 1, 2, 4, 8, …

2.1 Signal rates

Rates say how often a value needs to be recomputed. From slowest to fastest:

RateWhen computedTypical examples
Rate.constantAt compile time; folded away entirely Literals, arithmetic on literals
Rate.initOnce, when a synth instance is allocated fs(), T(), random values with rate: Rate.init, delay allocation
Rate.resetOnce per note-on (inside a voicer; once at init otherwise) Per-note setup: sample bank lookups (§10), per-note random values with rate: Rate.reset
Rate.eventWhen a parameter or note parameter change arrives control(...) and noteParam(...) values and math derived only from them
Rate.audioEvery sample Oscillators, filters, the gate() note parameter, anything downstream of an audio signal

Note parameters are event rate. A voice's noteParam(...) values change only at note events — note-on, and noteSetParams during the note — so math derived purely from note parameters (a frequency-dependent filter coefficient, a velocity curve) recomputes per voice when a note event arrives, not every sample. The one exception is gate(): the gate stays audio rate because it drives per-sample envelope and trigger idioms (adsr, tr) whose edge detection needs sample granularity — an event-rate trigger would be a held value rather than a one-sample impulse. A consequence worth knowing: feedforward z1-style state over a note parameter advances once per note event (the same behaviour such state has over controls), while recursions through a delay reader — lag, onepole — stay audio rate and smooth per sample as always.

eventToAudio(a) promotes an event-rate signal to audio rate: a stepped audio signal that re-reads the latched event value every sample. Use it when you want per-sample semantics over a control or note parameter — most usefully for triggers: eventToAudio(c) tr fires a genuine one-sample impulse on the sample a change of c lands, whereas c tr (all at event rate) is a held trigger — 1 from a zero-to-positive change until the next event over c clears it. It has no effect on signals of any other rate: constants, init-, reset-, and audio-rate inputs pass through unchanged.

Rate propagation. The rate of a derived expression is the maximum of its inputs' rates. Multiplying an audio-rate oscillator by an event-rate control gives an audio-rate result; adding two constants gives a constant that never reaches the generated code at all. You do not choose rates for computed expressions — the compiler infers them, and its rewrite passes actively factor low-rate subexpressions out of audio-rate code so that, e.g., freq * 2 * pi * T() driven by an event-rate control is recomputed only when the control changes.

The places you do state a rate are source nodes whose rate is a free choice — the random generators (§8) take a rate argument. frand(100, 600, 8, Rate.init) picks 8 random frequencies once per synth instance; with the default Rate.audio it would be white noise.

Rate values are ordered (<, <=, min, max are defined on them) with constant < init < reset < event < audio.

2.2 Element types

Each signal's per-channel values have one of four concrete numeric types: i32, i64, f32 (the usual audio sample type), and f64. During graph construction, types are tracked as constraint sets (NumType bit-sets such as ANY_NUM, ANY_FLOAT, ANY_INT) and iteratively narrowed to a concrete type by the compiler's type inference:

When you need a specific width, use the cast operators i32, i64, f32, f64 (postfix, like any ugen). The main practical uses:

-- Phase accumulators: accumulate in f64 for precision, read out in f32.
fn phasor(fm AsSignal) S {
    let phase = delayVar();
    phase <- frac(phase(1) + fm f64 * T() f64);
    phase(1) f32
}

-- Integer arithmetic: force a counter to integer before %.
c <- (c1 + t) i32 % n;

2.3 Channel counts

A signal carries chans parallel values per sample — its channels. A plain oscillator is 1-channel; [330.0, 331.0] sinosc is a 2-channel signal (two sines computed in lockstep). Multichannel expansion is how you get stereo, oscillator banks, and SIMD-friendly parallelism.

Channel counts are always rounded up to a power of two (asChans(n) = max(n,1) bitCeil): a 3-element vector becomes a 4-channel signal (the 4th channel duplicates cyclically). This lets generated code index channels with a bit-mask instead of a modulo, and makes all channel counts broadcast-compatible. Functions that create multichannel signals — vec, fill, control, the random generators, take, stutter, … — apply this rounding to their channel argument.

2.4 Channel broadcasting

When a binary operation combines signals of different widths, the result has max(a.chans, b.chans) channels, and the narrower signal's channels are repeated cyclically: channel i of the result reads channel i mod chans of each input. Because all widths are powers of two, this is always well-defined — a 2-channel signal against an 8-channel one repeats its pair four times.

-- scalar against stereo: the scalar applies to both channels
[330.0, 331.0] sinosc * 0.2              -- 2 chans

-- stereo against 8 channels: the [-1, 1] detune alternates across all 8
let detune = [-1, 1] vec;                -- 2 chans
let freqs = exprand(100, 600, 8, Rate.init) + detune;   -- 8 chans

-- unary ops and ugens are per-channel: this filters both channels
[200, 300] smoothSaw(4) lpf(1200)

Broadcasting composes with every ugen in the library, because ugens are built from these same per-channel operators — feeding a 2-channel frequency into sinosc gives a stereo oscillator whose internal phase accumulator is itself 2-channel. Delays declared inside a ugen widen the same way, so [0.2, 0.3] vec of delay times gives two independent delay lines.

Reducing width back down. Broadcasting only ever widens. To mix channels down, use the reductions in §5: sum mixes to 1 channel, sum(2) mixes to stereo by summing every other channel, and transpose(n) reorders channels so related groups are adjacent first. A typical bank-of-oscillators pattern is oscs sum(2) * 0.1 |> outlet.
↑ Back to top