home

Simple synthesizer

Let's implement a simple monophonic synthesizer.

Digital audio is made up of a series of samples.
A sample is a value that tells us about displacement from the equilibrium position on the graph.
If we combine those samples together we get a shape that represents sound.

We can make a synthesizer by creating a sequence of samples, giving them a value and sending them to the audio server which will play us the desired sound in our speakers.

So let’s get stared!

#include <SDL2/SDL.h>

int main()
{
    SDL_Init(SDL_INIT_EVERYTHING);

    SDL_AudioSpec spec;
    spec.freq = 48000;
    spec.channels = 1;
    spec.format = AUDIO_F32SYS;
    spec.samples = 256;
    spec.callback = [](void *data, Uint8 *bytes, int len) {
        // TODO
    };

    auto device = SDL_OpenAudioDevice(nullptr, 0, &spec, &spec, 0);
    SDL_PauseAudioDevice(device, 0);
    
    SDL_Quit();
}

This code creates audio spec - struct that contains information about audio such as sampling rate, sample format, buffer size and so on.

For our needs 48kHz and floats as sample format are satisfactory.
48kHz allows us to represent frequencies that humans can hear (Nyquist theorem) and floats as sampling rate gives us more precision and headroom for processing audio because ints are mapped to -1.0 and 1.0 whereas floats can exceed that values.
Generally speaking ints are used for playback purposes and floats for audio processing.

And lastly buffer size, 256 seems to be sensible (smaller buffer sizes reduce latency, however some small numbers can cause problems).

There are two ways of playing sound, either by using a callback or SDL_QueueAudio. I’ll feed audio device by callback.

Warning! While using the callback you need to be careful because audio works on another thread therefore passing data via spec.userdata might led to data races if you don’t use any form of locking (such as std::mutex), in this case it won’t be that bad but keep it in mind.

And next we open audio device and unpause it because it’s paused by default.

Playing waveforms

From now we can start making synthesizer, we’ll start by generating a waveform. Waveform is just a shape which represents sound.

Some cool waveforms

Let’s create a synthesizer class.

class Synthesizer {
private:
    float m_timestep = 0;
    float m_phasestep = 0;
    float m_phase = 0;
    float m_freq;
    bool m_playing = false;
public:
    Synthesizer(int sample_rate)
    {
        m_timestep = 1.f / sample_rate;
    }

    void synthesize(float *buffer, size_t buffer_size)
    {
        for (size_t i = 0; i < buffer_size; i++) {
            if (!m_playing && m_phase < 0.001f) {
                return;
            }

            buffer[i] = sinf(m_phase * 2 * M_PI) * 0.5f;
            m_phase += m_phasestep;
            m_phase = fmodf(m_phase, 1.f);
        }
    }

    void play(float freq)
    {
        m_phasestep = m_timestep * freq;
        m_freq = freq;
        m_playing = true;
    }

    void stop()
    {
        m_playing = false;
    }
};

Sample rate tells us how many samples are in one second, if we divide 1 second by it we get the interval by which we can move in time.

Another variable is phase which we use to move along the sine wave. We start at 0 which tells us that we’re on the beggining of the wave, then we grow this value towards 0.25 which means that we are getting closer to the peak, next we approach second peak at 0.75 value and after reaching 1 we loop this process by fmodf.

To calculate current phase we add to phase step which is time step * freq to our phase.

Synthesize method is straightforward, it takes buffer and writes appropriate samples into it.

But one thing might be unclear. What does m_phase < 0.001f do?

Without this, after releasing key our waveform might cut in some random position, which will result in irritating to ear sound.

Bad

Therefore we use m_phase < 0.001f to let the waveform’s cycle end.

Good

We can now play sine wave but we need one more thing - input device.

Mapping piano keys to keyboard keys

Keys

We will try to recreate piano layout by mapping each invidual piano key and it’s frequency to keyboard key.

Let’s start by putting all keys into lookup table.

std::unordered_map<SDL_Keycode, int> piano_keys = {
    {SDLK_z, 0},
    {SDLK_s, 1},
    {SDLK_x, 2},
    {SDLK_d, 3},
    {SDLK_c, 4},
    {SDLK_v, 5},
    {SDLK_g, 6},
    {SDLK_b, 7},
    {SDLK_h, 8},
    {SDLK_n, 9},
    {SDLK_j, 10},
    {SDLK_m, 11},
};

Now create a function that takes key index and returns proper frequency.

float get_freq(int index)
{
    return 440.f * powf(2.f, index / 12.f);
}

We just take the key and using this formula convert it into frequency.

Next let’s move into our event loop and manage key events.

SDL_Keycode pressed_key;
while (running) {
...
    while (SDL_PollEvent(&event)) {
        switch(event.type) {
            case SDL_QUIT:
                running = false;
                break;

            case SDL_KEYDOWN:
                pressed_key = event.key.keysym.sym;
                if (piano_keys.count(pressed_key)) {
                    synth.play(get_freq(piano_keys[pressed_key]));
                }
                break;
            case SDL_KEYUP:
                if (event.key.keysym.sym == pressed_key) {
                    synth.stop();
                }
                break;

            default:
                break;
        }
    }
...

We check for SDL_KEYDOWN event and if the key is pressed we play the audio with adequate frequency based on pressed key.

Why store pressed key?

Well, if you won’t check for it, in SDL_KEYUP you could easily interrupt playing by releasing old pressed key.

So that’s all for now, I guess.

Final code

#include <SDL2/SDL.h>
#include <cmath>
#include <unordered_map>
#include <iostream>

class Synthesizer {
private:
    float m_timestep = 0;
    float m_phasestep = 0;
    float m_phase = 0;
    float m_freq;
    bool m_playing = false;
public:
    Synthesizer(int sample_rate)
    {
        m_timestep = 1.f / sample_rate;
    }

    void synthesize(float *buffer, size_t buffer_size)
    {
        for (size_t i = 0; i < buffer_size; i++) {
            if (!m_playing && m_phase < 0.001f) {
                return;
            }

            m_phase += m_phasestep;
            buffer[i] = sinf(m_phase * 2 * M_PI) * 0.5f;
            m_phase = fmodf(m_phase, 1.f);
        }
    }

    void play(float freq)
    {
        m_phasestep = m_timestep * freq;
        m_freq = freq;
        m_playing = true;
    }

    void stop()
    {
        m_playing = false;
    }
};

std::unordered_map<SDL_Keycode, int> piano_keys = {
    {SDLK_z, 0},
    {SDLK_s, 1},
    {SDLK_x, 2},
    {SDLK_d, 3},
    {SDLK_c, 4},
    {SDLK_v, 5},
    {SDLK_g, 6},
    {SDLK_b, 7},
    {SDLK_h, 8},
    {SDLK_n, 9},
    {SDLK_j, 10},
    {SDLK_m, 11},
};

float get_freq(int index)
{
    return 440.f * powf(2.f, index / 12.f);
}

int main()
{
    SDL_Init(SDL_INIT_EVERYTHING);

    Synthesizer synth(48000);

    SDL_AudioSpec spec;
    spec.freq = 48000;
    spec.channels = 1;
    spec.format = AUDIO_F32SYS;
    spec.samples = 256;
    spec.callback = [](void *userdata, Uint8 *bytes, int len) {
        Synthesizer *synth = (Synthesizer *)userdata;
        memset(bytes, 0, len);
        synth->synthesize(
            (float *)bytes,
            len / sizeof(float));
    };
    spec.userdata = &synth;

    SDL_AudioDeviceID device = SDL_OpenAudioDevice(nullptr, 0, &spec, &spec, 0);
    SDL_PauseAudioDevice(device, 0);

    SDL_Window *window = SDL_CreateWindow("synth", 0, 0, 800, 600, 0);
    SDL_Surface *surface = SDL_GetWindowSurface(window);
    SDL_Event event;

    bool running = true;

    const int lag = 1000 / 60;
    Uint64 start, elapsed;

    SDL_Keycode pressed_key;

    while (running) {
        start = SDL_GetTicks64();
        while (SDL_PollEvent(&event)) {
            switch(event.type) {
                case SDL_QUIT:
                    running = false;
                    break;

                case SDL_KEYDOWN:
                    pressed_key = event.key.keysym.sym;
                    if (piano_keys.count(pressed_key)) {
                        synth.play(get_freq(piano_keys[pressed_key]));
                    }
                    break;
                case SDL_KEYUP:
                    if (event.key.keysym.sym == pressed_key) {
                        synth.stop();
                    }
                    break;

                default:
                    break;
            }
        }

        SDL_UpdateWindowSurface(window);

        elapsed = SDL_GetTicks() - start;
        if (lag > elapsed) {
            SDL_Delay(lag - elapsed);
        }
    }
    
    SDL_Quit();
}