
Creating Audio Plugins, Part 14
- 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 start creating a polyphonic synthesizer from the components that we have!
Last time we worked on the parameters and user interface, today we will begin work on the polyphonic audio processing underlying the plugin . In our case, we can play up to 64 notes at a time. This requires a profound change in the structure of the plug-in, but we can use the classes we have already written
In this post we will write a class
In the next post, we will clean the code from unnecessary obsolete parts, add tone modulation and bring the controls to the interface in working condition. At first glance, the work is full. But firstly, we already have almost all the necessary components, and secondly, in the end we will have a real-polyphonic-subtractive-pancake-synthesizer!
Let us think for a moment about which parts of the plugin architecture are global and which exist separately for each individual note. Imagine, here you are playing a few notes on the keys. Each time you press the key, a tone appears that fades and, possibly, the timbre of which changes by a filter along a certain envelope. When you press the second key, the first one still sounds, and the second tone appears with its envelopes of amplitude and filter. The second press does not affect the first tone, it sounds and changes by itself . So each voice is independent and has its own envelopes of amplitude and filter.
LFO is global and unique, it just works and does not restart when you press a key.
As for the filter, it’s clear that the cutoff frequency and resonance are global because all voices look at the same cutoff and resonance knobs in the GUI. But the cutoff frequency of the filter is modulated by the envelope, so that at each moment in time, the calculated cutoff frequency for each voice is different. Take a look at
Can we get by with two oscillators for all the voices? Everyone
In short, the structure is as follows:
As usual, create a new class, name it
In the body of the class, start with the section
Nothing new here: each voice has two oscillators, a filter and two envelopes.
Each voice starts with a specific MIDI note and volume. Add there:
Each of the following variables sets the modulation value of the parameters:
All of them, except
And there is one more parameter. Do you remember we added a flag
Now before
These lines initialize variables with reasonable values.
Add setters to
The only interesting point here is this
As
The first line ensures that when a voice is inactive nothing is calculated and zero is returned. The next three lines are calculated
Then the next sample of both envelopes is calculated. We apply
The tone modulation of both oscillators is simply the LFO output multiplied by the modulation value. We will write it in a minute.
The last line is interesting. First, the content of the brackets: we take the sum of two oscillators, apply the volume envelope and the note volume value. Then we pass the result through
The implementation is
As already mentioned, the call to this function is carried out every time it
It's time to write a class for voice control. Create a class named
And continue
The constant
She simply iterates over all the voices and finds the first one silent. We return the pointer (instead of the link
Now add the
As the name implies, it
First we find a free voice with
If you start the assembly right now, an error pops up saying that we are trying to access
Thanks to this line
We find all the voices with the number of the released note and translate their envelopes into the release stage. Why voices , not voices? Imagine that you have a very long stage of attenuation in an envelope of amplitude. You press the key and release it, and while the tail of the note still sounds, quickly press that key again. Naturally, you do not want to chop off the previous sounding note. That would be very ugly. It is necessary that the previous note is sounded, and that the new one starts to sound in parallel. Thus, you will need more than one voice per note. If you beat the keys very quickly, you will need a lot of votes.
So what happens if, for example, we have five active voices for Until the third octave and we release this key? Called
As you can see, nothing will happen for these four notes, there will be no snag here.
Let's now write a member function
We start with silence
Until now, we have created objects
We write the function body in .cpp:
As you can see, here the oscillators, envelopes and the filter are reset
In
This allows you to start the waveform first every time a voice starts to sound.
At the same time, while we are there, remove the flag
The function
Here you just need to reset more values, everything is linear. It remains to add
As you probably remember, these buffers contain the previous output filter samples. When we reuse voice, these buffers should be empty.
To summarize: every time it
The member variables for all votes are the same:
At first I thought that such redundancy is evil, and all these things should be static members. Let's imagine that it
This could be fixed by inheritance: by creating classes
However, one thing can be static:
That means we should not initialize this variable through the initialization list. Remove
The sampling rate is now static and all oscillators use one of its values.
Let's do the same for
In EnvelopeGenerator.h, make the setter static:
We have added a lot of new! Next time we will clean the excess and bring the GUI into working condition.
The code can be downloaded from here .
Original post .
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 start creating a polyphonic synthesizer from the components that we have!
Last time we worked on the parameters and user interface, today we will begin work on the polyphonic audio processing underlying the plugin . In our case, we can play up to 64 notes at a time. This requires a profound change in the structure of the plug-in, but we can use the classes we have already written
Oscillator
, EnvelopeGenerator
, MIDIReceiver
and Filter
. In this post we will write a class
Voice
(voice) representing one sounding note. Then create a class VoiceManager
that ensures that all notes are sounded and muffled on time.In the next post, we will clean the code from unnecessary obsolete parts, add tone modulation and bring the controls to the interface in working condition. At first glance, the work is full. But firstly, we already have almost all the necessary components, and secondly, in the end we will have a real-polyphonic-subtractive-pancake-synthesizer!
What to where?
Let us think for a moment about which parts of the plugin architecture are global and which exist separately for each individual note. Imagine, here you are playing a few notes on the keys. Each time you press the key, a tone appears that fades and, possibly, the timbre of which changes by a filter along a certain envelope. When you press the second key, the first one still sounds, and the second tone appears with its envelopes of amplitude and filter. The second press does not affect the first tone, it sounds and changes by itself . So each voice is independent and has its own envelopes of amplitude and filter.
LFO is global and unique, it just works and does not restart when you press a key.
As for the filter, it’s clear that the cutoff frequency and resonance are global because all voices look at the same cutoff and resonance knobs in the GUI. But the cutoff frequency of the filter is modulated by the envelope, so that at each moment in time, the calculated cutoff frequency for each voice is different. Take a look at
Filter::cutoff
- it is called in getCalculatedCutoff
. So for every voice you need your own filter. Can we get by with two oscillators for all the voices? Everyone
Voice
plays his own note, i.e. it has its own frequency, and therefore its own independent Oscillator
. In short, the structure is as follows:
- There is one
MIDIReceiver
and one in the pluginVoiceManager
- Have
VoiceManager
oneLFO
and many votesVoice
- There
Voice
are twoOscillator
, two envelope generatorsEnvelopeGenerators
(for amplitude and filter) and oneFilter
Voice Class
As usual, create a new class, name it
Voice
. And, as usual, remember to add it to all Xcode targets and all VS projects. In Voice.h add:#include "Oscillator.h"
#include "EnvelopeGenerator.h"
#include "Filter.h"
In the body of the class, start with the section
private
:private:
Oscillator mOscillatorOne;
Oscillator mOscillatorTwo;
EnvelopeGenerator mVolumeEnvelope;
EnvelopeGenerator mFilterEnvelope;
Filter mFilter;
Nothing new here: each voice has two oscillators, a filter and two envelopes.
Each voice starts with a specific MIDI note and volume. Add there:
int mNoteNumber;
int mVelocity;
Each of the following variables sets the modulation value of the parameters:
double mFilterEnvelopeAmount;
double mOscillatorMix;
double mFilterLFOAmount;
double mOscillatorOnePitchAmount;
double mOscillatorTwoPitchAmount;
double mLFOValue;
All of them, except
mLFOValue
, are associated with the values of the interface handles. In fact, these values are the same for all voices, but we will not make them global and drop them into the plugin class. Each voice needs access to these parameters every sample, and the Voice class does not even know about the existence of a plug-in class (absent #include "SpaceBass.h"
). Setting up such access would be a time-consuming task. And there is one more parameter. Do you remember we added a flag
isMuted
to the class Oscillator
? Move it to Voice
so that when the voice is silent, the values of the oscillator, envelopes and filter are not calculated: bool isActive;
Now before
private
add public
. Let's start with the constructor:public:
Voice()
: mNoteNumber(-1),
mVelocity(0),
mFilterEnvelopeAmount(0.0),
mFilterLFOAmount(0.0),
mOscillatorOnePitchAmount(0.0),
mOscillatorTwoPitchAmount(0.0),
mOscillatorMix(0.5),
mLFOValue(0.0),
isActive(false) {
// Set myself free everytime my volume envelope has fully faded out of RELEASE stage:
mVolumeEnvelope.finishedEnvelopeCycle.Connect(this, &Voice::setFree);
};
These lines initialize variables with reasonable values.
Voice
Not active by default . Also, using signals and slots EnvelopeGenerator
, we “release” the voice as soon as the amplitude envelope leaves the release stage. Add setters to
public
: inline void setFilterEnvelopeAmount(double amount) { mFilterEnvelopeAmount = amount; }
inline void setFilterLFOAmount(double amount) { mFilterLFOAmount = amount; }
inline void setOscillatorOnePitchAmount(double amount) { mOscillatorOnePitchAmount = amount; }
inline void setOscillatorTwoPitchAmount(double amount) { mOscillatorTwoPitchAmount = amount; }
inline void setOscillatorMix(double mix) { mOscillatorMix = mix; }
inline void setLFOValue(double value) { mLFOValue = value; }
inline void setNoteNumber(int noteNumber) {
mNoteNumber = noteNumber;
double frequency = 440.0 * pow(2.0, (mNoteNumber - 69.0) / 12.0);
mOscillatorOne.setFrequency(frequency);
mOscillatorTwo.setFrequency(frequency);
}
The only interesting point here is this
setNoteNumber
. It calculates the frequency for a given note using the formula we already know and passes it to both oscillators. After it add: double nextSample();
void setFree();
As
Oscillator::nextSample
it gives us a way out Oscillator
, it Voice::nextSample
gives out the resulting value of the voice after the envelope of the amplitude and filter. Let's write the implementation in Voice.cpp :double Voice::nextSample() {
if (!isActive) return 0.0;
double oscillatorOneOutput = mOscillatorOne.nextSample();
double oscillatorTwoOutput = mOscillatorTwo.nextSample();
double oscillatorSum = ((1 - mOscillatorMix) * oscillatorOneOutput) + (mOscillatorMix * oscillatorTwoOutput);
double volumeEnvelopeValue = mVolumeEnvelope.nextSample();
double filterEnvelopeValue = mFilterEnvelope.nextSample();
mFilter.setCutoffMod(filterEnvelopeValue * mFilterEnvelopeAmount + mLFOValue * mFilterLFOAmount);
return mFilter.process(oscillatorSum * volumeEnvelopeValue * mVelocity / 127.0);
}
The first line ensures that when a voice is inactive nothing is calculated and zero is returned. The next three lines are calculated
nextSample
for both oscillators and mixed them accordingly mOscillatorMix
. When mOscillatorMix
equal to zero, only audible oscillatorOneOutput
. When 0.5
both oscillators have equal amplitude. Then the next sample of both envelopes is calculated. We apply
filterEnvelopeValue
the filter cutoff frequency and take the LFO value into account. The overall cut modulation is the sum of the filter envelope and the LFO. The tone modulation of both oscillators is simply the LFO output multiplied by the modulation value. We will write it in a minute.
The last line is interesting. First, the content of the brackets: we take the sum of two oscillators, apply the volume envelope and the note volume value. Then we pass the result through
mFilter.process
, as a result we get a filtered output, which we return. The implementation is
setFree
extremely simple:void Voice::setFree() {
isActive = false;
}
As already mentioned, the call to this function is carried out every time it
mVolumeEnvelope
completely fades out.Voicemanager
It's time to write a class for voice control. Create a class named
VoiceManager
. In the header, start with these lines:#include "Voice.h"
class VoiceManager {
};
And continue
private
with the class members:static const int NumberOfVoices = 64;
Voice voices[NumberOfVoices];
Oscillator mLFO;
Voice* findFreeVoice();
The constant
NumberOfVoices
indicates the maximum number of voices simultaneously playing. The next line creates an array of voices. This structure uses a place for 64 votes, so it’s better to think about dynamic memory allocation . However, the plugin class new PLUG_CLASS_NAME
is already dynamically distributed (look for " " in Iplug_include_in_plug_src.h ), so that all members of the plugin class are also on the heap . mLFO
Is a global LFO for the plugin. It never restarts, it just oscillates independently. It can be argued that it should be inside the plugin class ( VoiceManager
no need to know about LFO). But this will add another layer of distinction between voices Voice
and LFOs, which means we will need more glue code .findFreeVoice
This is an auxiliary function for finding voices that are not currently sounding. Add its implementation to VoiceManager.cpp :Voice* VoiceManager::findFreeVoice() {
Voice* freeVoice = NULL;
for (int i = 0; i < NumberOfVoices; i++) {
if (!voices[i].isActive) {
freeVoice = &(voices[i]);
break;
}
}
return freeVoice;
}
She simply iterates over all the voices and finds the first one silent. We return the pointer (instead of the link
&
), because in this case, unlike the link, you can return NULL
. This will mean that all voices sound. Now add the
public
following function headers:void onNoteOn(int noteNumber, int velocity);
void onNoteOff(int noteNumber, int velocity);
double nextSample();
As the name implies, it
onNoteOn
is called when a MIDI Note On message is received. onNoteOff
, Correspondingly, is called when the Note Off message. We will write the code of these functions in the .cpp class file:void VoiceManager::onNoteOn(int noteNumber, int velocity) {
Voice* voice = findFreeVoice();
if (!voice) {
return;
}
voice->reset();
voice->setNoteNumber(noteNumber);
voice->mVelocity = velocity;
voice->isActive = true;
voice->mVolumeEnvelope.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK);
voice->mFilterEnvelope.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_ATTACK);
}
First we find a free voice with
findFreeVoice
. If nothing is found, we will not return anything. This means that when all voices sound, pressing another key will not have any result. Implementing the voice stealing approach will be one of the topics of the next post. If there is a free voice, we need to update it to its initial state ( reset
we will do it very soon). After that we set the correct values setNoteNumber
and mVelocity
. We mark the voice as active and transfer both envelopes to the attack stage. If you start the assembly right now, an error pops up saying that we are trying to access
private
members Voice
from outside. In my opinion, the best solution in this situation would be to use the keyword friend. Add the appropriate line before public
in Voice.h :friend class VoiceManager;
Thanks to this line
Voice
gives VoiceManager
access to its private
members. I'm not a fan of the extensive use of this approach, but if you have a class Foo
and a class FooManager
, this is a good way to avoid writing a lot of setters. onNoteOff
looks like that:void VoiceManager::onNoteOff(int noteNumber, int velocity) {
// Find the voice(s) with the given noteNumber:
for (int i = 0; i < NumberOfVoices; i++) {
Voice& voice = voices[i];
if (voice.isActive && voice.mNoteNumber == noteNumber) {
voice.mVolumeEnvelope.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE);
voice.mFilterEnvelope.enterStage(EnvelopeGenerator::ENVELOPE_STAGE_RELEASE);
}
}
}
We find all the voices with the number of the released note and translate their envelopes into the release stage. Why voices , not voices? Imagine that you have a very long stage of attenuation in an envelope of amplitude. You press the key and release it, and while the tail of the note still sounds, quickly press that key again. Naturally, you do not want to chop off the previous sounding note. That would be very ugly. It is necessary that the previous note is sounded, and that the new one starts to sound in parallel. Thus, you will need more than one voice per note. If you beat the keys very quickly, you will need a lot of votes.
So what happens if, for example, we have five active voices for Until the third octave and we release this key? Called
onNoteOff
and transfers the envelopes of all five voices to the release stage. Four of them are already at this stage, so let's look at the first line EnvelopeGenerator::enterStage
:if (currentStage == newStage) return;
As you can see, nothing will happen for these four notes, there will be no snag here.
Let's now write a member function
nextSample
for VoiceManager
. It should display the total value for all active votes:double VoiceManager::nextSample() {
double output = 0.0;
double lfoValue = mLFO.nextSample();
for (int i = 0; i < NumberOfVoices; i++) {
Voice& voice = voices[i];
voice.setLFOValue(lfoValue);
output += voice.nextSample();
}
return output;
}
We start with silence
(0.0)
, iterate over all the voices, set the current LFO value and add the voice output to the total output. As we remember, if a voice is inactive, its function Voice::nextSample
will not calculate anything and will end immediately.Reusable components
Until now, we have created objects
Oscillator
and Filter
used them throughout the whole time the plugin works. But VoiceManager reuses free voices, so you need to figure out how to completely put your voice back to its original state. Let's start by adding a function to the public
header Voice
:void reset();
We write the function body in .cpp:
void Voice::reset() {
mNoteNumber = -1;
mVelocity = 0;
mOscillatorOne.reset();
mOscillatorTwo.reset();
mVolumeEnvelope.reset();
mFilterEnvelope.reset();
mFilter.reset();
}
As you can see, here the oscillators, envelopes and the filter are reset
mNoteNumber
and mVelocity
then reset. Let's write it! In
public
the Oscillator.h section, add:void reset() { mPhase = 0.0; }
This allows you to start the waveform first every time a voice starts to sound.
At the same time, while we are there, remove the flag
isMuted
from the section private
. Remember to remove it also from the constructor initialization list and remove the member function setMuted
. We are now monitoring the state of activity at the level Voice
, so the oscillator no longer needs it. Remove this line from the function Oscillator::nextSample
:// remove this line:
if(isMuted) return value;
The function
reset
is a EnvelopeGenerator
little longer. In the public
header section, EnvelopeGenerator
write the following:void reset() {
currentStage = ENVELOPE_STAGE_OFF;
currentLevel = minimumLevel;
multiplier = 1.0;
currentSampleIndex = 0;
nextStageSampleIndex = 0;
}
Here you just need to reset more values, everything is linear. It remains to add
reset
for the class Filter
(also in public
):void reset() {
buf0 = buf1 = buf2 = buf3 = 0.0;
}
As you probably remember, these buffers contain the previous output filter samples. When we reuse voice, these buffers should be empty.
To summarize: every time it
VoiceManager
uses Voice
, it calls a function reset
to reset the voice to its initial state. This function, in turn, resets the voice oscillators, its envelope generators, and the filter.static or not static?
The member variables for all votes are the same:
Oscillator
:mOscillatorMode
Filter
:cutoff
,resonance
,mode
EnvelopeGenerator
:stageValue
At first I thought that such redundancy is evil, and all these things should be static members. Let's imagine that it
mOscillatorMode
is static. Then the LFO would have the same waveform as the other oscillators, but we don’t want this. Further, if the values of stageValue
the envelope generator EnvelopeGenerator
were static, the envelopes of the amplitudes and the filter would be the same. This could be fixed by inheritance: by creating classes
VolumeEnvelope
and FilterEnvelope
that inherit from the class EnvelopeGenerator
. The parameter stageValue
could be static and VolumeEnvelope
andFilterEnvelope
could change it. This would clearly separate envelopes and all voices could have access to static members. But in this case we are not talking about large amounts of memory. All that we have to do with the structure that we created is to synchronize these variables between the envelopes of amplitudes and filters of all voices. However, one thing can be static:
sampleRate
. It makes no sense that the synthesizer components operate at different sampling frequencies. Let's tweak this in Oscillator.h :static double mSampleRate;
That means we should not initialize this variable through the initialization list. Remove
mSampleRate(44100.0)
. In Oscillator.cpp after #include
add:double Oscillator::mSampleRate = 44100.0;
The sampling rate is now static and all oscillators use one of its values.
Let's do the same for
EnvelopeGenerator
. Make it sampleRate
static, remove the constructor from the initialization list, and add in EnvelopeGenerator.cpp :double EnvelopeGenerator::sampleRate = 44100.0;
In EnvelopeGenerator.h, make the setter static:
static void setSampleRate(double newSampleRate);
We have added a lot of new! Next time we will clean the excess and bring the GUI into working condition.
The code can be downloaded from here .
Original post .