Signals & Automation
A signal is a value that moves. Instead of setting a filter cutoff to a fixed
number, you hand it a signal and the cutoff sweeps on its own — an LFO, a slow ramp, a
drawn-in automation curve. Anything you set with << — an effect parameter, a track’s
volume or pan, a send level — can take a signal instead of a number. In DAW terms this
is the automation lane and the modulation matrix rolled into one operator.
The signal generators live in the std/signals module:
use "std/signals" { Sine, Saw, Tri, Square, signal, signal_ramp, automation };Here’s a lowpass whose cutoff rides a slow sine — the filter opens and closes once per cycle:
use "std/instruments" { Sampler, Kit };use "std/effects" { Lowpass };use "std/signals" { Sine };
let drums = AudioTrack("drums");drums.load_instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];
let filter = Lowpass(800);drums.load_effect(filter);
filter.param("Cutoff") << Sine(1).range_exp(400, 4000); // sweep the cutoff
PLAY;filter.param("Cutoff") names the parameter; << binds the moving signal to it;
Sine(1) is the LFO and .range_exp(400, 4000) maps its output into a useful frequency
range. The rest of this chapter unpacks each piece.
The built-in oscillators are your everyday modulators. Each runs from 0 to 1:
Sine()— smooth, rounded motion.Tri()— symmetric ramp up and down.Saw()— ramp up, then a hard reset.Square()— hard switch between low and high.Rand()— a new random value each step, held (sample-and-hold). Deterministic: the same time and speed always give the same value.Perlin()— smooth, organic noise that drifts slowly.
Each also has a bipolar variant — Sine2(), Saw2(), Tri2(), Square2() — that
swings from -1 to 1 instead. Those are handy for things centred on zero, like pan:
use "std/instruments" { Sampler, Kit };use "std/signals" { Sine2 };
let drums = AudioTrack("drums");drums.load_instrument(Sampler(Kit("CR-78")));drums << [hh*8];
drums.pan << Sine2(0.5); // auto-pan, left to right and back
PLAY;The argument sets the speed in cycles-per-pattern-cycle, so the LFO stays locked to the tempo:
- no argument or
1— one full sweep per cycle. Sine(4)— four sweeps per cycle (fast).Sine(0.25)— one sweep every four cycles (slow).
When you’d rather think in absolute terms, hz_to_periods(freq) converts Hertz and
sec_to_periods(secs) converts a period in seconds, both relative to the current tempo:
filter.param("Cutoff") << Sine(hz_to_periods(2)).range_exp(200, 4000); // 2 Hzdelay.param("Time") << Sine(sec_to_periods(0.5)).range(0.1, 0.4); // 0.5 s periodRange Mapping
Section titled “Range Mapping”An LFO’s raw 0..1 output is rarely the range you want, so map it. .range(min, max)
maps linearly; .range_exp(min, max) maps exponentially. For anything you hear as
pitch — filter cutoff, frequency — reach for .range_exp: it spends equal time in each
octave, so the sweep sounds even instead of rushing through the lows and crawling
through the highs.
filter.param("Cutoff") << Sine(1).range(200, 4000); // linear: midpoint ~2100 Hzfilter.param("Cutoff") << Sine(1).range_exp(200, 4000); // exponential: midpoint ~894 HzBipolar signals are normalised to 0..1 before mapping, so .range(100, 200) covers
the same span whether you start from Sine() or Sine2().
Stepped Signals
Section titled “Stepped Signals”signal(#[...]) steps through a list of values, one per beat — a sequenced parameter
rather than a smooth sweep:
delay.param("Time") << signal(#[0.1, 0.2, 0.35, 0.15]);A pattern works too, and patterns auto-convert when used with <<, so the signal()
wrapper is optional unless you want to keep chaining methods:
delay.param("Feedback") << [0.1 0.3 0.5 0.3]; // pattern auto-convertsdrums.volume << [-6 0 -6 0]; // works on track params toosignal_ramp(start, end) is a one-shot line that loops every cycle; add a third
argument to stretch it over several cycles:
filter.param("Cutoff") << signal_ramp(0, 1).range_exp(200, 4000); // ramp each cyclefilter.param("Cutoff") << signal_ramp(0, 1, 4).range_exp(200, 4000); // slow sweep over 4Automation
Section titled “Automation”For shaped, multi-stage moves — a fade, an envelope — use automation(). It takes
breakpoints as #[time, value] pairs, with time measured in cycles:
use "std/instruments" { Sampler, Kit };use "std/effects" { Lowpass };use "std/signals" { automation };
let drums = AudioTrack("drums");drums.load_instrument(Sampler(Kit("CR-78")));drums << [bd sd bd sd];
let filter = Lowpass(800);drums.load_effect(filter);
let sweep = automation(#[0, 0], #[4, 1]); // ramp over 4 cyclesfilter.param("Cutoff") << sweep.range_exp(200, 4000);
PLAY;The .at() builder reads the same, one breakpoint at a time:
let sweep = automation().at(0, 0).at(4, 1);An optional third element on a breakpoint sets the curve of the segment after it.
"linear" is the default; "step" holds the value with no interpolation; "exp",
"smooth", and the "ease-*" family shape the ramp; "bezier(x1,y1,x2,y2)" lets you
draw a custom curve. Mix them freely in one envelope:
let varied = automation( #[0, 0.0, "smooth"], #[2, 1.0, "ease-out"], #[4, 0.3, "step"], #[6, 0.0]);Before the first breakpoint the first value holds; after the last, the last value
holds. Times are in cycles unless you call .in_seconds() to switch to wall-clock
seconds.
Smoothing
Section titled “Smoothing”Stepped signals and fast sequences can zipper. .smooth(ms) runs the signal through a
one-pole lowpass to round off the jumps:
delay.param("Time") << signal(#[0.1, 0.2, 0.35, 0.15]).smooth(10);Combining Signals
Section titled “Combining Signals”Signals support arithmetic, so you can layer and scale them. Add two LFOs, scale one
down, offset another — it all composes with .range() and the rest:
filter.param("Cutoff") << (Sine(1) + Tri(3) * 0.5).range_exp(200, 8000);delay.param("Feedback") << Sine(4) * 0.2 + 0.5; // small wobble around 0.5filter.param("Cutoff") << (-Sine(1)).range(200, 4000); // invert the sweepThe arithmetic runs sample-by-sample at audio rate with no extra allocation, so nesting is cheap.
Rebinding and Phase
Section titled “Rebinding and Phase”By default each << binding gets its own local clock that starts at zero on the next
cycle boundary. That’s what you want for one-shots: re-running a signal_ramp(0, 1, 4)
line restarts the ramp cleanly. For a free-running LFO where you’d rather tweak the
speed without the phase jumping, add .continuous() to keep it on the global clock:
filter.param("Cutoff") << Sine(hz_to_periods(2)).range(200, 4000).continuous();.retrigger("cycle") or .retrigger("beat") resets an LFO’s phase at every cycle or
beat instead; "free" (the default) never resets.
Custom Signals
Section titled “Custom Signals”When the built-ins aren’t enough — per-sample logic, state, filtered noise — you can
write your own modulator as a dsp signal block, compiled by the same engine as
effects and instruments:
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); }}
filter.param("Cutoff") << Wobble().range_exp(200, 4000);The full story — state, parameters, buffers, upstream signal inputs — is its own chapter: see DSP Signals, Functions & Objects.
Next Steps
Section titled “Next Steps”You can move any parameter with LFOs, ramps, and envelopes. Next, bring in external processors and modulate their parameters the same way.
- Plugins — load and control VST3 and CLAP effects and instruments
- Routing, Buses & Sends — automate send levels with these same signals
- MIDI In & CC — drive parameters from external MIDI controllers