Part 2: Simulate a String Instrument

Your first step in building the Tone Matrix is to implement a StringInstrument type that performs a simplified physical simulation of a plucked string instrument. We’ll begin with a quick overview of how computers handle sound, then discuss what you need to do.

Overview: Sound Waves and Digital Sound

Sound is a type of wave that moves through the air. The characteristics of those waves determine what you hear. A full description of sound perception is beyond the scope of this course, so for the purposes of this assignment we’ll focus on three attributes of sound waves. Those attributes are the amplitude of the wave (how intensely the wave oscillates between high and low), the frequency of the wave (how many times the wave changes from high to low in one second), and the shape of the wave (the pattern by which the wave switches from high to low).

Below is an interactive demo that lets you change these three aspects of a sound wave so you can hear how those changes are perceived. The plot you see below shows a visual representation of the waveform.


Wave Shape:

Here’s a brief summary of how amplitude, frequency, and shape influence your perception of the sound:

  • The amplitude of the wave changes the volume of the sound. If you increase the amplitude, you will hear a louder sound. If you decrease the amplitude, you will hear a quieter sound.

  • The frequency of the wave changes the pitch of the sound. If you increase the frequency, you hear a higher pitch. If you decrease the frequency, you hear a lower pitch. Frequencies are typically measured in hertz (Hz). The human ear is not equally sensitive to all frequencies; two waves of equal amplitude but different frequency may not have the same volume. If you drag the frequency slider above, you may notice the volume changing even though the amplitude is constant.

  • The shape of the wave changes the timbre or “character” of the sound. If you change the shape of a wave, you’ll hear the same note, but its “style” will seem different.

Your computer generates sound by determining the shape of the waveform to play, then sending that waveform to the output device (your computer’s speakers, your headphones, etc.). The output device then creates the waveform by causing a speaker to vibrate.

There are many ways a computer could in principle store the shape of the wave. The most common method is called sampling: rather than storing the full sound wave, the computer stores the height of the wave at various evenly-spaced points in time. Here’s a visual of what this might look like:

A wave with various samples taken at evenly-spaced intervals

Each stored height is called a sample, and typically samples are real numbers in the range from -1 to +1. The more samples the computer takes, the better it’s able to reproduce the sound wave. It’s common for the computer to store 44,100 samples of a wave per second. The exact number of samples taken per second is called the sampling rate and is typically measured in hertz.

To summarize, the computer treats a sound wave as an array of real numbers from -1 to +1, where each real number gives information about the intensity of the sound wave at a given point in time. By changing what those real numbers are, the computer can change the shape of the waveform sent to the speakers. By changing the wave’s amplitude, frequency, and shape, the computer can change what sound you ultimately perceive.

For the purposes of this assignment, we will represent audio samples using the Sample type. This type works exactly like the double type - you can add two Samples, compare two Samples against one another for equality, etc. We use Sample rather than double for two reasons. First, many real-world sound processing libraries (for example, the Synthesis Toolkit originally developed at Stanford) use custom types to represent samples to provide better compatibility across hardware devices and operating systems. Second, using Sample rather than double allows us to interface with SimpleTest’s memory diagnostics framework, making it easier to spot memory leaks when writing tests.

Overview: The StringInstrument Type

Your first task in this assignment is to implement a simulation of a plucked string instrument.1 Specifically, you will implement the class StringInstrument, which is defined as follows:

class StringInstrument {
public:
    /* Constructs a string that vibrates at the given frequency. */
    StringInstrument(double frequency);
    
    /* Cleans up all memory allocated by this object. */
    ~StringInstrument();

    /* Simulates plucking the string. */
    void pluck();
    
    /* Returns the next sample in the sound wave corresponding to the
     * string vibrating.
     */
    Sample nextSample();

private:
    /* The waveform of the vibrating string, described below. */
    Sample* _waveform;
    
    /* How long that waveform is, described below. */
    int _length;
    
    /* Position of the next sample in the waveform, described below. */
    int _cursor;
};

When you create a StringInstrument, you specify the frequency in hertz at which the string vibrates. (As a reminder, the frequency controls the pitch of the sound.) The string initially is at rest and does not vibrate. Calling the pluck() member function simulates plucking the string and causing it to vibrate. Repeatedly calling nextSample() retrieve samples of the resulting sound wave, which can be sent to the computer speakers or stored for further processing. Here is an example of how a client of StringInstrument might use it:

StringInstrument guitarString(440.0); // 440Hz is concert A
guitarString.pluck();

/* Generate 10,000 samples of the sound wave from the vibrating string. */
for (int i = 0; i < 10000; i++) {
    Sample sample = guitarString.nextSample();
    // do something with the sound sample
}

Here is how this type works internally. The constructor creates an array of Samples whose size depends on the frequency. Specifically, the array has size AudioSystem::sampleRate() / frequency, where AudioSystem::sampleRate() denotes the computer’s sampling rate. Note that increasing the frequency decreases the number of array elements, while decreasing the frequency increases the number of array elements. Each Samples in the array must be be set to 0.

When the string is plucked by a call to pluck(), we change the samples stored in the array. Specifically, we set the first half of the array elements to +0.05 and the back half of the array elements to -0.05. (Those of you with a background in signal processing might recognize this as a square wave). 2

Finally, let’s describe how this array gives rise to the sound samples. We imagine a “cursor” that initially points at the first element of the array. To generate a sound sample, we do the following:

  • Make a note of the array element pointed at by the cursor. This will be our resulting sound sample.

  • Compute the average of this array element and the next array element. (If we’re at the end of the array, wrap back around to the start to get the next element.) Then, multiply that average by 0.995. 3

  • Replace the array element pointed at by the cursor with this new value.

  • Move the cursor forward one position, wrapping around if necessary.

Surprisingly, that’s all that’s needed to simulate a plucked string instrument! This approach is called the Karplus-Strong algorithm. If you’re interested in a detailed analysis of why this produces a good approximation of the sound of a vibrating string, check the linked article.

Milestone 1: Confirm Your Understanding

Before you proceed to write any code, answer each of the following questions. These questions are designed to ensure you understand the basics of the algorithm so that it’s easier to put the code together.

Milestone 1 Requirements
Write your answers to each of the following questions in short_answer.txt.

Question 5

Suppose the sample rate is 8,000Hz and we want to simulate a string that vibrates at 1,000Hz. Tell us what values will be in the waveform array when the string is first created and when it is first plucked.

Question 6

Now suppose you call nextSample four times on the string after it has been plucked. Tell us what values will be in the waveform array.

Question 7 Suppose instead of updating the waveform buffer with values +0.05 and -0.05 when the string is plucked, we fill the buffer with values +0.25 and -0.25. Does this change the amplitude, frequency, or shape of the wave? Based on your answer, what do you expect you would hear differently when listening to the sound of this string?

Milestone 2: Implement the Constructor and Destructor

Your first task is to implement the constructor StringInstrument::StringInstrument and destructor StringInstrument::~StringInstrument. The constructor initializes the _waveform array as described above and writes down its length for later (remember: arrays in C++ don’t know their own lengths). It also sets the cursor to position 0, at the start of the buffer.

As a reminder, the length of the array is AudioSystem::sampleRate() / frequency. This value is not necessarily an integer, and you should use the default C++ behavior of simply rounding down. An edge case to watch for: call error() if the frequency is zero or negative, or if the resulting array would have size 0 or 1.

The destructor frees all memory allocated by the StringInstrument.

We have provided some test cases to help you check whether your implementations are correct. However, these test cases are not exhaustive, and you will need to provide some test cases of your own.

To summarize, here’s what you need to do:

Milestone 2 Requirements

  1. Implement the StringInstrument constructor and destructor in StringInstrument.cpp.

  2. Add at least one STUDENT_TEST, and ideally more, to StringInstrument.cpp. Then, use your tests and our provided tests to validate that your code works correctly. You should pass all tests for Milestone 2 but fail the remaining tests.

Some notes on this problem:

  • You must not use any container types (e.g. Vector, Grid, string, etc.) when coding up these functions. More generally, these types are unavailable to you throughout this assignment.

  • Remember that when creating an array of doubles, each of those doubles will be uninitialized and hold garbage values. Those values may be zero, or they may not be.

  • If your code causes a memory error (for example, reading from an uninitialized pointer, walking off the end of an array or before the start of an array, deallocating an unallocated pointer, deallocating the same array twice, etc.), it can cause the entire program to crash even if you’ve clicked “Run Tests.” If that happens, run the program in debug mode. The debugger will automatically engage at the point where the program crashed, even if you haven’t set any breakpoints, which lets you “go to the scene of the crime” to determine what happened.

  • Our test cases make use of a function AudioSystem::setSampleRate() to change the sample rate. This makes it possible to control how many items should be in the _waveform array. Feel free to use this function when writing your own test cases. However, don’t use this function in your implementation of the StringInstrument class itself.

Milestone 3: Implement pluck()

Your next task is to implement StringInstrument::pluck(). As a reminder, this fills the first half of the array to +0.05 and the second half to -0.05. If the array length is odd, you can set the middle element to either +0.05 or -0.05, your choice.

In addition to updating the array contents, the pluck() function also resets the cursor to position 0. This is not strictly necessary from a simulation perspective but greatly simplifies debugging later on in the assignment.

As before, you will need to write at least one test case for this function.

To summarize, here’s what you need to do:

Milestone 3 Requirements

  1. Implement the pluck() function in StringInstrument.cpp.

  2. Add at least one STUDENT_TEST, and ideally more, to StringInstrument.cpp. Use your tests and our provided tests to validate that your code works correctly. You should pass all tests for Milestones 2 and 3 but fail the remaining tests.

Some notes on this problem:

  • As a reminder, you must not use any container types (e.g. Vector, Grid, string, etc.) when coding up these functions.

  • If your code causes a memory error (for example, reading from an uninitialized pointer, walking off the end of an array or before the start of an array, deallocating an unallocated pointer, deallocating the same array twice, etc.), it can cause the entire program to crash even if you’ve clicked “Run Tests.” If that happens, run the program in debug mode. The debugger will automatically engage at the point where the program crashed, even if you haven’t set any breakpoints, which lets you “go to the scene of the crime” to determine what happened.

  • You should not allocate or deallocate any arrays in the course of coding up this function. Instead, simply take the existing array and replace its values with these new ones.

  • Feel free to make use of AudioSystem::setSampleRate() to simplify the math in your test cases.

Milestone 4: Implement nextSample()

Your final task for StringInstrument is to implement StringInstrument::nextSample(). This function returns the next sound sample and updates the _waveform buffer and cursor position as described above.

As a note: if the user of StringInstrument calls nextSample() before pluck() has been called, the _waveform buffer will be filled with 0s. You should still perform the normal calculations and move the cursor forward in this case. This is actually a convenient feature to have: it means that if you ask for the sound of a string that hasn’t been plucked, you get a wave of all 0s, which has zero amplitude and thus zero sound.

As before, you will need to write at least one test case for this function.

To summarize, here’s what you need to do:

Milestone 4 Requirements

  1. Implement the nextSample() function in StringInstrument.cpp.

  2. Add at least one STUDENT_TEST, and ideally more, to StringInstrument.cpp. Run your tests and the provided tests to validate that your code works correctly. At this point you should pass all tests for the StringInstrument type.

Some notes on this problem:

  • You must not use any container types (e.g. Vector, Grid, string, etc.) when coding up these functions.

  • If your code causes a memory error (for example, reading from an uninitialized pointer, walking off the end of an array or before the start of an array, deallocating an unallocated pointer, deallocating the same array twice, etc.), it can cause the entire program to crash even if you’ve clicked “Run Tests.” If that happens, run the program in debug mode. The debugger will automatically engage at the point where the program crashed, even if you haven’t set any breakpoints, which lets you “go to the scene of the crime” to determine what happened.

  • You should not allocate or deallocate any arrays in the course of coding up this function.

  • Feel free to use AudioSystem::setSampleRate() to simplify the logic in your test cases.

(Optional) Milestone 5: Listen to Your Creation

Now that you have a working StringInstrument type, take a minute to listen to what it sounds like! Run the provided starter program and choose the “String Instrument” option from the top-level menu. You can then use the keys on your keyboard to play different notes. Each time you press a key, it calls pluck() on an appropriately-initialized StringInstrument. This demo is set up so that the values returned by nextSample() on the strings are added together and sent to the computer speakers, which gives the net sound across all the vibrating strings. Pretty neat, right?

Footnotes


  1. Examples of plucked string instruments are the guitar, the oud, the sarod, the qanun, the harp, etc. Contrast this with bowed string instruments like the violin, cello, or igil. ↩︎

  2. We arrived at the values +0.05 and -0.05 here through experimentation with the finished Tone Matrix. Those values are totally arbitrary. Also, in the original version of this algorithm, the array would be filled with white noise, random values from -0.05 to +0.05. When prototyping this assignment, we experimented with a number of different wave shapes and found that for our purposes the square wave was more aesthetically pleasing. ↩︎

  3. The value 0.995 here is arbitrary and arrived at by trial-and error. Increasing this value causes the amplitude of the wave to decay more slowly over time, resulting in a longer, ringing note. Decreasing this value cases the amplitude of the way to decay more quickly, resulting in less resonance and more of a “plunking” sound. ↩︎