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:

./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 doubleto 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 #defineand #endifin 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 mPhaseIncrementdepends on mFrequencyand 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 generatecurrently 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. Switchused 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 mFrequencyand mSampleRate. We give an increment mPhaseand limit it between 0and 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:
  • mPhaseincreases, starting with 0and jumps back to zero upon reaching the value twoPI.
  • Further, it (mPhase / twoPI)grows from 0to 1and jumps back to zero.
  • So, it (2.0 * mPhase / twoPI) grows from 0, and jumps back, as soon as it reaches two.
  • When mPhaseequal 0, expression 1.0 - (2.0 * mPhase / twoPI)is equal 1. While mPhaseincreasing, the value of this expression falls and, as it reaches -1, jumps to 1.

Well, we have a downward ("left") saw!
The condition whilewould seem to be redundant - it will occur in everyone case. But if done differently, then you have to include it switchin the loop. So we even get rid of the excess for, but then it switchwill 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 1to the second -1. When mPhaseit 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.5aligns the waveform with respect to zero. Multiplying by 2.0scales the value, and it changes from -1to 1. Here is the triangle.

Let's use our oscillator already! Turn on Oscillator.h and add a member Oscillatorto the class Synthesis:

// ...
#include "Oscillator.h"
class Synthesis : public IPlug
{
// ...
private:
    double mFrequency;
    void CreatePresets();
    Oscillator mOscillator;
};


We also need to rename mThresholdto 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 GetSampleRateinherited from the class IPlugBase.

OnParamChangealso 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 mOscillatorfills 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 mOscillatorModein 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_SQUAREand 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 mPhaseis subtracted from twoPIand 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

Also popular now: