Creating Audio Plugins, Part 6
- Tutorial
All posts in the series:
Part 1. Introduction and configuration
Part 2. Learning the code
Part 3. VST and AU
Part 4. Digital distortion
Part 5. Presets and GUI
Part 6. Signal synthesis
Part 7. Receiving MIDI messages
Part 8. Virtual keyboard
Part 9 Envelopes
Part 10. Refinement of the GUI
Part 11. Filter
Part 12. Low-frequency oscillator
Part 13. Redesign
Part 14. Polyphony 1
Part 15. Polyphony 2
Part 16. Antialiasing
After the interface improvements, it's time to do some programming. In this post we will generate classic sine, saw, triangle and meander.
Let's start by copying the previous project with the duplicate script:
In Xcode, you will again have to make changes to “Run” ( Product → Scheme → Edit Scheme ... ) so that the assembly starts with the REAPER project, as described earlier . If he complains that he cannot find the AU, he will need to change the names and IDs in resource.h or remove DigitalDistortion.component .
The material in this article is entirely related to the topic of DSP. We will not just write all the new code inside the function
Let's create a new class. On Mac File → New → File ...:
On Windows, right-click on the project, Add → Class:
Call it Oscillator . Make sure Oscillator.cpp is compiling. In Xcode, in the AU target, click on Build Phases . Click the plus under Compile Sourcesand add a .cpp file (this will have to be done for each target):
Let's write a title. Paste the following code between
To indicate what waveform our oscillator will generate, we use
A class object stores the frequency, phase, and sampling frequency. The phase value will constantly change, because it contains information about at what point in the waveform generation cycle the oscillator is. The increment of the phase is carried out each sample. For simplicity, imagine that the complete cycle of one wave is a circle. The value of the current sample is a point on the circle. Connect this point with the center of the circle: the angle that is formed by this segment and the x axis is the phase value. And the angle by which this segment will be rotated at the next moment is a phase increment.
The class has various functions for setting parameter values (for the frequency and sampling frequency, for example). But the most important function is this
Let's add an implementation of these functions to Oscillator.cpp :
The phase increment
The function
This function will be called every time it is called
For sine, everything is simple:
Please note that we do not use
This is how the saw looks:
Again, an interesting point with writing to the buffer. When I see such calculations, it is convenient for me to break them into parts:
Well, we have a downward ("left") saw!
The condition
Often in programming, we prefer conciseness and readability of the performance code. But DSP code that runs 44100 (or even 96000) times per second may be an exception to this rule. In addition, do not forget that the compiler optimizes a lot of things without your knowledge, and what may seem like a “bunch of work” for the programmer can be an elementary thing compared to those processes that you don’t even think about.
The next in line is the meander:
The second half of this code is already known to you. Each cycle in length
The triangle is a bit more complicated:
If you take apart in parts
So, the resulting value will first increase, and then decrease. Subtraction
Let's use our oscillator already! Turn on Oscillator.h and add a member
We also need to rename
Now change the initialization parameters in the constructor:
Turn the knob, test the class. We will change the oscillator frequency from 50 Hz to 20 kHz (we’ll set 440 by default).
Change function
We need to tell the oscillator what sampling rate is currently in use. This must be done in the function
If we do not do this and the oscillator has the wrong sampling frequency, it will generate the same waveforms, but the frequencies will be wrong, because the wrong phase increment will be calculated. A member function is
And finally, you need to use the oscillator in
In fact, it
Let's hear how it sounds! Launch. If linker errors crash in Xcode, check to see if you have added Oscillator.cpp to Compile Sources . When our craft starts, an even tone will be heard. Turn the knob and the frequency should change.
Now change
Restart the code, and now there will be a sharper sound. Play around with
With all waveforms except the sine, one can notice that strange noises appear at high frequencies. Additional tones appear even below the fundamental frequency. They sound inharmonious, and when you turn the knob up and down, these tones are shifted in opposite directions o_O
As we have already seen in the meander code, when the phase increases to a value greater than two pi, in the form of a wave there is an instant jump from a positive maximum in the current sample to a negative in the next. The opposite jump occurs when it
Imagine that you were asked to assemble this jump using only sine waves. Given that they are smooth, you will need many high-frequency sines. In general, to create an ideal leap theoretically, you need an infinite number of high-frequency components, each with an increasing frequency. The same thing happens when generating the meander, saw and triangle.
But in computing, everything is finite. The RAM and hard drive have a finite volume, so for recording one second of sound, the computer can use only a finite number of values to save this second. This number (sampling frequency) can be any, but the standards are 44100, 48000 and 96000 samples per second. Less commonly used are 176400 and 192000. A signal represented by a finite number of samples is called discrete.
To describe a signal jumping between -1 and 1, you need at least two samples per cycle: one for -1, the other for 1. So, if you sample at a frequency of 44100 times per second, the highest correctly recorded frequency will be 22050 Hz (read about Nyquist frequency )
So it is impossible to describe a perfect meander, saw or triangle with a discrete signal. If we still try to do this, we will very soon encounter the effect of aliasing . Read more here .
So how can we generate the best waveform for a given sampling rate without aliasing effects? “Best” in this case means “closest to the one we described above . ” The Nyquist frequency is a certain limitation in the frequency range. This limitation does not mean that the signal should not have peaks steeper than X, but that the signal should not contain frequencies above X Hz. So we need to switch to the frequency representation of the signal to solve such problems. We will do this a little later, but for now - in the next post - we have to figure out how to read MIDI.
The code from this post can be downloaded here .
Original article:
martin-finke.de/blog/articles/audio-plugins-008-synthesizing-waveforms
Part 1. Introduction and configuration
Part 2. Learning the code
Part 3. VST and AU
Part 4. Digital distortion
Part 5. Presets and GUI
Part 6. Signal synthesis
Part 7. Receiving MIDI messages
Part 8. Virtual keyboard
Part 9 Envelopes
Part 10. Refinement of the GUI
Part 11. Filter
Part 12. Low-frequency oscillator
Part 13. Redesign
Part 14. Polyphony 1
Part 15. Polyphony 2
Part 16. Antialiasing
After the interface improvements, it's time to do some programming. In this post we will generate classic sine, saw, triangle and meander.
Let's start by copying the previous project with the duplicate script:
./duplicate.py DigitalDistortion/ Synthesis YourName
In Xcode, you will again have to make changes to “Run” ( Product → Scheme → Edit Scheme ... ) so that the assembly starts with the REAPER project, as described earlier . If he complains that he cannot find the AU, he will need to change the names and IDs in resource.h or remove DigitalDistortion.component .
Oscillator Class
The material in this article is entirely related to the topic of DSP. We will not just write all the new code inside the function
ProcessDoubleReplacing
. Instead, create a class Oscillator
, call its functions from ProcessDoubleReplacing
, and it will fill the output buffer with type values double
to create the waveform. First we use an intuitive approach. Later, faced with the shortcomings of this approach, we will find a way to achieve better sound. Let's create a new class. On Mac File → New → File ...:
On Windows, right-click on the project, Add → Class:
Call it Oscillator . Make sure Oscillator.cpp is compiling. In Xcode, in the AU target, click on Build Phases . Click the plus under Compile Sourcesand add a .cpp file (this will have to be done for each target):
Let's write a title. Paste the following code between
#define
and #endif
in Oscillator.h :#include
enum OscillatorMode {
OSCILLATOR_MODE_SINE,
OSCILLATOR_MODE_SAW,
OSCILLATOR_MODE_SQUARE,
OSCILLATOR_MODE_TRIANGLE
};
class Oscillator {
private:
OscillatorMode mOscillatorMode;
const double mPI;
double mFrequency;
double mPhase;
double mSampleRate;
double mPhaseIncrement;
void updateIncrement();
public:
void setMode(OscillatorMode mode);
void setFrequency(double frequency);
void setSampleRate(double sampleRate);
void generate(double* buffer, int nFrames);
Oscillator() :
mOscillatorMode(OSCILLATOR_MODE_SINE),
mPI(2*acos(0.0)),
mFrequency(440.0),
mPhase(0.0),
mSampleRate(44100.0) { updateIncrement(); };
};
To indicate what waveform our oscillator will generate, we use
enum
. Now a sine will be generated by default, but this can be changed using the member function of the class setMode
. A class object stores the frequency, phase, and sampling frequency. The phase value will constantly change, because it contains information about at what point in the waveform generation cycle the oscillator is. The increment of the phase is carried out each sample. For simplicity, imagine that the complete cycle of one wave is a circle. The value of the current sample is a point on the circle. Connect this point with the center of the circle: the angle that is formed by this segment and the x axis is the phase value. And the angle by which this segment will be rotated at the next moment is a phase increment.
The class has various functions for setting parameter values (for the frequency and sampling frequency, for example). But the most important function is this
generate
. This is the very function that fills the output buffer with values. Let's add an implementation of these functions to Oscillator.cpp :
void Oscillator::setMode(OscillatorMode mode) {
mOscillatorMode = mode;
}
void Oscillator::setFrequency(double frequency) {
mFrequency = frequency;
updateIncrement();
}
void Oscillator::setSampleRate(double sampleRate) {
mSampleRate = sampleRate;
updateIncrement();
}
void Oscillator::updateIncrement() {
mPhaseIncrement = mFrequency * 2 * mPI / mSampleRate;
}
The phase increment
mPhaseIncrement
depends on mFrequency
and mSampleRate
, so that it is updated every time one of these two parameters changes. We could calculate it every sample in ProcessDoubleReplacing
, but it is much better to do it here. The function
generate
currently looks like this:void Oscillator::generate(double* buffer, int nFrames) {
const double twoPI = 2 * mPI;
switch (mOscillatorMode) {
case OSCILLATOR_MODE_SINE:
// ...
break;
case OSCILLATOR_MODE_SAW:
// ...
break;
case OSCILLATOR_MODE_SQUARE:
// ...
break;
case OSCILLATOR_MODE_TRIANGLE:
// ...
break;
}
}
This function will be called every time it is called
ProcessDoubleReplacing
. Switch
used to ensure that, depending on the desired waveform, the corresponding code is executed.Waveform generation
For sine, everything is simple:
case OSCILLATOR_MODE_SINE:
for (int i = 0; i < nFrames; i++) {
buffer[i] = sin(mPhase);
mPhase += mPhaseIncrement;
while (mPhase >= twoPI) {
mPhase -= twoPI;
}
}
break;
Please note that we do not use
mFrequency
and mSampleRate
. We give an increment mPhase
and limit it between 0
and twoPI
. The only "complex" operation here is the call to a function sin()
that runs at the iron level on most systems. This is how the saw looks:
case OSCILLATOR_MODE_SAW:
for (int i = 0; i < nFrames; i++) {
buffer[i] = 1.0 - (2.0 * mPhase / twoPI);
mPhase += mPhaseIncrement;
while (mPhase >= twoPI) {
mPhase -= twoPI;
}
}
break;
Again, an interesting point with writing to the buffer. When I see such calculations, it is convenient for me to break them into parts:
mPhase
increases, starting with0
and jumps back to zero upon reaching the valuetwoPI
.- Further, it
(mPhase / twoPI)
grows from0
to1
and jumps back to zero. - So, it
(2.0 * mPhase / twoPI)
grows from0
, and jumps back, as soon as it reaches two. - When
mPhase
equal0
, expression1.0 - (2.0 * mPhase / twoPI)
is equal1
. WhilemPhase
increasing, the value of this expression falls and, as it reaches-1
, jumps to1
.
Well, we have a downward ("left") saw!
The condition
while
would seem to be redundant - it will occur in everyone case
. But if done differently, then you have to include it switch
in the loop. So we even get rid of the excess for
, but then it switch
will be performed more often than necessary. Often in programming, we prefer conciseness and readability of the performance code. But DSP code that runs 44100 (or even 96000) times per second may be an exception to this rule. In addition, do not forget that the compiler optimizes a lot of things without your knowledge, and what may seem like a “bunch of work” for the programmer can be an elementary thing compared to those processes that you don’t even think about.
The next in line is the meander:
case OSCILLATOR_MODE_SQUARE:
for (int i = 0; i < nFrames; i++) {
if (mPhase <= mPI) {
buffer[i] = 1.0;
} else {
buffer[i] = -1.0;
}
mPhase += mPhaseIncrement;
while (mPhase >= twoPI) {
mPhase -= twoPI;
}
}
break;
The second half of this code is already known to you. Each cycle in length
twoPI
. The body of the conditional statement if
sets the first half of the cycle equal 1
to the second -1
. When mPhase
it gets bigger mPI
, a sharp jump appears in the form of a wave. This is how the meander looks. The triangle is a bit more complicated:
case OSCILLATOR_MODE_TRIANGLE:
for (int i = 0; i < nFrames; i++) {
double value = -1.0 + (2.0 * mPhase / twoPI);
buffer[i] = 2.0 * (fabs(value) - 0.5);
mPhase += mPhaseIncrement;
while (mPhase >= twoPI) {
mPhase -= twoPI;
}
}
break;
If you take apart in parts
-1.0 + (2.0 * mPhase / twoPI)
, as I did before, you will see that this is the opposite of a saw. The absolute value ( fabs
) of the ascending saw means that all values below 0 will be inverted (inverted about the x axis). So, the resulting value will first increase, and then decrease. Subtraction
0.5
aligns the waveform with respect to zero. Multiplying by 2.0
scales the value, and it changes from -1
to 1
. Here is the triangle. Let's use our oscillator already! Turn on Oscillator.h and add a member
Oscillator
to the class Synthesis
:// ...
#include "Oscillator.h"
class Synthesis : public IPlug
{
// ...
private:
double mFrequency;
void CreatePresets();
Oscillator mOscillator;
};
We also need to rename
mThreshold
to mFrequency
. Now change the initialization parameters in the constructor:
GetParam(kFrequency)->InitDouble("Frequency", 440.0, 50.0, 20000.0, 0.01, "Hz");
GetParam(kFrequency)->SetShape(2.0);
Turn the knob, test the class. We will change the oscillator frequency from 50 Hz to 20 kHz (we’ll set 440 by default).
Change function
createPresets
:void Synthesis::CreatePresets() {
MakePreset("clean", 440.0);
}
We need to tell the oscillator what sampling rate is currently in use. This must be done in the function
Reset
:void Synthesis::Reset()
{
TRACE;
IMutexLock lock(this);
mOscillator.setSampleRate(GetSampleRate());
}
If we do not do this and the oscillator has the wrong sampling frequency, it will generate the same waveforms, but the frequencies will be wrong, because the wrong phase increment will be calculated. A member function is
GetSampleRate
inherited from the class IPlugBase
. OnParamChange
also need to be edited so that you can change the frequency with the knob:void Synthesis::OnParamChange(int paramIdx)
{
IMutexLock lock(this);
switch (paramIdx)
{
case kFrequency:
mOscillator.setFrequency(GetParam(kFrequency)->Value());
break;
default:
break;
}
}
And finally, you need to use the oscillator in
ProcessDoubleReplacing
:void Synthesis::ProcessDoubleReplacing(double** inputs,
double** outputs,
int nFrames) {
// Mutex is already locked for us.
double *leftOutput = outputs[0];
double *rightOutput = outputs[1];
mOscillator.generate(leftOutput, nFrames);
// Copy left buffer into right buffer:
for (int s = 0; s < nFrames; ++s) {
rightOutput[s] = leftOutput[s];
}
}
In fact, it
mOscillator
fills the buffer of the left channel, and we simply copy these values to the buffer of the right channel. Let's hear how it sounds! Launch. If linker errors crash in Xcode, check to see if you have added Oscillator.cpp to Compile Sources . When our craft starts, an even tone will be heard. Turn the knob and the frequency should change.
Now change
mOscillatorMode
in Oscillator.h at the initialization stage in the constructor:Oscillator() :
mOscillatorMode(OSCILLATOR_MODE_SAW),
mPI(2*acos(0.0)),
mFrequency(440.0),
mPhase(0.0),
mSampleRate(44100.0) { updateIncrement(); };
Restart the code, and now there will be a sharper sound. Play around with
OSCILLATOR_MODE_SQUARE
and OSCILLATOR_MODE_TRIANGLE
, they have different tones. With all waveforms except the sine, one can notice that strange noises appear at high frequencies. Additional tones appear even below the fundamental frequency. They sound inharmonious, and when you turn the knob up and down, these tones are shifted in opposite directions o_O
Aliasing
As we have already seen in the meander code, when the phase increases to a value greater than two pi, in the form of a wave there is an instant jump from a positive maximum in the current sample to a negative in the next. The opposite jump occurs when it
mPhase
is subtracted from twoPI
and the value of the expression again becomes smaller mPI
. The main idea is that sudden jumps in the signal mean that it contains many high-frequency components.Imagine that you were asked to assemble this jump using only sine waves. Given that they are smooth, you will need many high-frequency sines. In general, to create an ideal leap theoretically, you need an infinite number of high-frequency components, each with an increasing frequency. The same thing happens when generating the meander, saw and triangle.
But in computing, everything is finite. The RAM and hard drive have a finite volume, so for recording one second of sound, the computer can use only a finite number of values to save this second. This number (sampling frequency) can be any, but the standards are 44100, 48000 and 96000 samples per second. Less commonly used are 176400 and 192000. A signal represented by a finite number of samples is called discrete.
To describe a signal jumping between -1 and 1, you need at least two samples per cycle: one for -1, the other for 1. So, if you sample at a frequency of 44100 times per second, the highest correctly recorded frequency will be 22050 Hz (read about Nyquist frequency )
So it is impossible to describe a perfect meander, saw or triangle with a discrete signal. If we still try to do this, we will very soon encounter the effect of aliasing . Read more here .
So how can we generate the best waveform for a given sampling rate without aliasing effects? “Best” in this case means “closest to the one we described above . ” The Nyquist frequency is a certain limitation in the frequency range. This limitation does not mean that the signal should not have peaks steeper than X, but that the signal should not contain frequencies above X Hz. So we need to switch to the frequency representation of the signal to solve such problems. We will do this a little later, but for now - in the next post - we have to figure out how to read MIDI.
The code from this post can be downloaded here .
Original article:
martin-finke.de/blog/articles/audio-plugins-008-synthesizing-waveforms