10
\$\begingroup\$

While working on a much more complicated audio application, I was thinking aboutmodular 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 classModule, which registers the object in a global registry. Every class that derives fromModule must also implement a functionupdate() that will update all its output values.

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

Apart from that, there are modules forVCOs,VCAs andVCFs (although instead of voltage controlled I guess they are "value controlled"), anenvelope generator and a very basicsequencer.

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

  1. The synthesizer itself is aModule consisting of several submodules, the latter are implemented as member variables. Theupdate() function implements the wires, by just copying values from outputs of the various submodules to inputs of other modules.
  2. There is aclass Wire that is aModule itself, the parameters are two references, and in itsupdate() 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 onGitHub andGitLab.

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 modulesstruct Module {    Module();    Module(const Module &other) = delete;    Module(Module &&other) = delete;    virtual ~Module();    virtual void update() = 0;};// Sourcesstruct 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;};// Modifiersstruct 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();};// Sequencerstruct 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;};// Sinksstruct Speaker: Module {    // Audio input    float left_in{};    float right_in{};    void update();};// Connectionsstruct 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 registrystatic 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 outputstatic 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);// Sourcesvoid 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;    }}// Modifiersvoid 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;}// SequencerSequencer::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;}// Sinksvoid Speaker::update() {    audio.left += left_in;    audio.right += right_in;}// Connectionsvoid 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
askedJan 7, 2021 at 22:03
G. Sliepen's user avatar
\$\endgroup\$
1
  • \$\begingroup\$I think only you can answeris it fun to create something with this code.\$\endgroup\$CommentedJan 10, 2021 at 20:39

1 Answer1

8
+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 everyModule but it's not clear that's a good thing. For example, any 70's vintage synth should have aPhaser 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 lineprivate because there's typically no input directly into the delay line of such a component. It's possible that otherModules may also benefit from that.

Use all required#includes

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

Consider using templates

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

Useoverride where appropriate

Theupdate() function is very important for all modules. I'd be inclined to mark that functionoverride to help the compiler identify if anyone inadvertently declared anupdate() 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. SeeSF.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 theSpeaker a mono object, so you don't limit to stereo. You might want5.1 surround sound for example. Also, I would consider creating separate classes forVCO_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 theSequencer 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 auser defined literal would allow you to freely intermix frequency in Hertz or musical notation and have all functions use frequency in Hertz.

Here's aconstexpr 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 theSequencer constructor:

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

Example use withinmain:

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

Eliminate unused data

I understand that theuserdata parameter of theAudio::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, theWire 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.

answeredJan 8, 2021 at 18:36
Edward's user avatar
\$\endgroup\$
4
  • \$\begingroup\$Thanks for the review, glad you had fun :) One question though, what felt better to wire the module together? Theupdate() method or theWire class?\$\endgroup\$CommentedJan 8, 2021 at 18:49
  • \$\begingroup\$For me, both theupdate() andWire 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 aWire class might be more appropriate if that's the direction.\$\endgroup\$CommentedJan 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 toprogram 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\$CommentedJan 8, 2021 at 18:55
  • 1
    \$\begingroup\$I've updated my answer to try to address that.\$\endgroup\$CommentedJan 8, 2021 at 19:04

You mustlog in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.