8
\$\begingroup\$

While working on a much more complicated audio application, I was thinking about modular synthesizers, and whether it could be made simple to build a C++ application that implemented a modular synthesizer in a way that feels just as easy as connecting the various modules of a real modular synthesizer. The basic trick I used is that almost everything derives from a base class Module, which registers the object in a global registry. Every class that derives from Module must also implement a function update() that will update all its output values.

There is a class Speaker that, when updated, will in effect cause samples to be written to the sound card. I'm using SDL to handle audio in a platform independent way, and it also requires surprisingly little code to get going.

Apart from that, there are modules for VCOs, VCAs and VCFs (although instead of voltage controlled I guess they are "value controlled"), an envelope generator and a very basic sequencer.

In the code below I have two ways of "wiring" the modules:

  1. The synthesizer itself is a Module consisting of several submodules, the latter are implemented as member variables. The update() function implements the wires, by just copying values from outputs of the various submodules to inputs of other modules.
  2. There is a class Wire that is a Module itself, the parameters are two references, and in its update() function it just copies from one reference to another.

I do know that the code is very inefficient, that is largely a consequence of the design. However I am interested in hearing whether any performance improvements are possible without requiring large changes in the design.

I am also interested whether you think this code is good from an educational perspective: is it fun to create something with this code? Does it teach you good and/or bad things about modular synthesizers and about C++?

The code is very basic, it could be made more robust by creating separate classes for input values and output values, perhaps also distinguishing between values that represent amplitudes, frequencies, trigger signals and so on. On the other hand, with modular synthesizers all jacks are also the same shape and the cables don't care what they connect to.

The code below can also be found on GitHub and GitLab.

example.cpp:

#include "modsynth.h"
#include <iostream>

using namespace ModSynth;

static struct Example: Module {
    // Components
    VCO clock{4};
    Sequencer sequencer{
        "C4", "E4", "G4", "C5",
        "D4", "F4", "A4", "D5",
        "Bb3", "D4", "F4", "Bb4",
        "F5", "C5", "A4", "F4",
    };
    VCO vco;
    Envelope envelope{0.01, 1, 0.1};
    VCA vca;
    Speaker speaker;

    // Routing signals using the update() function
    void update() {
        sequencer.clock_in = clock.square_out;
        envelope.gate_in   = sequencer.gate_out;
        vco.frequency      = sequencer.frequency_out;
        vca.amplitude      = envelope.amplitude_out;
        vca.audio_in       = vco.triangle_out;
        speaker.left_in    = vca.audio_out;
        speaker.right_in   = vca.audio_out;
    }
} example;

int main() {
    // Components
    VCO clock{1};
    Sequencer sequencer{"C2", "D2", "Bb1", "F1"};
    VCO vco;
    VCF vcf{0, 3};
    VCA vca{2000};
    Envelope envelope{0.1, 1, 0.1};
    Speaker speaker;

    // Routing signals using Wire objects
    Wire wires[]{
        {sequencer.clock_in, clock.square_out},
        {envelope.gate_in,   sequencer.gate_out},
        {vco.frequency,      sequencer.frequency_out},
        {vca.audio_in,       envelope.amplitude_out},
        {vcf.cutoff,         vca.audio_out},
        {vcf.audio_in,       vco.sawtooth_out},
        {speaker.left_in,    vcf.lowpass_out},
        {speaker.right_in,   vcf.lowpass_out},
    };

    std::cout << "Press enter to exit...\n";
    std::cin.get();
}

modsynth.h:

#pragma once

#include <initializer_list>
#include <string>
#include <vector>

namespace ModSynth {

// Base class for all modules

struct Module {
    Module();
    Module(const Module &other) = delete;
    Module(Module &&other) = delete;
    virtual ~Module();
    virtual void update() = 0;
};

// Sources

struct VCO: Module {
    // Parameters
    float frequency{}; // Hz

    // Audio output
    float sawtooth_out{-1}; // -1..1 output
    float sine_out{};
    float square_out{1};
    float triangle_out{};

    VCO() = default;
    VCO(float frequency): frequency(frequency) {}
    void update();
};

struct Envelope: Module {
    // Trigger input
    float gate_in{}; // > 0 triggers attack

    // Parameters
    float attack{}; // seconds
    float decay{};  // seconds/halving
    float release{}; // seconds/halving

    // Output
    float amplitude_out{}; // 0..1

    Envelope() = default;
    Envelope(float attack, float decay, float release): attack(attack), decay(decay), release(release) {}
    void update();

private:
    // Internal state
    enum {
        ATTACK,
        DECAY,
        RELEASE,
    } state;
};

// Modifiers

struct VCA: Module {
    // Audio input
    float audio_in{};
    
    // Parameters
    float amplitude{};

    // Audio output
    float audio_out{};

    VCA() = default;
    VCA(float amplitude): amplitude(amplitude) {}
    void update();
};

struct VCF: Module {
    // Audio input
    float audio_in{};

    // Parameters
    float cutoff{}; // Hz
    float resonance{}; // dimensionless,

    // Audio output
    float lowpass_out{};
    float bandpass_out{};
    float highpass_out{};

    VCF() = default;
    VCF(float cutoff, float resonance): cutoff(cutoff), resonance(resonance) {}
    void update();
};

// Sequencer

struct Sequencer: Module {
    // Trigger input
    float clock_in{}; // > 0 triggers next note

    // Parameters
    std::vector<float> frequencies; // Hz

    // Output
    float frequency_out; // Hz
    float gate_out;

    Sequencer(std::initializer_list<std::string> notes);
    void update();

private:
    // Internal state
    std::size_t index;
};

// Sinks

struct Speaker: Module {
    // Audio input
    float left_in{};
    float right_in{};

    void update();
};

// Connections

struct Wire: Module {
    // Value input
    float &input;

    // Value output
    float &output;

    Wire(float &output, float &input): input(input), output(output) {}
    void update();
};

}

modsynth.cpp:

#include "modsynth.h"

#include <algorithm>
#include <map>
#include <stdexcept>
#include <string>
#include <vector>
#include <mutex>

#include <SDL2/SDL.h>

namespace ModSynth {

// Module registry

static std::vector<Module *> modules;
static std::mutex mutex;

Module::Module() {
    std::lock_guard<std::mutex> lock(mutex);
    modules.push_back(this);
}

Module::~Module() {
    std::lock_guard<std::mutex> lock(mutex);
    modules.erase(std::find(modules.begin(), modules.end(), this));
}

// Audio output

static struct Audio {
    static float left;
    static float right;

    static void callback(void *userdata, uint8_t *stream, int len) {
        std::lock_guard<std::mutex> lock(mutex);
        float *ptr = reinterpret_cast<float *>(stream);

        for (size_t i = 0; i < len / sizeof ptr; i++) {
            left = 0;
            right = 0;

            for (auto mod: modules) {
                mod->update();
            }

            // Make the output a bit softer so we don't immediately clip
            *ptr++ = left * 0.1;
            *ptr++ = right * 0.1;
        }
    }

    Audio() {
        SDL_Init(SDL_INIT_AUDIO);
        SDL_AudioSpec desired{};

        desired.freq = 48000;
        desired.format = AUDIO_F32;
        desired.channels = 2;
        desired.samples = 128;
        desired.callback = callback;

        if (SDL_OpenAudio(&desired, nullptr) != 0) {
            throw std::runtime_error(SDL_GetError());
        }

        SDL_PauseAudio(0);
    }
} audio;

float Audio::left;
float Audio::right;

const float dt = 1.0f / 48000;
const float pi = 4.0f * std::atan(1.0f);

// Sources

void VCO::update() {
    float phase = sawtooth_out * 0.5f - 0.5f;
    phase += frequency * dt;
    phase -= std::floor(phase);

    sawtooth_out = phase * 2.0f - 1.0f;
    sine_out = std::sin(phase * 2.0f * pi);
    square_out = std::rint(phase) * -2.0f + 1.0f;
    triangle_out = std::abs(phase - 0.5f) * 4.0f - 1.0f; 
}

void Envelope::update() {
    if (gate_in <= 0.0f) {
        state = RELEASE;
    } else if (state == RELEASE) {
        state = ATTACK;
    }

    switch (state) {
    case ATTACK:
        amplitude_out += dt / attack;
        if (amplitude_out >= 1.0f) {
            amplitude_out = 1.0f;
            state = DECAY;
        }
        break;

    case DECAY:
        amplitude_out *= std::exp2(-dt / decay);
        break;

    case RELEASE:
        amplitude_out *= std::exp2(-dt / release);
        break;
    }
}

// Modifiers

void VCA::update() {
    audio_out = audio_in * amplitude;
}

void VCF::update() {
    float f = 2.0f * std::sin(std::min(pi * cutoff * dt, std::asin(0.5f)));
    float q = 1.0f / resonance;

    lowpass_out += f * bandpass_out;
    highpass_out = audio_in - q * bandpass_out - lowpass_out;
    bandpass_out += f * highpass_out;
}

// Sequencer

Sequencer::Sequencer(std::initializer_list<std::string> notes) {
    static const std::map<std::string, int> base_notes = {
        {"Cb", -1}, {"C", 0}, {"C#", 1},
        {"Db", 1}, {"D", 2}, {"D#", 3},
        {"Eb", 3}, {"E", 4}, {"E#", 5},
        {"Fb", 4}, {"F", 5}, {"F#", 6},
        {"Gb", 6}, {"G", 7}, {"G#", 8},
        {"Ab", 8}, {"A", 9}, {"A#", 10},
        {"Bb", 10}, {"B", 11}, {"B#", 12},
    };

    for (auto &note: notes) {
        auto octave_pos = note.find_first_of("0123456789");
        auto base_note = base_notes.at(note.substr(0, octave_pos));
        auto octave = std::stoi(note.substr(octave_pos));
        frequencies.push_back(440.0f * std::exp2((base_note - 9) / 12.0f + octave - 4));
    }

    index = frequencies.size() - 1;
}

void Sequencer::update() {
    if (clock_in > 0 && !gate_out) {
        index++;
        index %= frequencies.size();
    }

    frequency_out = frequencies[index];
    gate_out = clock_in > 0;
}

// Sinks

void Speaker::update() {
    audio.left += left_in;
    audio.right += right_in;
}

// Connections

void Wire::update() {
    output = input;
}

}

This can be compiled using this command on most operating systems, assuming the SDL2 library and headers are installed:

c++ -o example example.cpp modsynth.cpp -lSDL2
\$\endgroup\$
1
  • \$\begingroup\$ I think only you can answer is it fun to create something with this code. \$\endgroup\$
    – Reinderien
    Jan 10, 2021 at 20:39

1 Answer 1

6
+250
\$\begingroup\$

This was fun code to play with and to review. I haven't played with a modular synth for about forty years. Here are some things that may help you improve your code.

Consider hiding data

Right now everything's public in every Module but it's not clear that's a good thing. For example, any 70's vintage synth should have a Phaser so I implemented this one:

struct Phaser: Module {
    // Audio input
    float audio_in{};
    
    // Parameters
    unsigned limit{};

    // Audio output
    float audio_out{};

    Phaser() = default;
    Phaser(unsigned limit): limit(limit) {}
    void update();

private:
    // internal structure
    std::deque<float> delay;
};

void Phaser::update() {
    delay.push_back(audio_in);
    audio_out = audio_in + delay.front();
    if (delay.size() >= limit) {
        delay.pop_front();
    }        
}

I made the delay line private because there's typically no input directly into the delay line of such a component. It's possible that other Modules may also benefit from that.

Use all required #includes

The code uses std::exp2 so it should have #include <cmath>.

Consider using templates

A float is probably fine for this purpose, but one could imaging implementing this on, say, an 8-bit microcontroller in which an int might be more appropriate (if lower fidelity!). Templating that parameter would be a simple way to accommodate either.

Use override where appropriate

The update() function is very important for all modules. I'd be inclined to mark that function override to help the compiler identify if anyone inadvertently declared an update() function with the wrong prototype.

Use include guards

There should be an include guard in each .h file. That is, start the file with:

#ifndef MODSYNTH_H
#define MODSYNTH_H
// file contents go here
#endif // MODSYNTH_H

The use of #pragma once is a common extension, but it's not in the standard and thus represents at least a potential portability problem. See SF.8

Consider a more granular approach

I don't know about your speakers but mine are single channel devices. That is I have a left channel and a right channel, but they go to two different physical devices. I'd suggest making the Speaker a mono object, so you don't limit to stereo. You might want 5.1 surround sound for example. Also, I would consider creating separate classes for VCO_sine, VCO_square, etc. to make the system even more modular.

Consider abstracting out a musical note class

I'd suggest that rather than burying it within the Sequencer class, the notion of mapping from a note to a frequency is sufficiently central to most music that it warrants a separate class. Further, judicious use of a user defined literal would allow you to freely intermix frequency in Hertz or musical notation and have all functions use frequency in Hertz.

Here's a constexpr implementation of a user-defined literal for this purpose:

constexpr float operator "" _note(const char *ch, std::size_t ) {
    int base_note{0};
    switch (*ch++) {
        case 'C':
            base_note = -9;
            break;
        case 'D':
            base_note = -7;
            break;
        case 'E':
            base_note = -5;
            break;
        case 'F':
            base_note = -4;
            break;
        case 'G':
            base_note = -2;
            break;
        case 'A':
            base_note = 0;
            break;
        case 'B':
            base_note = 2;
            break;
        default:
            throw std::range_error("Note must be within A-G inclusive");
    }
    if (*ch == 'b') {
        --base_note;
        ++ch;
    } else if (*ch == '#') {
        ++base_note;
        ++ch;
    }
    auto octave{std::atoi(ch)};
    return 440.0f * std::exp2(base_note / 12.0f + octave - 4);
}

This greatly simplifies the Sequencer constructor:

Sequencer::Sequencer(std::initializer_list<float> notes) 
    : frequencies{notes}
    , index{frequencies.size() - 1}
{}

Example use within main:

Sequencer sequencer{"C3"_note, "D3"_note, "Bb2"_note, "F2"_note};

Eliminate unused data

I understand that the userdata parameter of the Audio::callback function is actually specified by the SDL library, but at the moment, it just generates an annoying compiler warning about an unused parameter. I'd suggest omitting the name until/unless you actually use the parameter. This may still generate a warning from picky compilers, but at least it would be apparent that the omission is deliberate.

Wire we here? (Sorry for the terrible pun!)

Since you've asked, the Wire class seems a bit contrived. Also, there's not much use in instantiating a module unless it actually eventually gets wired to a speaker output. If you further decided to break things into more modular chunks as I've suggested above, perhaps the << operator could be abused for this purpose. You might write:

left_speaker << phaser << sawtooth << sequencer;

This very clearly shows the connections and would be quite natural in style. I realize that some devices (like the sequencer) have multiple inputs and outputs, but it's not a huge leap to imagine how one might write that.

\$\endgroup\$
4
  • \$\begingroup\$ Thanks for the review, glad you had fun :) One question though, what felt better to wire the module together? The update() method or the Wire class? \$\endgroup\$
    – G. Sliepen
    Jan 8, 2021 at 18:49
  • \$\begingroup\$ For me, both the update() and Wire classes seemed a bit awkward (not least because I'd reverse the order of the latter so that it reads {from, to} but an obvious extension of this would be to make a GUI representation so a Wire class might be more appropriate if that's the direction. \$\endgroup\$
    – Edward
    Jan 8, 2021 at 18:52
  • \$\begingroup\$ I don't think I will go the GUI route with this. The goal was to make it as easy as possible to program a modular synth in C++. Do you think there is a better or less awkward way to write the modules together (besides fixing the order of the constructor parameters)? \$\endgroup\$
    – G. Sliepen
    Jan 8, 2021 at 18:55
  • 1
    \$\begingroup\$ I've updated my answer to try to address that. \$\endgroup\$
    – Edward
    Jan 8, 2021 at 19:04

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.