Tutorial: Wavetable synthesis

Table of Contents


Incorporate wavetables to optimise your synthesiser oscillators. Manage the state of a sine wave oscillator using a wavetable and write data to the audio output.

Level: Intermediate

Platforms: Windows, macOS, Linux

Classes: AudioBuffer, AudioAppComponent, Random, MathConstants

Getting started

Download the demo project for this tutorial here: PIP | ZIP. Unzip the project and open it in your IDE.

If you need help with this step, see Tutorial: Projucer Part 1: Getting started with the Projucer.

The demo project

The demo project simply generates and outputs a stack of random sine wave harmonics through its stereo output. The user interface allows us to monitor the CPU usage by comparing the traditional implementation of an oscillator and the one that makes use of a wavetable.

In order to properly evaluate and compare the CPU usage of our different implementations, we are going to run our application in the Release configuration instead of the regular Debug configuration used during testing and development. By building the project in Release mode the compiler will be able to optimise the code as much as possible by removing assertions and comments from the code and inlining functions for example.

To change the build configuration in Xcode, first click on the deployment targets in the top left corner of the interface and navigate to Edit Scheme... as shown below:

tutorial_wavetable_synth_screenshot1.png
Editing the scheme

In the pop-up window, select Release in the Build Configuration combo box as shown in the screenshot:

tutorial_wavetable_synth_screenshot2.png
Changing the build configuration

Your application will now run after heavy compiler optimisations and the CPU usage should decrease significantly.

Wavetables

Wavetable synthesis is a synthesis method that uses look-up tables that are pre-filled with periodic waveforms to generate oscillators without having to generate the same waveform for each sample calculated. The wavetable is initialised with periodic waveforms of your choice and the resolution of these waveforms can be specified. When retrieving the correct sample value to output, the value is found by interpolating between two wavetable samples if the number of samples in the table does not match with the number of samples in the audio buffer block and its corresponding requested frequency.

As an example, let's say that we want to look up a sine wave from the wavetable. We would first create a wavetable for a single cycle of a sine wave with a resolution of 128 sample points for instance. For each sample in the buffer block, we can then request the sine wave sample value by calculating the correct interpolated sample using a combination of the sample rate, the requested frequency to play, the resolution of the wavetable and the current phase or angle of the waveform.

Let's start with a simple sine wave oscillator implementation before diving into wavetables.

Sine Wave Oscillator

Note
This section is covered in more detail in Tutorial: Build a sine wave synthesiser and if you need help with these steps please refer to that tutorial first.

In the SineOscillator class, we keep track of two member variables that store the current angle or phase in the waveform cycle and the angle delta to increment between every cycle depending on the frequency and the sample rate:

class SineOscillator
{
public:
SineOscillator() {}
//...
private:
float currentAngle = 0.0f, angleDelta = 0.0f;
};

The setFrequency() function allows us to calculate the angle delta by first dividing the frequency by the sample rate and multiplying the result by 2pi, the length of a cycle in radians:

void setFrequency (float frequency, float sampleRate)
{
auto cyclesPerSample = frequency / sampleRate;
angleDelta = cyclesPerSample * MathConstants<float>::twoPi;
}

The getNextSample() function gets called by the getNextAudioBlock() function of the AudioSource on every sample in the buffer to retrieve the sample value from the oscillator. Here we calculate the sample value using the std::sin() function by passing the current angle as an argument and updating the current angle by calling the helper function updateAngle() defined after:

forcedinline float getNextSample() noexcept
{
auto currentSample = std::sin (currentAngle);
updateAngle();
return currentSample;
}

The angle is updated by incrementing the current angle with the angle delta calculated previously when setting the frequency and by wrapping the value when the angle exceeds 2pi:

forcedinline void updateAngle() noexcept
{
currentAngle += angleDelta;
if (currentAngle >= MathConstants<float>::twoPi)
currentAngle -= MathConstants<float>::twoPi;
}

Now let's switch to the implementation of our MainContentComponent class.

We keep track of the overall level of our output and an array of oscillators as private member variables as shown here:

class MainContentComponent : public AudioAppComponent,
public Timer
{
//...
private:
//...
float level = 0.0f;
//...
};

In the prepareToPlay() function, we have to initialise the oscillators and set their frequencies to play based on the sample rate as follows:

void prepareToPlay (int, double sampleRate) override
{
auto numberOfOscillators = 200; // [1]
for (auto i = 0; i < numberOfOscillators; ++i)
{
auto* oscillator = new SineOscillator(); // [2]
auto midiNote = Random::getSystemRandom().nextDouble() * 36.0 + 48.0; // [3]
auto frequency = 440.0 * pow (2.0, (midiNote - 69.0) / 12.0); // [4]
oscillator->setFrequency ((float) frequency, sampleRate); // [5]
oscillators.add (oscillator);
}
level = 0.25f / numberOfOscillators; // [6]
}

In the getNextAudioBlock() function we simply sum all the oscillator samples and write the result to the output buffers as shown below:

void getNextAudioBlock (const AudioSourceChannelInfo& bufferToFill) override
{
auto* leftBuffer = bufferToFill.buffer->getWritePointer (0, bufferToFill.startSample); // [7]
auto* rightBuffer = bufferToFill.buffer->getWritePointer (1, bufferToFill.startSample);
bufferToFill.clearActiveBufferRegion();
for (auto oscillatorIndex = 0; oscillatorIndex < oscillators.size(); ++oscillatorIndex)
{
auto* oscillator = oscillators.getUnchecked (oscillatorIndex); // [8]
for (auto sample = 0; sample < bufferToFill.numSamples; ++sample)
{
auto levelSample = oscillator->getNextSample() * level; // [9]
leftBuffer[sample] += levelSample; // [10]
rightBuffer[sample] += levelSample;
}
}
}

If we run the application now, we should be able to hear a random noise of stacked sine waves.

Exercise
Instead of generating random midi notes, find the midi notes of a certain chord and generate random notes from the chord.

Wavetable Oscillator

Let's change the oscillator implementation to a wavetable synthesis method.

In the MainContentComponent class, add an AudioSampleBuffer as a member variable that will hold the wavetable values of our single sine wave cycle [1]. We also define the wavetable resolution as a constant of 128 samples using the bit shift operator [2]:

class MainContentComponent : public AudioAppComponent,
public Timer
{
//...
private:
//...
const unsigned int tableSize = 1 << 7; // [2]
float level = 0.0f;
AudioSampleBuffer sineTable; // [1]
//...
};

Define a new function called createWavetable() that will be called in the MainContentComponent constructor before we start the audio processing.

void createWavetable()
{
sineTable.setSize (1, tableSize);
auto* samples = sineTable.getWritePointer (0); // [3]
auto angleDelta = MathConstants<double>::twoPi / (double) (tableSize - 1); // [4]
auto currentAngle = 0.0;
for (auto i = 0; i < tableSize; ++i)
{
auto sample = std::sin (currentAngle); // [5]
samples[i] = (float) sample;
currentAngle += angleDelta;
}
}

Add this function call in the MainContentComponent constructor as follows:

MainContentComponent()
{
//...
createWavetable();
setSize (400, 200);
setAudioChannels (0, 2); // no inputs, two outputs
startTimer (50);
}

The wavetable should now contain 128 samples of a full sine wave cycle.

In the for() loop of the prepareToPlay() function, change the below line to instantiate a WavetableOscillator object instead of a SineOscillator object:

void prepareToPlay (int, double sampleRate) override
{
//...
for (auto i = 0; i < numberOfOscillators; ++i)
{
auto* oscillator = new WavetableOscillator (sineTable);
//...
}
//...
}

This constructor takes as an argument the wavetable to use for the sound generation and therefore, create a corresponding new WavetableOscillator class as shown below:

class WavetableOscillator
{
public:
WavetableOscillator (const AudioSampleBuffer& wavetableToUse)
: wavetable (wavetableToUse)
{
jassert (wavetable.getNumChannels() == 1);
}
//...
private:
const AudioSampleBuffer& wavetable;
float currentIndex = 0.0f, tableDelta = 0.0f;
};

Instead of keeping track of the current angle and the angle delta of the waveform cycle, define two member variables that store the current wavetable index and the angle delta of the wavetable. Also, define an AudioSampleBuffer variable to hold a reference to the wavetable to use.

The setFrequency() function of the WavetableOscillator class is fairly similar to the one implemented previously except that the angle delta is calculated using the size of the wavetable instead of the full cycle in radians of 2pi as follows:

void setFrequency (float frequency, float sampleRate)
{
auto tableSizeOverSampleRate = wavetable.getNumSamples() / sampleRate;
tableDelta = frequency * tableSizeOverSampleRate;
}

The getNextSample() function is where the interpolation between the wavetable values occur in order to get the correct sample value.

forcedinline float getNextSample() noexcept
{
auto tableSize = wavetable.getNumSamples();
auto index0 = (unsigned int) currentIndex; // [6]
auto index1 = index0 == (tableSize - 1) ? (unsigned int) 0 : index0 + 1;
auto frac = currentIndex - (float) index0; // [7]
auto* table = wavetable.getReadPointer (0); // [8]
auto value0 = table[index0];
auto value1 = table[index1];
auto currentSample = value0 + frac * (value1 - value0); // [9]
if ((currentIndex += tableDelta) > tableSize) // [10]
currentIndex -= tableSize;
return currentSample;
}

This implementation should give us the same output sound when we run the application.

Exercise
Modify the number of oscillators and observe the change in CPU usage.
Note
The source code for this modified version of the code can be found in the WavetableSynthTutorial_02.h file of the demo project.

Wrapping the Wavetable

If you paid close attention to the previous code, you may have noticed that we have one missing value in our wavetable. The final value is skipped as it wraps around to the first value which happens to be the same so let's fix that now.

In the WavetableOscillator constructor, assign the table size variable to hold the resolution of the wave table minus one and define that member variable appropriately as follows:

class WavetableOscillator
{
public:
WavetableOscillator (const AudioSampleBuffer& wavetableToUse)
: wavetable (wavetableToUse),
tableSize (wavetable.getNumSamples() - 1)
//...
private:
const AudioSampleBuffer& wavetable;
const int tableSize;
float currentIndex = 0.0f, tableDelta = 0.0f;
};

The setFrequency() function needs to be updated using this variable and notice that the angle delta of the table will be slightly smaller:

void setFrequency (float frequency, float sampleRate)
{
auto tableSizeOverSampleRate = tableSize / sampleRate;
tableDelta = frequency * tableSizeOverSampleRate;
}

The getNextSample() function remains fairly similar except that we don't need to wrap the higher index anymore because we will increase the size of the table in the next step:

forcedinline float getNextSample() noexcept
{
auto index0 = (unsigned int) currentIndex;
auto index1 = index0 + 1;
//...
}

Here unlike before we set the resolution as one above the defined value and set the last sample as being the same as the first sample:

void createWavetable()
{
sineTable.setSize (1, tableSize + 1);
//...
samples[tableSize] = samples[0];
}

This allows us to reduce the wrapping condition in the processing call and transfering the load to the createWavetable() function that only gets called once at the start of the application.

The result should sound the same as the previous section but notice the slight decrease in CPU usage.

Exercise
Can you find a way to further optimise this code? Every arithmetic operation in DSP counts towards performance so you should try to eliminate as many as possible.
Note
The source code for this modified version of the code can be found in the WavetableSynthTutorial_03.h file of the demo project.

Selecting the Harmonics

Instead of outputing a random sine wave sound, let's create a harmonious sine wave by explicitly setting its harmonics.

Modify the createWavetable() function to incorporate the harmonics in the wavetable samples of the sine wave as follows:

void createWavetable()
{
sineTable.setSize (1, tableSize + 1);
sineTable.clear();
auto* samples = sineTable.getWritePointer (0);
int harmonics[] = { 1, 3, 5, 6, 7, 9, 13, 15 };
float harmonicWeights[] = { 0.5f, 0.1f, 0.05f, 0.125f, 0.09f, 0.005, 0.002f, 0.001f }; // [1]
jassert (numElementsInArray (harmonics) == numElementsInArray (harmonicWeights));
for (auto harmonic = 0; harmonic < numElementsInArray (harmonics); ++harmonic)
{
auto angleDelta = MathConstants<double>::twoPi / (double) (tableSize - 1) * harmonics[harmonic]; // [2]
auto currentAngle = 0.0;
for (auto i = 0; i < tableSize; ++i)
{
auto sample = std::sin (currentAngle);
samples[i] += (float) sample * harmonicWeights[harmonic]; // [3]
currentAngle += angleDelta;
}
}
samples[tableSize] = samples[0];
}
void prepareToPlay (int, double sampleRate) override
{
//...
auto numberOfOscillators = 10;
//...
}

Finally, reduce the number of oscillators to 10 in the prepareToPlay() function and listen to the result by running the application.

Exercise
Modify the harmonics to an even series and notice the change in timbre of the sound produced. What about an odd and even series?
Warning
Since you are adding higher frequency components to the audio signal you need to watch out for aliasing effects! Dealing with these is beyond the scope of this tutorial, but reading about the Nyquist–Shannon sampling theorem and upsampling would be a good place to start.
Note
The source code for this modified version of the code can be found in the WavetableSynthTutorial_04.h file of the demo project.

Notes

In this tutorial we explored how to create a wavetable from a sine wave but you can essentially store any type of periodic waveform of your choice as long as the first sample matches the last one.

Exercise
Modify the createWavetable() function to generate and store different types of waveforms such as square, triangle or sawtooth waves.

Summary

In this tutorial, we have learnt how to implement a wavetable synthesiser. In particular, we have:

See also