Making Audio Plugins Part 12: Envelope GUI

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:

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 IKnobMultiControls. 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 enums, 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 Signals 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!

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