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.load_instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];
drums.load_effect(Gain());
PLAY;Here’s what each piece does:
dsp effect Gain { … }is a top-level definition. The nameGainbecomes a type you instantiate later withGain(); 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.load_effect(Gain());instantiates the effect and adds it to the track’s chain, just like a built-in effect. Effects process in the order you load them; for reordering, removing, and inspecting a chain, 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.load_instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];drums.load_effect(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.load_instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];drums.load_effect(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.load_instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];drums.load_effect(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.load_instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];
let bass = AudioTrack("bass");bass.load_instrument(Sampler(Kit("CR-78")));bass << [bd _ _ _ bd _ _ _];
let comp = Compressor();bass.load_effect(comp);comp.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.
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.load_instrument(Sampler(Kit("CR-78")));bass << [bd _ bd _];bass.load_effect(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.load_instrument(Sampler(Kit("CR-78")));bass << [bd _ bd _];bass.load_effect(LookaheadLimiter());
PLAY;The declared latency is added to whatever the engine computes from the effect’s
internal graph; it defaults to 0 when omitted.
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