Making Audio Plugins Part 11: Envelopes

Sound is only interesting when there’s variation over time. Let’s create an envelope generator to make variations in volume!

Envelope Generator Basics

If you’re not familiar with the term ADSR (meaning Attack Decay Sustain Release), please read this before we go on.
Basically, our envelope generator is a finite state machine with the states Off, Attack, Decay, Sustain and Release. That’s a fancy way of saying that at any given point in time, it is in exactly one of those states. In envelope terms, these are called stages. Going from one stage to another will be done by calling the enterStage member function.
A few key points about envelope stages:

  • The generator gets out of the ATTACK, DECAY and RELEASE stage by itself: After a given time has passed, it calls enterStage to go to the next stage.
  • It stays in the OFF and SUSTAIN stages indefinitely, until enterStage is called from outside.
  • Therefore, ATTACK, DECAY and RELEASE are time values, but SUSTAIN is a level value.
  • It can enter the RELEASE stage coming from the ATTACK, DECAY or SUSTAIN stage.
  • When entering RELEASE, it should decay from the current level down to zero.

For each sample, the envelope generator will give us a double between zero and one. We’ll get the current value from the envelope generator, and then we’ll multiply our signal with this value. This way, the signal’s volume will be determined by the envelope: We’ll be able to create tones that fade in slowly or quickly decay in volume.

The EnvelopeGenerator Class

Create a new C++ class named EnvelopeGenerator and add it to all targets. Go into EnvelopeGenerator.h and add the following class declaration (between #define and #endif):

#include <cmath>

class EnvelopeGenerator {
public:
    enum EnvelopeStage {
        ENVELOPE_STAGE_OFF = 0,
        ENVELOPE_STAGE_ATTACK,
        ENVELOPE_STAGE_DECAY,
        ENVELOPE_STAGE_SUSTAIN,
        ENVELOPE_STAGE_RELEASE,
        kNumEnvelopeStages
    };
    void enterStage(EnvelopeStage newStage);
    double nextSample();
    void setSampleRate(double newSampleRate);
    inline EnvelopeStage getCurrentStage() const { return currentStage; };
    const double minimumLevel;

    EnvelopeGenerator() :
    minimumLevel(0.0001),
    currentStage(ENVELOPE_STAGE_OFF),
    currentLevel(minimumLevel),
    multiplier(1.0),
    sampleRate(44100.0),
    currentSampleIndex(0),
    nextStageSampleIndex(0) {
        stageValue[ENVELOPE_STAGE_OFF] = 0.0;
        stageValue[ENVELOPE_STAGE_ATTACK] = 0.01;
        stageValue[ENVELOPE_STAGE_DECAY] = 0.5;
        stageValue[ENVELOPE_STAGE_SUSTAIN] = 0.1;
        stageValue[ENVELOPE_STAGE_RELEASE] = 1.0;
    };
private:
    EnvelopeStage currentStage;
    double currentLevel;
    double multiplier;
    double sampleRate;
    double stageValue[kNumEnvelopeStages];
    void calculateMultiplier(double startLevel, double endLevel, unsigned long long lengthInSamples);
    unsigned long long currentSampleIndex;
    unsigned long long nextStageSampleIndex;
};

First, we’re defining an enum with all the envelope stages. We add kNumEnvelopeStages at the end so we know how many stages there are. Note that we’re scoping the enum to the EnvelopeGenerator class. This means that it won’t go into the global namespace.
We’ll discuss the member functions when we implement them. minimumLevel is needed because the envelope calculations don’t work with an amplitude of zero. We initialize it to the very small value of 0.001.
The initializer list makes sure that the envelope is in the OFF stage by default and initializes the stageValue array to some default values: Short attack, 0.5 seconds decay, quiet sustain, one second release.
In the private section, currentStage indicates what stage the envelope is currently in. currentLevel is the current envelope level that we’ll get on every sample. The multiplier is responsible for the exponential decay as described below.
During ATTACK, DECAY and RELEASE, the generator has to keep track of where it currently is so it can enter the next stage after a given time (i.e. after the transition is finished). Instead of comparing some double value, we’re using a currentSampleIndex. Open EnvelopeGenerator.cpp and add the following implementation:

double EnvelopeGenerator::nextSample() {
    if (currentStage != ENVELOPE_STAGE_OFF &&
        currentStage != ENVELOPE_STAGE_SUSTAIN) {
        if (currentSampleIndex == nextStageSampleIndex) {
            EnvelopeStage newStage = static_cast<EnvelopeStage>(
                (currentStage + 1) % kNumEnvelopeStages
            );
            enterStage(newStage);
        }
        currentLevel *= multiplier;
        currentSampleIndex++;
    }
    return currentLevel;
}

If the generator is in ATTACK, DECAY or RELEASE stage and the currentSampleIndex has reached the value of nextStageSampleIndex, we just get the next item from the EnvelopeStage enum. Because of the modulo operator, it will go back to ENVELOPE_STAGE_OFF after ENVELOPE_STAGE_RELEASE (which is what we want). Finally, we call enterStage to go into the next stage.
We then modify the currentLevel and increment the currentSampleIndex to keep track of time. Note that this doesn’t happen in the OFF and SUSTAIN stages: In these stages the level must stay the same, so there’s no need to calculate. The same goes for currentSampleIndex: The OFF and SUSTAIN stages don’t expire after a given time, so the generator doesn’t have to check if they are over.

Transitions Over Time

In the ATTACK, DECAY and RELEASE stage, the generator transitions between two values over a given amount of time. Our ear perceives volume in a logarithmic way. So in order to hear a volume change as linear, it has to be exponential.
There are different ways to calculate an exponential curve between two points. The most intuitive would be to call exp (from <cmath>) on every sample. However, there’s a smarter way that calculates a multiplier based on the two values and the given time. On every sample, the current envelope value is multiplied with this value.
Implement the following function to calculate the value (it’s based on Christian Schoenebeck’s Fast Exponential Envelope Generator):

void EnvelopeGenerator::calculateMultiplier(double startLevel,
                                            double endLevel,
                                            unsigned long long lengthInSamples) {
    multiplier = 1.0 + (log(endLevel) - log(startLevel)) / (lengthInSamples);
}

At this point it’s not that important to fully understand the equation. Just be aware that this function takes startLevel, endLevel and the transition’s lengthInSamples and calculates a multiplier that will be a number slightly below or slightly above 1. We’ll multiply currentLevel with this to get an exponential transition. By the way, log() is the natural logarithm.

Changing Envelope Stages

Now that we know how to calculate the multiplier, let’s implement enterStage:

void EnvelopeGenerator::enterStage(EnvelopeStage newStage) {
    currentStage = newStage;
    currentSampleIndex = 0;
    if (currentStage == ENVELOPE_STAGE_OFF ||
        currentStage == ENVELOPE_STAGE_SUSTAIN) {
        nextStageSampleIndex = 0;
    } else {
        nextStageSampleIndex = stageValue[currentStage] * sampleRate;
    }
    switch (newStage) {
        case ENVELOPE_STAGE_OFF:
            currentLevel = 0.0;
            multiplier = 1.0;
            break;
        case ENVELOPE_STAGE_ATTACK:
            currentLevel = minimumLevel;
            calculateMultiplier(currentLevel,
                                1.0,
                                nextStageSampleIndex);
            break;
        case ENVELOPE_STAGE_DECAY:
            currentLevel = 1.0;
            calculateMultiplier(currentLevel,
                                fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel),
                                nextStageSampleIndex);
            break;
        case ENVELOPE_STAGE_SUSTAIN:
            currentLevel = stageValue[ENVELOPE_STAGE_SUSTAIN];
            multiplier = 1.0;
            break;
        case ENVELOPE_STAGE_RELEASE:
            // We could go from ATTACK/DECAY to RELEASE,
            // so we're not changing currentLevel here.
            calculateMultiplier(currentLevel,
                                minimumLevel,
                                nextStageSampleIndex);
            break;
        default:
            break;
    }
}

After updating currentStage to the new value, we make sure that currentSampleIndex starts counting from zero again. Then we calculate how long (i.e. how many samples) it will take until the next stage. As already mentioned, this is only needed for the ATTACK, DECAY and RELEASE stages. Since stageValue[currentStage] gives us a double value (the stage duration in seconds), we multiply with sampleRate to get the stage length in samples.
The switch branches between the possible stages. In the OFF case, we just set the level to zero and the multiplier to one (actually we don’t have to do that, but to me it looks more consistent). For ATTACK, we make sure to start from the very silent minimumLevel and we calculate the multiplier, so the transition will be from the currentLevel to 1.0. For DECAY, we let the level fall from the current value to the sustain level (stageValue[ENVELOPE_STAGE_SUSTAIN]), but using fmax we make sure that it doesn’t reach zero. The RELEASE stage decays from the currentLevel (whatever that is) to the minimumLevel. As explained by the comment, we’re not changing currentLevel here because we don’t know from which stage and level it is entering RELEASE stage.
The SUSTAIN stage is a special case. As already mentioned, stageValue[ENVELOPE_STAGE_SUSTAIN] holds a level value, not a time value. So we just assign that to currentLevel.

A First Test

Add the (simple) implementation for setSampleRate:

void EnvelopeGenerator::setSampleRate(double newSampleRate) {
    sampleRate = newSampleRate;
}

Add a private member to the Synthesis class (in Synthesis.h):

EnvelopeGenerator mEnvelopeGenerator;

Make sure you also #include "EnvelopeGenerator.h" before the class declaration.
In Synthesis.cpp, replace the leftOutput[i] line in ProcessDoubleReplacing with the following:

// leftOutput[i] = rightOutput[i] = mOscillator.nextSample() * velocity / 127.0;
if (mEnvelopeGenerator.getCurrentStage() == EnvelopeGenerator::ENVELOPE_STAGE_OFF) {
    mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK);
}
if (mEnvelopeGenerator.getCurrentStage() == EnvelopeGenerator::ENVELOPE_STAGE_SUSTAIN) {
    mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE);
}
leftOutput[i] = rightOutput[i] = mOscillator.nextSample() * mEnvelopeGenerator.nextSample() * velocity / 127.0;

The code is for testing purposes: The two if statements make the envelope generator go automatically from OFF to ATTACK stage and from SUSTAIN to RELEASE stage. This means that it will loop indefinitely. Every sample is multiplied with the current envelope generator value.
When the sample rate is set, mEnvelopeGenerator has to be notified. Add the following line to Synthesis::Reset():

mEnvelopeGenerator.setSampleRate(GetSampleRate());

We’re now ready to test this! Run your plugin and hold a note on the virtual keyboard. Keep the mouse button pressed and you’ll hear that the generator keeps looping through the stages. Great!

Triggering with Note On/Off

A looping envelope is nice (maybe we’ll need this later), but right now we want the behaviour we know from classic synthesizers: When we play a key, it should start the ATTACK stage. When we release the key, it should go into RELEASE and fade out. The mMIDIReceiver knows about note on/off, so we have to somehow connect it to mEnvelopeGenerator.

A simple way to do this would be to #include EnvelopeGenerator.h in MIDIReceiver.h. We could then pass a reference to mEnvelopeGenerator from Synthesis.h, so mMIDIReceiver can access it. The MIDI receiver would then just call enterStage whenever it gets a note on/off message.
This is a bad idea because it makes MIDIReceiver depend on an EnvelopeGenerator instance. We want to have a clean separation between components: If we some day write a pure MIDI plugin without envelopes, we want to use the MIDIReceiver class without depending on EnvelopeGenerator.h.

A better approach is to use Signals and Slots. The pattern comes from the Qt framework. It can be used to connect a button to a text field, without the two knowing each other. When the button is clicked, it emits a signal. This signal can be connected to a slot on the text field, such as setText(). So when you click the button, the text changes. It’s important to know that the button’s signal doesn’t care if any (or how many) slots are connected. Whatever’s connected gets notified. We can use this pattern to connect the different components in our plugin (Oscillator, EnvelopeGenerator, MIDIReceiver). The connection will be done from outside, i.e. from the Synthesis class.

We won’t use the Qt framework just to get this one feature. We’ll use Patrick Hogan’s Signals library. Download and extract it. Now rename Signal.h to GallantSignal.h (this is to avoid name clashes). Drag the Delegate.h and GallantSignal.h into your project, making sure to “Copy items into destination group’s folder”, and add them to all targets.

We want the MIDIReceiver to emit a signal whenever a note is pressed, and whenever it is released. Add the following above the class definition in MIDIReceiver.h:

#include "GallantSignal.h"
using Gallant::Signal2;

Signal2 is a signal that passes two parameters. There’s Signal0 through Signal8, so you can choose depending on how many parameters you need. Add the following to the public section:

Signal2< int, int > noteOn;
Signal2< int, int > noteOff;

As you can see, both signals will pass two ints. Go into MIDIReceiver.cpp and modify the following parts of the advance function:

// A key pressed later overrides any previously pressed key:
if (noteNumber != mLastNoteNumber) {
    mLastNoteNumber = noteNumber;
    mLastFrequency = noteNumberToFrequency(mLastNoteNumber);
    mLastVelocity = velocity;
    // Emit a "note on" signal:
    noteOn(noteNumber, velocity);
}

// If the last note was released, nothing should play:
if (noteNumber == mLastNoteNumber) {
    mLastNoteNumber = -1;
    noteOff(noteNumber, mLastVelocity);
}

As you can see, the first argument is the note number and the second one is the velocity. We’re no longer setting mLastFrequency to -1 when a key is released: During the RELEASE stage we still need the frequency to fade out. The same goes for mLastVelocity: If we set it to zero, the sound will cut off immediately.
Note that the code still runs even though we haven’t connected any slot to the signals! The beauty of the signal/slot system is to keep components independent.

The next step is to connect mEnvelopeGenerator to the two signals. We could add the member functions onNoteOn and onNoteOff to the EnvelopeGenerator class and connect them to the signals. Not a bad solution, but it clutters the EnvelopeGenerator with the concept of notes. In my opinion, it shouldn’t know about this. Also we can’t connect the signals directly to enterStage because the arguments don’t match. So let’s add the member functions to the Synthesis class (in the private section):

inline void onNoteOn(const int noteNumber, const int velocity) { mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK); };
inline void onNoteOff(const int noteNumber, const int velocity) { mEnvelopeGenerator.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE); };

Note that the arguments match noteOn and noteOff.
To connect to the signals, add the following at the end of the constructor (in Synthesis.cpp):

mMIDIReceiver.noteOn.Connect(this, &Synthesis::onNoteOn);
mMIDIReceiver.noteOff.Connect(this, &Synthesis::onNoteOff);

The first argument is a pointer to the instance, the second one points to the member function.
We can now change ProcessDoubleReplacing and remove the envelope looping. Delete the two if statements we added before (but keep the line that generates the audio samples).

It’s done!

Run the plugin again. It should retrigger the envelope whenever you press a key. Also it should keep the sustain level as long as you hold the key. Try releasing a key during the DECAY stage: It should go into RELEASE and fade out from the current level. Try setting some different initial stageValues inside EnvelopeGenerator.h to get different timbres.
Now if there was a way to change these values in realtime with some nice knobs, something like this:

Let’s make it happen!

The source files for this part can be downloaded here.

If you found this useful, please feel free to
!
comments powered by | Disqus