This assignment walks you through adding Faust to your JUCE plugin. This tutorial is based on the Grame tutorial "...Adding Faust DSP Support to Your JUCE Plug-ins" up to but not including the last section on "Creating and Using a Polyphonic Faust DSP Object". Our method will be essentially the same, but doing a few things differently and adding parameter control via Plugin GUI Magic (PGM).
The main steps to incorporating Faust are
compute
function inside your
plugin's processBlock
function (the JUCE audio
callback)
In Assignment
JUCE, you chose a location for your Faust clone,
say ~/320c/faust/
. If you have not yet installed
the Faust compiler on your computer, cd
~/320c/faust/
now and type git checkout
master-dev && make && sudo make install
. If the
compilation succeeds, you will be prompted for your password
for installation under /usr/local/
at the end.
If you are already on Faust branch master
, that
should be fine as well. It is probably possible to use the
Faust IDE for Faust compilations below, but not equivalently
because we will supply our own simplified architecture file
(apparently not possible to supply to the online Faust IDE).
In general, the Faust IDE is great for trying out short
snippets of Faust code, but for most software development, we
use the command line (any UNIX shell equivalent).
There we have a simple JUCE+PGM project (basic audio plugin as before) including now a simple "ring modulator" written in Faust. Our task is to use the Faust ring modulator in a JUCE plugin.
This part of the tutorial follows the Grame tutorial up to "Creating an Empty JUCE Plug-In Project". Let's take a quick detour and look at the demo Faust code for this assignment:
import("stdfaust.lib");
// Linearly interpolates between "wet" and "dry" signals:
mixer = (_*wet + _*(1 - wet))/2
with
{
wet = hslider(/* label */ "Wet", /* default */ 0.5, /* min */ 0, /* max */ 1, /* increment */ 0.01);
};
// Ring modulation effect modulating the absolute value of the input signal with a sinusoid:
ringmod = _<: abs(_)*os.osc(freq), _ : mixer
with
{
freq = hslider("frequency", 1000, 20, 4000, 1);
};
// Main process
process = ringmod;
This code implements a ring modulation effect. There are two parameters controlled by horizontal sliders:
Freq
Sets the ring-modulation frequencyWet
Pans between the original signal and the ring-modulated signal
Now we will use the Faust compiler to compile ringmod.dsp
using the faustMinimal.h
architecture file.
Our command will be slightly different than in the Grame tutorial:
faust -i --in-place -a faustMinimal.h ringmod.dsp -cn RingMod -o RingMod.h
The Faust compiler options are as follows:
-cn
sets the class name in the output
to RingMod
instead of the default
mydsp
-i
(or --inline-architecture-files
) makes the
header-file more self-contained, allowing it to compile on
systems having no Faust header files installed--in-place
(or -inpl
) tells
the Faust compiler to write the compute()
function to allow the audio input audio buffers to be the
same as the output buffers ("in-place processing"),
like juce::AudioProcessor::processBlock()
RingMod.h
file containing your compiled Faust code.
faustMinimal.h
Here is our simple Faust architecture file, faustMinimal.h
:
#include <cmath>
#include <cstring>
#include <faust/gui/MapUI.h>
#include <faust/gui/meta.h>
#include <faust/dsp/dsp.h>
// BEGIN-FAUSTDSP
<<includeIntrinsic>>
<<includeclass>>
// END-FAUSTDSP
The Faust compiler replaces the
hook <<includeclass>>
with its
generated DSP class. Thus,
a Faust
architecture file is a wrapper for the Faust compiler
output that adapts it to a specific environment. We need very
little service from the architecture file in making a header
file. Our Faust-generated class RingMod
is a
derived class of dsp
, like all Faust DSP modules,
so we include dsp.h
to define that.
The MapUI
class is a derived class
of UI
, like all Faust user interfaces, and it
offers some nice features mapping parameter names and paths to
parameter addresses in memory. The include
file meta.h
is a very small file defining the
abstract class Meta
to specify the common
API declare(const char* key, const char* value);
used by the Faust compiler to define metadata such as name,
version, author, license, etc.
Now open a blank JUCE+PGM project and implement the
parameters. The code from
Assignment
PGM is good for this. Import the compiled
header RingMod.h
file into the Source folder of
your Projucer project as an "existing file".
Now at the top of your PluginProcessor.h
file import your Faust header file:
#include "RingMod.h"
Next, declare pointers to our Faust DSP class RingMod
and user-interface class MapUI
as member variables in our Plugin class:
std::unique_ptr<RingMod> ringModP;
std::unique_ptr<MapUI> ringModUIP;
The MapUI
class is one of Faust's parameter-handling
classes which you can read in RingMod.h
.-i
option when compiling Faust code causes all
Faust header files to be "inlined" in the compilation output.)
Using "smart pointers" for our Faust dsp
and UI
instances means we don't have to worry
about deleting them in our destructor. In larger projects, it is
typical to have an array of
type std::vector<std::unique_ptr<dsp>>
containing the processors for all of our synths and/or effects,
and similarly for the UI
interfaces. This facilitates
iteration over all of them in prepareToPlay()
and processBlock()
. If there is only ever going to be
one effect instance, then it is more readable to simply declare the
Faust DSP and its interface as member variables:
RingMod ringMod;
MapUI ringModUI;
In our .cpp
file, we need to initialize our pointers and connect the interface
instance ringModUI
to the DSP instance ringMod
:
ringModP = std::make_unique<RingMod>();
ringModUIP = std::make_unique<MapUI>();
ringModP->buildUserInterface(ringModUIP.get()); // load UI with the DSP parameter pointers
With smart pointers, there is no need to worry about deleting
our dynamically allocated classes!
(Member variables of course free automatically when they go out of scope, i.e., when
our plugin processor is freed by the plugin host).
As a last bit of setup, initialize your ring modulator with your plugin's sampling rate in
prepareToPlay
:
ringModP->init(sampleRate);
You're almost there, now we need to actually run the Faust DSP on our audio buffers!
The function used to process an audio block in Faust is the compute
function, and will normally
appear at the end of your header file generated by the Faust compiler. Taking a look, we see we have
virtual void compute(int count, FAUSTFLOAT** inputs, FAUSTFLOAT** outputs) = 0;
Arguments:
int count
- the number of samples to processFAUSTFLOAT** inputs
- array of input buffer pointers, one for each channelFAUSTFLOAT** outputs
- array of output buffer pointersFAUSTFLOAT
is defined as float
by
default, but you can define it as double
if you
prefer, such as when using the -double
option for
Faust compilation. The lengths of our input/output channel
arrays are determined by our Faust code, and we can call
ringModP->getNumInputs()
and
ringModP->getNumOutputs()
to find out the number
of inputs and outputs are expected. (In our case we should get
1 for both.) As a fun exercise, take a look at your
compiled RingMod.h
header and see if you can
understand the code in the compute
function!
In our simple example ringmod.dsp
above, we only
have one input and one output signal. To support typical
stereo audio processing
in juce::AudioProcessor::processBlock()
, we would
need to create an instance of RingMod
(and
interface MapUI
) for each audio channel.
In this treatment, we will only process the first audio
channel (typically the left channel), and copy the output to
all other audio channels (typically the right channel). Also,
we will write our code such that any number of DSP channels is
supported, so that we can change to a stereo
ring-modulator later, with mind-bending interactions
between the left and right channels. In summary, we'll write
for RingMod
having any equal number of
inputs and outputs.
Since we are doing in-place processing, we will pass an array
of pointers to the channel data of our multichannel JUCE audio
buffer as both the inputs
and outputs
in RingMod::compute()
. There is a handy function
juce::AudioBuffer::getArrayOfWritePointers()
that is
almost what we need:
float*const* writePointers = buffer.getArrayOfWritePointers();
We can
use writePointers
as inputs
above,
but its usage as outputs
gets a compiler
error.float*const*
to float**
is brutal but works in normal
computing environments:
ringModP->compute(buffer.getNumSamples(), writePointers, const_cast<float**>(writePointers));
This is called "casting away constness" which can result in
undefined behavior according to the C standard (such as when
the write-pointer array resides in protected memory).int nChannelsFaust = ringModP->getNumOutputs();
jassert(nChannelsFaust == ringModP->getNumInputs()); // sanity check
// jassert(nChannelsFaust == 1); // ringmod.dsp is presently written this way
float* bufferPointersFaust[nChannelsFaust];
for (int i=0; i<nChannelsFaust; i++)
bufferPointersFaust[i] = writePointers[i];
We only need one array of buffer pointers because juce::AudioProcessor::processBlock()
reads and writes to the same buffer ("in-place processing").
We used the Faust -inpl
compiler option to ensure this will work.
Now call compute
to process our audio:
ringModP->compute(buffer.getNumSamples(), bufferPointersFaust, bufferPointersFaust);
That wasn't so hard!
If you build and run your project, you should now have a
working ring modulator effect in the left stereo channel. You
can efficiently copy the left channel to the right channel
(and any higher channels), for mono centering, using
juce::AudioBuffer::copyFrom()
(or just copy in a
for
loop).
Note that we never used ringModUIP
, other than to
initialize it with
ringModP->buildUserInterface(ringModUIP.get())
.
We didn't touch the parameters!
We can now set them programmatically using API provided by MapUI
such
as
ringModUIP->setParamValue("Freq", 440.0f);
ringModUIP->setParamValue("Wet", 1.0f);
However, we want to control these parameters from our GUI (and
perhaps MIDI or OSC), so we need to set up a standard
juce::AudioProcessorParameter
for each one so that PGM can
hook up to them.
As in Assignment 1 (FFT),
add two juce::AudioParameterFloat
parameters to the layout returned by
createParameterLayout()
. You can reference either your Faust code or the
compiled header to determine the min, max, step, and default values needed for the
juce::NormalisableRange
. Note that the parameter order is different in JUCE and Faust:
juce::ParameterID freqID { "RingMod:Freq", /* parameter version hint */ 1 };
When this is done, PGM automatically creates a nice
hierarchical menu of plugin parameters organized into modules. The "parameter version hint" is required
by JUCE for AU plugins.
The string "Freq"
above is chosen to match the name given to hslider
in our Faust
code:
freq = hslider("Freq", 1000, 20, 4000, 1);
For example, we can add the Freq
parameter
with
layout.add ( std::make_unique (freqID, freqID.getParamID(),
juce::NormalisableRange(/* min */ 20.0f, /* max */ 4000.0f, /* step */ 1.0f), /* default */ 1000.0f)
); // KEEP MIN/MAX/STEP/DEFAULT IN SYNCH WITH NUMBERS IN ringmod.dsp
Also included in the starter code is FaustParameters.h
. Using it, all parameters
can be added to the layout with
simply
layout.add(FaustParameters::getParameterGroup(ringModUI, /* prefix */ "RingMod"));
// loops through the (built) UI and adds all Faust parameters found
Build your standalone plugin and verify that the parameters appear in the PGM Editor before moving on.
(E.g., try hooking up a slider to one of your parameters in the PGM Editor.)
Finally, add the processor as a parameter listener for each parameter, as in Assignment 1 (FFT), e.g.,
treeState.addParameterListener(freqID, this);
or you can use FaustParameters.h
to add yourself as listener to all parameter in one call:
FaustParameters::addParameterListeners(treeState,this,ringModUI,"RingMod");
With or without FaustParameters.h
, your parameterChanged
function only needs a single function-call to pass the changed value by name
(replacing the usual switch statement on parameter ID):
void FaustAndJuceAudioProcessor::parameterChanged(const juce::String ¶meterID, float newValue)
{
// Strip away the DSP module name prefix, e.g., "RingMod:Freq" -> "Freq":
juce::String paramName = parameterID.fromLastOccurrenceOf(":", /* includeSepChar */ false, /* ignoreCase */ true);
if (paramName.length() == 0) // no prefix found - assume there was no prefix:
paramName = parameterID;
ringModUI.setParamValue(paramName.getCharPointer(), newValue); // MapUI supports name-based parameter lookup
}
That's it!
Hook up your parameters in PGM and you should now be controlling your Faust code from its GUI.