SynthLab SDK
Create a Filter Module

Copy the synthmodule_nocores.h and synthmodule_nocores.cpp files into your project. You will probably want to rename them to something sensible. Open them in your compiler or a text editor and change the occurrances of SynthModuleNoCores to FilterModule or anything else that makes sense. Here I am calling the object FilterModule.

FilterModule Plan

The synthbase code contains a built-in object called BQAudioFilter that implements an augmented biquad structure that is described and used in detail in my FX book. We will use this object here to implement two filters that are in neither the synth book nor the sample project code: Vicanek's Analog Matched 2nd order lowpass and 2nd order bandpass filters with the "loose fit" algorithms, giving us two filter types that will become the module strings. We will use the parameter structure to test the object and will implement one mod knob control for filter keytrack modulation, that is explained in the next section.

Filter Module Specifications:

  • module strings: are the two filter types "VicLPF2" and "VicBPF2"
  • mod knobs: MOD_KNOB_A is "Key Track"

Figure 1 shows the frequency responses of the Vicanek loose and tight fit filters compared to the bilinear transform versions.


vicanek.png


Figure 1: Vicanek's frequency responses for LPF (left) and BPF (right) from "Designing Audio Effects Plugins in C++ 2nd Ed." by Will Pirkle

BQAudioFilter

The BQAudioFilter object is a thin wrapper for the augmented biquad structure from my FX book. It accepts a structure of coefficients and then runs the augmented biquad routine using the Transposed Canonical form. This object does not need to know about the sample rate or type of filter, it only runs the biquad routine. We will need two of these objects, one for each channel for stereo operation.

// --- thin biquad wrapper
class BQAudioFilter
{
public:
BQAudioFilter(void) {} /* C-TOR */
~BQAudioFilter(void) {} /* D-TOR */
public:
// --- reset the object
void reset();
// --- flush state variables
void flushDelays();
// --- set biquad coeffieicnets directly
void setCoeffs(BQCoeffs& _coeffs);
// --- copy biquad coeffieicnets to a destination
void copyCoeffs(BQAudioFilter& destination;
// --- run the filter
double processAudioSample(double xn);
protected:
enum { a0, a1, a2, b1, b2, c0, d0 };
enum { xz1, xz2, yz1, yz2, numStates };
double state[4] = { 0.0, 0.0, 0.0, 0.0 }; //< state registers
BQCoeffs bq; //< coefficients
};
//

FilterModule Code

We can design this module in the same manner as the ModuleCores by starting with the .h file, adding members, and then modifying the five operational phases in the .cpp file, starting with the constructor and working down through the rest of the functions thinking about those five phases and adding the code to implement them.

filtermodule.h file

We'll need to add two of the BQAudioFilter object to the SynthModule along with a couple of helper members and enumerations. Add this to you class declaration:

protected:
std::shared_ptr<FilterParameters> parameters = nullptr;
double sampleRate = 0.0;
double midiPitch = 0.0; // for keytrack
// --- stereo filters and coefficients; see FX book
enum { vicLPF2, vicBPF2 };
BQAudioFilter filters[STEREO_CHANNELS]; // stereo pair of filters
// --- enum for coefficient calculations
enum { a0, a1, a2, b1, b2, c0, d0 };
};
// end class declaration

filtermodule.cpp file

We will go through the operational phase methods one at a time, just as with the other objects and following the same paradigm as the synth book. We can start with the constructor and add the support code to create the audio buffers and set the module strings and mod knob labels.

constructor
Here's the code for the constructor; after creating the parameters and buffers, you see the code that is nearly identical to the ModuleCores for setting the module strings and mod knob labels. I am usign the enums to make the indexing a bit easier to read.

// --- construction
FilterModule::FilterModule(std::shared_ptr<MidiInputData> _midiInputData,
std::shared_ptr<FilterParameters> _parameters,
uint32_t blockSize) :
SynthModule(_midiInputData)
, parameters(_parameters)
{
// --- standalone ONLY: parameters
if (!parameters)
parameters.reset(new FilterParameters);
// --- create our audio buffers
audioBuffers.reset(new SynthProcessInfo(STEREO_INPUTS, STEREO_OUTPUTS, blockSize));
// --- module strings
moduleData.moduleStrings[vicLPF2] = "VicLPF2"; moduleData.moduleStrings[8] = empty_string.c_str();
moduleData.moduleStrings[vicBPF2] = "VicBPF2"; moduleData.moduleStrings[9] = empty_string.c_str();
moduleData.moduleStrings[2] = empty_string.c_str(); moduleData.moduleStrings[10] = empty_string.c_str();
moduleData.moduleStrings[3] = empty_string.c_str(); moduleData.moduleStrings[11] = empty_string.c_str();
moduleData.moduleStrings[4] = empty_string.c_str(); moduleData.moduleStrings[12] = empty_string.c_str();
moduleData.moduleStrings[5] = empty_string.c_str(); moduleData.moduleStrings[13] = empty_string.c_str();
moduleData.moduleStrings[6] = empty_string.c_str(); moduleData.moduleStrings[14] = empty_string.c_str();
moduleData.moduleStrings[7] = empty_string.c_str(); moduleData.moduleStrings[15] = empty_string.c_str();
// --- mod knobs
moduleData.modKnobStrings[MOD_KNOB_A] = "Key Track";
moduleData.modKnobStrings[MOD_KNOB_B] = "B";
moduleData.modKnobStrings[MOD_KNOB_C] = "C";
moduleData.modKnobStrings[MOD_KNOB_D] = "D";
}
//

reset()
For the reset function, we only need to reset the filters which flushes the state registers. The BQAudioFilter is a simple object that is not a SynthModule and its reset() function does not require a sample rate. We do need to store the sample rate for the update() function later.

// --- reset/init
bool FilterModule::reset(double _sampleRate)
{
// --- store
sampleRate = _sampleRate;
// --- flush buffers in filters
for (uint32_t i = 0; i < STEREO_CHANNELS; i++)
{
// --- reset; sample rate not needed
filters[i].reset();
}
return true;
}
//

update()
The main purpose for the update() function is to recalculate the biquadratic coefficients based on the current filter parameters, fc and Q. We will add code later to modulate the filter cutoff frequency. The code here examines the selected filter index and recalculates the coefficients based on the curoff frequency, then sends that information to the filters. This code is inefficient because it does not check first to see if the parameters have been changed; you might consider adding that code as an exercise. The theory and coefficient calculations may be found in my FX book.

// --- update module state
bool FilterModule::update()
{
// --- to be modulated
double filterFc = parameters->fc;
// --- setup biquad structures and load with coefficients depending on filter type
if (parameters->filterIndex == vicLPF2)
{
// http://vicanek.de/articles/BiquadFits.pdf
double theta_c = 2.0*kPi*filterFc / sampleRate;
double q = 1.0 / (2.0*parameters->Q);
// --- impulse invariant
double b_1 = 0.0;
double b_2 = exp(-2.0*q*theta_c);
if (q <= 1.0)
{
b_1 = -2.0*exp(-q*theta_c)*cos(pow((1.0 - q*q), 0.5)*theta_c);
}
else
{
b_1 = -2.0*exp(-q*theta_c)*cosh(pow((q*q - 1.0), 0.5)*theta_c);
}
// --- LOOSE FIT --- //
double f0 = theta_c / kPi; // note f0 = fraction of pi, so that f0 = 1.0 = pi = Nyquist
double r0 = 1.0 + b_1 + b_2;
double denom = (1.0 - f0*f0)*(1.0 - f0*f0) + (f0*f0) / (parameters->Q*parameters->Q);
denom = pow(denom, 0.5);
double r1 = ((1.0 - b_1 + b_2)*f0*f0) / (denom);
double a_0 = (r0 + r1) / 2.0;
double a_1 = r0 - a_0;
double a_2 = 0.0;
BQCoeffs bq;
bq.coeff[c0] = 1.0;
bq.coeff[d0] = 0.0;
bq.coeff[a0] = a_0;
bq.coeff[a1] = a_1;
bq.coeff[a2] = a_2;
bq.coeff[b1] = b_1;
bq.coeff[b2] = b_2;
// --- update on filters
filters[LEFT_CHANNEL].setCoeffs(bq);
filters[RIGHT_CHANNEL].setCoeffs(bq);
}
else if (parameters->filterIndex == vicBPF2)
{
// http://vicanek.de/articles/BiquadFits.pdf
double theta_c = 2.0*kPi*filterFc / sampleRate;
double q = 1.0 / (2.0*parameters->Q);
// --- impulse invariant
double b_1 = 0.0;
double b_2 = exp(-2.0*q*theta_c);
if (q <= 1.0)
{
b_1 = -2.0*exp(-q*theta_c)*cos(pow((1.0 - q*q), 0.5)*theta_c);
}
else
{
b_1 = -2.0*exp(-q*theta_c)*cosh(pow((q*q - 1.0), 0.5)*theta_c);
}
// --- LOOSE FIT --- //
double f0 = theta_c / kPi; // note f0 = fraction of pi, so that f0 = 1.0 = pi = Nyquist
double r0 = (1.0 + b_1 + b_2) / (kPi*f0*parameters->Q);
double denom = (1.0 - f0*f0)*(1.0 - f0*f0) + (f0*f0) / (parameters->Q*parameters->Q);
denom = pow(denom, 0.5);
double r1 = ((1.0 - b_1 + b_2)*(f0 / parameters->Q)) / (denom);
double a_1 = -r1 / 2.0;
double a_0 = (r0 - a_1) / 2.0;
double a_2 = -a_0 - a_1;
BQCoeffs bq;
bq.coeff[c0] = 1.0;
bq.coeff[d0] = 0.0;
bq.coeff[a0] = a_0;
bq.coeff[a1] = a_1;
bq.coeff[a2] = a_2;
bq.coeff[b1] = b_1;
bq.coeff[b2] = b_2;
// --- update on filters
filters[LEFT_CHANNEL].setCoeffs(bq);
filters[RIGHT_CHANNEL].setCoeffs(bq);
}
return true; // handled
}
// ---

render()
To render the filters, we need to pick up the audio input samples from the input buffers, process them through the filters, and write the outputs into the output buffer over the requested block of audio data. We've already seen much of this code in the previous sections and here you can see how to access the audio input buffers. And, notice how the update() function is called directly from render() to force the sequence of operations of update -> render.

// --- render filters
bool FilterModule::render(uint32_t samplesToProcess)
{
// --- update parameters for this block
update();
// --- FilterModule processes every sample into output buffers
float* leftInBuffer = audioBuffers->getInputBuffer(LEFT_CHANNEL);
float* rightInBuffer = audioBuffers->getInputBuffer(RIGHT_CHANNEL);
float* leftOutBuffer = audioBuffers->getOutputBuffer(LEFT_CHANNEL);
float* rightOutBuffer = audioBuffers->getOutputBuffer(RIGHT_CHANNEL);
// --- process block
for (uint32_t i = 0; i < samplesToProcess; i++)
{
// --- stereo output
leftOutBuffer[i] = filters[LEFT_CHANNEL].processAudioSample(leftInBuffer[i]);
rightOutBuffer[i] = filters[RIGHT_CHANNEL].processAudioSample(rightInBuffer[i]);
}
return true;
}
// ---

doNoteOn() and doNoteOff()
We only need to alter the doNoteOn() method to pick up the MIDI pitch of the note played. This will be used for key track modulation in the next section. There is nothing to do for the note-off handler.

// --- note on
bool FilterModule::doNoteOn(MIDINoteEvent& noteEvent)
{
// --- just save for keytrack
midiPitch = noteEvent.midiPitch;
return true;
}
// --- nothing to do in note-off
bool FilterModule::doNoteOff(MIDINoteEvent& noteEvent)
{
return true;
}
// ---

Testing FilterModule

Testing this object will be the same as the previous objects and you may use your MinSynth voice object, or test it independently following the same familiar programmign pattern.

Create the Filter Instance

#include "filtermodule.h"
// --- the filter declaration
std::unique_ptr<SynthLab::FilterModule> filterModule = nullptr;
// --- instantiation
uint32_t blockSize = 64; //< make this 1 for processing single samples if that is easier for you
// --- create the smart pointer (this "reset" function has no relationship to the SynthModule::reset() method)
filterModule.reset(new SynthLab::FilterModule(nullptr, /* MIDI input data */
nullptr, /* parameters */
blockSize)); /* process blocks (block size = 1 for processing samples instead)*/
// --- reset the object with the SynthModule method:
filterModule->reset(44100.0); //< get fs from your framework
//

Update the object to test Filter [0]
Here we will set the filterIndex to 0 for the "Vic LPF2" filter and then set the fc = 500.0 Hz and Q = 10.0 producing a very resonant filter whose affect on the oscillator will be easy to hear.

// --- get parameters
std::shared_ptr<SynthLab::FilterParameters> filterModuleParameters = filterModule->getParameters();
if (filterModuleParameters) // should never fail
{
// --- set the variable
filterModuleParameters->filterIndex = 0; // LPF2
filterModuleParameters->fc = 500.0;
filterModuleParameters->Q = 10.0;
// --- recalc coefficients
filterModule->update();
}
// ---

Send a Note-On Event
You can get this from a live MIDI source or fake it to test as done here.

// --- prepare a MIDI event for note-on
midiEvent.midiNoteNumber = 60;
midiEvent.midiNoteVelocity = 127;
// --- send the message
filterModule->doNoteOn(midiEvent);
//

Render the Filter
Render the filters and loop over the audio block to process input to output. Remember that we set the blockSize variable at construction time. This code shows both the oscillator render and the transfer of data from oscillator to filter, prior to the filter render.

// --- render output
addOsc->render();
// --- use helper function
SynthLab::copyOutputToInput(addOsc->getAudioBuffers(), /* output buffer */
filterModule->getAudioBuffers(), /* copied to input buffer */
SynthLab::STEREO_TO_STEREO, /* stereo to stereo */
blockSize); /* blocksize */
// --- render filter processing
filterModule->render();
// --- get output buffer pointers
float* leftOutBuffer = filterModule->getAudioBuffers()->getOutputBuffer(SynthLab::LEFT_CHANNEL);
float* rightOutBuffer = filterModule->getAudioBuffers()->getOutputBuffer(SynthLab::RIGHT_CHANNEL);
// --- loop over filter output block
for (uint32_t i = 0; i < blockSize; i++)
{
float leftSample = leftOutBuffer[i];
float rightSample = rightOutBuffer[i];
// --- send samples to your output buffer
}
//

Test the filter and check to make sure it is functioning correctly. Next, change the filter type to the Vic BPF2 variety and play with the fc and Q values a bit.

Update the object for Filter [1]

// --- get parameters
std::shared_ptr<SynthLab::FilterParameters> filterModuleParameters = filterModule->getParameters();
if (filterModuleParameters) // should never fail
{
// --- set the variable
filterModuleParameters->filterIndex = 1; // LPF2
// --- adjust harmonic filter params
filterModuleParameters->fc = 500.0;
filterModuleParameters->Q = 20.0;
// --- update phase
filterModule->update();
}
//

Testing Filter [1]
Test the filter to verify operation. Once satisified, move to the next section and add the cutoff modulation code.


synthlab_4.png