In the Minimal Standalone Synth section, you saw how to create the MinSynth C++ object that owns and maintains a small set of SynthModule objects, that it arranges and manages to render audio into output buffers. Please review that section prior to going through this exercise, where I convert the MinSynth object into a SynthVoice object and leverage off of the built-in code. Here is the class definition for the MinSynth object and the block diagram of the voice (patch) it implements.
class MinSynth
{
public:
MinSynth();
~MinSynth() {}
bool reset(double _sampleRate);
bool update();
const std::shared_ptr<SynthLab::AudioBuffer> render(uint32_t samplesToProcess = 1);
protected:
std::unique_ptr<SynthLab::SynthLFO> lfo = nullptr;
std::unique_ptr<SynthLab::EnvelopeGenerator> ampEG = nullptr;
std::unique_ptr<SynthLab::WTOscillator> wtOsc = nullptr;
std::unique_ptr<SynthLab::SynthFilter> filter = nullptr;
std::unique_ptr<SynthLab::DCA> dca = nullptr;
std::unique_ptr<SynthLab::ModMatrix> modMatrix = nullptr;
};
We are going to use the same set of SynthModules in the newly revised SynthVoice version with the added benefit of the template code and parameter sharing features.
Add the Voice's SynthModule Members
First, modify the synthvoice.h template file by adding the SynthModule object declarations as protected members. I am using the std::shared_ptr to manage my dynamically allocated objects but you may certainly use other methods.
#include "oscillator.h"
protected:
std::shared_ptr<SynthVoiceParameters> parameters = nullptr;
std::unique_ptr<SynthLab::SynthLFO> lfo = nullptr;
std::unique_ptr<SynthLab::EnvelopeGenerator> ampEG = nullptr;
std::unique_ptr<SynthLab::WTOscillator> wtOsc = nullptr;
std::unique_ptr<SynthLab::SynthFilter> filter = nullptr;
std::unique_ptr<SynthLab::DCA> dca = nullptr;
std::unique_ptr<SynthLab::ModMatrix> modMatrix = nullptr;
double sampleRate = 0.0;
uint32_t blockSize = 64;
std::shared_ptr<MidiInputData> midiInputData = nullptr;
Next, add shared parameter strucures to the SynthVoiceParameters struct in the same file. Add one new shared pointer for each object's parameters.
struct SynthVoiceParameters
{
SynthVoiceParameters() {}
std::shared_ptr<WTOscParameters> wtOscParameters = std::make_shared<WTOscParameters>();
std::shared_ptr<LFOParameters> lfoParameters = std::make_shared<LFOParameters>();
std::shared_ptr<EGParameters> ampEGParameters = std::make_shared<EGParameters>();
std::shared_ptr<FilterParameters> filterParameters = std::make_shared<FilterParameters>();
std::shared_ptr<DCAParameters> dcaParameters = std::make_shared<DCAParameters>();
std::shared_ptr<ModMatrixParameters> modMatrixParameters = std::make_shared<ModMatrixParameters>();
uint32_t synthModeIndex =
enumToInt(SynthMode::kMono);
uint32_t filterModeIndex =
enumToInt(FilterMode::kSeries);
bool enablePortamento = false;
double glideTime_mSec = 0.0;
bool legatoMode = false;
bool freeRunOscMode = false;
double unisonDetuneCents = 0.0;
double unisonStartPhase = 0.0;
double unisonPan = 0.0;
}
Construct the SynthModule Members
In the SynthVoice constructor, you create the SynthModules. Unlike the standalone versions, these will reecive non-null shared pointers to shared resources. Some of these resources came from the engine, and arrived into the voice object's constructor. The shared parameter structures are part of the voice's SynthVoiceParameter structure. So you can see how the shared resources are divided between the engine (MIDI input, wavetable, and PCM sample data) and the voice (all module parameter structures). Here is the first part of the constructor where the modules are instantiated with smart pointers.
SynthVoice::SynthVoice(std::shared_ptr<MidiInputData> _midiInputData,
std::shared_ptr<MidiOutputData> _midiOutputData,
std::shared_ptr<SynthVoiceParameters> _parameters,
std::shared_ptr<WavetableDatabase> _wavetableDatabase,
std::shared_ptr<PCMSampleDatabase> _sampleDatabase,
uint32_t _blockSize)
: midiInputData(_midiInputData)
, midiOutputData(_midiOutputData)
, parameters(_parameters)
, wavetableDatabase(_wavetableDatabase)
, sampleDatabase(_sampleDatabase)
, blockSize(_blockSize)
{
if (!midiInputData)
midiInputData.reset(new (MidiInputData));
if (!midiOutputData)
midiOutputData.reset(new (MidiOutputData));
if (!parameters)
parameters = std::make_shared<SynthVoiceParameters>();
lfo.reset(new SynthLFO(midiInputData, parameters->lfoParameters, blockSize));
ampEG.reset(new EnvelopeGenerator(midiInputData, parameters->ampEGParameters, blockSize));
wtOsc.reset(new WTOscillator(midiInputData, parameters->wtOscParameters, wavetableDatabase, blockSize));
filter.reset(new SynthFilter(midiInputData, parameters->filterParameters, blockSize));
dca.reset(new DCA(midiInputData, parameters->dcaParameters, blockSize));
modMatrix.reset(new ModMatrix(parameters->modMatrixParameters));
mixBuffers.reset(
new SynthProcessInfo(
NO_CHANNELS, STEREO_CHANNELS, blockSize));
Now that the objects have been created with shared resources, we can program the modulation matrix. This code is lifted directly from the MinSynth section in Minimal Standalone Synth.
modMatrix->clearModMatrixArrays();
modMatrix->addModSource(SynthLab::kSourceLFO1_Norm, lfo->getModulationOutput()->getModArrayPtr(SynthLab::kLFONormalOutput));
modMatrix->addModSource(SynthLab::kSourceAmpEG_Norm, ampEG->getModulationOutput()->getModArrayPtr(SynthLab::kEGNormalOutput));
modMatrix->addModDestination(SynthLab::kDestOsc1_fo, wtOsc->getModulationInput()->getModArrayPtr(SynthLab::kBipolarMod));
modMatrix->addModDestination(SynthLab::kDestFilter1_fc_Bipolar, filter->getModulationInput()->getModArrayPtr(SynthLab::kBipolarMod));
modMatrix->addModDestination(SynthLab::kDestDCA_EGMod, dca->getModulationInput()->getModArrayPtr(SynthLab::kEGMod));
modMatrix->getParameters()->setMM_HardwiredRouting(SynthLab::kSourceLFO1_Norm, SynthLab::kDestOsc1_fo);
modMatrix->getParameters()->setMM_HardwiredRouting(SynthLab::kSourceLFO1_Norm, SynthLab::kDestFilter1_fc_Bipolar);
modMatrix->getParameters()->setMM_HardwiredRouting(SynthLab::kSourceAmpEG_Norm, SynthLab::kDestDCA_EGMod);
Descend the Operational Phase Functions
Next, move through the operational phase functions, adding code as needed. Many of these will simply call the same named function on one or all of the member modules.
reset() and initialize()
- the reset function forwards the reset() calls to the modules and sets some member variables
- the initialization function only needs to be called on oscillators in SynthLab; that is done here even though the SynthLab wavetable oscillators do not need the path; yours might need this for parsing wavetable data files or other initialization chores
bool SynthVoice::reset(double _sampleRate)
{
sampleRate = _sampleRate;
currentMIDINote = -1;
lfo->reset(_sampleRate);
ampEG->reset(_sampleRate);
wtOsc->reset(_sampleRate);
filter->reset(_sampleRate);
dca->reset(_sampleRate);
return true;
}
bool SynthVoice::initialize(const char* dllPath)
{
wtOsc->initialize(dllPath);
return true;
}
update()
The voice only needs to set the unison mode detuning and phase start variables on the oscillators. This is done for the sole wavetable oscillator only.
bool SynthVoice::update()
{
wtOsc->setUnisonMode(parameters->unisonDetuneCents, parameters->unisonStartPhase);
return true;
}
render()
The render() function follows the same logic and code as the MinSynth object. The only difference here is the bit of code at the end, that checks to see if the amp EG has expired, so that the voice state variable may be set.
bool SynthVoice::render(SynthProcessInfo& synthProcessInfo)
{
uint32_t samplesToProcess = synthProcessInfo.getSamplesInBlock();
mixBuffers->flushBuffers();
lfo->render(samplesToProcess);
ampEG->render(samplesToProcess);
modMatrix->runModMatrix();
wtOsc->render(samplesToProcess);
filter->getAudioBuffers(),
SynthLab::STEREO_TO_STEREO,
blockSize);
filter->render(samplesToProcess);
dca->getAudioBuffers(),
SynthLab::STEREO_TO_STEREO,
blockSize);
dca->render(samplesToProcess);
synthProcessInfo,
STEREO_TO_STEREO,
samplesToProcess);
if (voiceIsActive)
{
if (ampEG->getState() ==
enumToInt(EGState::kOff))
{
voiceIsActive = false;
}
}
return true;
}
doNoteOn()
The note-on handler mainly passes the message down to the modules. But it is also where you will start the glide modulator if neeeded.Each SynthModule includes a GlideModulator object, documented in the synth book, to perform portamento.
bool SynthVoice::doNoteOn(midiEvent& event)
{
int32_t lastMIDINote = currentMIDINote;
currentMIDINote = (int32_t)event.midiData1;
MIDINoteEvent noteEvent(midiPitch, event.midiData1, event.midiData2);
lfo->doNoteOn(noteEvent);
ampEG->doNoteOn(noteEvent);
GlideInfo glideInfo(lastMIDINote, currentMIDINote, parameters->glideTime_mSec, sampleRate);
wtOsc->startGlideModulation(glideInfo);
wtOsc->doNoteOn(noteEvent);
filter->doNoteOn(noteEvent);
dca->doNoteOn(noteEvent);
voiceIsActive = true;
voiceNoteState = voiceState::kNoteOnState;
voiceMIDIEvent = event;
return true;
}
doNoteOff()
The note-on handler mainly passes the message down to the modules. This also sets the voice state to kNoteOffState but that does not mean the note event is finished, which is only triggered by the expiration of the amp EG object.
bool SynthVoice::doNoteOff(midiEvent& event)
{
MIDINoteEvent noteEvent(midiPitch, event.midiData1, event.midiData2);
lfo->doNoteOff(noteEvent);
ampEG->doNoteOff(noteEvent);
wtOsc->doNoteOff(noteEvent);
filter->doNoteOff(noteEvent);
dca->doNoteOff(noteEvent);
voiceNoteState = voiceState::kNoteOffState;
return true;
}
That's all there is to coding the SynthVoice object for note rendering operation. You will see that the example voice objects really only differ by including multiple modules: 2 LFOs, 3 EGs, 2 filters, 4 oscillators, etc... but the code follows the same pattern.