Skip to content

Write a Modulation Source

When the built-in LFOs and envelopes don’t do what you need, write a dsp signal. It is compiled like an effect, returns one value per sample, and binds with << wherever a built-in signal does.

A signal has one entry point, fn process() -> out:

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

phase moves on by CPS * INV_SR in each sample — one whole turn per cycle. Ramp() makes an instance, and every signal method works on it: .range_exp maps it onto the cutoff.

A signal’s output is taken to go from -1 to 1. range: 0..1 says this one goes from 0 to 1, so .range and .range_exp map the right span.

param works as in an effect. A signal’s params are the arguments of its constructor:

dsp signal Wobble {
range: 0..1
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);
}
}
let slow = Wobble(0.5);
let shallow = Wobble(speed: 8, depth: 0.2);
let wobbly = Wobble(Wobble(0.25).range(2, 8)); // speed moved by another signal

A param given a signal follows it sample by sample.

The clock constants follow the transport, so you don’t need a phase of your own. CYCLE_PHASE and BEAT_PHASE ramp from 0 to 1 over each cycle or beat:

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

BEAT_EDGE and CYCLE_EDGE are 1 on the one sample where a beat or cycle starts. Use them to sample and hold — here, a new random value on every beat:

dsp signal BeatRandom {
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;
}
}

Every constant is listed in the DSP builtins.

Give process arguments, and the signal takes other signals as inputs. They come first in the constructor, before the params:

use "std/signals" { Sine };
dsp signal Quantize {
param steps: 4 range(1, 32);
fn process(source) -> out {
return __native("round", source * steps) / steps;
}
}
let stepped = Quantize(Sine(2)); // input, positionally
let finer = Quantize(source: Sine(2), steps: 16);
let still = Quantize(0.5); // a number is a constant signal

cc, pitchbend and aftertouch read the live MIDI input:

dsp signal CcSmooth {
range: 0..1
state value: 0.0;
fn process() -> out {
value = value + (__native("cc", 74) - value) * 0.001;
return value;
}
}

This follows CC 74 on any channel, and glides to each new value instead of stepping. To read one channel only, give it first: __native("cc", 0, 74).