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 int
s. 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 stageValue
s 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.