Functions
Compiled by niminal. Functions become part of the compiled sound.
fn is the one way to write reusable code. What a function becomes depends on its body:
- Without
stateit is pure, and works at any rate: while compiling (a constant, a table), at note start, per block or per sample. - With
state(astateline,prev, or a call to a function that has one) it is a unit generator. Each call has its own state, for each voice, and runs sample by sample.
/// A one-pole low-pass filter.fn one_pole(x, cutoff: hz) { state y = 0 a = exp(-2 * pi * cutoff / sample_rate) // worked out only as often as cutoff changes y = x * (1 - a) + y * a y}
fn mtof(n: num) -> hz { 440hz * pow(2, (n - 69) / 12) }
out = osc(saw, mtof(48)).one_pole(800hz)- Definition order doesn’t matter. A function can call one defined later; calling itself, directly or through others, is an error.
- No side effects: a function can’t send to a bus, read
noteorinput, or send OSC. Its instrument does those. - Doc comments: a
//or///comment directly above afnorinstris its hover documentation in the editor.
Parameters
Section titled “Parameters”fn f(x, cutoff: hz, res: 0..1 = 0.5, freq: init hz, size: const int = 4096, mode: (low | band | high) = low, taps: [num])| Written | Accepts |
|---|---|
x |
Anything: the unit comes from each call (fn twice(x) { x * 2 } works on hz, db or plain numbers) |
cutoff: hz |
A value in hz. The types are num, int, hz, db, sec, st and deg |
res: 0..1, cut: 20hz..20khz |
A value between the ends, clamped to them |
mode: (low | band | high) |
One of the names, known while compiling. Inside the body, mode == low picks a branch then |
length: division = 1/4beat |
A length of a 32nd note to four beats. Inside the body it is snapped to the nearest musical division at the current tempo: straight (1/32, 1/16, 1/8, 1/4, 1/2, 1/1), triplet (1/16t, 1/8t, 1/4t …) or dotted (1/16d, 1/8d, 1/4d …). Set it in seconds, or move it with an envelope or a ramp, and it steps through the divisions as the tempo moves. Give it a default in beats, and an effect’s length follows the tempo |
make: fn |
A user function name or fn(x) { … }, known while compiling; anonymous functions capture values from where they are written |
taps: [num] |
A fixed-length array whose elements are checked as plain numbers. Signals supply their channels, including a mono signal as one element |
= default |
Makes the parameter optional. A default may use earlier parameters |
A rate before the type says what the function needs from the caller:
| Rate | Inside the body the value is | When the caller passes something faster |
|---|---|---|
| (none) | Whatever was passed | Nothing changes |
control |
At most once per block | Read at the start of each block |
init |
Fixed at note start | Its value at note start is used |
const |
Known while compiling | Compile error |
Arguments: the leading required parameters may be given in order; the rest are named. sig.lpf(800hz) and osc(saw, 440hz) work, sig.lpf(800hz, 0.3) doesn’t (write res: 0.3).
Functions as parameters
Section titled “Functions as parameters”A fn parameter takes a named user function or an anonymous function. Calls are specialized while compiling; there is no runtime dispatch. A function value can be forwarded to another function or supplied to .map. Defaults can name another function.
fn unison(freq: hz, make: fn, voices: const int = 7) { layers = for i in 0..<voices { make(freq * (1 + (i - (voices - 1) / 2) * 0.003)) } layers.sum / voices}
instr pad(freq: hz, cutoff: hz = 2khz) { unison(freq, fn(f) { osc(saw, f).lpf(cutoff) }) * adsr(10ms, 100ms, 0.6, 300ms)}Anonymous functions capture the surrounding values they read. Each invocation has independent state, including when expanded by a loop. A callback’s argument count, units and result are checked when its caller is specialized. Bodies of functions with fn parameters are checked at their calls, rather than against a guessed callback at definition time. Function parameters cannot have init or control rates, and instruments can accept them as structural parameters with defaults.
Results
Section titled “Results”- The last expression is the result, including an array or a nested array, or build
outwithout = …andout += …(not both). -> hzdeclares the result’s unit, which is checked.- Several results, each of which can also be an array, are named after
->; the body binds each, and they come back as fields. The body runs once for all of them.
fn svf(x, cutoff: hz, res: 0..1 = 0.5) -> (low, band, high) { state low = 0 state band = 0 f = 2 * sin(pi * cutoff / sample_rate) high = x - low - 2 * (1 - res) * band band = band + f * high low = low + f * band}
f = osc(saw, 110hz).svf(cutoff: 2khz)out = f.bandUsed where one value is expected, a function with several results gives the first.
state y = 0keepsyfrom one sample to the next. A read above an assignment sees last sample’s value; a read below it sees the new one.- The starting value is worked out at note start and can use the parameters:
state y = xstarts at the input’s first sample. Its unit is the state’s unit, sostate y = 0holds plain numbers andstate y = 0hzfrequencies. prev(x)isxone sample ago.y = x + prev(y) * 0.5is a feedback loop, written on one line. (In a bus send,previs an error:late(x)is the block-late copy; see Routing.)- A state array holds a buffer:
state buf = zeros(4096), written withbuf[i] = yand read withbuf[i]orbuf.at(pos, interp: linear). Its length is known while compiling. Indexes wrap in both directions; reading or writing an empty array is a compile error. It supports the same mapping, arithmetic, slices and reductions as other arrays. Its initial elements can use note-start parameters.
/// A plucked string: Karplus-Strong.fn string(x, freq: init hz, decay: 0..1 = 0.996, size: const int = 4096) { state buf = zeros(size) state pos = 0 len = sample_rate / freq delayed = buf.at(pos - len, interp: linear) y = x + (delayed + prev(delayed)) * 0.5 * decay buf[pos] = y pos = (pos + 1).mod(size) y}Reset scopes
Section titled “Reset scopes”reset_on(trigger) { … } evaluates a DSP block and restarts state created inside its body on every positive edge: when the trigger changes from zero or negative to positive. An initially positive trigger resets on the first sample. Holding it positive does not repeatedly reset it.
instr pulse(freq: hz) { trigger = osc(square, 4hz) * (1 - note.released) reset_on(trigger) { osc(saw, freq) * env[1 150ms.exp 0] }}Resets happen before processing that sample, including between block boundaries. They restart nested functions, built-in DSP effects, prev, state arrays, and initialization inside the scope. State initializers read the current inputs again. Scopes may nest; resetting an outer scope also restarts the inner one. Resetting clears existing delay and reverb contents.
Signals calculated outside the scope keep running. Pass audio into a scope to reset only its processing, or create the generator inside to restart both. A scope returns its last expression or its own out, and its locals stay inside. It may declare state at the beginning, just like a function. Stateful scope bodies follow the function rules: bind note context outside and pass it in through a captured value. All storage is allocated before rendering; resetting does not allocate.
Custom completion
Section titled “Custom completion”signal.finish_when(done) silences a layer permanently when done becomes positive and reports that layer as finished. It works inside custom functions as well as instruments. A reset scope can restart it.
fn decay(released, amount: 0..1 = 0.9995) { state level = 1 if released { level = level * amount } level.finish_when(level < 0.00001)}
instr tone(freq: hz) { osc(sine, freq) * decay(note.released)}Completion follows the existing envelope rules: the voice waits until release, all envelopes and custom completion signals have finished, and downstream delay/reverb tails have drained. tail: still applies. Each layer can report completion independently. Choose a completion point where the layer is already silent to avoid an abrupt cut.
Decisions and loops
Section titled “Decisions and loops”ifas a value needs anelse; each branch is a block ending with its value. As a statement it can change names, state andout:
fn latch(x, trig) { state h = 0 if trig > 0 and prev(trig) <= 0 { h = x } // sample and hold, on a rising edge h}A condition known while compiling picks its branch then; otherwise both are worked out and the result is chosen each sample, and state is only written on the branch taken.
match name { … }is theelse ifchain on one name, for choices or numbers. An arm is one or more patterns joined by|, a colon, and a value or a block;else:takes the rest. As a value it needs anelse.
fn pick(f, mode: (low | band | high) = low) { match mode { low: f.lpf(cutoff: 800hz) band | high: f.hpf(cutoff: 800hz) }}for x in xs { … }repeats a block a number of times known while compiling: over a range or an array, including a state buffer. As a value it gives an array. As a statement, assigning to a name from outside carries the value from one pass to the next.
Arrays
Section titled “Arrays”Arrays are the general collection type. Tables and writable DSP buffers use the same array operations. A signal is an array of channels with a rate, a unit and a channel layout.
[a, b, c],forand.map(…)make fixed-length arrays. Elements can be constants, changing values or nested arrays.[num],[hz]and[[num]]check element types in function parameters. Functions with state can accept and return computed arrays of any fixed length.- Lengths are known while compiling. Arrays do not inherit the audio channel limit: a 2048-element table or computed frame is an array. Audio output and layout conversion enforce the channel limit.
- Arithmetic, comparisons, math, unit conversion,
.rangeand.clampwork element by element. A scalar broadcasts to all elements; two arrays must have matching shapes. Built-in DSP operations expand over arrays and return arrays, soosc(saw, frequencies)can make a large oscillator bank. xs[i]floors a fractional index and wraps (xs[-1]is the last). A changing index reads an element each sample; the possible elements must have matching shapes and units. Slices such asxs[0..<4]produce arrays of the selected values.(0..1).step(0.25)is the array0, 0.25, 0.5, 0.75, 1for a range that isn’t whole numbers ((0..<1).step(0.25)leaves the end out); the step is in the range’s unit.xs.active(count)keeps the firstcountentries and silences the rest, wherecountmay change while playing: a bank of 16 partials of which a parameter chooses how many sound, without recompiling.xs.len,xs.sum,xs.min,xs.max,xs.indices,xs.map(f)work on constants, computed arrays and state buffers. Reducing nested arrays combines their rows element by element..maptakesfn(x) { … }, or an expression ofit.xs.at(pos, interp: linear)reads between numeric elements with wrapping. Interpolation isnone,linear(the default) orcubic, for every storage type.[],zeros(0)and empty slices are valid arrays. They support length, mapping and iteration. Indexing, interpolation and reductions on an empty array are compile errors.- Signals offer these operations directly:
signal[0],signal.map(it * 0.5)andsignal.sum. Mapping and element-wise processing preserve a signal’s channel layout..channelsexplicitly gives its channels as an ordinary array. - Ordinary array values are snapshots. Mapping, slicing, passing or returning a state buffer reads its current elements. Only a buffer declared with
statecan be written usingbuffer[i] = value. Each call and voice has its own buffer; processing does not resize arrays or allocate storage.
fn smooth_bank(xs: [num], amount: 0..1 = 0.04) { state history = zeros(xs.len) for i in xs.indices { history[i] = history[i] + (xs[i] - history[i]) * amount } history}
instr cloud(freq: hz) { frequencies = (0..<32).map(freq * (1 + (it - 15.5) * 0.001)) osc(saw, frequencies).smooth_bank(amount: 0.04).sum / 32}Constant tables
Section titled “Constant tables”const numeric arrays are worked out while compiling and their storage is shared by voices. Named constructors (harmonics, curve, cheby, window, file, …), transforms and readers are documented under Tables.
const saw8 = harmonics([1, -1/2, 1/3, -1/4], size: 4096)const taps = [0.25, 0.5, 0.25]An oscillator plays a table as one cycle of a wave, band-limited at any pitch: osc(saw8, freq).
Vector operations
Section titled “Vector operations”Arrays held in memory (a state array, a table, or the result of one of these operations) are worked on a whole array at a time, so a line costs about the same however long the array is. Operations that involve a state array or one of these results:
- Arithmetic and math:
+ - * /,min,max,-xand the one-argument math functions, entry by entry. A single value is used for every entry, and two arrays need the same length:y = x * 0.01 + 1.98 * cos(w) * s1 - 0.9801 * s2. buf = exprwrites every entry of a state array you own, from an array of the same length (or one value for all):s2 = s1followed bys1 = yis one step of a delay per entry.xs.sum,xs.min,xs.maxreduce an array to one value. A sum is added in four parts, in a fixed order, so it is the same on every machine (but not bit-for-bit what a left-to-right loop gives).a.dot(b)isΣ a[i] * b[i], andm.matvec(v)multiplies a matrix stored row by row (a table or array withrows × colsentries) by a vector ofcolsentries, givingrowsentries.buf.fir(taps, pos)isΣ taps[i] * buf[pos - i]over a state array used as a ring, newest first, wrapping: a filter output without a loop.tapsis no longer than the ring.buf.taps(offsets, pos, interp: linear)reads the ring at many offsets back frompos(each can be fractional and can change every sample) and gives an array of the values: a multitap delay.
/// Eight resonators in parallel: modal synthesis of a bar or a drum.const modes = [1, 2.76, 5.4, 8.9, 13.3, 18.6, 24.8, 31.9]fn chime(x, freq: init hz) { state s1 = zeros(8) state s2 = zeros(8) w = 2 * pi * freq / sample_rate * modes // worked out once, at note start y = x * 0.01 + 1.98 * cos(w) * s1 - 0.9801 * s2 s2 = s1 s1 = y y.sum}Whole pieces that use these forms, with audio, are on the standard library page (examples/modal.nml and examples/multitap.nml).
init on freq lets everything that depends only on it be worked out when the note starts. These forms need a function with state; using one makes a function a kernel. These names, like the other built-ins, can’t be used for a function you write: see Reserved words.
Frames and the FFT
Section titled “Frames and the FFT”x.frames(size, fn(w) { ... }) runs its function every size / 2 samples on the last size samples of x, windowed with a Hann window, as an array w. The function returns size samples, which are overlapped into the output. If it returns w, the output is x exactly, size samples late. size is a power of two from 4 to 65536.
fft(w)gives[re, im], the spectrum of an array ofnsamples, each withn / 2 + 1entries.ifft(re, im)is its inverse (alsoifft([re, im])).atan2(im, re)gives a phase andsqrt(re * re + im * im)a magnitude.fftworks anywhere in a function with state; in a frame it runs once per hop.- State declared at the top of the function lasts from frame to frame, so a spectrum can be held, smeared or accumulated.
- Latency is
sizesamples, declared to the graph, so a path beside the function is delayed to match. - Size is the user’s, not the engine block’s, so the sound doesn’t depend on
--block-sizeand a log replays exactly. overlap:is a compile-time choice of 2 (default), 4, 8 or 16, no larger thansize. The hop issize / overlap. Half overlap uses an analysis Hann and unwindowed synthesis as above. Higher overlaps use Hann analysis and a Hann synthesis window normalized by the sum of overlapping window products, preserving unity reconstruction while tapering changed frames. Latency remainssizesamples. Paired inputs share the chosen hop.
For phase-vocoder processing, std/spectral supplies phase_frequency and phase_accumulate. Inside a frame callback, turn phases from atan2(im, re) into instantaneous frequencies in cycles per sample, reshape those frequencies, then accumulate synthesis phases. Both helpers need the FFT size and the actual hop (size / overlap); each call site owns independent history. Multiplying cycles/sample by sample_rate gives Hz. Unchanged frequencies reconstruct the input phases, including the first frame. spectral_pitch provides native peak-region remapping, phase locking, attack resets and independent envelope control; see Spectral processing.
/// Keep only the lowest 40 bins: a brick-wall low-pass.const keep = (0..<513).map(it < 40)fn low(x) { x.frames(1024, fn(w) { s = fft(w) ifft(s[0] * keep, s[1] * keep) })}Pass other: to window a second signal on exactly the same clock. The callback then takes two arrays. Input paths with different processing latencies are aligned before analysis; the result adds one frame of latency after that alignment. Both inputs are plain mono signals (multichannel user functions are expanded per channel).
fn cross_spectrum(carrier, donor) { carrier.frames(1024, fn(a, b) { c = fft(a) d = fft(b) // Keep the carrier's phase, with the donor's magnitudes. magnitude = sqrt(d[0] * d[0] + d[1] * d[1]) phase = atan2(c[1], c[0]) ifft(magnitude * cos(phase), magnitude * sin(phase)) }, other: donor)}Several spectral operations can share the same callback and FFT results, avoiding repeated analysis and frame latency. std/spectral also provides spectral_envelope for smoothing an FFT’s magnitude and envelope_transfer for applying one sound’s broad spectral shape to another.
examples/spectral.nml is a piece built from frames, fft and ifft.
Tables as arguments
Section titled “Tables as arguments”A table can be an argument: a parameter written curve: [num] takes one, and so does an opcode’s table parameter. x.waveshape(table) maps x in -1..1 through a transfer table; osc(table, freq) plays one as a wave, and several tables (an array of them) as a bank to morph with position:; x.convolve(ir) convolves with an impulse response (see below). waveshape and osc work inside functions with state; convolve works on whole blocks, so it goes in a function without state, or outside it.
fn shape(x, curve: [num]) { x.waveshape(curve) }out = shape(osc(sine, 110hz), cheby([1, 0.3]))Built-ins
Section titled “Built-ins”Core opcodes (osc, filters, gain, delay, reverb, pan, math, …) are listed under Built-in opcodes. That page is generated from the compiler, so it stays in sync with the binary. A function can call any of them except pan and to_layout inside a function with state. Other DSP built-ins expand element by element over array arguments there as well.
Oversampling
Section titled “Oversampling”oversample(2) { ... }, oversample(4) { ... } and oversample(8) { ... } run sample DSP at a higher rate with filtered upsampling and downsampling:
instr driven(freq: hz) { oversample(4) { (osc(saw, freq) * 5).tanh }}Native opcodes and the overdrive, soft_clip and ladder_lowpass presets run at the session rate. Oversampling is optional: use the native rate to save CPU live, or choose 2x, 4x or 8x for less aliasing. For an oversampled overdrive, define a custom fx:
fx clean_drive { out = oversample(4) { in.distortion(drive: 6, tone: 0.5) }}Create generators and nonlinear DSP effects inside the scope to run them at its sample rate. A signal calculated outside is upsampled; its generator still runs at the original rate. sample_rate inside is the higher rate, keeping oscillators, delays, filters and user-written DSP at the intended pitch and time scale. Input conversion preserves constant values. The conversion filters add 16 samples at the enclosing scope’s rate. The compiler aligns parallel paths automatically, including across instruments, nodes and feed-forward buses. See Latency compensation.
The body supports state, arrays, envelopes and DSP functions. It follows the sample DSP function restrictions on channel-layout processing and side effects. Scopes can nest up to 16 levels and return arrays or named results. An oversampled spawned voice keeps independent state and release behavior. Capture transport or other parent context in a local before entering the scope. Read slow controls with .control outside the scope when they must retain the parent’s block cadence; processing inside is sample by sample.
instr cloud(freq: hz) { spawn(osc(square, 20hz), dur: 80ms, max: 8) { oversample(4) { (osc(saw, freq) * 3).tanh * env[0 2ms 0.2 | 20ms 0] } }}A node can use oversample(4) { in.tanh } directly. Each nested scope uses the sample rate of its immediate parent; two nested 2x scopes run the inner DSP at 4x the project rate. spawn captures the values it reads when a layer starts; placing an effect inside that layer gives in independent state.