Skip to content

Signals & Automation

This content is for v0.7. Switch to the latest version for up-to-date documentation.

Signals are continuous value streams used to modulate effect parameters, track volume, panning, and send levels. Resonon provides built-in oscillators, stepped sequences, custom functions, and breakpoint automation.

ConstructorWaveform
Sine()Sine wave
Saw()Sawtooth
Tri()Triangle
Square()Square wave
Rand()Random (S&H)
Perlin()Perlin noise
ConstructorWaveform
Sine2()Sine wave
Saw2()Sawtooth
Tri2()Triangle
Square2()Square wave
OscillatorShape
Sine() / Sine2()Smooth sinusoidal. Starts at midpoint (0.5 / 0.0), peaks at 1/4 cycle, troughs at 3/4 cycle
Saw() / Saw2()Ramp up from min to max over one cycle, then jumps back. Saw(): 0→1. Saw2(): -1→1
Tri() / Tri2()Symmetric triangle. Tri(): starts at 0, peaks at 1.0 at mid-cycle. Tri2(): starts at 1.0, troughs at -1.0 at mid-cycle
Square() / Square2()High for first half, low for second half. 50% duty cycle
Rand()Deterministic sample-and-hold. Same time + frequency always produces the same value (reproducible)
Perlin()Smooth continuous 1D gradient noise. Organic, slowly-varying modulation

LFO speed is controlled by the argument:

  • No argument or 1 — 1 complete cycle per pattern cycle (tempo-synced)
  • Number nn complete cycles per pattern cycle
  • hz(freq) — absolute frequency in Hertz (tempo-independent)
Sine() // 1 cycle per pattern cycle (default)
Sine(4) // 4 cycles per pattern cycle (fast)
Sine(0.25) // 1 cycle over 4 pattern cycles (slow)
Sine(hz(2)) // Always 2 Hz regardless of tempo

At 120 BPM, Sine(4) completes 4 cycles in 2 seconds. With hz(), Sine(hz(1)) always completes 1 cycle per second regardless of tempo.

By default, every signal binding (<<) gets its own local clock that starts at 0 on the next cycle boundary after the bind. This means one-shot signals like signal_ramp(0, 1, 4) always start from the beginning when you (re)bind them — no manual phase reset needed.

filter.Cutoff << signal_ramp(300, 4000, 4);
// Re-evaluating the line restarts the ramp at 300 Hz on the next cycle.

For LFOs where you want phase continuity across rebinds (so tweaking frequency doesn’t jump phase), use .continuous():

filter.Cutoff << Sine(hz(2)).range(200, 4000).continuous();
// Stays on the global playback clock; rebinding doesn't reset phase.

.continuous() composes with other wrappers in any order:

Sine(2).continuous().range(0.1, 0.9)
Sine(4).retrigger("cycle").continuous()

Controls when the LFO phase resets within its local clock:

lfo.retrigger("cycle") // Reset phase at every cycle start
lfo.retrigger("beat") // Reset phase at every beat
lfo.retrigger("free") // Free-running, never resets (default)
delay.Feedback << Sine(4).retrigger("beat").range(0.2, 0.7);

Maps signal output linearly from min to max.

filter.Cutoff << Sine(1).range(200, 4000);
// LFO=0 -> 200, LFO=0.5 -> 2100 (arithmetic midpoint), LFO=1 -> 4000

Maps signal output exponentially. The midpoint is the geometric mean, making sweeps sound perceptually even. Ideal for frequencies.

filter.Cutoff << Sine(1).range_exp(200, 4000);
// LFO=0 -> 200, LFO=0.5 -> 894 (geometric midpoint), LFO=1 -> 4000

Exponential mapping spends equal time in each octave, while linear mapping rushes through lows and lingers in highs.

Signals support arithmetic operators for combining and scaling:

ExpressionResult
signal + signalSample-wise sum
signal - signalSample-wise difference
signal * signalSample-wise product (ring modulation)
signal / signalSample-wise division
signal + numberDC offset
signal * numberAmplitude scaling
number - signalInverted offset
-signalNegation (flips sign)
// Mix two LFOs
filter.Cutoff << (Sine(1) + Tri(3)) * 0.5;
// Scale a signal
delay.Time << Sine(2) * 0.3;
// DC offset
delay.Feedback << Sine(4) * 0.2 + 0.5;
// Ring modulation
filter.Cutoff << (Sine(1) * Sine(7)).range_exp(200, 4000);
// Negate
filter.Cutoff << (-Sine(1)).range(200, 4000);

Arithmetic composes freely with .range(), .smooth(), and other signal methods:

let lfo = (Sine(1) + Saw(3) * 0.5).range_exp(200, 8000).smooth(5);
filter.Cutoff << lfo;

Steps through an array of values, one per beat:

delay.Time << signal(#[0.1, 0.2, 0.35, 0.15]);

Converts a pattern into stepped signal values:

delay.Feedback << signal([C4 E4 G4 C5]).range(0.1, 0.7);
delay.Time << signal([0.1 0.25 0.4 0.15]);

Patterns auto-convert to signals when used with <<, so the signal() wrapper is optional unless you need chaining:

delay.Feedback << [0.1 0.3 0.5 0.3]; // Auto-converts
drums.volume << [0.5 1.0 0.5 1.0]; // Works for track params too

Linear ramp that loops every cycle:

filter.Cutoff << signal_ramp(0, 1).range_exp(200, 4000); // Sawtooth ramp
filter.Cutoff << signal_ramp(1, 0).range_exp(200, 4000); // Inverted ramp

Linear ramp over a custom number of cycles:

filter.Cutoff << signal_ramp(0, 1, 4).range_exp(200, 4000); // Slow sweep over 4 cycles

Custom signals are built with dsp signal blocks — see the DSP Signals section below for the full reference. They compile to per-sample graphs and replace the old signal_full() wrapper.

dsp signal blocks define compiled modulation sources using the same DSP engine as effects and instruments. They run at audio sample rate and have access to state, parameters, buffers, helper functions, and all DSP builtins.

dsp signal Name {
fn process() -> out {
// return a value per sample
}
}

The process function returns a single f64 value per sample. Runtime builtins available inside the body:

BuiltinDescription
TIMEElapsed time in seconds since the signal’s epoch
CPSCycles per second (tempo-derived)
CYCLECurrent cycle number (integer)
BPCBeats per cycle (default 4, configurable via setbpm)
SRSample rate in Hz
INV_SRInverse sample rate (1 / SR)
CYCLE_PHASEFractional position within the current cycle (0→1)
BEAT_PHASEFractional position within the current beat (0→1)
CYCLE_EDGE1.0 on the sample at a cycle boundary, 0.0 otherwise
BEAT_EDGE1.0 on the sample at a beat boundary, 0.0 otherwise

All DSP builtins are available: sin, cos, abs, noise(), delay(), filters, etc.

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 = fract(phase + speed * CPS * INV_SR);
return 0.5 + depth * 0.5 * sin(phase * TWOPI);
}
}
  • param declares configurable values with defaults and ranges
  • state declares mutable per-sample variables that persist across samples
  • buffer declares fixed-size arrays (for delay lines, etc.)

Local fn declarations inside a dsp signal are helper functions. They can read and write the signal’s state.

dsp signal SmoothNoise {
state prev: 0.0;
param smoothing: 0.001 range(0.0001, 0.1);
fn smooth(input) -> out {
prev = prev + smoothing * (input - prev);
return prev;
}
fn process() -> out {
return smooth(noise());
}
}

dsp signal can call module-level dsp fn helpers and instantiate dsp object components:

dsp object OnePole {
state prev: 0.0;
fn lp(input, cutoff) -> out {
prev = prev + cutoff * (input - prev);
return prev;
}
}
dsp signal FilteredNoise {
param cutoff: 0.01 range(0.001, 0.5);
fn process() -> out {
let filt = OnePole();
return filt.lp(noise(), cutoff);
}
}

A dsp signal process function can accept other signals as per-sample inputs. Declare them as parameters in the fn process(...) signature:

dsp signal MyRange {
param lo: 0;
param hi: 1;
fn process(source) -> out {
return lo + source * (hi - lo);
}
}

Pass signals at instantiation — positionally or by name:

let ranged = MyRange(Sine(2)); // positional
let ranged = MyRange(source: Sine(2)); // named
let half = MyRange(0.5); // numbers auto-wrap as constant signals

Multiple inputs work the same way:

dsp signal CrossFade {
param mix: 0.5;
fn process(a, b) -> out {
return a * (1.0 - mix) + b * mix;
}
}
let blended = CrossFade(Wobble(), SmoothNoise());

Upstream signals are composable — one dsp signal can feed into another:

let chain = MyRange(Wobble()).range_exp(200, 8000);

Parameters can be driven by signals instead of static values. Pass a signal as a param argument at instantiation — it will be processed per-sample, modulating the parameter in real time:

// Named: lo bound modulated by a slow sine
let dynamic = MyRange(Sine(2), lo: Sine(0.5), hi: 0.9);
// Positional: args fill upstream inputs first, then params in order
let dynamic = MyRange(Sine(2), Sine(0.5), 0.9);

When there are no upstream inputs, positional args fill params directly:

let fast_wobble = Wobble(8.0, 0.8); // speed=8, depth=0.8
let modulated = Wobble(Sine(0.5), 1); // speed modulated by a sine

Signal-driven params bypass range() clamping and smoothing — the signal provides continuous values directly.

Call the signal name with () to create an instance. The result is a regular signal — use it with <<, .range(), .smooth(), etc. Signals with upstream inputs require the matching signal arguments.

filter.Cutoff << FilteredNoise().range_exp(200, 4000);
delay.Feedback << Wobble().range(0.1, 0.6);
Use caseRecommended approach
Simple oscillatorBuilt-in Sine(), Saw(), etc.
Stepped valuessignal(#[...]) or signal([pattern])
Breakpoint envelopesautomation()
Complex per-sample logic with statedsp signal
Noise + filtering + nonlinear processingdsp signal
Reusing DSP components as modulationdsp signal

automation() takes breakpoints as #[time, value] arrays. Time is in cycles (tempo-relative).

let sweep = automation(#[0, 0], #[4, 1]);
filter.Cutoff << sweep.range_exp(200, 4000);
// Linear ramp from 200 Hz to 4000 Hz over 4 cycles

Chain breakpoints one at a time:

let sweep = automation().at(0, 0).at(4, 1);
filter.Cutoff << sweep.range_exp(200, 4000);

Each breakpoint accepts an optional third element specifying the interpolation curve for the segment after that point:

CurveDescription
"linear"Straight line interpolation (default)
"step"Hold value until next breakpoint
"exp"Exponential interpolation
"smooth"Cosine interpolation (eases in and out)
"ease-in"Slow start, fast finish
"ease-out"Fast start, slow finish
"ease-in-out"Slow start and end, fast middle
"ease"CSS default ease timing
"bezier(x1,y1,x2,y2)"Custom cubic bezier (x in [0,1], y unrestricted)
// Step curve: discrete changes, no interpolation
let steps = automation(
#[0, 0.1, "step"],
#[1, 0.25, "step"],
#[2, 0.4, "step"],
#[3, 0.15, "step"]
);
delay.Time << steps;
// Exponential sweep
filter.Cutoff << automation(#[0, 0.0, "exp"], #[4, 1.0]).range_exp(200, 4000);
// Mixed curves in one envelope
let varied = automation(
#[0, 0.0, "smooth"],
#[2, 1.0, "ease-out"],
#[4, 0.3, "exp"],
#[6, 0.3, "step"],
#[8, 0.3, "linear"],
#[10, 0.0]
);

With .at() chaining:

let envelope = automation()
.at(0, 0.0, "ease-in")
.at(3, 1.0, "ease-out")
.at(5, 0.5)
.at(8, 0.0);

Specify control points for precise curve shaping:

// S-curve: slow start and end, fast middle
automation(#[0, 0.0, "bezier(0.7, 0.0, 0.3, 1.0)"], #[4, 1.0]);
// Overshoot: y values outside [0,1] create elastic motion
automation().at(0, 0.0, "bezier(0.2, 1.5, 0.8, -0.5)").at(4, 1.0);

Convert from cycle-based to seconds-based timing:

let fade = automation(#[0, 1.0], #[5, 0.0]).in_seconds();
// Breakpoint times are now in seconds, not cycles
  • Before the first breakpoint, the first value is held
  • After the last breakpoint, the last value is held
let delayed_start = automation(#[2, 0.0], #[4, 1.0]);
delay.Feedback << delayed_start.range(0.1, 0.6);
// Cycles 0-2: holds at 0.1 (first value)
// Cycles 2-4: ramps to 0.6
// Cycle 4+: holds at 0.6 (last value)

Apply a one-pole lowpass filter to remove zipper noise from stepped or fast-changing signals:

delay.Time << signal(#[0.1, 0.2, 0.35, 0.15]).smooth(10);

Route signals to effect parameters with <<:

filter.Cutoff << Sine(2).range_exp(400, 4000); // Filter sweep
delay.Time << Sine(0.25).range(0.1, 0.4); // Slow delay modulation
delay.Feedback << Saw(4).range(0.1, 0.6); // Sawtooth feedback
drums.volume << Sine(2).range(0.6, 1.0); // Tremolo
drums.pan << Sine2(0.5); // Autopan

Combine multiple modulation sources across a track’s effects:

use "std/instruments" { Sampler, Kit };
let drums = AudioTrack("drums");
drums.load_instrument(Sampler(Kit("cr78")));
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);
// Stepped filter positions + slow ramping feedback
filter.Cutoff << signal(#[0.2, 0.5, 0.8, 0.4]).range_exp(400, 4000);
delay.Feedback << signal_ramp(0, 1, 2).range(0.1, 0.6);