Rust VST part 2: Generating a signal with fundsp
We'll be building off of code built in the previous chapter. I recommend you take a look, or download the project files here.
What is fundsp?
We briefly covered what fundsp is last time. This time, we'll be using it to generate a basic signal for our Rust synthesizer 1. Let's take a look at the kind of syntax we'll expect when using
// Taken from: https://github.com/SamiPerttu/fundsp/blob/3d2696a1782c80799e23f027dbaaf22af47bda53/examples/beep.rs let synth = lfo >> pulse; let mix = synth >> declick >> dcblock >> >> reverb_stereo >> limiter_stereo;
It's important to note that
>>is not a bit-shifting, but piping audio (also known as chaining).
fundspprovides custom operators that allow us to succinctly describe an audio graph in pure Rust code 2.
So, what does this sound like?
There's a few moving parts to this synth, but it is relatively easy to understand.
fundsp does a lot of heavy lifting for us. The first part,
lfo, describes some inputs for the
pulse function (
duty). In the
mix block, the pulse wave — modified by those inputs — is then processed further, split into stereo channels, and given a stereo reverb effect.
With this simple syntax, we can achieve some interesting sounds! So, how can we take advantage of
fundsp to process an audio buffer
Let's revisit our white noise generator, and replace the
rand sample generation with
fundsp. First, let's add
fundsp to our
Cargo.toml and remove the
 # ... remove rand = "*" = "0.3.1" # need to be this version at least
We use the
noise() function of
fundsp to generate some bipolar white noise to get used to
fundsp. After that, we'll generate some more interesting tones.
// ---------------- // // 0. Hacker import // // ---------------- // use *; use AudioBuffer; use *; plugin_main!;
Now, let's explain what is going on here.
0. Hacker import
fundsp specifies not one, but two preludes —
fundsp::hacker::*. The hacker environment uses 64-bit precision internally, which is what we want. Because the precision of our VST is
f32, we will need to cast values when processing later.
1. Boxed audio graph
Our audio "graph" is hardly a graph, it's one
noise() node. But as the graph increases in complexity, its concrete type will become difficult or impossible to manually provide. We instead store the graph as a boxed
dyn AudioUnit64 for access. It is important to initialize the graph once in our
fundsp graphs must maintain an internal state. We further restrict the type to require
Send, which is a bound defined by the
2. New audio graph description
We use the
new method to construct a new audio graph. In this case, it is one
noise() node, split into a left and right track using the
split::<U2>() function of
fundsp. (Without the
split, we would only hear audio on the left side.)
3. Using fundsp to process our audio buffer
The trickiest part is getting our arbitrarily-sized output buffer of
f32 precision to work with
fundsp provides the
MAX_BUFFER_SIZE constant which defines the number of samples the given audio unit can handle. At the time of writing, that number is 64. If we attempt to give the
process method more than
MAX_BUFFER_SIZE samples, the plugin will panic. To resolve this, we
chunk our buffer into a
MAX_BUFFER_SIZE length using a temporary left and right buffer. We pass those buffers as "out" parameters to our
process method, and then assign the values of that buffer to our actual output audio buffer.
Whew, that was a lot... tl;dr, We split
&mut [f32] into chunks of length 64, so it can be processed by
What does it sound like?
White noise! That's it. But it's done properly this time, with values between
Creating something tonal
Now let's create a signal that actually sounds like something. How about an "A" key (440Hz)? With
fundsp, it is trivial: modify the
audio_graph defined in your
// ... let audio_graph = sine_hz >> ; // ...
Let's take it a step further, and create an FM oscillator as described in
// ... let pitch = 440.; let modulation = 1.; // #[allow(clippy::precedence)] // if you use clippy let audio_graph = sine_hz * pitch * modulation + pitch >> sine >> ; // ...
Now we have a simple FM synthesizer to play around with... sort of. Our synth doesn't turn off, let alone play different notes with different timbres. Let's add a couple parameters, so we can start to adjust the synth in real time.
Because our VST doesn't have a UI yet, we need to ensure our plugin host provides some way to tweak parameters4.
We're going to add two parameters, one for
pitch, and another for
modulation. We need to specify this in our info struct, as well as other parts of our application. Before this, we're going to add a couple dependencies to our
Cargo.toml. We'll talk about these dependencies more later.
 # ... = "0.3" = "0.2" # ...
Next, create a new file in the
src directory called
params.rs. It should look like this:
use FromPrimitive; use FromPrimitive; use Display; use *; // ----------------------------------- // // 1. Creating the `Parameters` struct // // ----------------------------------- // // ---------------------------------------- // // 4. Tagged enum instead of magic numbers // // ---------------------------------------- // // --------------------------------------------- // // 5. Optional display to make things look nice // // --------------------------------------------- //
1. Creating Parameters
First, we define a
Parameters struct. Later, we will add this as a field in our synth, and enable real-time modulation through our host. This struct has two parameters:
modulation5. We use the thread-safe
vst::util::AtomicFloat type, as parameter values are shared across both audio and UI threads.
2. Setting defaults
Keep in mind that float parameters in VSTs are always between
1.0. If we want to create a 440Hz sine wave, we will need to scale our normalized
pitch value by some factor. Here, we set the
0.44, which will allow us to scale it by
1000. later for a final number of
1. here, but could set it to whatever we like within that range.
3. Getting and setting
Time for boilerplate. For our parameters to work correctly, we need to implement
4. Tagged enum
We use a tagged enum to provide a friendlier interface when accessing parameters. Without this, we would need to get and set parameters by an arbitrary ID (0, 1, 2, ...). This is where our
num-derive crate can generate code that will let us match
i32s as our
At this point, our plugin host does not know the name of any parameters. To resolve this, we implement
Adding parameters to
Let's modify our
lib.rs file to match the following. (Don't replace your
// ------------------------------ // // 0. The params module & imports // // ------------------------------ // use *; use ; use Arc; use *; const FREQ_SCALAR: f64 = 1000.; plugin_main!;
0. Modules and imports
Here we add our recently created
params module, and import some required items.
1. Adding a parameters field
We add a new field to our
Synthy struct titled
params with the type of
Arc<Parameters>. Audio and UI threads share
Parameters, which explains the use of
2. Adding parameters to our audio graph
tag(..) nodes define queryable variables. A
tag(..) takes an identifier used to query the value, as well as a starting value. For easier composition, we create a couple closures to create any tag nodes. This is especially useful when composing the audio graph with repeated tags.
Consider the following syntax:
let freq = ; let modulation = ; let audio_graph = freq >> sine * freq * modulation + freq >> sine >> ;
Without closures, the equivalent code reads:
let audio_graph = tag >> sine * tag * tag + tag >> sine >> ;
The first version is much more legible.
We also destructure a default
Parameters struct to provide default values for our audio graph. Lots of defaults.
3. Revealing parameters to our DAW
Our host still needs to know how many parameters are available. We specify this in our
Info struct with the
parameters: 2, line.
Now that we have parameters, we also need to implement this function. All it does is clone our parameters object for access.
5. Modifying the audio graph
Before processing audio, we get our parameter values and apply them to our audio graph using the
set method of our
AudioUnit64. Sharp eyes might spot an issue: parameters are set every 64 samples. That shouldn't be an issue here.
With this code, we should now have a functioning FM synthesizer. Let's open it up in our host or DAW of choice, and see what it sounds like. You'll hopefully notice two new parameters: "frequency" and "modulation". If you tweak these parameters, you should be able to hear the difference in pitch and timbre
What does it sound like?
The following is a sample of the synth playing while the host automates its
modulation . With little code, we're able to achieve something that actually sounds somewhat interesting.
fundsp is in active development, and it's possible by the time you read this that some parts of the API have changed or moved. For example, the tagging described later in this article literally came out the day I wrote this. I love Rust's community.
This is an excellent display of both the ingenuity of the Rust community (specifically Sami Perttu, the crate's author) and the extensibility and expressiveness that the Rust language provides. For a full list of operators that
fundsp provides, please check out the excellent documentation provided in its README.md.
Experiment with these lines to make them feel more comfortable with your style of Rust. Note that this method only handles stereo audio, which is fine for our purposes.
Your host probably supports natively tweaking parameters, but it might not. It depends on what you're using.
In case you like to shorten variable names, remember that
mod is a reserved keyword in Rust.