Connoise
Connected Noise Machine
CONNOISE
was an exercise in exploring software architecture for real-time audio synthesis. I wanted to take the concept of a modular synthesizer and replicate that in software, whereby each module has a number of inputs and outputs and performs a single function. This was also an exercise in learning some of the newer c++17 language features, but I also set myself the goal that the project should be as well documented as possible. The project builds with Doxygen docs and has a pretty complete README.md
file. The documentation was a lot of extra work, but ensured that I took the project seriously and encouraged me to publish it all as open source.
I stared this in January 2021 and worked on it as my primary spare time project for about 2 months.
Architecture
CONNOISE
ended up being structured as a library (libcno
) including all of the synthesizer implementation and a file format parser and serialiser, a CLI application (connoise
) and a GUI application (connoise-gui
).
This makes the CLI application pretty trivial, it parses the arguments and instantiates the system from the library. Similarly for the GUI, the application is all GUI code and uses the library APIs to manage the synthesizer system.
graph LR connoise <--> System connoise-gui <--> System subgraph C[libcno] direction TB System Parser -.-> System System <-.-> SystemModel Transport -.-> System System -.-> ModulesManager ModulesManager -.- Registry Registry -.- Inputs Registry -.- Outputs Registry -.- Modules Inputs -.- I01[Constant] Inputs -.- I02[MIDI Device] Inputs -.- I03[Null] Outputs -.- O01[Audio Device] Outputs -.- O02[Null] Outputs -.- O03[WAV File] Modules -.- M01[ADSR] Modules -.- M02[Delay] Modules -.- M03[FIR Filter] Modules -.- M04[Hold] Modules -.- M05[Hz to MIDI Note] Modules -.- M06[MIDI Note to Hz] Modules -.- M07[Math Abs] Modules -.- M08[Math Add] Modules -.- M09[Math Differential] Modules -.- M10[Math Multiply] Modules -.- M11[Mixer] Modules -.- M12[Oscillator Saw] Modules -.- M13[Oscillator Sine] Modules -.- M14[Oscillator Square] Modules -.- M15[Random] Modules -.- M16[Sequencer] end
System
and SystemModel
The interesting part here is the use of the SystemModel
which implements the directed graph of Module
port connections - this is used not only in the GUI for rendering the system layout, but also to figure out the graph dependencies in order to actually render the flow of signals in the correct order. The SystemModel
is an abstract model representation of the actual System
. The System
owns all the objects which actually do any useful work, but the SystemModel
contains the description of the System
which makes it easier to inspect and work with. The SystemModel
implements the algorithm for figuring out how to process the nodes in the graph in parallel. Given that all modern computers have multi-core processors, and real-time audio rendering requires the entire graph to be calculated with strict timing requirements, the algorithm is required in order to distribute the calculations amongst all CPU cores (using threads) and therefore allowing larger graphs to be rendered than would be possible if only one CPU core were available.
The parallel calculation algorithm takes the system graph and performs a breadth first search across all nodes from inputs to outputs. As it does this, it records the distance of each node from the output, and groups the nodes by distance. Since the distance describes the dependency order of the nodes, we can process all nodes with the same distance value at once. This is how rendering proceeds - all nodes in each distance group are processed in parallel before moving over to the next group. The distance groups themselves are sorted highest to lowest, meaning the nodes furthest from the output node are processed first, as we would expect the signals to be flowing from inputs to outputs.
Modules
The Module
s themselves have a fairly basic API, they can declare their own type, get allocated unique ID
s and have accessors for any number of named input and output ports. The remaining methods allow the System
to initialise and de-initialise the module's ports for rendering and call the ports themselves for each update. Notably, there is not a single update
method in each Module
, the processing actually happens at each input and output port. For example, in a module which implements a sine wave oscillator, the sine function would be part of the v
output port and not the module itself.
As an aside, in this manner actually
CONNOISE
only really needs one oscillator module type, because they all have mostly the same input parameters (frequency, phase) but each waveform type could be implemented on each of the output ports, we could have a port for sine, a port for square, etc. In practice though there are some variations on the parameters for each oscillator type, so it makes sense to have them as separate module types.
Other components
Transport
controls the playing state of the system, much like the controls on any media player, it has a basic state machine to ensure only the correct transitions can happen (although in practice this is mostly Stop
<--> Play
). It is also responsible for preparing port rendering buffers before rendering starts, and for calling all of the port update functions in the rendering loop, and for cleaning up the buffers at the end of rendering.
Parser
implements the grammar for the cno
file format, and during parsing can populate the System
with all of the valid items found in the file data.
We needed ModulesManager
and Registry
to be able to find module implementations by name, this maps the names declared in the cno
file description to code implementation.
GUI
I wrote the GUi using Dear Imgui and imgui-node-editor. Dear Imgui is amazingly simple to use and extremely performant. Being a immediate mode renderer the GUI code ends up being just a big long list of things to draw. I find that this makes the code a lot easier to follow and there is not much in the way of framework or abstractions to get in the way. That said, I'm not sure I would want to use it for any application more complex than this, but in this case I was happy with the choices made.
The current state of the GUI application is shown here; this model has a variety of nodes created and connected together. There is an inspector panel on the right for adjusting the static node parameters, and most numerical node parameters can receive inputs from other nodes. Nodes can be fully created, configured and connected in the GUI, and the system saved to a .cno
file. The colour of the connections between nodes changes according to the current value of the signal on that connection.
File format
CONNOISE
implements its own file format for describing a synthesis system, a basic example shown here:
Module Sine {
id 1
params {
frequency 220.0
}
}
Module Square {
id 2
params {
frequency 7.65
}
}
Module Multiply {
id 3
}
Connection {
2 v
3 a
}
Connection {
1 v
3 b
}
Output AudioDevice {
id 4
}
Connection {
3 v
4 L
}
Module
s, Input
s, Output
s, are all declared separately. Each of these has an id
and a number of named ports. The Connection
s declare which ports are connected together by referencing an id
and a port name.
The full file format specification is here.