Skip to content

DSP Signals, Functions & Objects

Once you’ve written a few effects and instruments, two needs show up. First, you start repeating yourself — the same soft-clip or one-pole filter copied into block after block. Second, you want modulation the built-in LFOs can’t give you: a filtered-noise source, a beat-synced ramp, something driven by live MIDI.

This chapter covers both. The first half is about reusing DSP code — three ways to factor it out, from a quick local helper to a shared, stateful component. The second half introduces the dsp signal — a modulation source you write yourself, compiled by the same engine as your effects and instruments.

Resonon gives you three tiers of reuse, all inlined at compile time with zero overhead, all usable inside dsp effect, dsp instrument, and dsp signal:

  • a local fn — a helper scoped to one block,
  • a dsp fn — a pure helper shared across the whole file (and importable),
  • a dsp object — a reusable component that carries its own state.

Here’s all three working together before we take them one at a time:

use "std/instruments" { Sampler, Kit };
// Module-level pure helper
dsp fn softclip(x) -> out {
return x / (1.0 + __native("abs", x));
}
// Reusable stateful component
dsp object OnePole {
state prev: 0.0;
fn filter(input, cutoff) -> out {
prev = prev + cutoff * (input - prev);
return prev;
}
}
dsp effect SoftDrive {
param drive: 2.0 range(1, 10);
param tone: 0.3 range(0, 1);
// Local helper — scoped to this effect
fn apply_drive(x) -> out {
return softclip(x * drive);
}
fn process(left, right) -> (out_l, out_r) {
let lp_l = OnePole();
let lp_r = OnePole();
return (lp_l.filter(apply_drive(left), tone),
lp_r.filter(apply_drive(right), tone));
}
}
let drums = AudioTrack("drums");
drums.load_instrument(Sampler(Kit("CR-78")));
drums << [bd sd bd sd];
drums.load_effect(SoftDrive());
PLAY;

You’ve already met these in the effects and instruments chapters: any fn inside a DSP block that isn’t the entry point (process or render) is a local helper. No keyword needed — just define it. It’s visible only inside its block, can read and write the block’s state, buffer, and param, and shadows any top-level dsp fn of the same name.

dsp effect Name {
fn helper(x) -> out { // can read/write this block's state and params
return x * 2.0;
}
fn process(input) -> output {
return helper(input);
}
}

Reach for a local helper first. Promote it only when you need the same code in another block.

A dsp fn is a pure function defined at the top level of a file: inputs in, outputs out, no state, buffer, or param. It’s hoisted like a regular fn, so it’s available everywhere in the file, and it can be imported from other files.

dsp fn clamp01(x) -> out {
return __native("clamp", x, 0.0, 1.0);
}
dsp effect SafeGain {
param level: 1.0 range(0, 4);
fn process(input) -> output {
return clamp01(input * level);
}
}

A dsp fn returns a single value. To process both channels of a stereo effect, call the helper once per channel — that’s the pattern every effect in these chapters uses:

dsp fn softclip(x) -> out {
return x / (1.0 + __native("abs", x));
}
dsp effect Saturate {
param drive: 2.0 range(1, 10);
fn process(left, right) -> (out_l, out_r) {
return (softclip(left * drive), softclip(right * drive));
}
}

dsp object — Reusable Stateful Components

Section titled “dsp object — Reusable Stateful Components”

A dsp fn can’t remember anything between calls. When a helper needs its own state — a filter’s previous sample, a delay line’s buffer — make it a dsp object. Define it at module scope with state and buffer declarations, then instantiate it inside an effect or instrument. Each instance gets an independent copy of all its state.

use "std/instruments" { Sampler, Kit };
dsp object OnePole {
state prev: 0.0;
fn lp(input, cutoff) -> out {
prev = prev + cutoff * (input - prev);
return prev;
}
}
dsp effect Smooth {
param amount: 0.1 range(0.001, 1);
fn process(left, right) -> (out_l, out_r) {
let lp_l = OnePole(); // each let is an independent instance
let lp_r = OnePole();
return (lp_l.lp(left, amount), lp_r.lp(right, amount));
}
}
let drums = AudioTrack("drums");
drums.load_instrument(Sampler(Kit("CR-78")));
drums << [bd sd bd sd];
drums.load_effect(Smooth());
PLAY;

A few rules worth knowing:

  • Methods can be named anything — there’s no required entry point.
  • An object can declare buffers for delay lines and circular buffers.
  • An object cannot declare param — parameters belong to the containing effect or instrument, which can pass their values into the object’s methods.
local fndsp fndsp object
Scopeone blockwhole filewhole file
Own state / buffersno (uses parent’s)noyes
Importablenoyesyes
Best forblock-internal logicpure math helpersreusable stateful parts

dsp fn and dsp object are module-level values, so you import them with use like anything else:

filters.non
dsp fn softclip(x) -> out {
return x / (1.0 + __native("abs", x));
}
dsp object OnePole {
state prev: 0.0;
fn lp(input, cutoff) -> out {
prev = prev + cutoff * (input - prev);
return prev;
}
}
main.non
use "./filters" as filters;
dsp effect Warm {
param tone: 0.2 range(0, 1);
fn process(input) -> output {
let lp = filters.OnePole();
return lp.lp(filters.softclip(input), tone);
}
}

Prefix a definition with private to keep it from being exported. See Modules for the full import system.

The built-in LFOs and envelopes from Signals & Automation cover most modulation. When they don’t — when you need per-sample logic, internal state, filtered noise, or live MIDI — write a dsp signal. It compiles through the same pipeline as effects and instruments, and you bind it to any parameter with <<, exactly like a built-in LFO.

A dsp signal has one entry point, fn process() -> out, that returns one value per sample:

use "std/instruments" { Sampler, Kit };
use "std/effects" { Lowpass };
dsp signal SmoothRamp {
state phase: 0.0;
fn process() -> out {
phase = __native("fract", phase + CPS * INV_SR);
return phase;
}
}
let drums = AudioTrack("drums");
drums.load_instrument(Sampler(Kit("CR-78")));
drums << [bd sd bd sd];
let filter = Lowpass(2000);
drums.load_effect(filter);
filter.param("Cutoff") << SmoothRamp().range_exp(300, 4000);
PLAY;

CPS is cycles-per-second and INV_SR is 1 / sample_rate, so each sample advances phase by exactly one cycle’s fraction — a sample-accurate 0→1 ramp. You instantiate the signal with () and bind it with <<, attaching .range_exp to map its 0..1 output onto the filter’s cutoff range.

A signal’s natural output is bipolar -1..1. Declare range: when it’s something else, so the engine and the scope know its bounds:

dsp signal UnipolarRamp {
range: 0..1
state phase: 0.0;
fn process() -> out {
phase = __native("fract", phase + CPS * INV_SR);
return phase;
}
}

A dsp signal uses the same state, param, buffer, and local-helper building blocks as everything else. Here’s a parameterized LFO with a one-pole-smoothed noise source split into a helper:

dsp signal Wobble {
param speed: 4.0 range(0.1, 40);
param depth: 0.5 range(0, 1);
state phase: 0.0;
fn process() -> out {
phase = __native("fract", phase + speed * CPS * INV_SR);
return 0.5 + depth * 0.5 * __native("sin", phase * TWOPI);
}
}
dsp signal SmoothNoise {
param smoothing: 0.001 range(0.0001, 0.1);
state prev: 0.0;
fn smooth(input) -> out {
prev = prev + smoothing * (input - prev);
return prev;
}
fn process() -> out {
return smooth(__native("noise"));
}
}

dsp signal can also use module-level dsp fn and dsp object, just like effects and instruments do — the same softclip or OnePole you wrote above works here.

Accumulating a phase by hand works, but for clock-synced modulation Resonon gives you ready-made signal constants. They cost no state and stay locked to the transport:

ConstantMeaning
CYCLE_PHASE0→1 sawtooth that resets every cycle
BEAT_PHASE0→1 sawtooth that resets every beat
CYCLE_EDGE1.0 on the exact sample a cycle boundary is crossed, else 0.0
BEAT_EDGE1.0 on the exact sample a beat boundary is crossed, else 0.0
CYCLEthe current cycle number
CPS / BPCcycles-per-second and beats-per-cycle
TIMEelapsed seconds since the signal started

CYCLE_PHASE replaces a manual phase accumulator for cycle-length ramps:

dsp signal CycleRamp {
range: 0..1
fn process() -> out {
return CYCLE_PHASE;
}
}

The edge constants fire for a single sample at a boundary — perfect for sample-and-hold or resetting an accumulator. This holds a fresh random value at the start of every beat:

dsp signal BeatPulse {
range: 0..1
state held: 0.0;
fn process() -> out {
if BEAT_EDGE > 0.0 {
held = __native("noise") * 0.5 + 0.5;
} else {
held = held;
}
return held;
}
}

A dsp signal can take other signals as per-sample inputs. Declare them as parameters of process, then pass signals in at instantiation:

dsp signal Wobble {
param speed: 4.0 range(0.1, 40);
state phase: 0.0;
fn process() -> out {
phase = __native("fract", phase + speed * CPS * INV_SR);
return 0.5 + 0.5 * __native("sin", phase * TWOPI);
}
}
dsp signal Remap {
param lo: 0;
param hi: 1;
fn process(source) -> out {
return lo + source * (hi - lo);
}
}
let ranged = Remap(Wobble()); // positional
let ranged_named = Remap(source: Wobble()); // named
let half = Remap(0.5); // a number auto-wraps as a constant signal
let chain = Remap(Wobble()).range_exp(200, 8000); // signals chain

Numbers passed where a signal is expected auto-wrap as constant signals, so you can mix the two freely.

Parameters aren’t limited to static values either — pass a signal as a param argument and it modulates that parameter per sample. Positional arguments fill the upstream inputs first, then the parameters in declaration order:

// Named: modulate the lo bound with another signal
let dynamic = Remap(Wobble(), lo: Wobble(), hi: 0.9);
// With no upstream inputs, positional args fill params directly
let fast = Wobble(8.0); // speed = 8
let modulated = Wobble(Wobble()); // speed modulated by another Wobble

Inside a dsp signal you can read live MIDI input directly. These builtins refresh once per audio block from shared atomics — no state needed:

  • cc(n) — MIDI CC n, normalized 0.01.0, any channel; cc(channel, n) for a specific channel (015)
  • aftertouch() — channel pressure 0.01.0; aftertouch(channel) for one channel
  • pitchbend() — pitch bend -1.01.0; pitchbend(channel) for one channel
dsp signal CcFilter {
fn process(source) -> out {
let cutoff = __native("cc", 74) * 10000.0 + 200.0;
return __native("svf_lp", source, cutoff, 0.707);
}
}

Here CC 74 sweeps a filter cutoff in real time. For the full MIDI-input story — channels, controllers, MPE — see MIDI In & CC.

A signal makes a great modulation source for anything that takes << — effect parameters, instrument parameters, even a track’s volume:

use "std/instruments" { Sampler, Kit };
use "std/effects" { Lowpass, Delay };
dsp signal SmoothNoise {
param smoothing: 0.001 range(0.0001, 0.1);
state prev: 0.0;
fn process() -> out {
prev = prev + smoothing * (__native("noise") - prev);
return prev;
}
}
dsp signal Wobble {
param speed: 4.0 range(0.1, 40);
state phase: 0.0;
fn process() -> out {
phase = __native("fract", phase + speed * CPS * INV_SR);
return 0.5 + 0.5 * __native("sin", phase * TWOPI);
}
}
let drums = AudioTrack("drums");
drums.load_instrument(Sampler(Kit("CR-78")));
drums << [bd sd [bd bd] sd];
let filter = Lowpass(2000);
let delay = Delay(0.25, 0.4);
drums.load_effect(filter);
drums.load_effect(delay);
filter.param("Cutoff") << SmoothNoise().range_exp(300, 4000);
delay.param("Feedback") << Wobble().range(0.1, 0.6);
drums.volume << Wobble().range(-6, 0);
PLAY;

You can now factor DSP code for reuse and build your own modulation sources. When even hand-written Resonon DSP isn’t enough — you need a tight Rust loop, a crate, or system access — drop down to native code: