Skip to content

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 Hz
delay.param("Time") << Sine(sec_to_periods(0.5)).range(0.1, 0.4); // 0.5 s period

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 Hz
filter.param("Cutoff") << Sine(1).range_exp(200, 4000); // exponential: midpoint ~894 Hz

Bipolar signals are normalised to 0..1 before mapping, so .range(100, 200) covers the same span whether you start from Sine() or Sine2().

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-converts
drums.volume << [-6 0 -6 0]; // works on track params too

signal_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 cycle
filter.param("Cutoff") << signal_ramp(0, 1, 4).range_exp(200, 4000); // slow sweep over 4

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 cycles
filter.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.

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);

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.5
filter.param("Cutoff") << (-Sine(1)).range(200, 4000); // invert the sweep

The arithmetic runs sample-by-sample at audio rate with no extra allocation, so nesting is cheap.

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.

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.

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