Skip to content

Sweep a Parameter with an LFO

Anything you set with << — an effect parameter, a track’s volume or pan, a send level — takes a signal as well as a number. The LFOs are in std/signals. Signals & Time explains how a signal keeps time.

Put the effect on the track, then send a signal to its parameter:

use "std/instruments" { Sampler, Kit };
use "std/effects" { Lowpass, Delay };
use "std/signals" { Sine, Sine2, Tri };
let drums = AudioTrack("drums");
drums.set_instrument(Sampler(Kit("CR-78")));
drums << [bd sd bd sd];
let filter = Lowpass("lowpass", 800);
let delay = Delay("delay", 0.25, 0.4);
drums.set_effects(#[filter, delay]);
filter.parameter("Cutoff") << Sine(1).range_exp(400, 4000);
PLAY;

For pan, take a bipolar LFO — Sine2, Tri2, Saw2 or Square2 — which swings from -1 to 1:

drums.pan << Sine2(0.5);

The argument is sweeps per cycle, so the LFO follows the tempo: Sine(4) sweeps four times a cycle, Sine(0.25) once every four cycles. To count in another unit, give the LFO a timebase. A note value keeps it synced to the tempo:

filter.parameter("Cutoff") << Sine(1).set_timebase(note(1, 8).dotted()).range_exp(200, 4000); // a dotted eighth
filter.parameter("Cutoff") << Tri(1).set_timebase(note(1, 4).triplet()).range_exp(200, 4000); // a quarter triplet
filter.parameter("Cutoff") << Sine(1).set_timebase(cycles(2)).range_exp(200, 4000); // two bars

Seconds free it from the tempo, like switching an LFO’s sync off:

filter.parameter("Cutoff") << Sine(2).set_timebase(seconds(1)).range_exp(200, 4000); // 2 Hz
delay.parameter("Time") << Sine(1).set_timebase(seconds(0.5)).range(0.1, 0.4); // a 0.5 s period

.range(min, max) maps linearly. For cutoff, and anything else you hear as pitch, use .range_exp(min, max), so the sweep spends equal time in each octave:

filter.parameter("Cutoff") << Sine(1).range(200, 4000); // midpoint ~2100 Hz
filter.parameter("Cutoff") << Sine(1).range_exp(200, 4000); // midpoint ~894 Hz

Add, scale and invert LFOs with arithmetic:

filter.parameter("Cutoff") << (Sine(1) + Tri(3) * 0.5).range_exp(200, 8000);
delay.parameter("Feedback") << Sine(4) * 0.2 + 0.5; // a small wobble around 0.5
filter.parameter("Cutoff") << (-Sine(1)).range(200, 4000); // inverted

Binding a signal restarts it at the next cycle boundary. To change an LFO’s speed without its phase jumping, put it on the global clock with .continuous():

filter.parameter("Cutoff") << Sine(2).set_timebase(seconds(1)).range(200, 4000).continuous();

To restart it on a grid instead, give .set_retrigger() the period: .set_retrigger(cycles(1)) on every cycle, .set_retrigger(beats(1)) on every beat, .set_retrigger(note(1, 8).dotted()) every dotted eighth.

Bind one signal to both, and they move in phase. Two separate signals drift independently:

let lfo = Sine(0.5).range(0.1, 0.4);
delay.parameter("Time") << lfo;
delay.parameter("Feedback") << lfo;