Introduction to the topics data structures, algorithms and complexity / computability in C++
Caution - this tutorial is a theory-loaded one - we will discuss a bit of the basics about data structures, algorithms and complexity - all that especially in context with C - so let's get started with the data structures in C
Lists, Dictionaries and Queues
We begin with some basic data structures we already used without going much into the topics; those two are std::vector (basically lists) and std::map (basically dicts).
Lists and Vectors
Let us begin with the std::vector we already use those data types for the different voices. The complexity is almost O(1) or worst case scenario O(n). For a std::vector, accessing a random item is its "superpower." Because a vector stores its elements in a contiguous block of memory, it doesn’t need to traverse the list like a chain. Thus, the complexity for accessing any item in the list is O(1) - so constant.
Dictionaries and maps
Let us continue with the dictionary (or better map) in C++ . We use std::map to assign the different keys to a certain frequency. Unlike std::vector, which is built on a contiguous array, a std::map is typically implemented as a Red-Black Tree (a type of self-balancing binary search tree). This structural difference completely changes how it handles data.
For std::map, adding, removing, and even searching for an item all share the same complexity: Logarithmic Time O(\log n).
Why O(\log n)?
In a balanced tree, every time you make a "move" (comparing your key to the current node), you effectively rule out half of the remaining possibilities.If you have 8 elements, it takes at most 3 steps ($2^3 = 8$).If you have 1,024 elements, it takes at most 10 steps ($2^{10} = 1024$).If you have 1,000,000 elements, it only takes about 20 steps.
Queues, Deques and Stacks
While vectors are great for random access, sometimes we need a structure that strictly manages the order of processing. In C++, this is typically handled by std::queue.A queue follows the FIFO (First-In, First-Out) principle. Imagine a literal line at a grocery store: the first person to get in line is the first one to leave. In your synth project, a queue (specifically a thread-safe ring buffer) is essential for passing MIDI messages or audio samples between the UI thread and the high-priority audio callback thread. Complexity: For std::queue, both adding an element (push) and removing the front element (pop) are $O(1)$. Deques are double ended queues where you can add remove any element from both sides of the queue.
data structure | Access | Search | Insert (Average) |
std::vector | O(1) | O(n) | O(1) (amortized at end) |
std::map | O(log(n)) | O(log(n)) | O(log(n)) |
std::list | O(n) | O(n) | O(1) |
std::deque | O(1) | O(n) | O(1) (at ends) |
Crafting a custom data type
Now after itching on the surface of the topic we now want go deeper and create our own data structure the (audio) ring buffer. We make use of the standard template library and create our own implementation of a ring buffer…
| |
| |
This custom ring buffer is a perfect bridge between pure theory and practical synth development. In audio programming, we often can’t use std::vector or std::map directly inside the audio callback because they might allocate memory, which triggers a system call and risks an audio glitch (a "priority inversion").
Your implementation uses Atomics, which is the secret sauce for lock-free concurrency. Let’s break down the "how" and "why" of this structure to wrap up the Data Structures section before hitting Algorithms.
The Anatomy of the Audio Ring Buffer
A ring buffer (or circular buffer) treats a fixed-size array as if it were connected end-to-end. When the writeIndex reaches the end of the array, it wraps back to the beginning using the modulo operator (%).
Why std::atomic?
In a multi-threaded synth, the Audio Thread (Producer) and the GUI Thread (Consumer) are running simultaneously. If we used a regular size_t, one thread might see a partially updated value, leading to a crash or "garbage" audio data. std::atomic ensures that the index update is "atomic" (all-or-nothing) and visible across different CPU cores.
Memory Barriers (Acquire/Release)
You’ll notice std::memory_order_release and std::memory_order_acquire in the code. These aren’t just for show; they are Memory Barriers:
Release: Tells the CPU: "Make sure all my data writes (the audio samples) are finished before you update the index."
Acquire: Tells the CPU: "Before I read the index, make sure I have the latest data associated with it."