Let’s add some knobs to change our volume envelope in real-time! While we’re at it, we will also add a switch to change the waveform. This is the look we’re going for (click here for a TIF with all the layers):
Creating the GUI
Download the following files and add them to the project:
- bg.png
- knob.png (Credits to Bootsie)
- waveform.png
Make sure you copy them to the project folder and add them to all targets (as always). Now open resource.h and add the references:
// Unique IDs for each image resource.
#define BG_ID 101
#define WHITE_KEY_ID 102
#define BLACK_KEY_ID 103
#define WAVEFORM_ID 104
#define KNOB_ID 105
// Image resource locations for this plug.
#define BG_FN "resources/img/bg.png"
#define WHITE_KEY_FN "resources/img/whitekey.png"
#define BLACK_KEY_FN "resources/img/blackkey.png"
#define WAVEFORM_FN "resources/img/waveform.png"
#define KNOB_FN "resources/img/knob.png"
While you’re there, change the GUI height to match the dimensions of bg.png:
#define GUI_HEIGHT 296
Also edit the beginning of Synthesis.rc:
#include "resource.h"
BG_ID PNG BG_FN
WHITE_KEY_ID PNG WHITE_KEY_FN
BLACK_KEY_ID PNG BLACK_KEY_FN
WAVEFORM_ID PNG WAVEFORM_FN
KNOB_ID PNG KNOB_FN
We need to add parameters for the waveform and for the envelope stages Attack, Decay, Sustain and Release. Go into Synthesis.cpp and change the EParams
:
enum EParams
{
mWaveform = 0,
mAttack,
mDecay,
mSustain,
mRelease,
kNumParams
};
Also change the virtual keyboard’s position so it’s at the bottom:
enum ELayout
{
kWidth = GUI_WIDTH,
kHeight = GUI_HEIGHT,
kKeybX = 1,
kKeybY = 230
};
Now go to Oscillator.h and change the OscillatorMode
to include the total number of modes:
enum OscillatorMode {
OSCILLATOR_MODE_SINE = 0,
OSCILLATOR_MODE_SAW,
OSCILLATOR_MODE_SQUARE,
OSCILLATOR_MODE_TRIANGLE,
kNumOscillatorModes
};
Change the initializer list so the sine wave is the default:
Oscillator() :
mOscillatorMode(OSCILLATOR_MODE_SINE),
// ...
Building the GUI is done in the constructor. Add the following just before the AttachGraphics(pGraphics)
line:
// Waveform switch
GetParam(mWaveform)->InitEnum("Waveform", OSCILLATOR_MODE_SINE, kNumOscillatorModes);
GetParam(mWaveform)->SetDisplayText(0, "Sine"); // Needed for VST3, thanks plunntic
IBitmap waveformBitmap = pGraphics->LoadIBitmap(WAVEFORM_ID, WAVEFORM_FN, 4);
pGraphics->AttachControl(new ISwitchControl(this, 24, 53, mWaveform, &waveformBitmap));
// Knob bitmap for ADSR
IBitmap knobBitmap = pGraphics->LoadIBitmap(KNOB_ID, KNOB_FN, 64);
// Attack knob:
GetParam(mAttack)->InitDouble("Attack", 0.01, 0.01, 10.0, 0.001);
GetParam(mAttack)->SetShape(3);
pGraphics->AttachControl(new IKnobMultiControl(this, 95, 34, mAttack, &knobBitmap));
// Decay knob:
GetParam(mDecay)->InitDouble("Decay", 0.5, 0.01, 15.0, 0.001);
GetParam(mDecay)->SetShape(3);
pGraphics->AttachControl(new IKnobMultiControl(this, 177, 34, mDecay, &knobBitmap));
// Sustain knob:
GetParam(mSustain)->InitDouble("Sustain", 0.1, 0.001, 1.0, 0.001);
GetParam(mSustain)->SetShape(2);
pGraphics->AttachControl(new IKnobMultiControl(this, 259, 34, mSustain, &knobBitmap));
// Release knob:
GetParam(mRelease)->InitDouble("Release", 1.0, 0.001, 15.0, 0.001);
GetParam(mRelease)->SetShape(3);
pGraphics->AttachControl(new IKnobMultiControl(this, 341, 34, mRelease, &knobBitmap));
First, we create the mWaveform
parameter. It’s an Enum
parameter with the default value of OSCILLATOR_MODE_SINE
and kNumOscillatorModes
possible values. The we load the waveform.png bitmap. Here we use the literal 4
for the number of frames. We could use kNumOscillatorModes
, which at the moment has the value of 4
. But if we add additional waveforms to the oscillator and we don’t change waveform.png to include them, it will break. We then create a new ISwitchControl
, passing the coordinates and linking it to the mWaveform
parameter.
For the knobs, we import knob.png just once and use it for all four IKnobMultiControl
s. We use SetShape
to make the knobs more sensitive for small values (and more coarse for large values). We’re setting the same default values as in the EnvelopeGenerator
‘s constructor. This duplication could be avoided. You can choose the minimum and maximum values freely (the 3rd and 4th parameter to InitDouble
).
Handling Value Changes
Reacting to user input is done by implementing OnParamChange
(in Synthesis.cpp):
void Synthesis::OnParamChange(int paramIdx)
{
IMutexLock lock(this);
switch(paramIdx) {
case mWaveform:
mOscillator.setMode(static_cast<OscillatorMode>(GetParam(mWaveform)->Int()));
break;
case mAttack:
case mDecay:
case mSustain:
case mRelease:
mEnvelopeGenerator.setStageValue(static_cast<EnvelopeGenerator::EnvelopeStage>(paramIdx), GetParam(paramIdx)->Value());
break;
}
}
For the mWaveform
case, we get the int
value and simply cast it to OscillatorMode
.
As you can see, all envelope parameters share the same line of code. If you compare the EParams
and EnvelopeStage
enum
s, you’ll see that in both of them, Attack, Decay, Sustain and Release have the values 1
, 2
, 3
and 4
, respectively. Therefore, static_cast<EnvelopeGenerator::EnvelopeStage>(paramIdx)
gives us the changed EnvelopeStage
. And GetParam(paramIdx)->Value()
gives us the value of the changed stage. So we can just call setStageValue
with these two. But we haven’t implemented it yet! Add the following member function prototype to the public
section of the EnvelopeGenerator
class:
void setStageValue(EnvelopeStage stage, double value);
Let’s imagine for a moment that this was a simple setter:
// This won't be enough:
void EnvelopeGenerator::setStageValue(EnvelopeStage stage,
double value) {
stageValue[stage] = value;
}
What if we change the stageValue[ENVELOPE_STAGE_ATTACK]
while the generator is in that stage? This implementation doesn’t call calculateMultiplier
or set nextStageSampleIndex
. So the generator will only consider the change the next time it enters the given stage. This is also true for the SUSTAIN stage: You can’t hold a note and tweak the knob to find the right sustain level.
This is inconvenient and you wouldn’t find this in a professional plugin. When we turn a knob, we want to hear the change immediately.
So whenever we change the value for the stage the generator is currently in, the generator should update its values. This means calling calculateMultiplier
with a new time interval and recalculating nextStageSampleIndex
.
void EnvelopeGenerator::setStageValue(EnvelopeStage stage,
double value) {
stageValue[stage] = value;
if (stage == currentStage) {
// Re-calculate the multiplier and nextStageSampleIndex
if(currentStage == ENVELOPE_STAGE_ATTACK ||
currentStage == ENVELOPE_STAGE_DECAY ||
currentStage == ENVELOPE_STAGE_RELEASE) {
double nextLevelValue;
switch (currentStage) {
case ENVELOPE_STAGE_ATTACK:
nextLevelValue = 1.0;
break;
case ENVELOPE_STAGE_DECAY:
nextLevelValue = fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel);
break;
case ENVELOPE_STAGE_RELEASE:
nextLevelValue = minimumLevel;
break;
default:
break;
}
// How far the generator is into the current stage:
double currentStageProcess = (currentSampleIndex + 0.0) / nextStageSampleIndex;
// How much of the current stage is left:
double remainingStageProcess = 1.0 - currentStageProcess;
unsigned long long samplesUntilNextStage = remainingStageProcess * value * sampleRate;
nextStageSampleIndex = currentSampleIndex + samplesUntilNextStage;
calculateMultiplier(currentLevel, nextLevelValue, samplesUntilNextStage);
} else if(currentStage == ENVELOPE_STAGE_SUSTAIN) {
currentLevel = value;
}
}
}
The inner if
statement checks if the generator is in a stage that uses nextStageSampleIndex
to expire (i.e. ATTACK, DECAY or RELEASE). nextLevelValue
is the level value the generator is currently transitioning to. It is set just like inside the enterStage
function. The interesting part is below the switch
statement: Whatever phase the generator currently is in, it should behave according to the new value for the rest of the current stage. So we have to split the current stage into the past and the future part. First we calculate how far the generator is into the current stage. For example, 0.1
means “10% done”. remainingStageProcess
is how much is left in the current stage. We can then calculate samplesUntilNextStage
and update nextStageSampleIndex
. Finally (and most importantly), we call calculateMultiplier
to get a transition from currentLevel
to nextLevelValue
over samplesUntilNextStage
samples.
The SUSTAIN case is simple: We just set currentLevel
to the new value.
With this implementation, we have covered almost all cases. There’s one more special case we have to handle: When the generator is in DECAY stage and the SUSTAIN value is changed. With the current implementation, it will decay to the old sustain level, and when the decay stage is over, it will jump to the new sustain level. To correct this behaviour, add the following at the end of setStageValue
:
if (currentStage == ENVELOPE_STAGE_DECAY &&
stage == ENVELOPE_STAGE_SUSTAIN) {
// We have to decay to a different sustain value than before.
// Re-calculate multiplier:
unsigned long long samplesUntilNextStage = nextStageSampleIndex - currentSampleIndex;
calculateMultiplier(currentLevel,
fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel),
samplesUntilNextStage);
}
This makes sure we’re decaying to the new sustain level. Note that we’re not changing nextStageSampleIndex
here because it’s not affected by the sustain level.
Run the plugin. You can cycle through waveforms by clicking on the waveform icon. Tweak all four knobs while playing and holding notes and see how it immediately reacts and does what we want.
Further Improvements
Have a look at this part of ProcessDoubleReplacing
:
int velocity = mMIDIReceiver.getLastVelocity();
if (velocity > 0) {
mOscillator.setFrequency(mMIDIReceiver.getLastFrequency());
mOscillator.setMuted(false);
} else {
mOscillator.setMuted(true);
}
Remember that we decided not to reset the MIDI receiver’s mLastVelocity
anymore? This means that after the first played note, mOscillator
will never be muted again. So it will keep generating a waveform even when no note is played. Change the for
loop to look like this:
for (int i = 0; i < nFrames; ++i) {
mMIDIReceiver.advance();
int velocity = mMIDIReceiver.getLastVelocity();
mOscillator.setFrequency(mMIDIReceiver.getLastFrequency());
leftOutput[i] = rightOutput[i] = mOscillator.nextSample() * mEnvelopeGenerator.nextSample() * velocity / 127.0;
}
So when should mOscillator
generate a waveform? Whenever mEnvelopeGenerator.currentStage
is not ENVELOPE_STAGE_OFF
. So the right place to react is inside mEnvelopeGenerator.enterStage
. Of course, for reasons explained before, we’re not going to call something on mOscillator
here. We’re again using Signals & Slots for a clean solution. In EnvelopeGenerator.h, add the following two lines before the class definition:
#include "GallantSignal.h"
using Gallant::Signal0;
Add two Signal
s to the public
section:
Signal0<> beganEnvelopeCycle;
Signal0<> finishedEnvelopeCycle;
In EnvelopeGenerator.cpp, add the following at the very beginning of enterStage
:
if (currentStage == newStage) return;
if (currentStage == ENVELOPE_STAGE_OFF) {
beganEnvelopeCycle();
}
if (newStage == ENVELOPE_STAGE_OFF) {
finishedEnvelopeCycle();
}
The first if
statement just makes sure that the generator can’t go from a stage into that same stage. The other two if
statements mean:
- When we go out of the OFF stage, it means we’re beginning a new cycle.
- When we go into the OFF stage, it means we have finished a cycle.
Let’s react to the Signal
! Add the following private
member functions to Synthesis.h:
inline void onBeganEnvelopeCycle() { mOscillator.setMuted(false); }
inline void onFinishedEnvelopeCycle() { mOscillator.setMuted(true); }
When an envelope cycle begins, we unmute the oscillator. When it ends, we mute the oscillator again.
In Synthesis.cpp, connect signal and slot at the very end of the constructor:
mEnvelopeGenerator.beganEnvelopeCycle.Connect(this, &Synthesis::onBeganEnvelopeCycle);
mEnvelopeGenerator.finishedEnvelopeCycle.Connect(this, &Synthesis::onFinishedEnvelopeCycle);
That’s it! Run the plugin and it should behave just like before! If you press Cmd+Alt+P in Reaper (Ctrl+Alt+P on Windows) you’ll get a performance meter:
The percent value marked red is the track’s total CPU usage. It should go up whenever you play a note, and down again whenever it has fully faded out. That’s because the oscillator won’t have to calculate sample values.
Now we have a very nice envelope generator! Click here to download the source files for this post.
Up next: How to create a filter!