📜 ⬆️ ⬇️

Creating audio plugin, part 6

All posts series:
Part 1. Introduction and setup
Part 2. Learning Code
Part 3. VST and AU
Part 4. Digital Distortion
Part 5. Presets and GUI
Part 6. Signal synthesis
Part 7. Receive MIDI Messages
Part 8. Virtual Keyboard
Part 9. Envelopes
Part 10. Refinement 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 a classic sine, saw, triangle and meander.

Let's start by copying the previous project with the duplicate script:
')
./duplicate.py DigitalDistortion/ Synthesis YourName

Xcode again has to make changes to “Run” ( Product → Scheme → Edit Scheme ... ) so that the build starts with the REAPER project, as described earlier . If he complains that he cannot find an 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 DSP topic. We will not just write all the new code inside the ProcessDoubleReplacing function. Instead, create an Oscillator class, call its functions from ProcessDoubleReplacing , and it will fill the output buffer with double values ​​to create a waveform. First, we use an intuitive approach. Later, faced with the shortcomings of this approach, we will find a way to achieve a better sound.

Let's create a new class. On Mac File → New → File ...:



On Windows, right-click on the project, Add → Class:



Let's call it Oscillator . Make sure Oscillator.cpp is compiled. In Xcode on AU, click on Build Phases . Click on the plus under Compile Sources and add a .cpp file (this will need to be done for each target):



Let's write a headline. Paste the following code between #define and #endif in Oscillator.h :

 #include <math.h> 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 which waveform our oscillator will generate, we use enum . Now the default sine will be generated, but this can be changed using the member function of the setMode class.
The class object stores the frequency, phase, and sampling frequency. The value of the phase will constantly change, since it contains information about the moment in which the oscillator waveform generation cycle is. The phase increment is carried out by each sample. For simplicity, imagine that a complete cycle of a single wave is a circle. The value of the current sample is the point on the circle. Connect this point with the center of the circle: the angle formed by this segment and the x axis is the value of the phase. And the angle to which this segment will be rotated in the next moment is the phase increment.



The class has different functions for setting parameter values ​​(for frequency and sampling frequency, for example). But the most important function is generate . This is the very function that fills the output buffer with values.

Let's add the 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 of mPhaseIncrement depends on mFrequency and mSampleRate , so that it is updated each time one of these two parameters changes. We could calculate it each sample in ProcessDoubleReplacing , but it’s much better to do it here.
The generate function at the moment 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 ProcessDoubleReplacing is ProcessDoubleReplacing . Switch used so that the appropriate code is executed depending on the desired waveform.

Waveform generation



For sine, it's simple:

 case OSCILLATOR_MODE_SINE: for (int i = 0; i < nFrames; i++) { buffer[i] = sin(mPhase); mPhase += mPhaseIncrement; while (mPhase >= twoPI) { mPhase -= twoPI; } } break; 


Note that we do not use mFrequency and mSampleRate . We increment the mPhase and limit it to between 0 and twoPI . The only "difficult" operation here is the function call sin() , which is performed at the hardware level on most systems.

It looks like a saw:

 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 pieces:

Well, we have a downward ("left") saw!
The while condition would seem redundant - it will occur in each case . But if you do otherwise, then you have to turn on the switch in the loop. So we even get rid of the excess for , but then the switch will be executed more often than necessary.
Often in programming we prefer concise and readable performance code. But DSP code that runs 44100 (or even 96000) once a second can 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 a programmer can be an elementary thing in comparison with those processes that you do not even think about.

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 is twoPI . The body of the if sets the first half of the cycle to 1 , the second to -1 . When mPhase becomes larger than mPI , a sharp jump appears in the wave form. It looks like a meander.

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 -1.0 + (2.0 * mPhase / twoPI) in parts, 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 relative to the x axis).
This means that the resulting value will first increase and then decrease. A subtraction of 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 a triangle.

Let's use our oscillator! Enable Oscillator.h and add the Oscillator member to the Synthesis class:

 // ... #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); 


Twist the handle, test the class. We will change the oscillator frequency from 50 Hz to 20 kHz (by default, we set 440).
Change the createPresets function:

 void Synthesis::CreatePresets() { MakePreset("clean", 440.0); } 


We need to transfer to the oscillator what sampling frequency is currently used. This must be done in the Reset function:

 void Synthesis::Reset() { TRACE; IMutexLock lock(this); mOscillator.setSampleRate(GetSampleRate()); } 


If we do not do this and the sampling frequency is incorrectly set for the oscillator, it will generate the same waveforms, but the frequencies will be wrong, because the wrong phase increment will be calculated. The member function GetSampleRate inherited from the IPlugBase class.

OnParamChange also needs to be edited so that you can change the frequency with the pen:

 void Synthesis::OnParamChange(int paramIdx) { IMutexLock lock(this); switch (paramIdx) { case kFrequency: mOscillator.setFrequency(GetParam(kFrequency)->Value()); break; default: break; } } 


Finally, 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, mOscillator fills the left channel buffer, and we simply copy these values ​​into the right buffer.
Let's hear how it sounds! Launch. If linker errors occur in Xcode, check whether you have added Oscillator.cpp to the Compile Sources . When our craft starts, it will be heard even tone. Twist the knob and the frequency should change.
Now change the mOscillatorMode in Oscillator.h during the initialization phase 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 timbres.
With all waveforms except sine, it can be seen that strange noises appear at high frequencies. Additional tones appear even below the fundamental frequency. They sound inharmonious, and when you twist the knob up and down, these tones shift in opposite directions o_O

Aliasing



As we have already seen in the meander code, when the phase rises to a value greater than two pi, an instantaneous jump from a positive maximum in the current sample to a negative one in the next occurs in the wave form. The opposite jump occurs when mPhase is subtracted from twoPI and the value of the expression again becomes less than the mPI . The basic idea is that abrupt signal jumps mean that it contains many high-frequency components.
Imagine that you were asked to collect this jump using only sinusoidal waves. Given that they are smooth, you will need a lot of high frequency sines. In general, to create the perfect jump, theoretically, an infinite number of high-frequency components will be needed, each with an ever increasing frequency. That's the same thing when generating a meander, saw and triangle.

But in computing everything is finite. The RAM and hard disk have a finite volume, so the computer can use only a finite number of values ​​to save one second of sound to save that second. This number (sampling frequency) can be any number, but the standards are 44,100, 48,000 and 96,000 samples per second. Less commonly, 176400 and 192000 are used. The signal represented by a finite number of samples is called discrete.
To describe a signal jumping between -1 and 1, you will need at least two samples per cycle: one for -1, the other for 1. So, if sampled at a frequency of 44,100 times per second, the highest correctly recorded frequency would be 22050 Hz (read about Nyquist frequency )
So a discrete signal to describe the ideal meander, saw or triangle is impossible. If we still try to do this, we will very soon encounter the effect of aliasing . You can read more here .

So how can we generate the best waveform for a given sample rate without aliasing effects? “Best” in this case means “closest to the one we described above . The Nyquist frequency is some limitation in the frequency range. This restriction does not mean that the signal should not have peaks steeper than X, but that there should not be any frequencies above X Hz in the signal . 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

Source: https://habr.com/ru/post/226439/


All Articles