Tutorial: SuperCollider server plugins in C++
A tutorial on how to write SuperCollider server plugins in C++, by Mads Kjeldgaard, Notam
Example code and PDF available here
Introduction
In SuperCollider, a unit generator (UGen) is a building block for creating sound patches. These building blocks may be something as simple as a sine wave generator or as complex as an algorithmic reverb. With these, the user of SuperCollider may put together synthesis and sound manipulation patches for creating almost any sound imaginable.
SuperCollider comes prepackaged with a plethora of different UGens.
Expanding the selection of UGens is done by installing plugins - collections of user contributed UGens (the words UGen, unit generators and plugins are often used interchangeably in SuperCollider).
The sc3-plugins repository contains a large library of community developed plugins (of varying age and quality) and other notable examples of plugins include IEMās vstplugin plugin and The Fluid Corpus Manipulation Project.
This is all made possible by SuperColliderās plugin API that provides a simple way to write plugins in C or C++ and hook them in to SuperColliderās synthesis server as one of many building blocks. Each addition of a plugin makes possible an abundance of new possibilities for the user when combined with the existing plugins.
In this tutorial series, we will cover some of the basics of writing such plugins using the C++ interface available to plugin authors (if you prefer writing your plugin in pure C, then I would advise reading Dan Stowellās excellent chapter on just that in the MIT SuperCollider book).
Before continuing I would like to extend a big thank you to the ever friendly and generous SuperCollider community for helping me understand these things a bit better, and to my workplace Notam for allowing me time to work on this.
On that note: If you need help with plugin development, there is an entire subforum dedicated to the subject over on the SuperCollider forum.
The code to accompany this tutorial may be found here.
Why write a plugin for SuperCollider?
Most users of SuperCollider will never experience the need for writing a plugin simply because the library of UGens that comes with SuperCollider is so rich in selection and effective in performance.
That said, there are some reasons for doing it anyway:
- Learning: Itās a great way to learn or get better at C++ or DSP (Digital signal processing) because it lets you focus on the sound and the algorithms, with fairly short compilation times and the ability to quickly test it out in SuperCollider.
- Contributing: You get to expand the ever evolving world of sonic possibilities in SuperCollider and contribute to a lively and creative community that has been going for more than 25 years.
- Expanding sonic possibilities: There may be some functionality you would love to see added to SuperCollider (see the flucoma project for a really impressive example of this).
Part 1: Demystifying the SuperCollider plugin
Before actually creating a plugin, letās take a bit of a detour to have a look at what a plugin project contains and how to use the CMake build generator system to turn our code into UGen objects for SuperCollider.
Quick starting a plugin project
The fastest way to create a plugin project is to use the SuperCollider development teamās cookiecutter template. By running this script in a terminal and answering the questionnaire it presents you with, you should be able to quickly generate all the files and project structure necessary for a plugin. See the cookiecutter templateās project page for the most updated information about how to use this, but in short it boils down to first installing the cookiecutter tool which is a python library:
$ python3.7 -m pip install cookiecutter
Then you can run the cookiecutter command line tool and supply it with the URL of SuperColliderās cookiecutter template:
$ cookiecutter https://github.com/supercollider/cookiecutter-supercollider-plugin
A typical project structure
A typical project structure for a plugin looks something like this:
|-- CMakeLists.txt
|-- cmake_modules
| |-- SuperColliderCompilerConfig.cmake
| |-- SuperColliderServerPlugin.cmake
|
|-- LICENSE
|
|-- plugins
| |-- SimpleGain
| |-- SimpleGain.cpp
| |-- SimpleGain.hpp
| |-- SimpleGain.sc
| |-- SimpleGain.schelp
|
|-- README.md
Letās take it from the bottom up:
First there is a readme. This explains what the plugin is and how to install it.
Then there is a subfolder called plugins - this is where we will be spending most of our time writing plugins. This folder contains another subfolder called SimpleGain which is the name of this particular plugin, and then the four files you need for a plugin:
- A .hpp file that declares your C++ code and imports all the necessary files.
- A .cpp file - this C++ implementation file is where we define our algorithms (typically in a setup function (a constructor), an optional teardown function (a destructor) and a calculation function that is called on each block of sound samples we put in to our plugin).
- A .sc SuperCollider class file that bridges our C++ code to the SuperCollider language
- A .schelp file for the SuperCollider help system which explains the usage of the plugin and demonstrates its usage in one or more examples.
The cmake_modules subfolder and the CMakeLists.txt file are used for building and compiling our project (we will get back to this later).
And then lastly, there is a LICENSE file containing the license for the pluginās code (SuperCollider itself is licensed under the GPL-3 license).
CMake is your friend
Building, compiling and installing your plugin(s) is one process handled by a program called CMake.
It is easy to get freaked out by CMake. At first, using it may seem like a semi-esoteric experience but CMake is actually your friend. In a SuperCollider plugin project, the main job of CMake is to keep track of your build options, what platform your are on and where the files needed are - and then, with that information at hand, construct a build system, compile your code and install it for you.
It is not strictly necessary to go deep in to the world of CMake to write a SuperCollider plugin but familiarizing yourself with some of the absolute basics can be helpful, which is why we will spend a bit of time doing just that before actually starting on our plugin (should you feel the need to go deep, then Craig Scottās book āProfessional CMakeā is a nice resource).
CMakeāing a plugin project
CMake needs a copy of the SuperCollider source code to be able to build a plugin project.
The path to this is supplied to CMake by using the -DSC_PATH
flag like so:
-DSC_PATH=/path/to/supercollider/sourcecode
The reason for this is that the SuperCollider source code contains all the actual library code you need to make your plugin work - including the plugin API, so the compiler needs these to be able to create the necessary objects.
CMake also needs to know where your plugin code is. This is done in the CMakeLists.txt
file at the root of your project where the paths (and other useful options) are defined. You generally only need to change this file if you rename your files, move them or want to add more files.
This is done using two CMake commands:
set(variablename ...variable_contents)
sc_add_server_plugin(destination name cpp_files sc_files schelp_files)
Here is an example of what that might look like in your plugin project:
set(SimpleGain_cpp_files
plugins/SimpleGain/SimpleGain.hpp
plugins/SimpleGain/SimpleGain.cpp
)
set(SimpleGain_sc_files
plugins/SimpleGain/SimpleGain.sc
)
set(SimpleGain_schelp_files
plugins/SimpleGain/SimpleGain.schelp
)
sc_add_server_plugin(
"${project_name}" # destination directory
"SimpleGain" # target name
"${SimpleGain_cpp_files}"
"${SimpleGain_sc_files}"
"${SimpleGain_schelp_files}"
)
When you add more plugin files to your project you simply have to repeat the chunk of CMake code above in your CMakeLists.txt, modified to contain information about the newly added files.
Build and install using CMake
What makes CMake really nice is that it makes it possible to build, compile and install your project with the help of a few terminal commands.
The workflow for this is divided into these steps:
Step 1: Create a build directory and generate a build system.
From the root of your project, run:
mkdir build # Create a build sub directory
cmake -B "build" -S . -DSC_PATH="/path/to/sc/sourcecode" \
-DCMAKE_INSTALL_PREFIX="/path/to/your/extensions/dir"
Once this is done, CMake will fill the subfolder build
with a bunch of files and data that is needed every time you compile your code (and it also serves as a sort of cache or memory).
Note always wrap the path arguments in quotes, CMake is very particular about this.
Step 2: Compile (and optionally install) the plugin(s).
To compile and install all you need to do is run the following command from the root of your project (repeat this command every time you change your code and want to see if it compiles and installs correctly):
cmake --build build --config "Release" --target install
Step 3: Try it out in SuperCollider
If compilation was successful, you should now have compiled and installed your project to your SuperCollider extensions directory.
If you have SuperCollider open, recompile the class library and search the help system for your plugin to verify that it showed up.
In the next part of this tutorial series, we will go deeper into the actual code of a plugin.
Part 2: Creating a ramp generator plugin
In this part of the tutorial we will start work on a plugin project. We will create a very simple oscillator that ramps from 0.0 to 1.0. This is sometimes known as a phasor as it is often used internally in code to read through lookup tables or as a way to have an internal clock that steps through data. It is also known as a sawtooth oscillator and may be used at audio rate to create sound signals with many (harsh) overtones.
Control, audio and scalar rate: A note on calculation rates
But first: We need to talk about calculation rates.
You may or may not be aware that UGens in SuperCollider often have multiple different output rates at which they may run, these are denoted by the class method they are called with. Some run at audio rate, some at control rate, others at demand rate (which we will not cover here) and others again only at scalar rate (abbreviated ir
sometimes for initialization rate).
Here are examples of how that might look in SuperCollider:
// Control rate
SinOsc.kr(100);
// Audio rate
SinOsc.ar(100);
// Scalar rate
SampleRate.ir();
These three different calculation rates are optimized for different purposes:
- Scalar rate, also known as initialization rate is useful if your plugin should output a value when it is initialized and then do nothing else.
- Audio rate is self-explanatory, but what it means in practice is your calculation function will receive a block of audio samples, loop over each sample, do something with the data and then return an output block of samples. Every time your pluginās calculation function is called it is supplied with an argument containing the value of the number of samples to process. This is typically 64 samples, but the user may change this in the server options.
- Control rate. This is a more performant alternative to audio rate calculation that only produces one value per block of samples. It is equivalent to setting the number of samples in the calculation function to 1.
Here is a little table to help you remember this:
Rate | sclang method name | Update rate |
---|---|---|
Audio rate | *ar | numSamples/sampleblock |
Control rate | *kr | 1/sampleblock |
Scalar | *ir | at UGen initialization |
Ramping values
A ramp generator is a simple algorithm that counts up to a certain threshold, then wraps back to where it began. This technique is seen in sawtooth and phasor oscillators. Not only is the signal itself useful in modulating parameters as an LFO, but the core algorithm is often used in other oscillator functions to index into wavetables and similar things.
The idea is simple, really: Imagine you are counting from 0 to 10 and then every time you reach 10, you start over at 0. If you were to plot this on a graph you would see a ramp signal (which looks like a sawtooth).
We will be using the same concept in our ramp UGen but instead of counting whole numbers we will be counting floats, which is the type of number that comes out of a UGen.
So, instead of counting from 0 to 10, we will be counting from 0.0 to 1.0.
Creating the RampUpGen SuperCollider class
If you followed the previous tutorial in this series, you will know that the easiest way to generate the scaffolding for a plugin is using the supercollider plugin cookiecutter template. Running this will generate all the files needed for a plugin and going forward I will assume youāve done that and that youāve called your plugin RampUpGen.
The first thing to write is the SuperCollider class that will be responsible for calling our C++ code. The file is called something like RampUpGen.sc
.
It defines the UGenās audio rate (*ar
) and control rate (*kr
) methods and sets the frequency argumentās default value.
Another important (and often overlooked) aspect of the SuperCollider class interface for plugins is that they are tasked with checking the rates of the inputs to the parameters and ensure an error is raised if for example a user inputs an audio rate signal in an input that was written for control rate.
RampUpGen : UGen {
// Audio rate output
*ar { |frequency=1.0|
^this.multiNew('audio', frequency);
}
// Control rate output
*kr { |frequency=1.0|
^this.multiNew('control', frequency);
}
// Check all inputs' rates
checkInputs {
// Check the rate of the frequency argument
if(inputs.at(0) == \audio, {
"You're not supposed to use an audio rate as input to frequency.".error
});
// Checks if inputs are valid UGen inputs (and not a GUI slider or something)
^this.checkValidInputs;
}
}
Important note on multichannel UGens:
Our SuperCollider class above inherits from UGen - this results in a 1 channel UGen. If you need more outputs than that, you need to inherit from MultiOutUGen instead (donāt forget to call this.initOutputs(numChannels, rate)
somewhere in your class if you do end up using this, it will (silently) fail otherwise).
Setting up the header file
Next step is to open up RampUpGen.hpp
in a text editor. This is your header file for your plugin where the correct files are imported and your new C++ class RampUpGen
inherits its functionality from the SCUnit class. The class should look something like this:
class RampUpGen : public SCUnit {
public:
RampUpGen();
private:
// Calc function
void next(int nSamples);
};
As you can see above, it defines a public constructor for the class RampUpGen()
and a private calculation function called next(int nSamples)
. If you create a UGen that allocates memory (for example delay based UGens), then you should also define a destructor function which is the same name as the constructor but with a tilde prefixed: ~RampUpGen()
. It is omitted here since we donāt need it.
We only need to make one small adjustment to this. Under the private:
keyword, declare a member variable that we can use to store our phase value in between calls to the next
calculation function:
// Ramp generator phase storage. Initialized to 0.0.
double m_phase{0.0};
Creating the calculation function
Itās now time to define the behaviour of our plugin. Move into the RampUpGen.cpp
file.
The core of our plugin will be the calculation function RampUpGen::next(int nSamples)
.
Letās define this function.
First, we need to figure out how much to increment our counter at every clock tick.
This is done by dividing the frequency of our ramp generator with the samplerate (which is available to us using the sampleRate()
function that comes with the plugin API) of our UGen.
We will let the user of our UGen decide what frequency our ramp generator should be running at.
This is done by polling the value that is supplied via the frequency
parameter in the SuperCollider class defined above.
For this purpose, we use the in0(int argumentInputNum)
method that comes with the plugin interface - it takes the index of the input argument as a parameter, in this case there is only one input parameter frequency
so this will be equal to 0.
One last thing to note here is that we will poll this argument at control rate, that is: It only inputs one value per sample block (see explanation above). To get the full sample block input for audio rate parameters you use the in(int argumentInputNum)
method.
// First UGen input is the frequency parameter
const float frequency = in0(0);
// Calculate increment value.
// Double precision is important in phase values
// because division errors are accumulated as well
double increment = static_cast<double>(frequency) / sampleRate();
Every time our calculation function is called, we need to increase our output by increment amount.
m_phase += increment;
Then, once our ramp generator reaches 1.0, we reset it to 0.0 to make it wrap back around at the beginning of the ramp.
const double minvalue = 0.0;
const double maxvalue = 1.0;
// Wrap
if (m_phase > maxvalue) {
// Wrap phase value
m_phase = minvalue + (m_phase - maxvalue);
} else if (m_phase < minvalue) {
// in case phase is below minimum value
m_phase = maxvalue - std::fabs(m_phase);
}
Thatās it for the calculation function.
Documentation
The last thing we need to do is to document the functionality of our UGen so that it is usable for others. This is done in SuperCollider help file that will go with our plugin called RampUp.schelp
. See the writing help and / or scdoc syntax help files for more information on how to do this. Be sure to add some good and clear examples to this.
The schelp file could look something like this:
class:: RampUpGen
summary:: A simple ramp generator
related:: Classes/Saw
categories:: UGens
description::
A simple ramp generator
classmethods::
method::kr
argument::frequency
Set the frequency of the ramp generator.
examples::
code::
// Scope the ramp generator at 1 hz
{ RampUpGen.kr(1.0) }.scope
::
Compile the plugin and try it out in SuperCollider
Now a first version of your plugin should be finished. Build and compile your plugin using the CMake commands discussed earlier in this tutorial, recompile the SuperCollider class library and try messing around with it.
Part 3: Finishing touches
Before our plugin is done, there are some nice little things we can do to touch it up and make it even better.
Using enums to keep track of inputs
Inputs and outputs in the C++ side of your UGen are represented as integers. The frequency-parameter in our RampUpGen UGen is represented by 0
since itās the first input to the UGen. To make it more readable and easier to keep track of parameters when you add more of them later on, a simple trick is to use enums. At itās most basic, an enum may be used as a collection of aliases for integers. Using this for your parameters makes your code easier to read and maintain.
In your header file, under the private:
-keyword, add the following:
enum Inputs { Frequency };
Then, in your plugin code, whenever you need the input number for frequency you simply type Frequency
. Another nice thing is that you only have to reorder the items in this enum if you reorder your parameters, and the changes will automatically propagate.
Delegating functionality to functions
Another thing that can help keep your code clean is to delegate some of the functionality to methods outside of the calculation function. Because we are about to add a second calculation function, we can extract a bit of the ramp code to its own method and then call that in the calculation function.
To do this, add in your header file:
inline float progressPhasor(float frequency);
And then implement it in your .cpp-file:
inline float RampUpGen::progressPhasor(float frequency) {
// Calculate increment value.
// Double precision is important in phase values
// because division errors are accumulated as well
double increment = static_cast<double>(frequency) / sampleRate();
m_phase += increment;
const double minvalue = 0.0;
const double maxvalue = 1.0;
// Wrap the phasor if it goes beyond the boundaries
if (m_phase > maxvalue) {
m_phase = minvalue + (m_phase - maxvalue);
} else if (m_phase < minvalue) {
// in case phase is below minimum value
m_phase = maxvalue - std::fabs(m_phase);
}
return m_phase;
}
Interpolating control rate parameters
Sometimes the user of a plugin may wish to modulate one of the UGenās parameters. In an environment such as SuperCollider, this is to be expected. When doing this, you may end up hearing crunchy or glitchy effects in the sound of your UGen. This is caused by a lack of interpolation in the control signal between each sample being processed. This results in steppy jumps between values instead of smooth trajectories. Letās fix this!
The plugin API contains a very useful function for exactly this purpose. The trick is to use the type SlopeSignal<float>
to represent and contain our input parameterās value. This is produced using the makeSlope(current_value, previous_value)
function. And then, on each iteration of our calculation functionās for
-loop, we use the .consume()
method to slowly step from the previous sample blockās value to the current one. And then, after the for
-loop we store the final value in a member variable that will be used next time the block is being processed.
This is what our calculation function looks like with parameter interpolation:
void RampUpGen::next(int nSamples) {
const float frequencyParam = in(Frequency)[0];
SlopeSignal<float> slopedFrequency =
makeSlope(frequencyParam, m_frequency_past);
float *outbuf = out(0);
for (int i = 0; i < nSamples; ++i) {
// Calculate increment value.
// Double precision is important in phase values
// because division errors are accumulated as well
double increment =
static_cast<double>(slopedFrequency.consume()) / sampleRate();
m_phase += increment;
const double minvalue = 0.0;
const double maxvalue = 1.0;
// Wrap the phasor if it goes beyond the boundaries
if (m_phase > maxvalue) {
m_phase = minvalue + (m_phase - maxvalue);
} else if (m_phase < minvalue) {
// in case phase is below minimum value
m_phase = maxvalue - std::fabs(m_phase);
}
outbuf[i] = m_phase;
}
// Store final value of frequency slope to
// be used next time the calculation
// function runs
m_frequency_past = slopedFrequency.value;
}
Setting calculation function based on input rates
Letās take this a step further. We actually only need to interpolate the frequency parameter if the parameterās input is control rate. If the input is audio rate, we get a full blockās worth of frequency
-parameter and thus we donāt need the interpolation.
The way to work around this and similar problems with multiple situations for the same plugin is to use multiple calculation functions, one for each calculation rate input for example. In our example we only have one parameter so we can make a next_a
and next_k
version of that function, one for audio rate inputs to the frequency
-parameter and one for control (and scalar) rate inputs.
The Server Plugin API comes with a function you can use to poll an input number to see what rate it is running at:
inRate(int inputNumber)
This is useful when used in combination with the constants (calc_ScalarRate
for scalar, calc_BufRate
for control and calc_FullRate
for audio rate) that represent each of the input rates (these also come with the API):
RampUpGen::RampUpGen() {
// Set the UGen's calculation function depending on the rate of the first
// argument (frequency)
if (inRate(Frequency) == calc_FullRate) {
mCalcFunc = make_calc_function<RampUpGen, &RampUpGen::next_a>();
// Calculate first value
next_a(1);
} else {
mCalcFunc = make_calc_function<RampUpGen, &RampUpGen::next_k>();
// Calculate first value
next_k(1);
};
}
And then our two calculation functions (next_a
for audio rate frequency
-parameter and next_k
for control rate):
// Calculation function for audio rate frequency input
void RampUpGen::next_a(int nSamples) {
const float *frequency = in(Frequency);
float *outbuf = out(0);
for (int i = 0; i < nSamples; ++i) {
// Calculate increment value.
// Double precision is important in phase values
// because division errors are accumulated as well
double increment = static_cast<double>(frequency[i]) / sampleRate();
m_phase += increment;
const double minvalue = 0.0;
const double maxvalue = 1.0;
// Wrap the phasor if it goes beyond the boundaries
if (m_phase > maxvalue) {
m_phase = minvalue + (m_phase - maxvalue);
} else if (m_phase < minvalue) {
// in case phase is below minimum value
m_phase = maxvalue - std::fabs(m_phase);
}
outbuf[i] = m_phase;
}
}
// Calculation function for control rate frequency input
void RampUpGen::next_k(int nSamples) {
const float frequencyParam = in(Frequency)[0];
SlopeSignal<float> slopedFrequency =
makeSlope(frequencyParam, m_frequency_past);
float *outbuf = out(0);
for (int i = 0; i < nSamples; ++i) {
// Calculate increment value.
// Double precision is important in phase values
// because division errors are accumulated as well
double increment =
static_cast<double>(slopedFrequency.consume()) / sampleRate();
m_phase += increment;
const double minvalue = 0.0;
const double maxvalue = 1.0;
// Wrap the phasor if it goes beyond the boundaries
if (m_phase > maxvalue) {
m_phase = minvalue + (m_phase - maxvalue);
} else if (m_phase < minvalue) {
// in case phase is below minimum value
m_phase = maxvalue - std::fabs(m_phase);
}
outbuf[i] = m_phase;
}
// Store final value of frequency slope to be used next time the calculation
// function runs
m_frequency_past = slopedFrequency.value;
}
General tips and troubleshooting
Tools that may come in handy
- Quick bench: An online benchmarking tool for C++. Use it to write small algorithms to compare their performance.
- Get familiar with compiler optimizations. There is a nice online tool godbolt.org that can help you with this. See this as an example of using it in a SuperCollider plugin workflow.
Make the compiler more strict
Adding the following section to your CMakeLists.txt will make the compiler a lot more strict and give you warnings on unused variables etcetera. This may be overwhelming at first but it helps make your code cleaner.
# Set compiler pickyness for Unix builds
# ... This picks up a ton of extra little things
if(CMAKE_COMPILER_IS_GNUCXX)
add_compile_options(-Wall -Wextra -pedantic)
endif(CMAKE_COMPILER_IS_GNUCXX)
Common issues
One of the difficulties of coding plugins in C++ is that when something is wrong, you have to debug - you rarely get nice error messages to help you out. That said, here are some of the problems and causes that have caused me trouble in 9/10 situations.
Problem: The server exits as soon as the plugin is initialized in a patch.
Whenever I have experienced this, it has come down to problems in the way I have allocated memory.
This could also be caused by something dividing by zero somewhere in your code.
Another common mistake I make is to inherit the wrong class in the .sc
-file containing the SuperCollider interface for my C++ code. This error may occur if, for example, you are inheriting from MultiOutUGen
but havenāt defined how many channels to use.
Problem: The sound is crunchy when I modulate the parameters of my plugin
You probably havenāt used interpolation for control rate parameters. See the previous part of this tutorial about smoothing control rate signals using SlopeSignal
.
Problem: [Esoteric things] are happening
Make sure that all variables are initialized correctly. C++ allows you to use variables even though you havenāt initialized them with any values - this will lead to the variable being filled with random junk from your computerās memory that can easily cause strange behaviour.
This tutorial was written with the support of Notam - The Norwegian Centre for Technology in Arts and Music.