Husserl tutorial series (6). Envelope Followers, Limiters, and Compressors
Part 6: Introduction
A codebox implementation of limiters and compressors, or indeed anything based on an envelope follower, can consume virtually no CPU at all. Despite limiters and compressors requiring a divide operation, codebox conditionals can let this CPU-intensive operation only happen once every 25 msecs, as will be explained below.
This is part 6 of a series of tutorials on the design of Husserl and the SynthCore library on https://yofiel.com. All tutorials in this series:
Designing a good LFO in gen~ Codebox: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-one
Resampling: when Average is Better: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-2
Wavetables and Wavesets: https://cycling74.com/forums/gen~-codebox-tutorial-oscillators-part-3
Anti-Aliasing Oscillators: https://cycling74.com/forums/husserl-tutorial-series-part-4-anti-aliasing-oscillators
Implementing Multiphony in Max: https://cycling74.com/forums/implementing-multiphony-in-max
Envelope Followers, Limiters, and Compressors: https://cycling74.com/forums/husserl-tutorial-series-part-6-envelope-followers-limiting-and-compression
Repeating ADSR Envelope in gen~: https://cycling74.com/forums/husserl-tutorials-part-7-repeating-adsr-envelope-in-gen~
JavaScript: the Oddest Programming Language: https://cycling74.com/forums/husserl-tutorial-series-javascript-part-one
JavaScript for the UI, and JSUI:<a href="https://cycling74.com/forums/husserl-tutorial-9-javascript-for-the-ui-and-jsui"> https://cycling74.com/forums/husserl-tutorial-9-javascript-for-the-ui-and-jsui
Programming pattrstorage with JavaScript: https://cycling74.com/forums/husserl-tutorial-series-programming-pattrstorage-with-javascript
Applying gen to MIDI and real-world cases. https://cycling74.com/forums/husserl-tutorial-series-11-applying-gen-to-midi-and-real-world-cases
Custom Voice Allocation. https://cycling74.com/forums/husserl-tutorial-series-12-custom-voice-allocation
Envelope followers, limiting, and compression
Before updating my tutorial on envelopes, which is frequently requested, this is a comparatively quick topic now, because I've already covered most of the major code design issues for floating-point operations. Here's a demo patch for download for your own use.

Below is the dynamics postprocessor for each of Husserl3's 16 channels. It's got a bit more in it than the demo patch. The voices also contain a compressor+limiter function with the same structure, and Husserl23 again has the same function as a final limiter and compressor on the summed monophonic output of the 16 channels. This is because the placement of dynamics control in a multiphonic instrument has different effects in different places. On the individual voices, it makes individual voice peaks stand out the most, and there are greater volume changes depending on the number of voices playing. On channels, it makes channels more or less pronounced compared to each other, and voices in the sustain phase are overall more significant than new notes. And on the final output, it has the effect of softening the overall mix such that ALL envelope attacks can entirely fade into the medley.
To understand the example properly here are a few more details on the system design in Husserl3. Its channels receive voices on 16 wires from a separate polyphonic object that will eventually contain 64 voice instances. Each of the current 32 voices receives a message as to which channel sound it's to play, just before a gate-on message. The channel number selects the voice parameters from a shared 16-channel buffer of 128 sound parameters, each channel of which contains the settings for one of the 16 MIDI channels, so any voice can play the sound on any channel. Each voice also uses the channel index to select which of the 16 wires on which the voice sends its final output out, via a gate function in codebox, kindly explained by Ben Bracken. A slight wrinkle is that the gate() function in gen only supports up to 15 targets:
// selects amplitude modulation source:
y = a.peek(amps);
//voice limiting and compression:
x = compander2(out1, limon, limthresh, limratio, limatt, limrel, lclk);
// declicking and amplitude modulation
out1 = env1lvl * ramp0(limlvl *x + ampm*y, linc);
// multiplexed channel output from voices:
if (chan>0){
out2, out3, out4, out5, out6, out7, out8, out9, out10,
out11, out12, out13, out14, out15 = gate(chan, out1, choices=15);
out1 = 0;
}
The channel object thus receives 16 inputs on which the voices for each channel have already been multiplexed and summed together. Each of the 16 instances of the channel object then looks in the same shared buffer for parameters to determine the settings for its channel dynamics, applying them as follows. The individual parts of this long piece of code are described in the following sections:
ramp0(in1, incr){
History prev, diff, val;
if (change(in1) != 0){
diff = incr * (in1 - prev);
prev = in1;
}
val += diff;
if ((diff >0 && val > prev)||(diff < 0 && val < prev)){
diff = 0;
val = in1;
}
return val;
}
Buffer programs("programs"), dat("dat");
Param multi;
History thispoly(1), dinit(1), dclk, lclk, ltime, linc,
mode, lvl(1), thresh, ratio, att, rel,
gain0, gain1(1), rate(.0002), lvl0, lvl1;
if(dinit==1){
ltime = 40/samplerate;
linc = 40/samplerate;
thispoly = voice -1;
dclk = voice;
lclk = voice/samplerate;
dinit = 2;
}
dclk = (dclk >67) ? 0 : dclk + 1;
lclk = (lclk >1) ? 0 : lclk + ltime;
out1 = selector(voice, in1, in2, in3, in4, in5, in6 ,in7 ,in8,
in9, in10, in11, in12, in13, in14, in15, in16);
if (dclk == thispoly){
lvl = programs.peek(multi + 7, thispoly);
mode = programs.peek(multi + 104, thispoly);
thresh = programs.peek(multi + 105, thispoly);
ratio = programs.peek(multi + 106, thispoly);
att = programs.peek(multi + 107, thispoly);
rel = programs.peek(multi + 108, thispoly);
dat.poke(lvl0, voice +31);
}
x = abs(out1);
out1 *= lvl;
if(change(lclk) < 0){
if (mode==4){ //compressor
rate = (lvl1 > lvl0)? att : rel;
gain1 = (lvl1 < thresh)? 1: thresh +(lvl1 -thresh)/ratio;
} else if (mode == 2){ // limiter
rate = (lvl1 > lvl0)? att : rel;
gain1 = (lvl1 < thresh)? 1 : thresh /lvl1;
} else { // off
rate = (gain1 > gain0)? att : rel;
gain1 = 1;
}
gain0 = gain1;
lvl0 = lvl1;
lvl1 = 0;
} else {
if (x > lvl1) lvl1 = x;
}
out1 *= ramp0(gain1, rate);
out1 = selector(voice, in1, in2, in3, in4, in5, in6 ,in7 ,in8,
in9, in10, in11, in12, in13, in14, in15, in16);
The Envelope Follower
Envelope following is for some reason regarded as a dark art, but it's really very simple. first of all, this design uses a trick suggested by Graham Wakefield to initialize constants just once, which is retriggered externally whenever the audio sample rate is changed:
if(dinit==1){
ltime = 10/samplerate;
linc = 10/samplerate;
thispoly = voice -1;
dclk = voice;
lclk = voice/samplerate;
dinit = 2;
}
The [dclk] is a ramp for fetching channel parameters from the shared audio buffer, and the [lclk] is for the limiter ramp.
The lclk is initialized at a value dependent on the channel instance, to spread the processing load of more CPU-intensive activities. The parameters for limiter settings are also read at staggered intervals to spread the buffer peek load on the data cache.
The [lclk] sets the envelope follower's update rate, which runs at 40Hz because that's the lowest frequency possible to capture the rising or falling peak of any audible wave. When the [lclk]'s ramp swings back to zero, the envelope follower's [lvl] value is reset to zero. Then before the next trigger from [lclk], it is raised each time the absolute level of the audio signal rises above the prior value of [lvl]:
x = abs(in1);
...
if (x > lvl1) lvl1 = x;
For which a ternary operator is unnecessary. Besides keeping the absolute maximum signal value, all the action in an envelope follower happens on the 'window edges' of the ramp clock, described in the next section.
In Husserl3, the resulting value of the envelope follower is repurposed for meter display of the total volume of all the voices in each channel (the atodb conversion for the meter display is on the javascript thread, because it's only for display and thus does not need to occur in the higher-priority audio thread).
Limiter and Compressor
The [lclk] trigger causes the absolute maximum signal level over the sample window to be compared to its prior stored maximum value, to determine whether the amplitude of the signal is rising and falling. A ternary operator chooses whether to apply the attack or decay increment to a ramped level control over the next sample window:
rate = (gain1 > gain0)? att : rel;
...
out1 *= ramp0(gain1, rate);
The limiter level is determined in this single line of code:
gain1 = (lvl1 > thresh)? 1: thresh /lvl1;
If the signal level is above the limit level, it is divided by that amount. In this case the division is on the ternary's fail condition so it can start as soon as possible and quickly be discarded if not needed. The division only occurs on [lclk] intervals, so the limiter is just about as efficient as it can be. The compressor is also a single line of code:
gain1 = (lvl1 > thresh)? 1: thresh +(lvl1 -thresh)/ratio;
The compressor shares the same envelope follower. It's really almost the same as limiting, but a compressor also adds the threshold and applies the compressor ratio, when the envelope follower has found the signal to have exceeded the compressor's threshold.
If the dynamics off since its last [lclk] trigger, it ramps back to unity gain output at attack or decay rate, which is necessary, or there will be a click on switch changes:
rate = (gain1 > gain0)? att : rel;
gain1 = 1;
The envelope follower continues to run when the dynamics are off, so it starts to work immediately when the limiter or compressor is turned on, and its output is always available for meter display.
Smoothing and Ramping
The determined gain is smoothed by the ramp0() method, to remove clicks as the dynamics adjust to the sound. The ramp0() method reaches the new level it has been given in exactly the [lclk] interval:
ramp0(in1, incr){
History prev, diff, val;
if (change(in1) != 0){
diff = incr * (in1 - prev);
prev = in1;
}
val += diff;
if ((diff >0 && val > prev)||(diff < 0 && val < prev)){
diff = 0;
val = in1;
}
return val;
}
Audio software developers frequently use an integrator for ramping levels and smoothing signals, because it is a simple operation that requires little CPU:
smooth0(in1, incr){
History prev;
prev = prev + incr * (in1 -prev);
return prev;
}
If the time it takes to reach a new level is not critical to functionality, a smoothing integrator is better, but if the time it takes to reach the stated level is important, a ramp is better. This demo patch shows the difference:

But as you can see the integrator never quite reaches the final level, and the time it takes to reach a close approximation of the final level is dependent on the increment interval. I felt in this design that the attack/release times should be exactly the interval the musician specifies, so it has a ramp. But note, this ramp is controlling amplitude, so its effective loudness in ramping to the final level does not sound linear.
'Predictive' Limiting and Latency
That said, this design is not predictive; it does not attempt to make sure the output never exceeds the set limit level. To do so requires introducing a delay for the length of the envelope follower window. The output of each channel is made available on 16 outputs from the instrument, so that musicians may apply their own effects to each channel separately, and because of that, a zero-latency limiter/compressor was here implemented. Although it would certainly be possible to add a delay line so that the output never exceeds the limiter level, the same delay would have to be applied when the limiter and compressor are off, or there would be a wooshing effect when enabling or disabling it, which may be fun sounding but not what musicians want. Producers have a particular beef with any unnecessary latency in audio signals for historical reasons, although one does hear less complaints about it these days.
Future Improvements
Now if you are an experienced sound designer, you would have observed that the output gain of each stage is currently applied to each voice in the same channel. That incurs redundant calculations, and It would make more sense to apply the output gain to all the voices in one channel. That flaw is a remaining artifact from when the voice design was for a polyphonic instrument. So I'm about to modify the channel stage so that it applies the gain instead, and add mute and solo functions into it the same function for limiting and compression, in order that the same smoothing ramp can be applied to channel mute and solo to avoid clicks. Also I will be adding some logic to disable channel processing when it's not needed. But that will make the compressor and limiter more complicated in the channel object, so I paused to share its design before it got more complex.
Another enhancement would be to make the limiter respond to sudden increases in volume immediately, without adding any latency. I've been working on doing that with Bezier-curve prediction, and I do think it's possible for controlling sudden volume gains in self-resonating filters. If I ever do get it working, it will be unique, because no one else has ever done it.
The next two tutorials will introduce Husserls' envelopes and voice allocator. Wishing you all happy patching )
Thx!!!!