📜 ⬆️ ⬇️

Creating audio plugin, part 14

All posts series:
Part 1. Introduction and setup
Part 2. Learning Code
Part 3. VST and AU
Part 4. Digital Distortion
Part 5. Presets and GUI
Part 6. Signal synthesis
Part 7. Receive MIDI Messages
Part 8. Virtual Keyboard
Part 9. Envelopes
Part 10. Refinement 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 those components that we have!

Last time we worked on the parameters and the user interface, today we will begin work on the underlying polyphonic audio processing plug-in. In our case, we can play up to 64 notes at the same time. This requires a solid change in the structure of the plugin, but we can use the Oscillator , EnvelopeGenerator , MIDIReceiver and Filter classes we have already written.
')
In this post we will write the Voice class, representing one sounding note. Then we will create a VoiceManager class, ensuring that all notes are sounded and drowned out on time.
In the next post, we will clean the code of unnecessary obsolete parts, add tone modulation and bring the interface controls into working condition. At first glance, the work is complete. But firstly, we already have almost all the necessary components, and secondly, at the end we will have a real-polyphonic-subtractive-pancake-synthesizer!



What where?



Let us think for a moment about which parts of the plug-in's architecture are global, and which parts exist separately for each individual note. Imagine, here you are playing a few notes on the keys. Each time you press a key, a tone appears that fades out and, perhaps, the timbre of which is changed 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 amplitude and filter envelopes. The second press does not affect the first tone, it sounds and changes by itself . So each voice is independent and has its own amplitude and filter envelopes.
The LFO is global and unique, it just works and does not restart when you press the keys.
As for the filter, it is clear that the cut-off frequency and resonance are global, because all voices look at the same cut-off 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 - Filter::cutoff is called in it. So each voice needs its own filter.
Can we do with two oscillators for all voices? Each Voice plays its own note, i.e. he has his own frequency, which means his own independent Oscillator .

In short, the structure is as follows:



Voice class



As usual, create a new class, name it Voice . And, as usual, do not forget 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 class body, we start with the private section:

 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 too:

  int mNoteNumber; int mVelocity; 


Each of the following variables specifies the magnitude of the modulation 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 throw them into the class of the plugin. 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 ( #include "SpaceBass.h" ). Setting up such access would be time consuming.
And there is one more parameter. Do you remember we added the isMuted flag to the Oscillator class? 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 let 's add public before private . 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 strings initialize variables with reasonable values. By default, Voice not active. Also, using signals and slots of the EnvelopeGenerator , we “release” the voice as soon as the amplitude envelope exits 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 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 gives us an Oscillator output, so Voice::nextSample gives the resulting voice value after the amplitude envelope and filter. We write 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 the voice is inactive nothing is calculated and zero is returned. The next three lines calculate the nextSample for both oscillators and mix them according to mOscillatorMix . When mOscillatorMix is zero, only oscillatorOneOutput is heard. At 0.5 both oscillators have equal amplitude.
Then the next sample of both envelopes is calculated. We apply filterEnvelopeValue to the filter cutoff frequency and take the LFO value into account. The overall modulation of the slice is the sum of the filter envelope and the LFO.
The modulation of the tone of both oscillators is simply the output of the LFO multiplied by the magnitude of the modulation. We will write it in a minute.
The last line is interesting. First, the content of the brackets: take the sum of two oscillators, use 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 of setFree extremely simple:

 void Voice::setFree() { isActive = false; } 


As already mentioned, the call to this function is made whenever mVolumeEnvelope completely fades out.

Voicemanager



It's time to write a class to control the voices. Create a class named VoiceManager . In the header, start with these lines:

 #include "Voice.h" class VoiceManager { }; 


And continue to private members of the class:

 static const int NumberOfVoices = 64; Voice voices[NumberOfVoices]; Oscillator mLFO; Voice* findFreeVoice(); 


The constant NumberOfVoices denotes the maximum number of simultaneously sounding voices. The next line creates an array of votes. This structure uses space for 64 voices, so it is better to think about the dynamic allocation of memory . However, the plugin class is new PLUG_CLASS_NAME dynamically distributed (look for " new PLUG_CLASS_NAME " in Iplug_include_in_plug_src.h ), so all the members of the plugin class are also on the heap .

mLFO is the global LFO for the plugin. It never restarts, it just oscillates independently. You can bet that it should be inside the plugin class ( VoiceManager doesn't need to know about the LFO). But this will add another layer of distinction between the voices of the Voice and the LFO, which means we will need more gluing code .
findFreeVoice is a helper function to search for voices that do not currently sound. 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 voices and finds the first silent one. We return a pointer (instead of the & reference), because in this case, unlike the reference, you can return NULL . This will mean that all voices are heard.

Now let's add the following function headers to the public :

 void onNoteOn(int noteNumber, int velocity); void onNoteOff(int noteNumber, int velocity); double nextSample(); 


As is clear from the name, onNoteOn is called when a MIDI Note On message is received. onNoteOff , Respectively, is called when the Note Off message. We write the code for 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 the free voice with findFreeVoice . If nothing is found, we return nothing. This means that when all voices are heard, pressing another key will have no result. The implementation of 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 the initial state ( reset , we will do it very soon). After that we set the correct values ​​for setNoteNumber and mVelocity . Mark the voice as active and translate both envelopes into the attack stage.
If you start the build right now, an error will pop up saying that we are trying to access private members of the Voice from outside. In my opinion, the best solution in this situation would be to use the friend keyword. Add the appropriate line before public in Voice.h :

 friend class VoiceManager; 


With this line, Voice gives VoiceManager access to its private members. I’m not a FooManager this approach, but if you have the Foo class and the FooManager class, this is a good way to avoid writing many setters.

onNoteOff looks like this:

 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 voices with the number of the note released and translate their envelopes into the release stage. Why voices and not voices? Imagine that you have a very long decay stage in the amplitude envelope. You press a key and release it, and while the tail of the note still sounds, quickly press this key again. Naturally, you do not want to chop off the previous sounding note. It would be very ugly. It is necessary that the previous note be heard, and that the new one starts to sound in parallel. So you need more than one voice per note. If you hammer on the keys very quickly, you will need a lot of votes.
So what happens if, for example, we have five active voices for Up to the third octave and we release this key? It is called onNoteOff and translates the envelopes of all five voices into the release stage. Four of them are already in this stage, so let's look at the first line of the EnvelopeGenerator::enterStage :

 if (currentStage == newStage) return; 


As you can see, nothing will happen for these four notes, there will be no hiccups here.

Let's now write the nextSample member 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 votes, set the current LFO value and add the voice output to the total output. As we remember, if a voice is inactive, its Voice::nextSample function will not calculate anything and will end immediately.

Reusable components



Until now, we created Oscillator and Filter objects and used them throughout the plug-in's work. But VoiceManager re-uses free voices, so you have to figure out how to fully transfer the voice to the initial state. Let's start by adding a function to the public Voice header:

 void reset(); 


The body of the function is written in .cpp:

 void Voice::reset() { mNoteNumber = -1; mVelocity = 0; mOscillatorOne.reset(); mOscillatorTwo.reset(); mVolumeEnvelope.reset(); mFilterEnvelope.reset(); mFilter.reset(); } 


As you can see, mNoteNumber and mVelocity are dropped mVelocity , then oscillators, envelopes and a filter are dropped. Let's write it!

In the public section of Oscillator.h, add:

 void reset() { mPhase = 0.0; } 


This allows you to start the waveform first every time the voice starts to sound.

At the same time, while we are there, remove the isMuted flag from the private section. Remember to remove it also from the constructor initialization list and remove the setMuted member setMuted . We now monitor the state of activity at the Voice level, so the oscillator is no longer necessary. Remove this line from the Oscillator::nextSample :

 // remove this line: if(isMuted) return value; 


The reset function in the EnvelopeGenerator slightly longer. In the public section of the 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 a reset for the Filter class (also in public ):

 void reset() { buf0 = buf1 = buf2 = buf3 = 0.0; } 


As you probably remember, these buffers contain the previous output samples of the filter. When we reuse the voice, these buffers must be empty.

To sum up: every time VoiceManager uses Voice , it calls the reset function to reset the voice to its initial state. This function, in turn, resets voice oscillators, its envelope generators and filter.

static or non static?



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 mOscillatorMode is static. Then the LFO would have the same waveform as the other oscillators, and we do not want this. Further, if the EnvelopeGenerator envelope generator stageValue values ​​were static, the amplitude and filter envelopes would be the same.

This could be remedied by inheritance: by creating the VolumeEnvelope and FilterEnvelope classes that would inherit from the EnvelopeGenerator class. The stageValue parameter could be static and VolumeEnvelope and FilterEnvelope could change it. This would clearly separate the envelopes and all voices might have access to static members. But in this case we are not talking about large amounts of memory. All that has to do with the structure that we have created is to synchronize these variables between the amplitude envelopes and the filters of all voices.

However, one thing can be static: sampleRate . It makes no sense for the synthesizer components to work at different sampling rates. Let's fix this in Oscillator.h :

 static double mSampleRate; 


So, 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 frequency is now static and all oscillators use one of its value.
Let's do the same for the EnvelopeGenerator . Make sampleRate static, remove the constructor from the initialization list and add it to EnvelopeGenerator.cpp :

 double EnvelopeGenerator::sampleRate = 44100.0; 


In EnvelopeGenerator.h, make the static setter:

 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 .

Source: https://habr.com/ru/post/231513/


All Articles