C++ tips
Some weird C++ syntaxes that may be unfamiliar to users of other prorgamming languages
Description
Microcontrollers are typically programmed in C or in a C-like way using C++. MEAP does the latter, utilizing C++ features like classes and templates but not including the C++ standard library by default. When programming most microcontrollers, you have very limited access to memory and computation speed when compared to desktop/laptop computers so using a comparatively low level language like C is useful for having more direct control over memory usage and implementation. MEAP uses an ESP32 microcontroller, which is fairly powerful, so these issues are minimized, however many of these approaches to programming are useful to some degree.
What follows is a summary of several important concepts for programming MEAP that may be unfamiliar to users who are only familiar with higher level languages like python
Data types
When a computer program stores data in a variable, what it is actually doing is writing binary data at a specific memory address in RAM. It can often be useful for the program to have some information about what this binary data represents; the same sequence of 1s and 0s means a different thing if it is representing a letter, a sample of PCM audio or a series of configuration flags for registers on an audio codec chip. Data types serve to describe a variable and tell the program (and programmer) how to interpret the data in a variable. Many higher level languages (like python or javascript) take a loose and intentionally obscured approach to data types, however on microcontrollers (and especially when dealing with audio with MEAP and Mozzi) directly controlling and converting between datatypes is often essential.
A datatype gives two important pieces of information:
How many bytes of memory the variable will occupy
What sort of data the variable represents
Integers
Take an integer for example. An integer represents whole numbers (ie no decimal/fractional values). But what is the largest and smallest values an integer can represent? This actually can depend on what computer architecture, language, etc you are using. In C++ we can define an integer variable as follows:
int my_variable = 12;The int keyword specifies that my_variable will store an integer, but it is unclear what kind of integer. For our system, what this actually evaluates to is a 32-bit two’s complement signed integer
32-bit specifies that four bytes (one byte is equivalent to eight bits) are used to represent this integer
signed specifies that the integer can represent positive or negative numbers
two’s complement specifies a particular method for representing negative numbers (not important for this discussion)
An integer can represent 2^N different numbers where N is the number of bits in that integer. So this integer can represent 2^32 or 4,294,967,296 different numbers. Because the integer can represent positive or negative numbers, we’ll center that range around zero so our 32-bit signed integer can ultimately represent numbers from -2,147,483,648 to 2,147,483,647
This 32-bit integer has a range that is likely sufficient for most use cases but there may be cases where you want more control over the specific kind of integer you use. A few of the most common reasons are:
You may need to count very large numbers. Maybe you are counting nanoseconds or some other high frequency event; it is feasible that you may need to represent numbers above 2 billion, in which case you may need a 64-bit integer.
You may only need to work with small numbers and you can save memory by using a smaller integer. Maybe you are recording external audio into a buffer in your system for processing/playback. MEAP’s audio input is 16-bit so we know that any numbers coming into our system will never exceed 16-bits. If you store that data in a buffer of 32-bit integers, you are essentially leaving half of the buffer unused, wasting a lot of memory. By storing this data in a 16-bit buffer instead, you can record twice as much audio! If you wanted to you could also reduce the bit-rate of the audio to 8 bits, store it in an 8-bit integer buffer and increase your maximum recording time by another factor of two!
Similarly, if you know you are only going to be working with positive numbers, you can use an unsigned integer and move the whole 2^N range of your integer into the positive range.
So, with these two parameters of number of bytes and signed vs unsigned, we have a number of different integer datatypes available to us
int8_t: signed 8-bit integer (-128 to 127)
uint8_t: unsigned 8-bit integer (0 to 255)
int16_t: signed 16-bit integer (-32,768 to 32,767)
uint16_t: unsigned 16-bit integer (0 to 65,535)
int32_t: signed 32-bit integer (-2,147,483,648 to 2,147,483,647)
uint32_t: unsigned 32-bit integer (0 to 4,294,967,296)
int64_t: signed 64-bit integer (-9,223,372,036,854,775,808 to 9,223,372,036,854,775,807)
uint64_t: unsigned 64-bit integer (0 to 18,446,744,073,709,551,616)
You may also see these datatypes referred to as char (8-bit), short (16-bit), int (32-bit) and long (64-bit) which are functionally identical, however many microcontroller programmers prefer the above syntax because it leaves no room for ambiguity. You may see these used throughout MEAP documentation; don’t be scared they are all just different kinds of integers!
Decimal numbers
Float point numbers (floats and doubles) are the most common method of representing decimal numbers nowadays, however there are other options. Many microcontrollers do not have dedicated hardware for floating point arithmetic (called an FPU), making their use prohibitively slow. Programmers using these microcontrollers often opt to instead used fixed-point numbers. The ESP32 does have an FPU so it is totally fine for us to use floats (though be aware that floating point division is a slow operation and should be avoided if possible), but the Mozzi library is designed with minimal float usage so you may come across fixed-point numbers if you look through its source code.
======== FIXED-POINT NUMBERS ========
Uses a fixed number of bits to represent the integer portion of a number and a fixed number of bits to represent the decimal portion of the number.
Possible and fast on all microcontrollers because it just uses integer arithmetic.
More complicated to program
If you know the range of the numbers you need to represent, fixed-point numbers can have higher precision than floating-point
======== FLOATING-POINT NUMBERS ========
Represents numbers in an exponential format with a fixed number of bits representing the significand and a fixed number of bits representing the exponent
Slow on microcontrollers that don’t have an FPU
Easy to program
Good general-purpose decimal format. Has decent precision, though there can be strange unexpected precision problems such as common numbers such as 0.1 not having exact representations as floats. Also be aware that floats have good precision for small numbers (magnitude less than 1) but the percision drops off exponentially with higher magnitude numbers.
Converting between data types (casting)
Many functions require a specific datatype as an argument so it can be useful to understand how to convert between datatypes. Luckily C++ takes care of most of the hard stuff for us using a process called casting. With casting we can specify a desired datatype next to a variable that contains some other kind of data and if there is a valid conversion it will be converted for us. An example follows:
float my_float = 23.56;
int my_integer = (int)my_float; // my_float's value (23.56) is converted to an integer, giving a value of 23Note that when a float is converted to an integer, the decimal portion is truncated down to 23 rather than being rounded up to 24.
In many cases, this conversion will be implicitly handled for us. The following code will function just the same:
float my_float = 23.56;
int my_integer = my_float; // the program will see that you are trying to store a float in an int and implicitly perform the same castYou may come across cases where this conversion is not done implicitly and you get either a compiler error or code that doesn’t work how you expect. If this is the case you may need to explicitly perform the cast. A good example is if you are dividing one integer by another and expecting a floating point result.
int a = 7;
int b = 2;
float c = a / b; // integer division is performed and decimal portion is thrown away giving a result of 3
float d = (float)a / (float) b; // operands are cast to float before division, ensuring floating point division is used , giving a result of 3.5Data types for representing digital audio
An essential part of working with audio from a technical standpoint is determining how the audio will be represented: as grooves in a vinyl disk, varying magnetic fields impressed on the magnetic tape within a cassette, voltage varying withing a specific range within an analog mixing console, or as some sort of digital number. There are no hard rules for what kind of digital numbers are used to represent digital audio, but there are some conventions.
The overwhelming majority of digital audio programs will represent audio as some sort of a floating point number while you are modifying it. Within this format, the audio resides between -1 and 1. While this may seem like we are only using a very small amount of the range of a float, remember that floats are very precise for numbers less than 1, in fact using the remaining bits wouldn’t allow us to store much extra information at all without losing some degree of fidelity. A standard single precision float has 23 bits (or 138 dB) of resolution in this range. Floats are great both because of this resolution and because multiplying two numbers less than 1 will always result in another number less than 1. In other words, multiplication of two signals or a signal and an envelope (assuming they are all in range -1 to 1) will never cause overflow or an increase in volume.
Not all digital audio is stored as floats though. You may know that audio on standard CDs is stored as 16-bit 44.1kHz audio. Rather than being stored as floats, the audio is stored as 16-bit integers. This is okay in this case because the audio is not being modified so we don’t need to worry about overflow happening and is is assumed that the audio is mixed and master in a way such that the 96 dB dynamic range of 16-bit audio is sufficient (which it almost always is).
Another place where audio is represented using integers is in DACs and ADCs. When audio is coming in and out of a computer, this process is typically handled using hardware that deals with discrete bits, so a floating point representation would be useless when dealing with this hardware.
The Mozzi library was designed to make sophisticated audio generation possible on the Arduino UNO, which uses a microcontroller that cannot quickly work with floats. Their solution was to always represent audio as integers: not just at the input and output, but also while we are working with it. This allows the audio to be generated and processed very quickly, but does leave us with a few dangers we need to work with. Integer audio may at first seem illogical (especially on the ESP32 which could use floating point audio) but I find that it has a charm and leads you to a more pure computational experience with few issues once you get the hang of it.
Remember the primary benefit of floats that we lose when using integers to represent audio:
- When using integers, all almost audio and envelopes are represented as numbers greater than 1 so when multiplying signals and envelopes together, we end up with a larger number with the possibility of overflow.
The main takeaway here is that bit-rate and volume are inherently tied in our system.
The output DAC of the MEAP system deals in 16-bit signed integers (numbers between -32768 and 32767). Suppose we have a full volume 16-bit sine wave and an 8-bit envelope. The sine wave will vary between -32768 and 32767 and the envelope will vary between 0 and 255. If we multiply these numbers together, we get a resulting enveloped waveform with numbers greater in magnitude than 32768, meaning we have overflowed the range of the output and if we send this signal to the DAC as is, we will get digital clipping distortion.
Whenever integer signals/envelopes/etc are multiplied together, we need to compensate for the increase in volume if we want the signal to remain at the same level
Let’s think about this in terms of bits. We had a 16-bit signal and we multiplied it by an 8-bit signal. The resulting signal will be at a level that is the addition of their bit-rates; in this case 24 bits.
If we want our resulting signal to return back to a 16-bit level, we need to make it 8 bits quieter. There are two ways to do this.
First we could realize that for every increase of a bit, a number doubles in magnitude. (a 1-bit number can represent numbers up to 1, a 2-bit number up to 2, a 3-bit number up to 4, a 4-bit number up to 8, etc.). So if we want to make a signal 8 bits quieter, we can divide it by 2 eight times.
int32_t sine_signal = my_sine.next(); // a 16-bit sine wave
int32_t envelope = my_env.next(); // an 8-bit envelope
int32_t enveloped_sine = sine_signal * envelope; // 24-bit enveloped signal
int32_t output = enveloped signal / 2 / 2 / 2 / 2 / 2 / 2 / 2 / 2; // attenuate enveloped signal back to 16-bitsThe syntax for this is of course very awkward, so we can instead accomplish this using bit-shift operations.
int32_t sine_signal = my_sine.next(); // a 16-bit sine wave
int32_t envelope = my_env.next(); // an 8-bit envelope
int32_t enveloped_sine = sine_signal * envelope; // 24-bit enveloped signal
int32_t output = enveloped signal >> 8; // attenuate enveloped signal back to 16-bitsThe down shift operator >> N shifts a signal down by N bits, which is equivalent to dividing it by two N times.
There also exists an up shift operator << N, which shifts a signal up by N bits, equivalent to multiplying it by two N times.
The shift operators allow us to easily and predictably adjust volume of signals in muliples/divisions of 2.
This can also be useful for adding two signals together. Suppose we have two full volume 16-bit sine waves that we want to add together. This will overflow the 16-bit limit of our audio format, resulting in a 17-bit number (a doubling from 16-bits). It is trivial to knock this back down to 16 bits with a 1-bit downshift operation, resulting in another full-volume signal. To do this kind of multiplication in a floating point format would require calculating what decimal number corresponds to a -6dB volume change; a number which won’t be nearly as nice or easy to memorize as “>> 1”
Templates and Constructors
One of the most “C++”-y features commonly used by MEAP and Mozzi is templates (or more specifically template meta-programming).
Templates are one of two ways that we can define the initial functionality of objects, along with constructors. Here we will discuss both, starting with constructors, which are more common.
Constructors
A constructor is a function that is run when an instance of a class (called an object) is created. They are typically used to initialize the object and give it starting functionality by giving values to uninitialized variables or performing some preliminary calculations. The constructor runs just once at runtime and for globally scoped classes (which is the majority of what we tend to use in MEAP) this occurs right after the device is powered on or reset.
For example, the constructor for mDigitalDelay (which is a recirculating delay effect) is defined as follows:
mDigitalDelay(float delay, int32_t max_delay, float feedback, float mix)This lets us set set some initial parameters for the object like the dry/wet mix of the effect and the amount of feedback of the effect. Most of these things can be changed later our choices generally do not alter the core functionality of the class.
Constructors are typically called using the following syntax when an object is defined
mDigitalDelay my_delay(12000, 30000, 0.6, 0.4);Templates
Templates allow us to configure an object prior to runtime (ie during compilation). Templated classes (such as mDigitalDelay) can be configured to create a variety of differnt versions of said class, with different compiled source. Hence the class is a “template” from which other classes emerge. The simplest and most common use of templates is for changing some data type within a class. Maybe you want the class to retain most of the same functionality but be able to deal with a few different kinds of data as inputs or outputs.
Let’s again look at mDigitalDelay as an example, this time looking at its template:
template <class T>In this template, we have one parameter we can set: T, which is of type class. As explained in the documentation, this lets us choose the data type of the delay line used within the effect. This could let us easily create a version of the class that deals with 16-bit audio and another version that deals with 32-bit audio.
mDigitalDelay<int16_t> my_16bit_delay(12000, 30000, 0.6, 0.4); // delay effect with 16-bit delay lines
mDigitalDelay<int32_t> my_32bit_delay(12000, 30000, 0.6, 0.4); // delay effect with 32-bit delay linesIf you look up C++ templates online or in a textbook, the discussion will likely end with this use case (setting data types within a class), but the sick and twisted among you may discover a thing called template meta-programming (which unto itself is a turing complete language built within C++). The idea is that you can template things other than data-types to create versions of classes that are drastically different in functionality.
An example of this is the mSample class whose template is defined as follows:
template <uint64_t NUM_TABLE_CELLS, uint32_t UPDATE_RATE, T int8_t, MEAP_INTERPOLATION INTERP>NUM_TABLE_CELLS and UPDATE_RATE are used as internal constants to change how the sample is moved through and looped.
T is used as a data type configuration that allows the mSample class to deal with audio stored in different formats.
INTERP is used to set the function to use either linear interpolation or no interpolation, which will drastically change the executed code in the next() function
Ultimately, template meta-programming is used to increase the efficiency of code. You can eliminate conditional logic that would otherwise be used to support classes with several different functionalities embedded, or to precalculate values at compile time which can then be used throughout your code as if they were #defined constants.
As the casual MEAP user, you don’t really need to know how this is all working behind the scenes, just know that when you create a MEAP or Mozzi object you may need to configure it through both the template and constructor.
Pointers
Pointers in C/C++ have an undeserved reputation for being confusing, yet are essential for some low level programming operations.
A computer’s memory is as a linear sequence of bytes, each having its own address, starting at 0 and increasing until you reach the last byte of memory. When a variable is created in a program, the computer designates a specific point in memory for it (either one byte or a sequential series of several bytes). This byte has an address that is known to the computer, but that you as the programmer don’t always need to know.
Let’s look at an example of creating a variable
int8_t my_variable = 40; // my_variable is stored in a specific byte of memory, for example 0x00000005The computer allocates one byte in memory for our variable, in this case at memory address 5 (displayed in hexadecimal format above)
Whenever our code refers to my_variable, the computer knows that it needs to go look at memory address 5 and either retrieve or store data there depending on what the code does.
Pointers let us take one step into cyborg transformation and act a little bit more like a computer. Just like the computer, we can also refer to this data using its memory address rather than the nice human-friendly variable name. There are a few tools that help us along this transformation.
- The first is the pointer. A pointer is a variable that holds a memory address within it, typically pointing to another variable. When you declare a variable, you can specify that it is a pointer with an asterisk. When you declare a pointer, you typically also specify the data type of the variable it will be pointing at.
int8_t *my_pointer; // declares a pointer that will point to an int8_t- The next is the address operator; the ampersand (&). The address operator will return the memory address of the variable that follows it.
int8_t my_variable = 40; // stored at memory address 0x00000005
int8_t *my_pointer = &my_variable; // my_pointer now contains the value "5" which is the address of my_variableNow we’re halfway to working with variables like a computer. We can find the variable, but how can we do something with it?
- Our final tool is the dereference operator, which uses the same asterisk symbol we used when creating our pointer. Dereferencing a pointer basically specifies that you want to follow its path to the memory location it points to and do something with it.
int8_t my_variable = 40; // stored at memory address 0x00000005
int8_t *my_pointer = &my_variable; // my_pointer now contains the value "5" which is the address of my_variable
int8_t retrieved_data = *my_pointer; // retrieves data from memory location 5, ultimately storing the value "40" in retrieved_data
*my_pointer = 50; // stores the value "50" in my_variable, overwriting the previous value "40"Pointers and arrays
It is probably not obvious yet why pointers are useful. They have a variety of odd uses you will come across, but one of the most common uses and the one that you will come across the most often is with arrays.
It turns out that all arrays in C/C++ are essentially implemented as pointers.
An array is a sequence of several pieces of data referenced by a single variable. The variable itself doesn’t contain all of these pieces of data, but a pointer to the firstelement of the array.
int8_t my_array[4] = {3, 9, 15, -3}; // allocates 4 consecutive pieces of memory and stores the address of the first one in the variable my_array
int8_t *array_ptr = my_array; // memory address of array can be stored directly in a pointer without using & operator
int8_t array_value = my_array[1]; // retrieve value at index 1 of array (9)
int8_t pointer_value = *(array_ptr + 1); // dereference pointer to start of array and retrieve data from next memory address (also 9)This process can be used to access/modify arrays in interesting/convenient ways using the process demonstrated above *(array_ptr + N) which is called pointer arithemetic, which I won’t go into here.
The main takeaway of this is that you may see arrays being referred to using pointers rather than the square brackets you may be more familiar with; know that they are functionally similar. Referring to arrays using pointers is primarily convenient for passing the array around to different functions. Take the following example of initializing an mFIR. In C++, there is no way to pass an array directly to a function without knowing how many elements are going to be in it, so we use pointers instead and manually tell the constructor how many coefficients the filter will have.
float filter_coefficients[7] = {0.9, 0.34, 0.1, 1.2, 0.1, 0.1, 0.05}; // random coefficients, don't expect these to do anything useful
float *coefficient_pointer = filter_coefficients;
mFIR<7> my_filter(coefficient_pointer); // specify number of filter coefficients in templates and a pointer to those coefficients in constructor
mFIR<7> second_filter(filter_coefficients); // remember that arrays are already contain memory addresses to we don't need to explicitly convert to a pointer first