Every expression in the graph has a signal type with three components, inferred by the compiler — you rarely write any of them explicitly:
| Component | What it is | Values |
|---|---|---|
| rate | When the expression is computed | constant, init, reset, event, audio |
| element type | The numeric type of each channel value | i32, i64, f32, f64 |
| channels | How many parallel values it carries per sample | a power of two: 1, 2, 4, 8, … |
Rates say how often a value needs to be recomputed. From slowest to fastest:
| Rate | When computed | Typical examples |
|---|---|---|
Rate.constant | At compile time; folded away entirely | Literals, arithmetic on literals |
Rate.init | Once, when a synth instance is allocated | fs(), T(), random values with rate: Rate.init, delay allocation |
Rate.reset | Once 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.event | When a parameter or note parameter change arrives | control(...) and noteParam(...) values and math derived only from them |
Rate.audio | Every 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.
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:
ANY_NUM — they adapt to whatever consumes them.
440 becomes an f32 when fed to sinosc and an
i64 when used as a bit mask.ANY_FLOAT.sin, exp, …) constrain
their operand to float; bit operations (&, <<,
popCount, …) constrain theirs to integer.i32+f32 → f32,
f32+f64 → f64).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;
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.
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.
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.