Creating Audio Plugins, Part 10

  • Tutorial
All posts in the series:
Part 1. Introduction and configuration
Part 2. Learning the code
Part 3. VST and AU
Part 4. Digital distortion
Part 5. Presets and GUI
Part 6. Signal synthesis
Part 7. Receiving MIDI messages
Part 8. Virtual keyboard
Part 9 Envelopes
Part 10. Refinement of the GUI
Part 11. Filter
Part 12. Low-frequency oscillator
Part 13. Redesign
Part 14. Polyphony 1
Part 15. Polyphony 2
Part 16. Antialiasing

Let's add some controls so that you can change the envelope and waveform. Here is the result we want to get ( from here you can download layered TIFF):

Download and upload the following files to the project:
knob.png (the author of the file is Bootsie )

As always, add the links and ID to resource.h :

// 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"

And change the height of the window so that it matches the size of the background image:

#define GUI_HEIGHT 296

Make changes to the Synthesis.rc header :

#include "resource.h"

Now you need to add parameters for the waveform and stages of the envelope generator. Add in EParamsSynthesis.cpp :

enum EParams
    mWaveform = 0,

The virtual keyboard needs to be moved down:

enum ELayout
    kWidth = GUI_WIDTH,
    kHeight = GUI_HEIGHT,
    kKeybX = 1,
    kKeybY = 230

In Oscillator.h you need to supplement the OscillatorModetotal number of modes:

enum OscillatorMode {

In the initialization list, specify the sine as the default waveform:

Oscillator() :
    // ...

Build the GUI in the constructor. Add immediately before AttachGraphics(pGraphics)these lines:

// 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);
pGraphics->AttachControl(new IKnobMultiControl(this, 95, 34, mAttack, &knobBitmap));
// Decay knob:
GetParam(mDecay)->InitDouble("Decay", 0.5, 0.01, 15.0, 0.001);
pGraphics->AttachControl(new IKnobMultiControl(this, 177, 34, mDecay, &knobBitmap));
// Sustain knob:
GetParam(mSustain)->InitDouble("Sustain", 0.1, 0.001, 1.0, 0.001);
pGraphics->AttachControl(new IKnobMultiControl(this, 259, 34, mSustain, &knobBitmap));
// Release knob:
GetParam(mRelease)->InitDouble("Release", 1.0, 0.001, 15.0, 0.001);
pGraphics->AttachControl(new IKnobMultiControl(this, 341, 34, mRelease, &knobBitmap));

First we create a mWaveformtype parameter Enum. By default, its value is equal OSCILLATOR_MODE_SINE, and it can have total kNumOscillatorModesvalues. Then, load waveform.png . Here 4stands for the number of frames, as we know. It could be used kNumOscillatorModes, which is now also equal to four. But if we add new waveforms and do not change waveform.png , then everything will creep. However, this could serve as a reminder that you need to update the image.
Then we create ISwitchControl, transfer the coordinates and bind to the parameter mWaveform.
We upload one knob.png file and use it for all four IKnobMultiControls.
CustomizeSetShapeso that the pens are more sensitive on small values ​​and coarser on larger ones. The default values ​​are the same as in the constructor EnvelopeGenerator. But you can choose some other minimum and maximum values.

Handling Value Changes

As you remember, the reaction to a user changing the parameters is written into the function OnParamChangein the main project .cpp file:

void Synthesis::OnParamChange(int paramIdx)
    IMutexLock lock(this);
    switch(paramIdx) {
        case mWaveform:
        case mAttack:
        case mDecay:
        case mSustain:
        case mRelease:
            mEnvelopeGenerator.setStageValue(static_cast(paramIdx), GetParam(paramIdx)->Value());

When changing mWaveform, the type value is intconverted to type OscillatorMode.
As you can see, there is one line for all envelope parameters. If we compare EParamsand EnvelopeStage enums, it is evident that there, and there stages of Attack, Decay, Sustain and Release correspond to the values of 1, 2, 3and 4. Therefore, it gives the variable stage of the envelope , and gives the value of the variable stage. Therefore, we can simply call with these two arguments. Only this function has not been written yet. Add to class :static_cast(paramIdx)EnvelopeStageGetParam(paramIdx)->Value()setStageValuepublicEnvelopeGenerator

void setStageValue(EnvelopeStage stage, double value);

Imagine for a moment that this function would be a simple setter:

// This won't be enough:
void EnvelopeGenerator::setStageValue(EnvelopeStage stage,
                                      double value) {
    stageValue[stage] = value;

What if changed stageValue[ENVELOPE_STAGE_ATTACK]at the attack stage? Such an implementation does not cause calculateMultiplieror recount nextStageSampleIndex. The generator will only use the new values ​​the next time it is at this stage. The same with SUSTAIN: I would like to be able to keep a note and at the same time search for the desired level.
Such an implementation is inconvenient, and such a plug-in would look absolutely unprofessional.
The generator should immediately update the parameters of the current stage when the corresponding knob is spinning. So, you need to call calculateMultiplierwith a new time argument and calculate the new value 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;
                case ENVELOPE_STAGE_DECAY:
                    nextLevelValue = fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel);
                case ENVELOPE_STAGE_RELEASE:
                    nextLevelValue = minimumLevel;
            // 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;

Nested ifchecks whether the generator is at a time-limited stage nextStageSampleIndex(ATTACK, DECAY or RELEASE). nextLevelValuethis is the signal level in the next stage to which the envelope tends. Its value is set in the same way as in a function enterStage. The most interesting after switch: in any current stage, the generator should work in accordance with the new values ​​for the rest of this stage. For this, the current stage is divided into the past and the remaining parts. First, it is calculated how far in time the generator is already inside the stage. For example, it 0.1means that 10% is passed. RemainingStageProcessreflects, respectively, how much is left. Now you need to calculate samplesUntilNextStageand update nextStageSampleIndex. And the most important thing is the challengecalculateMultiplierto go from level currentLevelto nextLevelValueper samplesUntilNextStagesamples.
C SUSTAIN is simple: update currentLevel.

This implementation covers almost all possible cases. It remains to figure out when the generator is in DECAY, and the SUSTAIN value changes. Now it is made so that the level drops to the old value, and when the stage of decline ends, the level jumps to a new one. To avoid this, add to the end setStageValue:

if (currentStage == ENVELOPE_STAGE_DECAY &&
    // We have to decay to a different sustain value than before.
    // Re-calculate multiplier:
    unsigned long long samplesUntilNextStage = nextStageSampleIndex - currentSampleIndex;
                        fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel),

Now there will be a smooth transition to a new level. Here we do not change nextStageSampleIndex, because it does not depend on Sustain .
Launch the plugin, click on the waveforms and twist the knobs - all changes should immediately be reflected in the sound.

Performance improvement

Take a look at this part ProcessDoubleReplacing:

int velocity = mMIDIReceiver.getLastVelocity();
if (velocity > 0) {
} else {

Remember we decided that we would not reset mLastVelocitythe MIDI receiver? This means that after the first note it mOscillatorwill generate a wave even when no note sounds. Modify the loop foras follows:

for (int i = 0; i < nFrames; ++i) {
    int velocity = mMIDIReceiver.getLastVelocity();
    leftOutput[i] = rightOutput[i] = mOscillator.nextSample() * mEnvelopeGenerator.nextSample() * velocity / 127.0;

It is logical that the oscillator should generate a wave when mEnvelopeGenerator.currentStageit is not equal ENVELOPE_STAGE_OFF. Means, to turn off generation it is necessary somewhere in mEnvelopeGenerator.enterStage. For the reasons that we discussed in the previous post, we will not call anything directly from here, but again we will use signals and slots. Before defining a class in EnvelopeGenerator.h, add a couple of lines:

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

Then add a couple of signals to public:

Signal0<> beganEnvelopeCycle;
Signal0<> finishedEnvelopeCycle;

At the very beginning, enterStagein EnvelopeGenerator.cpp add:

if (currentStage == newStage) return;
if (currentStage == ENVELOPE_STAGE_OFF) {
if (newStage == ENVELOPE_STAGE_OFF) {

The first is ifto prevent the generator from looping at the same stage. The meaning of the other two is as follows:
  • Exiting the OFF stage means starting a new cycle
  • Entering OFF means end of cycle

Now let's write a response to Signal. Add the following privatefunctions to Synthesis.h :

inline void onBeganEnvelopeCycle() { mOscillator.setMuted(false); }
inline void onFinishedEnvelopeCycle() { mOscillator.setMuted(true); }

When the envelope cycle begins, we let the oscillator generate a wave. When it finishes, drown it out.
At the end of the constructor in Synthesis.cpp, connect the signals to the slots:

mEnvelopeGenerator.beganEnvelopeCycle.Connect(this, &Synthesis::onBeganEnvelopeCycle);
mEnvelopeGenerator.finishedEnvelopeCycle.Connect(this, &Synthesis::onFinishedEnvelopeCycle);

That's all! At startup, everything should work. In REAPER, when you press Cmd + Alt + P (on Mac) or Ctrl + Alt + P (on Windows), a performance monitor will appear: The

total track load on the processor is highlighted in red. When a note starts to sound, this value should increase, and when it finally calms down - to fall, because the oscillator no longer calculates samples in vain.

Now we have a perfectly acceptable envelope generator.
From here you can download the code.

Next time we will create an equally important synthesizer component: a filter!

Original article:

Also popular now: