DSP Effects
You don’t have to reach for a plugin every time you want to shape a sound. With a
dsp effect block you write the processing yourself, directly in resonon — and it
runs at the audio sample rate with no overhead beyond the math you type. Think of
it like an insert on a DAW channel strip, except you’re the one who decides what
happens to each sample.
This chapter builds effects up from the simplest possible gain control to multi-input sidechain compressors. If you’ve only skimmed the Custom DSP quickstart, this is the full story.
Your First Effect
Section titled “Your First Effect”The smallest useful effect is a volume control. Write this into a .non file and execute it — you’ll hear the drums play through your own gain stage.
use "std/instruments" { Sampler, Kit };
dsp effect Gain { param level: 1.0 range(0, 2);
fn process(left, right) -> (out_l, out_r) { return (left * level, right * level); }}
let drums = AudioTrack("drums");drums.instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];
drums.fx(#[Gain("gain")]);
PLAY;Here’s what each piece does:
dsp effect Gain { … }is a top-level definition. The nameGainbecomes a type you instantiate later withGain("gain"); everything inside the braces describes how the effect processes audio.param level: 1.0 range(0, 2);declares a knob — a value you read inside the effect and change from outside. It has a default (1.0) and an optionalrange(min, max)that tools use for sliders and normalized control. Insideprocessyou read it as a plain identifier.fn process(left, right) -> (out_l, out_r)is the heart of the effect. It runs once per audio sample, receives the current left and right input, and returns the processed stereo pair. Multiplying both channels bylevelgives a working gain.drums.fx(#[Gain("gain")]);instantiates the effect and sets it as the track’s chain, just like a built-in effect. Effects process in array order; for building multi-effect chains and inspecting them, see Effects.
process runs once per sample and forgets everything between calls — unless you
give it state. A state variable persists from one sample to the next, which is
exactly what you need for anything that evolves over time, like an envelope
follower that ducks the signal when it gets loud:
use "std/instruments" { Sampler, Kit };
dsp effect EnvFollower { param attack: 0.01 range(0.001, 0.5); param release: 0.1 range(0.01, 2.0); state env: 0.0;
fn process(left, right) -> (out_l, out_r) { let input_level = __native("abs", left) + __native("abs", right); if input_level > env { env = env + (input_level - env) * attack; } else { env = env + (input_level - env) * release; } let gain = 1.0 / (1.0 + env * 4.0); return (left * gain, right * gain); }}
let drums = AudioTrack("drums");drums.instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];drums.fx(#[EnvFollower("envfollower")]);
PLAY;env carries the running signal level across samples. The processing math here
uses __native("abs", ...) — a built-in DSP function. There are dozens of these
(sin, sqrt, clamp, the svf_* filters, and more); your editor’s LSP shows
the signature and a description for each one when you hover the name, so reach for
that rather than memorizing a table.
Buffers
Section titled “Buffers”A buffer is a fixed-size array that lives in the effect’s state — the building
block for delay lines, where you write the input now and read it back later. Index
wrapping is automatic (modulo the size), so buf[size] is the same as buf[0]:
use "std/instruments" { Sampler, Kit };
dsp effect Echo { param time: 0.25 range(0.001, 1.0); param feedback: 0.5 range(0, 0.99); param mix: 0.4 range(0, 1); buffer buf_l(48000); buffer buf_r(48000); state wp: 0;
fn process(left, right) -> (out_l, out_r) { let delay_samples = time * SR; let rp = wp + 48000.0 - delay_samples; let delayed_l = buf_l[rp]; let delayed_r = buf_r[rp]; buf_l[wp] = left + delayed_l * feedback; buf_r[wp] = right + delayed_r * feedback; wp = (wp + 1.0) % 48000.0; return (left + delayed_l * mix, right + delayed_r * mix); }}
let drums = AudioTrack("drums");drums.instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];drums.fx(#[Echo("echo")]);
PLAY;SR is the sample rate (e.g. 48000) and INV_SR is 1.0 / SR — both are always
available inside DSP blocks. time * SR converts the delay time from seconds into
a number of samples to read back.
Helper Functions
Section titled “Helper Functions”Long process functions get hard to read. Define extra fns alongside process
to break the work into named pieces — they can read and write the effect’s state
and params, and the compiler inlines them, so there’s zero call overhead:
use "std/instruments" { Sampler, Kit };
dsp effect SoftClipper { param drive: 2.0 range(1, 10); param mix: 0.5 range(0, 1);
fn soft_clip(x) -> out { return x / (1.0 + __native("abs", x)); }
fn process(left, right) -> (out_l, out_r) { let clipped_l = soft_clip(left * drive); let clipped_r = soft_clip(right * drive); return (left * (1.0 - mix) + clipped_l * mix, right * (1.0 - mix) + clipped_r * mix); }}
let drums = AudioTrack("drums");drums.instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];drums.fx(#[SoftClipper("softclipper")]);
PLAY;When you want to share a helper across several effects — or give it its own state —
promote it to a top-level dsp fn or dsp object. That’s the subject of
Signals, Functions & Objects.
Multi-Input Effects and Sidechaining
Section titled “Multi-Input Effects and Sidechaining”By default an effect has one stereo input and one stereo output. Declare them explicitly when you want something different — for example a sidechain compressor that ducks one track using the level of another:
use "std/instruments" { Sampler, Kit };
dsp effect Compressor { input main: stereo; input sidechain: mono; output: stereo;
param threshold: 0.3 range(0, 1); param ratio: 4.0 range(1, 20); state env: 0.0;
fn process(main_l, main_r, sc) -> (out_l, out_r) { let level = __native("abs", sc); let target = __native("max", level - threshold, 0.0) * (1.0 - 1.0 / ratio); if target > env { env = env + (target - env) * 0.01; } else { env = env + (target - env) * 0.001; } let gain = 1.0 / (1.0 + env); return (main_l * gain, main_r * gain); }}
let drums = AudioTrack("drums");drums.instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];
let bass = AudioTrack("bass");bass.instrument(Sampler(Kit("CR-78")));bass << [bd _ _ _ bd _ _ _];
let comp = Compressor("compressor");bass.fx(#[comp]); // place first — connect_input needs the slot to existcomp.connect_input("sidechain", drums);
PLAY;Each input declaration names a port and its width (stereo or mono), and the
process parameters line up with those ports in order: main contributes main_l
and main_r, the mono sidechain contributes sc. connect_input(port, track)
wires another track into a named input — here the drum track drives the
compression while the bass passes through main.
connect_input edits the audio graph and is keyed on the effect’s slot name, so the effect
has to be on a track already: .fx() first, then connect
(A constructor is a description).
The same machinery gives you mono effects. Declare a single mono input and
output and process becomes a one-in, one-out function:
use "std/instruments" { Sampler, Kit };
dsp effect MonoGain { input in: mono; output: mono; param level: 1.0 range(0, 2);
fn process(input) -> output { return input * level; }}
let bass = AudioTrack("bass");bass.instrument(Sampler(Kit("CR-78")));bass << [bd _ bd _];bass.fx(#[MonoGain("monogain")]);
PLAY;Declaring Latency
Section titled “Declaring Latency”Some effects introduce a fixed delay — a lookahead limiter has to buffer samples
before it can react. Declare that delay with latency: and the engine compensates
for it across the whole graph (plugin delay compensation), so your tracks stay in
time:
use "std/instruments" { Sampler, Kit };
dsp effect LookaheadLimiter { latency: 512; param threshold: 0.8 range(0, 1); buffer buf(512); state wp: 0;
fn process(left, right) -> (out_l, out_r) { let delayed = buf[wp]; buf[wp] = left; wp = (wp + 1.0) % 512.0; let level = __native("abs", left); let gain = if level > threshold { threshold / level } else { 1.0 }; return (delayed * gain, delayed * gain); }}
let bass = AudioTrack("bass");bass.instrument(Sampler(Kit("CR-78")));bass << [bd _ bd _];bass.fx(#[LookaheadLimiter("lookaheadlimiter")]);
PLAY;The declared latency is added to whatever the engine computes from the effect’s
internal graph; it defaults to 0 when omitted.
Live Editing
Section titled “Live Editing”You don’t have to stop the transport to change an effect. Edit the body of a
dsp effect you’re already hearing, re-execute it, and the recompiled version is
crossfaded into the running instance over about 10 ms — short enough to feel instant,
long enough not to click.
Selecting just the dsp effect { … } block is enough: every live instance of that effect
picks up the edit, wherever it sits in the graph. Re-executing the whole scene works too,
and does exactly the same thing — the edit is applied once either way.
Run this, let it play, then change 0.5 to 0.9 and execute the whole thing again — or
select just the Crush block and execute that:
use "std/instruments" { Sampler, Kit };
dsp effect Crush { param mix: 1.0 range(0, 1); state hold_l: 0; state hold_r: 0; state count: 0;
fn process(left, right) -> (out_l, out_r) { count = count + 1.0; let resample = if count >= 8.0 { 1.0 } else { 0.0 }; count = count * (1.0 - resample); hold_l = hold_l + (left - hold_l) * resample; hold_r = hold_r + (right - hold_r) * resample; return (left + (hold_l - left) * mix, right + (hold_r - right) * mix); }}
let drums = AudioTrack("drums");drums.instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];
drums.fx(#[Crush("crush")]);
PLAY;The slot is not torn down and rebuilt, so everything hanging off it survives the edit:
- Parameter values. If you’d turned
mixdown to0.2, it’s still0.2after the edit — the default in the source seeds a new slot, it doesn’t reset a live one. - Modulation. Signals and automation driving the effect’s parameters keep running.
- Sidechain connections. Anything wired into an extra input stays wired.
- State variables, by name.
hold_lin the edited version continues from the valuehold_lheld in the old one. A state variable you add starts at its initial value.
Two kinds of edit can’t be carried over:
- Changing the parameter set — adding, removing, or renaming a
param. The live parameter values have nowhere to go. - Changing the I/O layout — adding, removing, or renaming an extra input (see Multi-Input Effects and Sidechaining).
What happens then depends on what you selected. Re-executing the .fx() line rebuilds
the slot: the effect starts fresh with the defaults from your source, audible as a restart
rather than a fade. Re-executing only the body leaves the running instance alone and
warns you — a bare edit never throws away the parameter values, modulation and state you’ve
built up. Execute the .fx() line when you do want the rebuild.
This is the placement rule from
A constructor is a description seen
from the other side: the .fx() line owns the slot, the body owns the code.
Everything else is a fade. See Live Workflow for how this fits into a set.
Next Steps
Section titled “Next Steps”You can now write effects, give them state and buffers, take extra inputs, and declare latency. From here:
- DSP Instruments — the same ideas, applied to polyphonic synthesizers
- Signals, Functions & Objects — share DSP code across effects and write your own modulation sources
- Effects — built-in effects, chains, and managing the effect order on a track