My original objective was to write a polyrhythmic metronome. As I started working on libmetro, the objectives became more concrete:
Write a library for building basic metronomes i.e. set a bpm for a sine wave click track. The cornerstone of libmetro is the use of a real-time audio library, http://libsound.io/. To use it effectively, I had to learn how to use ringbuffers and other techniques of real-time audio code.
Support additional timbres by using https://github.com/thestk/stk drum sounds. This is important when playing simultaneous rhythms (e.g. 4:3) to distinguish each rhythm by timbre.
Learn (some of) the theory behind rhythm and time signatures, and implement them alongside the basic click track e.g. polyrhythms, compound time.
Ensure the resulting code is easy to read and understand - don't let the ugly bits of C++ show too much.
Ensure the underlying code of the library is as correct and well-behaved as possible by testing and verifying the code using industry best practices. Users should only focus on whether or not their metronomes sound good, and not need to debug a segmentation fault, undefined behavior, or a memory leak.
Methodology
Real-time audio
The following articles are a good introduction to the challenges of real-time audio code:
The code that's sending audio data directly to the sound device should not have any delays (e.g. network calls, waiting for mutexes, sleeps) to avoid the risk of glitchy audio or worse. Since a metronome is timer-based, I make use of sleeps heavily in the code. In practice, libmetro's timer-based note player is kept separate from the audio callback thread - the two components communicate with a ringbuffer - source code here.
I've recorded a clip of a polyrhythm played at an impractically high 1900bpm (31.58ms period) by libmetro. This should demonstrate that my timing assumptions and ringbuffer fill/drain strategy are working correctly at very low latencies:
A downside of my code is that I had great difficulty implementing an example with a gradual tempo change without introducing glitchy audio, due to how I rely on libsoundio's software latency parameter to make my timings work correctly. In one attempt I constantly created new metronome objects, which sort of worked, but the amount of hacks I had to add to the code made it not worthwhile to support.
Evolution of libmetro's UX
V0: too much C++
The first version of the UX exposed too much "wiring", e.g. requiring the users to write lambda functions to create a metronome. Original 4/4 drum track:
int bpm = std::stoi(argv[1]);
auto tempo = metro::Tempo(bpm);
auto audio_engine = metro::audio::Engine();
auto stream = audio_engine.new_outstream(tempo.period_us);
The lambda syntax (i.e. [&]() { }) is new to C++ in the 2011 standard, so making it a necessary part of libmetro's UX was a bad decision.
V1: too much music
After learning a (very little) bit about time signatures (mostly from YouTube tutorials or asking classmates and friends), I went too far. I created a notion of "note durations", defined an enum with the values Whole, Half, HalfTriplet, Quarter, QuarterTriplet, Eighth, EighthTriplet, Sixteenth, SixteenthTriplet, each with a separate bpm computed from the input bpm (expected to be the quarter note bpm).
My first 3:2 polyrhythm is one where I threw together 3 "QuarterTriplet" notes and 2 "Quarter" notes and expected things to Just Work. This is what it sounded like:
I realized that baking in my incomplete knowledge of music could create a dangerously incorrect library. Note that this is the same rationale for why I don't have any examples that automatically create metronomes based on input time signatures (I put in many, and removed them). Somebody with a better grasp on time signatures could create a better frontend for libmetro that does that.
V2: just right
The final UX of libmetro is more "automated manual" than fully-automatic-but-incorrect as in V1. When following YouTube tutorials on how one should create a 3:2 or 4:3 polyrhythm by considering the least common multiple, I reverted to exposing a single timer, and expect that the user should lay the beats out in a measure.
This led to the simplest code, and the best sounding metronomes.
I thought that the above example (V2) could be easily expressed in a text file, eliminating the need for users to write code at all. This is what it looks like:
>>>Blocks the execution of the current thread for at least the specified sleep_duration. This function may block for longer than sleep_duration due to scheduling or resource contention delays.
Writing a unit test to measure the clock accuracy led me to discover how badly the naive ticker was drifting, and led to the creation of a more precise timer:
This might be familiar from the first exam - it's better to sleep in tiny amounts (since sleeping is non-premptible) to exercise finer-grained control over execution times. Here's an example of a naive ticker vs precise ticker, playing a 4-note measure that consists of 1 beep and 3 silences, at 500, 700, and 900bpm. Notice the out-of-sync beeps - that's the naive clock drifting away from the expected bpm:
The clock accuracy unit test verifies that the precise sleep stays within 2% clock drift (i.e. it ticks when it's expected to, +-2% at most). Of course, this is hardware and platform-dependent, but it's better than the naive clock.
Finally, here's an article (from a highly respected computer science professor) advising the use of -fsanitize=address and -fsanitize=unknown, both of which I use in libmetro (target: make build-ubsan) to try to detect bugs. Here's an example of asan I found in the wild in the systemd project.
Quality tools
The simplest (but very effective) quality tool I use is clang-format, which is a code formatter. You should always have consistent source code formatting in a project. I borrowed my .clang-format file from a good-looking codebase which I found on GitHub.
The target for clang-tidy in libmetro is make build-clang-tidy (clang-tidy needs to run at compile time). clang-tidy and cppclean mostly provide suggestions for code clarity:
This might be a common idiom but it's not as explicit as if (ringbuf == nullptr). nullptr in C++11 is a safer version of NULL.
Likewise, cppclean (make cpp-clean) lets us avoid a few redundant header includes:
/home/sevagh/repos/libmetro/src/metronome.cpp:2: 'outstream.h' already #included in '/home/sevagh/repos/libmetro/src/metronome.h'
clang-analyze (make clang-analyze) runs the entire cmake/ninja compilations under the scanbuild command:
[5/16] Building CXX object CMakeFiles/metro.dir/src/timbregen.cpp.o
../src/timbregen.cpp:106:2: warning: 2nd function call argument is an uninitialized value
populate_frames(frames, timbre, freq, vol);
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
../src/timbregen.cpp:106:2: warning: 3rd function call argument is an uninitialized value
populate_frames(frames, timbre, freq, vol);
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
../src/timbregen.cpp:106:2: warning: 4th function call argument is an uninitialized value
populate_frames(frames, timbre, freq, vol);
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The problem in the code is in the text file format parser:
metro::Note::Timbre timbre;
float freq;
float vol;
// logic to parse the text file and extract values of timbre, freq, vol
populate_frames(frames, timbre, freq, vol);
The problem is that if the parsing logic never reaches the code that sets those variables (entirely possible - pass it a blank file, an invalid file, etc.), those values are used uninitialized, and uninitialized values in C++ have unpredictable values - it's that undefined behavior again.
I chose ridiculous names in the example to make the serious point that bad C/C++ code is problematic in safety-critical applications. In action:
sevagh:ub-test $ ./a.out
Setting airplane altitude to: 4.59121e-41
C++ features and idioms
I make some use of modern C++ features and common idioms.
I use the PIMPL idiom to hide the implementation complexity of the Metronome class. In the public header file, Metronome is defined as:
// forward declare the private implementation of Metronome
namespace metro_private {
class MetronomePrivate;
};
class Metronome {
public:
Metronome(int bpm);
~Metronome();
void add_measure(Measure& measure);
void start();
void start_and_loop();
private:
metro_private::MetronomePrivate* p_impl;
};
Anybody reading the public header file to discover the usage of the Metronome class can see the public constructors and methods. This way, I can present a very neat public API of the Metronome, while hiding everything under the hood in src/metronome.h, src/metronome.cpp.
Another eyebrow-raising bit of code you'll see in libmetro is the FRIEND_TEST macro from googletest. This lets me test private class members, only when the code is compiled with the UNIT_TESTS macro:
target_compile_definitions(test_${name} PUBLIC UNIT_TESTS )
add_test(test_${name} ${BIN_DIR}/test_${name})
...
I make use of std::call_once and std::once_flag to ensure that I only initialize stk once, since there's no global libmetro initialization function:
static std::once_flag stk_init_flag;
static void stk_init()
{
std::call_once(stk_init_flag, []() {
stk::Stk::showWarnings(true);
stk::Stk::setSampleRate(metro::SampleRateHz);
});
}
// note constructor
metro::Note::Note(...) {
stk_init();
}
Since users of libmetro will be repeatedly creating new Notes, and Notes are built from stk objects, std::call_once ensures that the stk initialization is done once the first Note is created, and then skipped afterwards.
I use std::atomic for a thread-safe quit boolean in my metronome ticker thread:
void metro_private::MetronomePrivate::start()
{
stream.start();
auto blocking_ticker = [&](std::atomic<bool>& on) {
This allows me to do a graceful shutdown of the thread.
I also use lambda expressions, in the same code snippet pasted above (in fact, there are two nested lambdas - the blocking_ticker lambda is dispatching unnamed play_next_note lambdas):
auto blocking_ticker = [&](std::atomic<bool>& on) {
>>>Typically lambdas are used to encapsulate a few lines of code that are passed to algorithms or asynchronous methods.
By using lambda captures, I can capture all current references with the [&] expression, so that I don't need to pass thread arguments piecemeal. Overall, code using lambdas is neat - right at the call-site, I define the code that I want to dispatch asynchronously, like the play_next_note method, which I fire in the background on every metronome tick.
Results
Here are a series of metronomes implemented with libmetro:
5/4
9/8
4:3
Some complicated polyrhythm
Other subpages in these docs contain the full results, including usage docs and multiple examples: