Assignment Faust: Adding Faust to JUCE

Due: Start of Week 5
Deliverables: None

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

  1. Use the Faust compiler to compile your Faust code (.dsp) along with a Faust architecture file (.cpp) to generate a C++ header file (.h or .hpp) to be included by your project
  2. In your plugin processor, initialize the Faust DSP and UI classes defined in the header file
  3. Hook up Faust parameters to your JUCE plugin parameters (managed by PGM in our case)
  4. Call your Faust DSP's compute function inside your plugin's processBlock function (the JUCE audio callback)
In more detail:

0. Install the Faust Compiler, Tools, and Libraries

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).

1. Download the FaustAndJuce starter code

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.

2. Faust to C++

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:

Try running this code in the online Faust IDE, and play around with the controls to get a feel for the effect!

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: You should now have a RingMod.h file containing your compiled Faust code.

3. Our Simple Architecture File 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.

3. JUCE-ing Faust

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.
(Our use of the -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!

4. Compute!

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: FAUSTFLOAT 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.
Casting 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).
A more portable method is to copy the pointers to a new array:
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.

4. Faust Parameters in JUCE

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:

When working with multiple DSP modules, it is nice to prefix each parameter with its module name:
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 &parameterID, 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.