In this tutorial, we will synthesize a patch to illustrate several basic features of yase including oscillators, filters, envelopes, and anti-aliasing. We will build up complexity incrementally starting from a sawtooth wave. Our starting point is to include the Yase library, open the yase namespace (so we do not have to prepend Yase class names with yase::), and write a main function that does nothing for now.
At this point you should be able to compile and run the above code, but it will not do anything. Next we will fill in the main function. Many Yase main functions are divided into three into three sections: declarations, setup, and execution, which we describe in turn.
Declarations: First, we declare three Modules that are enough to give us a basic sound.
The first is a Container into which we will put all other syntheiszer components. The second is an Audio module that interfaces with your computer's audio card to produce sound. The third is a Sawtooth waveform module. The "raw" argument to the Saw constructor tells yase to return a digital sawtooth wave that simply increases linearly up to a certain point and then resets. Students of digital signal processing will know that such a waveform, when sampled, will produce unwanted frequencies due to aliasing. We'll fix that soon.
Setup: Next we connect the output of the sawtooth module to the two inputs of the audio module.
Specifcally, these connect the "signal" output of the saw module to the "left" and "right" inputs of the audio module. Later, each time the synth module is updated, it will first copy the output of the saw module to the inputs of the audio module, and then call the update methods of the saw and audio modules. Note the we are using the strings "signal", "left", and "right" to refer to the inputs and outputs of these modules. That may seem ill-advised in what is supposed to be fast C++ code, since string methods are typically slow. But they are only used in the setup phase. Internally, Yase translates these names to integer indices that are used during execution, so everything is as fast as can be.
Note: Many Container methods, like connect return a reference to the container so that you can "method chain". The above is the same as writing
but slightly more compact. Not having to see synth over and over again will looke nicer when we start connecting dozens of modules.
Execution: Finally, we need a run statement.
It tells the synth module to start updating (at the default frequency of 44100 Hz).
The entire program now looks like this:
Compiling and running this example will produce a somewhat annoying, sawtooth wave at 440 Hz (the default). You can stop it by pressing Cntl-C. Pressing Cntl-C will pop out of the run method, so that any clean up code can execute. Here we simply return 0 to the OS and exit.
It is hard to evaluate a patch out of context, that is, outside of a melody or harmony. With Yase you can supply a melody in various ways, for example by connecting an external sequencer via MIDI. But for our purposes, it will be much simpler and still ullustrative to use a Player Module. In the declarations section of the main function, we write:
and then we add it to the synth container. Our connect statement becomes
Note that all modules contained within synth need to be added. However, the saw and audio modules are added implicitly by connecting them within the synth module. The player module on the other hand does not directly use signals, so we have add it to the synth module explicitly so that it's update method is called.
Now we need to define the behavior of the player. We can do that with the set method to the player as follows.
The set method takes three arguments. The first is a vector of frequencies. Here, we are using Yase's names for notes, like yase::G3 which we can just write as G3 since we have the yase name space open. The third argument is the duration of each note in seconds. And the second argument is a callback method that defines what to do each time the note changes. This argument is a C++ lambda, which is a way of specifying a function without naming it. The player will call the callback every three seconds cycling through the frequencies. You can see in the body of the callback the single line that changes the frequency of the sawtooth wave. The entire program is now
Compiling and running the resulting code should now play the melody over and over again — using our annoying sawtooth wave — until you press Cntl-C. Try it!
The raw sawtooth waveform we are using does not sound great. One reason is that sampling it at 44100 Hz produces artifacts due to aliasing. These can be heard as a faint buzz. There are a variety of ways to fix this in Yase. One is to build the sawtooth wave from sine waves. Another is to soften the waveform. These each have their pros and cons. Here, we'll wrap an AntiAlias module around the sawtooth module. It samples the sawtooth at twice the normal sample rate, so 88200 Hz, and then applies a low pass filter to cut off frequencies lower than 22050 Hz. The module then downsamples back to 44100 Hz. Thus, frequencies that would have reflected off of the 22050 Nyquist sampling rate to produce a buzz are now significantly attenuated. The result is a noticable improvement. Using this module is as simple as replacing the saw declartion with
Nothing else needs to change. Compiling and running this code produces a slightly less annoying sawtooth wave. But it is still a basic sawtooth wave, which not everyone likes.
One reason the sound of the sawtooth is annoying is that it abrubtly starts and abruptly changes when a new note is played. Musical instruments don't do that. Notes start slowly or quickly, but not instantly. And they decay to a sustained level and ring a bit after they are released. In audio synthesis, this is accomplished with an Envelope, which sets an attack, decay, and release time as well as a sustain level. To add an envelope, we declare one:
and set its attack, decay, sustain, and release parameters, also known as the ADSR of the envelope, in the setup phase:
The attack of the standard envelope in Yase is linear, so 0.8 means it will take 0.8 s to get to maximum output. The decay and release are exponential. The 1.0 and 0.1 parameters mean it will take 1.0 s and 0.1 s to get to 90% decayed or released. The 0.5 is the sustain level. The note will decay to 50% volume expoentially.
Next, we have to connect the envelope to the amplitude of the sawtooth, which we do in the setup phase with the statement:
And finally, we need to make sure the envelope starts over every time we begin a new note with the player. This can be done using the trigger method of the Envelope class:
The entire example is now
Now when you compile and run, your ears won't be nearly as offended. Although we can continue to refine this patch.
To mellow out the sound of the sawtooth wave, we can filter out some of the high frequencies using a low pass filter. A standard choice is the Biquad filter, which is a second order IIR filter with an easily tunable resonance and cutoff. We declare a Biquad filter like this:
By default, this filter is a low pass filter, but you can use the Biquad::set_type method to change it to a high pass, band pass, or all pass filter.
Next, we insert the filter into the synth container after the sawtooth and before the audio module.
Note that the two argument version of the connect method assumes the names of the inputs and outputs are "signal". This just makes the code a bit easier to read.
Next, we can set the resonance and cutoff frequency of the filter in the set up secction:
Try different resonances and frequencies for the filter. Be careful with high resonances because the filter can self-oscillate, which is hard on the ears.
The entire program now looks like this:
Just like an envelope can modulate a sound's amplitude over time, an envelope can also modulate a filter's parameters over time. This makes a much more interesting sound than a constant filter. For example, the "wah" sound of a wah-wah pedal comes from modulating a low pass filter's cuttoff frequency from low to high as a note is played.
To accomplish this, let's use another envelope. So that we don't get our two filters confused, we'll change the name of our existing envelop as well.
We would like to connect the "signal" output of the filter_envelope to the "frequency" input of the filter. But the envelope's output ranges from 0 to 1, while an interesting cutoff frequency would range from something like 500 Hz to 4000 Hz. Thus, we need to transform the output of the filter_envelope to that range. We can do this with a Transform module, which we declare as follows:
The constructor to Transform takes a function, which we specify here using a C++ lambda expression. The lambda takes an argument u, which is the input to the transform, and returns a scaled and shifted value, which will be the output to the transform. We can then connect the transform, filter_envelope, and filter like this:
Each time a new note is played, we need to re-trigger both envelopes, which we do like this:
Then we'll just set up the parameters of the filter envelope, using slightly different values than the wave envelope for a nice effect.
Now our program looks like this:
Finally, we'll take our patch to an entirely new level by adding an Echo. This will introduce significantly more texture to the patch. One thing that will happen is that many attenuated copies of our original sound will be added to each other. The result is interesting, but also a lot louder. So we'll also want to attenuate the total sound, which we'll do by adding a volume Gain. We can also get a cute stereo effect by sending the output of the echo to the left channel, and an inverted copy of the output to the right channel.
We declare these modules like this:
set up the gain like this:
and connect them like this:
Then we set the parameters of the echo effect. The "duration" of the echo is an integer number of samples to store in the echo's delay buffer. For a 0.5 s delay, we use SAMPLE_RATE / 2 = 22050 samples:
We also need the amount of signal to feed back into the system. Choosing 80% sounds good:
The final program is now:
Compile and run this program and you will hear a pretty cool sound!
As you can see, building a patch in Yase is fairly straightforward. You declare some modules, connect them together, set their parameters, and run. Try building on this example. Here are some ideas: