SynthQuest 4: Adding Voices to our Synth


Welcome back to the SynthQuest series, where we’re building a VST from scratch. This is Episode 4, and today’s quest is to organize our code by empowering each synth voice with its oscillator and responsibilities. If this is your first time here, be sure to check out the previous episodes to catch up. Let’s get started. First, we will move the oscillator object to SynthVoice’s private section, from where it was previously defined, since each SynthVoice is responsible for playing a separate note, it makes sense to move the oscillator object inside SynthVoice. This way, each voice gets its own oscillator.
After this, go to the pluginProcessor.cpp file and start implementing the functions that we defined in the last episode. Firstly, canPlaySound() function,
bool SynthVoice::canPlaySound(juce::SynthesiserSound* sound){
return dynamic_cast<juce::SynthesiserSound*>(sound) != nullptr;
}
For safety, we are dynamically casting the sound argument, and if it is a valid SynthesiserSound object instead of a null pointer, then it can play sound.
Then similarly implement the startNote() function :
void SynthVoice::startNote(int midiNoteNumber, float velocity, juce::SynthesiserSound* sound, int currentPitchWheelPosition){}
Then stopNote() function :
void SynthVoice::stopNote(float velocity, bool allowTailOff) {}
Then controllerMoved() function :
void SynthVoice::controllerMoved(int controllerNumber, int newControllerValue) {}
Then, for the prepareToPlay() function, move everything that was written in the BasicOscAudioProcessor::prepareToPlay() to SynthVoice::prepareToPlay() since, again, SynthVoice is the real deal now. So, it should look like this :
void SynthVoice::prepareToPlay(double sampleRate, int samplesPerBlock, int outputChannels) {
juce::dsp::ProcessSpec spec;
spec.maximumBlockSize = samplesPerBlock;
spec.sampleRate = sampleRate;
spec.numChannels = outputChannels;
osc.prepare(spec);
}
Now we will implement the renderNextBlock() function and move the things we defined in BasicOscAudioProcessor::processBlock.
void SynthVoice::renderNextBlock(juce::AudioBuffer< float >& outputBuffer, int startSample, int numSamples) {
juce::dsp::AudioBlock<float> audioBlock{ outputBuffer };
osc.process(juce::dsp::ProcessContextReplacing<float>(audioBlock));
}
So, everything together, your pluginProcessor.cpp should look like this :
Now, we need to make some changes in BasicOscAudioProcessor::prepareToPlay and BasicOscAudioProcessor::processBlock, as we have moved those functions to different parts of the SynthVoice class. Let’s try to understand this with an analogy.
Our synth is producing only one note constantly, but we want it to play all the notes (like playing two keys on the piano together). And handling each voice with BasicOscAudioProcessor::prepareToPlay() will be pretty difficult; that is why SynthVoice::prepareToPlay() will firstly deal with each voice, and then the whole sound will be sent to BasicOscAudioProcessor::prepareToPlay().
Similarly, in SynthVoice::renderNextBlock() function, each voice is processed separately, but BasicOscAudioProcessor::processBlock() function will process them as a whole. Just like a sound engineer mixing all instruments after each musician plays their part.
void BasicOscAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock){
synth.setCurrentPlaybackSampleRate(sampleRate);
for (int i = 0;i < synth.getNumVoices(); i++) {
if (auto voice = dynamic_cast<SynthVoice*>(synth.getVoice(i))) {
voice->prepareToPlay(sampleRate, samplesPerBlock, getTotalNumOutputChannels());
}
}
}
Let’s understand each line of code, synth.setCurrentPlaybackSampleRate(sampleRate); here, we are defining the sample rate for the synth, ensuring all the voices use the correct sample rate.
In the loop, we are iterating through each voice, retrieving each voice with the synth.getVoice() function and then dynamically casting each voice to a pointer to the SynthVoice class and then storing it in the voice variable and then if it exists then calling out the function prepareToPlay() to initialize the voice with the necessary parameters.
Then the BasicOscAudioProcessor::processBlock() function should look something like this :
void BasicOscAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages){
juce::ScopedNoDenormals noDenormals;
auto totalNumInputChannels = getTotalNumInputChannels();
auto totalNumOutputChannels = getTotalNumOutputChannels();
for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i)
buffer.clear (i, 0, buffer.getNumSamples());
for (int i = 0; i < synth.getNumVoices(); ++i) {
if (auto voice = dynamic_cast<juce::SynthesiserVoice*>(synth.getVoice(i))) {
}
}
synth.renderNextBlock(buffer, midiMessages, 0, buffer.getNumSamples());
}
Here, we are iterating through each voice but not doing anything inside the for loop because that is for extra per-voice processing, like adding a filter or any other effect to the voices, which we are not doing as of right now. At the end, I called the renderNextBlock() function, which handles the sound processing for each active voice.
Now, we are going to let our synth object know about these two classes that we have implemented so that it will know what sounds it can play and how to play them using voices. In the BasicOscAudioProcessor::BasicOscAudioProcessor() class inside #endif
, we are going to initialize our synth with classes like this:
synth.addSound(new SynthSound());
synth.addVoice(new SynthVoice());
You can check out the synth-quest-4 branch in the GitHub repository for the code till here. In the next episode, we will be writing ADSR code. Danke, and see you in the next one!
Subscribe to my newsletter
Read articles from Harshit Sarma directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
