A stereo chorus
A delay whose length moves, and two of them out of phase with each other.
Anyone who wants width.
A chorus is a short delay whose read position moves. Moving the read position gently changes the pitch of the delayed signal. Mix it with the original and you hear two versions of the sound drifting around each other.
Here a mono input feeds two delay reads. Their LFOs are a quarter cycle apart, so their pitch motion differs. The two results become the left and right lanes of one stereo output.
Controls#
| Parameter | Default | Range | Meaning |
|---|---|---|---|
rate | 0.7 Hz | 0.02–8 Hz | LFO cycles per second. |
delay | 0.012 s | 0.001–0.030 s | Centre delay, before buffer-capacity limits. |
depth | 0.004 s | 0–0.012 s | Requested modulation either side of the centre delay. |
mix | 0.45 | 0–1 | Dry/wet blend applied to each channel. |
in is mono. out is float<2>, with the left lane first. Two separate
scalar outputs would make the second one a side channel, not the right speaker.
The complete patch#
processor StereoChorus
{
input stream float in;
output stream float<2> out;
param float rate = 0.7f [0.02f, 8.0f] smooth 25.0f;
param float delay = 0.012f [0.001f, 0.030f] smooth 25.0f;
param float depth = 0.004f [0.0f, 0.012f] smooth 25.0f;
param float mix = 0.45f [0.0f, 1.0f] smooth 25.0f;
float line[4096];
int pos = 0;
float lfo = 0.0f;
void main()
{
let sr = processor.frequency;
loop
{
line[pos] = in;
let step = rate / sr;
let adv = lfo + step;
lfo = adv > 1.0f ? adv - 1.0f : adv;
let ang = 6.2831853f * lfo;
let modL = sin(ang);
let modR = cos(ang);
let baseSamples = clamp(delay * sr, 2.0f, 4094.0f);
let available = min(baseSamples - 1.0f, 4094.0f - baseSamples);
let depthSamples = clamp(depth * sr, 0.0f, available);
let dL = baseSamples + modL * depthSamples;
let dR = baseSamples + modR * depthSamples;
let iL = int(dL);
let fL = dL - float(iL);
let aL = line[pos - iL];
let bL = line[pos - iL - 1];
let wetL = aL + (bL - aL) * fL;
let iR = int(dR);
let fR = dR - float(iR);
let aR = line[pos - iR];
let bR = line[pos - iR - 1];
let wetR = aR + (bR - aR) * fR;
pos = pos + 1 > 4095 ? 0 : pos + 1;
let left = in * (1.0f - mix) + wetL * mix;
let right = in * (1.0f - mix) + wetR * mix;
out <- float<2>(left, right);
advance();
}
}
}Follow a moving read#
The patch writes each input sample into a shared buffer. sin(ang) and
cos(ang) then give two modulation values from the same LFO phase. Their
quarter-cycle offset is what makes the channels move differently.
For each channel, the requested delay has an integer part and a fraction.
aL and bL are neighbouring samples; aL + (bL - aL) * fL blends them at
the fractional position. Without that interpolation, a smoothly moving
control would still jump between whole-sample read positions.
The centre and depth are constrained in samples, leaving room for both interpolation reads inside the 4,096-element buffer. This fits the maximum 30 ms centre plus 12 ms depth through 96 kHz. Depth is still limited by the centre delay: with a 1 ms centre, asking for 12 ms of movement cannot make the read position travel into the future. Raise the centre to at least 13 ms to use the entire depth range.
Try it#
Start with depth at 0: both channels become the same static delay blend.
Increase depth slowly and the stereo motion appears. Raise rate toward
several hertz to hear the modulation become more like vibrato.
If the channels sound identical: check the cos call on the right and
the stereo constructor. Both channels share the dry input intentionally;
their delayed components are what differ.
Next: a wavefolder, for when subtlety is not the goal.