Envelope Generators
In this tutorial, we'll look at algorithms for generating envelopes to add movement and character to sound. We'll start simple, with the classic Attack/Release envelope, then explore an approach for implementing the standard ADSR or ADHSR envelope, and then end with a small creative twist. Each of the algorithms we explore here can be extended and tweaked to form more and more complex shapes, so the ideas we cover here serve both as complete and useful envelopes in their own right, and as fundamental building blocks for further exploration.
Note: this tutorial is best experienced in a modern desktop browser with your keyboard available. As you read ahead, you'll encounter examples that activate as they scroll into view. Press the keys along the home row (a, s, d, e, f, ...) on your keyboard to play sounds through the example envelopes and you'll see the relevant behavior at each step. If you're on Safari, before proceeding through the examples.
Before we visit our first envelope, its worth taking a moment to acknowledge that the way we approach the following ideas in Elementary differ from the conventional approach. Conventionally, the idea of implementing an envelope involves a very stateful methodology along the lines of "for each sample if we're in the attack phase then increment our envelope towards 1. if our envelope has reached 1 then we're now in the decay phase, etc." Elementary approaches these ideas from a functional perspective; we're interested in finding a higher level way of describing a system that has the characteristics we're interested in over time. If you're already well-versed in the more conventional approach, taking the leap to the functional model can feel awkward. But afterwards, the simplicity with which we can describe our desired processes will become apparent.
Exponential Attack/Release Envelopes
Let's begin with the classic exponential Attack/Release (AR) envelope: a shape which typically corresponds to two events, the envelope "onset" and "offset," often associated with pressing a key and then releasing it, respectively. After the onset (key down), the Attack/Release envelope will exponentially reaches its maximum amplitude (1) at some time T1 which is often set by the user, and then exponentially falls back to 0 at time T2 after the offset. The beauty of the exponential AR envelope is its simplicity; if we model the onset and offset events as a signal itself, which goes from 0 to 1 at the time of the onset and then from 1 to 0 at the time of the offset, then the AR envelope is simply a filtered version of that signal. Take a look at the example below, as you press the keys on your keyboard we'll see two lines plotted. In white, the onset/offset signal that abruptly alternates between 0 and 1, and in pink the filtered version which will match what you hear in the amplitude of the synthesizer you're playing.
There are a few things to examine in the code above. First, note that this is only a snippet of the code that drives
what you hear as you play the example; this renderSynthVoice
function will be called once for each voice of the
synthesizer (this first example has only one voice), each time the state of that voice changes. That is, when you press
down a key on your keyboard, the voice state updates to take on a particular frequency and to take a voiceState.gate
value
of 1.0 indicating the onset. When you let go, the voice state updates to take on a voiceState.gate
value of 0, indicating
the offset. The second thing to note is how we derive env
from gate
: here we're using el.smooth
(opens in a new tab)
which is a simple unity-gain one-pole lowpass filter perfect for control signals like this. el.tau2pole
(opens in a new tab)
parameterizes our smoothing filter by mapping a constant time value (in this case, 0.2 seconds) to the relevant pole position
for our filter (which governs how quickly the filter catches up to the input signal).
Noting that this value of 0.2 seconds is chosen for taste, we could write an application that allows the end user to set a
value they like for the speed of the envelope. Taking that one step further, we can expand on this simple example with one
small change using el.select
(opens in a new tab) to enable different attack and release times:
let env = el.smooth(
el.select(
gate,
el.tau2pole(0.1), // For when the gate is "high" (meaning 1, our attack phase)
el.tau2pole(0.8), // For when the gate is "low" (meaning 0, our release phase)
),
gate,
);
Exponential A(H)DSR Envelopes
Let's now move on to a slightly more complicated example, the ADSR envelope– short for Attack, Decay, Sustain,
and Release. This is perhaps the bread and butter of envelope generators, the classic that you'll find everywhere
from software synths to analog synths to eurorack modules. Elementary offers an el.adsr
(opens in a new tab)
generator in the standard library, but we'll set that aside for a minute and build our own ADSR generator from scratch,
introducing a Hold (H) phase between our attack and decay.
There are several ways to generate an A(H)DSR, but within the paradigm of functional audio, this idea of filtering
an abrupt control signal to derive a nice envelope can take us quite far. Keeping with that, then, we can build on our
Attack/Release envelope algorithm. We can arrive at an ADSR envelope from the general structure of an AR envelope if
we take our abrupt onset/offset signal and insert a new step: rather than toggling from 0 to 1 and then simply back to 0,
we'll have our signal go 0 to 1 at the note onset, decrease to a value 0 <= S <= 1
some time after the onset, and then
finally fall to 0 at the note offset. This intermediate step, run through our smoothing filter, produces the Decay phase
which arrives at our desired Sustain level S
. Let's see our next example and follow with some discussion on generating
this intermediate step in our control signal.
Notice as you play with the example above how the envelope generator starts with the same abrupt transition from 0 to 1 that gets smoothed out into an exponential attack. Exactly 800ms after the note onset, the control signal steps abruptly to 0.4, which gets smoothed out into our decay phase. From there, as you continue holding the note the synth voice remains at 0.4 amplitude, our sustain phase. Finally, upon the release of the key, we abruptly step to 0 and smooth into our release phase.
As you can see, the principles of the original AR envelope apply directly to the A(H)DSR. The primary
difference is generating the precisely timed step from 1 to S
, which we accomplish here using
el.sparseq
(opens in a new tab), though really the idea could be
accomplished using various other means of precisely sequencing values, such as el.seq2
(opens in a new tab) or
el.table
(opens in a new tab).
Now, so far, we haven't been very clear about the Hold (H) portion of this envelope, and indeed this
part becomes clear when we start to think about parameterizing the filter speed for the various phases
of the envelope as we did with the AR envelope above. In the same way that we saw before, we can adjust
the timing of each phase using different time coefficients for el.smooth
, and perhaps the simplest
way to do that would be to just sequence those timing coefficients as well.
// This sequence governs our control signal to be filtered
let seq = el.sparseq({seq: [
{ value: 1, tickTime: 0 },
{ value: 0.4, tickTime: 800 },
]}, el.train(1000), gate);
// This sequence governs the time coefficient to apply to the
// smoothing filter itself
let tc = el.sparseq({seq: [
{ value: 0.1, tickTime: 0 },
{ value: 0.2, tickTime: 800 },
], el.train(1000), gate);
let env = el.smooth(
el.select(
gate,
el.tau2pole(tc), // For our attack / decay phases
el.tau2pole(0.4), // For our release phase
),
el.mul(gate, seq),
);
Viewed this way, we can see that there are actually a few things to parameterize now, and we can
assign them as we like or provide controls for the end user to tune them. The Hold
phase that we've been discussing comes from tuning the coefficient of the smoothing filter for the
attack phase relative to the time at which the control signal falls to the sustain value.
Here it's helpful to understand the notion of the Time Constant (opens in a new tab)
as it relates to el.tau2pole
. If we want an attack phase of 100ms, then we need to derive a time constant
with the understanding that the time constant in tau2pole
expresses the time taken to reach ~63.2% of
its target value. A helpful rule of thumb here is timeConstant = desiredTimeInSeconds / 6.9
, thus for an attack
phase of 100ms, we should set a time constant of ~0.01449
. With this in mind, if we want an explicit
hold phase before the decay, we need to ensure that our attack coefficient resolves to the target 1.0 before
the control signal drops to our sustain value. How far before governs the duration of our hold period.
Getting Creative
Now that we've got the fundamental algorithms under our belt, let's have a little fun. By now, hopefully
you can see that we have a ton of room to experiment. For one, we could create sequences of totally arbitrary
values, perhaps drawn by a user in a breakpoint function plot popularized by synths like Xfer Records' Serum.
We could remove the smoothing filter and use a more high-resolution sequence of values to create abrupt edges
when we want them, or we could swap a slew limiter in place of our el.smooth
nodes for linear ramps to the
target values (slew limiting will be added to the Elementary core library in a forthcoming update).
We'll generally leave these ideas as exercises for the reader, but finish with one final example. In this last example, we've enabled polyphony on the synthesizer so you will see four envelope shapes on the plot as you press multiple keys. We'll keep the shape of our A(H)DSR envelope, but on each keypress we'll generate a random time constant for the smoothing filter, and use that same value to derive random sustain levels and random hold times. The result is an unpredictable polyphonic synth that's as fun to play as it is to watch.
Thanks for reading! The full code listing for the examples in this tutorial, including the full synthesizer, are available here as a GitHub Gist (opens in a new tab). The code listings under each example here have been slightly simplified for clarity. If you liked this tutorial, help us spread the word by sharing on Twitter (opens in a new tab) or join the Elementary Audio community on Discord (opens in a new tab).