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:
- The synthesizer itself is a
Moduleconsisting 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. - There is a
class Wirethat is aModuleitself, 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 ¬e: 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- \$\begingroup\$I think only you can answeris it fun to create something with this code.\$\endgroup\$Reinderien– Reinderien2021-01-10 20:39:23 +00:00CommentedJan 10, 2021 at 20:39
1 Answer1
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_HThe 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.
- \$\begingroup\$Thanks for the review, glad you had fun :) One question though, what felt better to wire the module together? The
update()method or theWireclass?\$\endgroup\$G. Sliepen– G. Sliepen2021-01-08 18:49:21 +00:00CommentedJan 8, 2021 at 18:49 - \$\begingroup\$For me, both the
update()andWireclasses 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 aWireclass might be more appropriate if that's the direction.\$\endgroup\$Edward– Edward2021-01-08 18:52:19 +00:00CommentedJan 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\$G. Sliepen– G. Sliepen2021-01-08 18:55:33 +00:00CommentedJan 8, 2021 at 18:55
- 1\$\begingroup\$I've updated my answer to try to address that.\$\endgroup\$Edward– Edward2021-01-08 19:04:44 +00:00CommentedJan 8, 2021 at 19:04
You mustlog in to answer this question.
Explore related questions
See similar questions with these tags.
