Skip to content

The Audio-Thread Contract

A dsp block runs on the audio thread. That thread has to hand the sound card a buffer every few milliseconds, and a late buffer is a click that everyone hears. It may therefore never wait, never allocate, never touch the disk, and never do an unbounded amount of work. The language of a dsp block is built so that the code you write in it keeps those rules without your having to think about them.

When you run a dsp effect definition, resonon compiles it into the audio graph. Nothing of the interpreter — its values, its garbage, its locks — reaches the audio thread. Helpers, dsp fns and object methods are inlined when the block is compiled, so splitting code into functions costs nothing per sample.

It is also why a dsp block is its own small language rather than all of resonon: only what can run in bounded time without allocating is in it.

process and render run once per sample. Every value in the block may change on every sample, and modulation reaches a parameter per sample too. A block can hold a feedback path — a filter, a delay with feedback, two oscillators modulating each other — and it is exact to the sample, because nothing is computed a block ahead.

Everything a block remembers between samples is declared: state for numbers, buffer for arrays. The engine knows the whole memory of a block before the first sample, allocates it once, off the audio thread, and never again. A buffer has a fixed size for the same reason — a delay line that grew while playing would have to allocate. Its index wraps around, so a buffer is a circular delay line with no bounds to check.

In a dsp block, an if without else is an error. Every sample must produce a value on every path; a branch that could leave a variable unset for one sample would leave a hole in the audio. The same rule makes if usable as an expression: let gain = if level > t { t / level } else { 1.0 };.

A value set on a param applies at once. For many parameters that is what you want; for a gain or a cutoff, a jump from one value to another is a step in the signal, heard as a click or a zipper. Adding smooth ms to a param makes set values glide there over ms milliseconds. It is a choice per parameter, not a blanket rule, because what is right for a gain is wrong for a step count.

A signal bound to a parameter is never smoothed: it already moves per sample, and smoothing it would change its shape.

Some processing can only react to a sample after it has seen later ones: a lookahead limiter delays its output so its gain is down before a peak comes out. That delay has to be the same everywhere in the mix, or the tracks drift apart. The engine lines them up — it delays the other paths by what each one adds — but only for delays it knows about.

The engine knows the delay of the graph it builds from your block. It cannot know the delay your code creates by reading a buffer late, because that is a fact about your algorithm, not about the graph. So you declare it: latency: 512; adds 512 samples to what the engine computes for the block. It is a fixed number because the engine’s compensation is planned for the whole graph at once, not re-planned sample by sample.