Due Friday, November 8 at 11:59 pm Pacific
- Having trouble meeting the deadline? Read about our course's late policy.
- In this course, we express all date/times in Pacific time GMT -7. Our Paperless submission system also displays/records due dates and submission times in Pacific time.
- This assignment is to be completed individually. Working in pairs/groups is not permitted.
Written by Keith Schwarz. Inspired by André Michelle's amazing Tone Matrix. The idea to use the Karplus-Strong algorithm comes from Kevin Wayne's "Guitar Heroine" Nifty Assignment. Thanks to Ge Wang for his advice and guidance and to Javokhir Arifov and Sebastian Ingino for feedback on early drafts. Debugging exercise adapted from one initially written by Julie Zelenski.
In this assignment, you'll build a digital instrument, the Tone Matrix, controlled with a simple visual interface. The Tone Matrix is a 16 × 16 grid of lights, each of which is initially turned off. The user can turn lights on and off by clicking and dragging across the grid.
Each light in the grid represents a musical note at a specific point in time. Lights further to the left are played before lights further to the right. The row of a light determines which note is played: lights toward the bottom of the Tone Matrix have a lower pitch than lights toward the top. All lights on the same row play the same note and correspond to playing that note at different points in time. If multiple lights are turned on in the same column, they will be played simultaneously when their column is played.
Here's a video demonstrating the Tone Matrix in action:
Over the course of this assignment, you will implement all the parts of the Tone Matrix - the code to generate the sounds, the code that interfaces with the mouse, and the code that draws everything on the screen. In doing so, you will gain experience implementing classes and working with dynamic memory allocation.
This assignment has three parts:
-
Debugging Warmup: This warm-up debugging exercise is designed to get you comfortable exploring arrays in memory and seeing what lies beyond them.
-
String Simulation: Implement a simulation of a string instrument that generates the sounds used by the Tone Matrix.
-
Tone Matrix: Respond to input from the user, plus requests from your computer's sound hardware, to build and display the overall Tone Matrix.
As usual, we suggest making slow and steady progress. Here’s our recommended timetable:
-
Aim to complete the Debugging Warmup the day this assignment goes out.
-
Aim to complete String Simulation within three days of this assignment going out.
-
Aim to complete Tone Matrix within seven days of this assignment going out.
Starter Files
We provide a ZIP of the starter project. Download the zip, extract the files, and double-click the .pro
file to open the project in Qt Creator.
Getting Help
Keep an eye on the Ed forum for an announcement of the Assignment 5 YEAH (YEAH = Your Early Assignment Help) group session where our veteran section leaders will answer your questions and share pro tips. We know it can be daunting to sit down and break the barrier of starting on a substantial programming assignment – come to YEAH for advice and confidence to get you on your way!
We also here to help if you get run into issues along the way! The Ed forum is open 24/7 for general discussion about the assignment, lecture topics, the C++ language, using Qt, and more. Always start by searching first to see if your question has already been asked and answered before making a new post.
To troubleshoot a problem with your specific code, your best bet is to bring it to the LaIR helper hours or office hours.
Part One: Debugging Warmup
This exercise will add a few new debugger tricks to your repertoire that will come in handy for this assignment and into the future.
Debugging Objects
This warmup exercise uses a bouncing ball simulation. The files Ball.h
and Ball.cpp
define a Ball
class that models a ball bouncing in a window. The member variables for a ball include its _x
and _y
position, its velocity on both axes, and the bounding box in which it bounces.
First, look over the code to get acquainted. Each ball is created with an ID number that is used as the label when drawing the ball. The ID number helps identify which ball is which in the drawing.
In Ball.cpp
there is a helper function bounceBalls
that creates a Vector
of many objects and runs an animation loop to repeatedly move and redraw each ball.
Run the program and select the "Bounce Balls" option from the top-level menu. Click the "Start" button at the bottom of the window. A swarm of numbered balls bounce around within the window. Cool! After about 10-15 seconds, the balls disappear.
The ball animation has a bug that sometimes causes balls to bounce incorrectly. If you don't notice the bug, re-run the simulation, and it should show up after no more than a few tries. In this exercise, you will use the debugger to track down the bug.
Run the program again under the debugger. As before, choose "Bounce Balls" from the top-level menu and click "Start" when you're ready. While the balls bounce, arrange the graphics and the debugger windows on your screen so you can see both simultaneously. In the debugger window, set a breakpoint on the line in Ball.cpp
where pause
is called at the bottom of the animation loop. The debugger stops at this breakpoint almost immediately.
Click the Continue button to resume execution, and the program will complete another loop iteration and stop again at the breakpoint. Repeatedly click the Continue button and watch the graphics window as you do this. All of the balls move slightly on each iteration.
Object Member Variables
When stopped at the breakpoint, look at the "Variables" pane in the upper-right of the debugger window. Click the triangle to expand the allBalls
Vector
to see its contents.
Click the triangles to expand the vector elements at indexes [0]
and [1]
. Each element is one Ball
object. The member variables for a Ball
include its x/y position, velocity, ID, and bouncing box. Note that each Ball
has its own copy of the fields. The _x
and _y
of allBalls[0]
are distinct from the _x
and _y
of allBalls[1]
.
Click Continue a few more times, observing the updates to the member variables for allBalls[0]
and allBalls[1]
in the Variables pane and match those updates to the movement of those balls in the graphics window.
Answer this question in ShortAnswers.txt
:
Q1. How do the values of the member variables of allBalls[0]
change from iteration to iteration? Specifically, what happens to the values of _id
, _x
, and _y
?
The Variable this
While the program is stopped in the debugger, click the red dot for the breakpoint on pause to delete it. Click to add a new breakpoint on the first line of the Ball::move
method. Now click Continue. The debugger is now going to stop on each ball move. When stopped at your new breakpoint, look at the variables in the Variables pane. Because the program is stopped inside a C++ member function, there is a special variable called this
that refers to the object currently executing the member function. When stopped inside the function Ball::move
, this
refers to the specific ball that is taking its turn to move. Expand this
to see the member variables of the object. Use this ball's position and ID to locate the corresponding ball in the graphics window.
Click the Continue button to resume. The breakpoint is hit again on the next call to Ball::move
. If you look at the Variables pane, you will see that this
now refers to a different Ball
object who is now taking its turn to move. Click Continue a few more times and note how the value in the Variables pane change. Each you'll see a different this
with its own independent member variables. When both the graphical animation and the debugger making demands on your computer, things can bog down a a bit as you try to step and observe. Be patient!
Bring up the context menu for your breakpoint by right-click or control-click on the red dot and choose Disable breakpoint. Disabling the breakpoint leaves it in place but makes it inactive. It is marked with a dim red dot. Use Continue to resume execution, and the balls should freely animate without stopping.
Debugging in the Context of Randomness
When the program begins, each ball is created with a starting position and velocity; these values are chosen randomly. Click the "Start" button, watch the balls move, then click it again to observe how the initial configuration varies from run to run. Occasionally a ball or two is initially placed in a location that straddles the bottom or right edge of the window. When you let the simulation run, you'll see that these balls don't move properly; they wiggle along the "stuck" edge. This is the bug we want you to diagnose.
Trying to debug a program using a randomized test case is tough. You could try to repeatedly quit and restart until you hit upon the right conditions to trigger the buggy scenario, but this would be tedious and frustrating. A better option is to configure the random number generator to behave deterministically. Our random.h library has a function setRandomSeed(int)
used to configure the random sequence to a fixed starting point. Setting the seed at the beginning of the test case will cause it to use the same sequence of random values every time.
Edit the provided test for the ball animation to setRandomSeed
to your favorite number. You only need to set the seed once, and should do so at the start of your test case, before all calls that generate random values.
Now run, quit, and restart the program a few times and you'll see the same configuration of balls is used on every run. Controlling the random seed allows you to bring order out of chaos!
The reproducible scenario you want for debugging is a starting configuration that has at least one ball stuck on the edge and enough of the stuck ball is visible that you can see its ID number. If using your favorite number as the seed didn't produce a configuration like this, pick a different number for the seed and try again. Once you have a seed that works, you're ready to go under the debugger to investigate further.
Run the program under the debugger. Control-click on the dim red dot for the disabled breakpoint in Ball::move
and choose Enable breakpoint from the pop-up menu. With this breakpoint now active, it stops at every single move for every single ball. Since there are many balls and each moves many times per second, having to stop and continue on each move is slow-going. We would much rather narrow our focus to the ball in trouble. For example, in this case, we know the ID of the stuck ball. Ideally, we want to stop and single step through the turn of the stuck ball, but zip past all the others.
Conditional Breakpoints
The debugger offers just the tool we need: a conditional breakpoint. A condition can be added to a breakpoint to tell the debugger to only stop the program at this breakpoint if a certain condition is met.
Control-click on the breakpoint's red dot and choose Edit breakpoint from the pop-up menu. Look for the text field labeled Condition. The condition is any C++ expression that is valid to be evaluated at the breakpoint location. It can refer to any local variables and parameters. Each time execution reaches the breakpoint location, the condition is re-evaluated, and the debugger stops only if the condition evaluates to true; otherwise, it behaves as if the breakpoint were inactive and just continues on.
Let's try it out! Add a condition on the breakpoint to only stop if _id
is the ID number of the stuck ball in your configuration. The breakpoint in Ball::move
will only stop when executing for the stuck ball. Each Continue should take the program straight through to this particular ball's next move.
Focus on observing what's happening to the stuck ball on each move. Do a couple of rounds with Continue, noting how the values of its member variables are changing. Contrast what is happening for the stuck ball to what you observed earlier for a correctly moving ball.
Answer the following question in ShortAnswers.txt
:
Q2. How do the values of the member variables of the stuck ball change from iteration to iteration? Contrast this to your answer to the previous question.
Instructor's Note: Conditional breakpoints in Qt debugger can be a little flaky, sometimes stopping when it shouldn't or not stopping when it should. I've seen that removing and resetting the breakpoint often straightens it out. If you give it a good try and Qt refuses to cooperate, just note your experience in your answer and move on to next question.
Changing Variables in the Debugger
You have a hunch that moving the stuck ball to a different position would cause it to fix itself. So far, we have used the Variables pane of the debugger only to look at the values of variables. Pro Tip: it is also possible to change a value! Double-click the value for the x
field of the stuck ball and change it to 0. Change its y
to 0 also. These changes forcibly move the stuck ball to position (0, 0)
. The next time this ball is drawn, it will appear in the upper left corner of the graphics window, the location where your changes have teleported the ball.
Answer the following question in ShortAnswers.txt
Q3. After forcing the stuck ball to position (0, 0), does the ball move normally from there or does it stay stuck?
Given the above detective work, do you see what caused the ball to become stuck? What fix is needed to avoid getting into this situation? You may optionally modify the code to fix the bug, though this is not required.
A conditional breakpoint can be a godsend when debugging a program that works correctly almost all the time and only rarely hits a bug. Rather than having to wade through many uninteresting/irrelevant steps, the conditional breakpoint lets you narrow in on exactly and only the situation that needs attention.
In this exercise, you also learned how to set the random seed to make a test case behave deterministically in every run. Writing a test case that generates a large amount of random data to use as input can be great way to get broad "fuzz" coverage. If you let the computer choose the seed, it's even hitting different combos from run to run. But when trying to track down a test case failure, make use of setRandomSeed
to ensure you are working through reproducible, repeatable behavior.
Debugging Arrays and Memory
The last task is to practice debugging on arrays and memory. Pointers can be a fairly lethal part of the C++ language, as there are many pitfalls you can fall into with harsh consequences. It will take your finest debugging skills to diagnose these woes, so let's do some practicing now.
Changing the Display Format
You've used the Variables pane to see the values of variables and know how to expand an ADT variable to see its contents. Our next Pro Tip is how to view the contents of an array.
In this warmup exercise, you will be working with a small struct called DataPoint
. Its definition is shown below. It declares the type for the label
and priority
fields and packages those two fields together into a new struct type called DataPoint
.
struct DataPoint {
string label;
double priority;
};
In the assignment, you will be working with arrays of DataPoint
elements so let's practice how to use the debugger to examine and manipulate these arrays.
Open the DebuggerWarmup.cpp
file and review the given code and the five provided test cases (four of which are currently commented out). The first test case demonstrates correct use of an array. Set a breakpoint within this test case on the statement after the loop has completed initializing the array elements. Run the program under the debugger and click "Run Tests."
When the program stops at the breakpoint, note how the taskList
variable is displayed in the Variables pane. A C++ array does not track its length, so the debugger does not know how many items to display. Unless told otherwise, the debugger assumes the array length is 1 and displays only the first item. To see additional items beyond the first, you must tell the debugger how many to display.
In the Variables pane, control-click on the variable taskList
to bring up the pop-up menu. From the menu, select "Change Value Display Format," and in the submenu section labeled "Change Display for Object Named local.taskList," select "Array of 10 items." Be sure to choose the entry "Array of 10 items" that appears in the upper section of the popup menu. (The lower section repeats the same entries as the upper section; this lower entry is not the one you want).
The available options for array length are 10, 100, 1000, and 10000. If your array contains 50 items, you must choose whether to display only the first 10 or display 100, where the first 50 are valid followed by an additional 50 garbage items.
Examine Array Memory
Once you have changed the display format for taskList
to an Array of 10 items, the Variables pane will display a triangle next the variable, and if you click to expand, it will display ten DataPoint
structs, indexed by position.
The code allocates memory to store six DataPoint
structs. The loop initializes the first three elements. Examine the values of the elements at indices [0]
, [1]
, and [2]
to see that the contents are as expected.
The subsequent three elements are a valid part of the memory allocated for the array, but the contents of these elements have not been initialized. C++ has set the values according to what it normally does for that type of variable. In particular,
- the strings have been set to the empty string
- the numeric values are uninitialized, leftover garbage values
Use the debugger to view the contents at indices [3]
, [4]
, and [5]
and confirm these default values.
Because we told the debugger to display a total of 10 elements, it also shows slots for indices [6]
through [9]
. These locations are outside the bounds of the allocated memory, but remember there is no such thing as bounds-checking. We told the debugger to display 10 items, so it does. What is being displayed is the contents of memory at those locations, despite being out of bounds. Unsurprisingly, these contents are even trashier than the valid but uninitialized entries. If you examine slots [6]
, [7]
, [8]
, and [9]
, you find all of the number and string values are an unpredictable mix of empty, <not accessible>
, and random garbage.
What you see in the debugger parallels what you would observe in an executing program. The value of a valid variable that has not been initialized is the default value for that variable's data type. If your code accesses an index beyond the valid range of the array, you retrieve garbage contents. There is no helpful "out of bounds" message to alert you to your mistake. The programmer herself must be vigilant!
Debugging Memory Mistakes
The warmup.cpp
file contains test cases to demonstrate various errors in handling array/memory. Whereas ordinary errors may cause a program to compute the wrong answer, mis-draw a fractal, or get stuck in a infinite loop, memory errors tend to have more catastrophic consequences. We want you to observe how some of the commonplace memory errors present themselves, so you can begin to get a feel for how you might diagnose such bugs when you encounter them.
There are four test cases that each demonstrate a particular error in handling array/memory:
- dereferencing a
nullptr
address - accessing an index outside the allocated array bounds
- deleting the same memory twice
- accessing memory after it has been deleted
Study the code in each test case to understand why the code is in error.
These test cases are currently commented out and you should add them into the program one at a time to observe each in isolation. Pro tip: To quickly change whether a section of code is commented in or out, select the lines and use "Toggle Comment Section" under the Edit Menu, bound to the hot key Command
+ /
.
The first test case dereferences a nullptr
address. Uncomment the code in this test case and rebuild. Run the program without the debugger.
Attempting to access a nullptr
address (memory location zero) causes a swift fatal error – the program immediately halts with a pitiful cry for help written in red text to the Console window and Qt "Application Output" tab. This crash report says:
*** STANFORD C++ LIBRARY
*** The PQueue program has terminated unexpectedly (crashed)
*** A segmentation fault (SIGSEGV) occurred during program execution
This error indicates your program attempted to dereference a pointer
to an invalid memory address (possibly out of bounds, deallocated, nullptr, ...)
*** To get more information about a program crash,
*** run your program again under the debugger.
Whenever you encounter a crash, your next move should be to run that same code under the debugger.
When running under the debugger, a crashing program will halt at the critical moment without exiting. This will give you a chance to see where the code was executing at the time of the crash and to poke around and observe the program state. Run this test case under the debugger, and it will stop at the exact line of the bad dereference, allowing you to see that the value of taskList
is 0x0
. (nullptr
is the memory address zero). Note that you may have to click on a different level in the debugger stack trace in order to get back to the DebuggerWarmup.cpp
file.
After you're done exploring a test case, comment it back out. Then uncomment the next test case and try that one.
For each test case, you want to observe what happens without the debugger and again with it. Some memory errors present loud and clear with a distinctive crash; others silently and insidiously do the wrong thing. The very same error can get different results in a different context or when executing on a different operating system. All of this makes debugging memory errors extra challenging.
Consider yourself a detective who is looking through the evidence for clues that help you diagnose the situation from its aftermath. Look in the Application Output tab to see if any message was printed. Read the stack trace in the debugger to learn which action led up to the crash. Review variables to look for null or wild addresses or data values that appear uninitialized or corrupted.
Answer the following question:
Q4. On your system, what is the observed consequence of these memory errors:
- access an index outside the allocated array bounds?
- delete same memory twice?
- access memory after it has been deleted?
Note: There is no "right answer" to these questions as there is no single expected behavior for a given memory error. By trying the error, you will see one possible outcome, but the same code in a different program or running on a different computer could behave differently. The only predictable thing about memory errors is their unpredictability! In answering these questions, you are making observations about how the memory error presents on your system and learning what symptoms to look for should you need to diagnose a similar error in your own program.
Part Two: 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.
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:
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 Sample
s, compare two Sample
s 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 Sample
s 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 Sample
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 One: 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 One Requirements
Write your answers to each of these questions in the file ShortAnswers.txt
.
Q5. 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.
Q6. 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.
Q7. 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 Two: 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 Two Requirements
-
Implement the
StringInstrument
constructor and destructor inStringInstrument.cpp
. -
Add at least one
STUDENT_TEST
, and ideally more, toStringInstrument.cpp
. Then, use your tests and our provided tests to validate that your code works correctly. You should pass all tests for Milestone Two 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. -
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 theStringInstrument
class itself.
Milestone Three: 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 Three Requirements
-
Implement the
pluck()
function inStringInstrument.cpp
. -
Add at least one
STUDENT_TEST
, and ideally more, toStringInstrument.cpp
. Use your tests and our provided tests to validate that your code works correctly. You should pass all tests for Milestone Two and Milestone Three 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 Four: 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 Four Requirements
-
Implement the
nextSample()
function inStringInstrument.cpp
. -
Add at least one
STUDENT_TEST
, and ideally more, toStringInstrument.cpp
. Run the your tests and provided tests to validate that your code works correctly. At this point you should pass all tests for theStringInstrument
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 Five: 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 is added together and sent to the computer speakers, which gives the net sound across all the vibrating strings. Pretty neat, right?
Part Three: Implement the Tone Matrix
You now have a working string instrument simulation. Your next task is to implement the ToneMatrix
type, which will maintain a grid of lights, respond to user input, determine when to pluck each string, and decide what data gets sent to the computer speakers.
Here's how ToneMatrix
is defined:
class ToneMatrix {
public:
/* Creates a new ToneMatrix where each light in the grid has
* dimensions lightSize x lightSize pixels. The tone matrix
* itself always is a 16 x 16 grid of lights.
*/
ToneMatrix(int lightSize);
/* Frees all memory used by this tone matrix. */
~ToneMatrix();
/* Reacts to the mouse being pressed at a given position on
* the screen, toggling the state of the light the mouse
* hovers over.
*/
void mousePressed(int mouseX, int mouseY);
/* Reacts to the mouse being dragged from its previous
* position to position (mouseX, mouseY). The light at the
* new position has its state updated appropriately. Details
* are given later.
*/
void mouseDragged(int mouseX, int mouseY);
/* Draws each light in the Tone Matrix. Details are
* given later.
*/
void draw() const;
/* Advances time forward one step, returning the next sound
* sample to play. Details are given later.
*/
Sample nextSample();
private:
/* How big each light in the tone matrix is. */
int _lightDimension;
/* The 2D grid of lights in the matrix; details are given below. */
bool* _grid;
/* The instruments corresponding to each row. */
StringInstrument* _instruments;
/* Additional data members and member functions of your own choosing;
* you're free to add extra information here if you'd like. Details are
* given below.
*/
};
We've broken the task of implementing this type down into four smaller milestones to be completed in sequence.
Milestone One: Implement Constructor, Destructor, and mousePressed
Your first task is to implement the constructor ToneMatrix::ToneMatrix
, the destructor ToneMatrix::~ToneMatrix
, and the function ToneMatrix::mousePressed
.
As a reminder, the Tone Matrix is a 16 × 16 grid of lights, all initially off. When drawn to the screen, each of those lights renders as a square of some size. The dimensions of each light is specified as an argument to the ToneMatrix
constructor. Store this information in the _lightDimension
data member. You'll need this later to react to the mouse and draw the Tone Matrix on screen. You can assume _lightDimension
is greater than zero and don't need to call error
if this is not the case.
Your constructor also needs to store the state of each of the 16 × 16 = 256 lights. In the past, you'd do this with a Grid<bool>
, where true
indicates a light is on and false
indicates it's off. However, on this assignment, you are doing all your own memory management, so Grid
and other container types are off-limits. Instead, you will store the state of the lights as a standard array of bool
s that you will dynamically allocate and deallocate. The _grid
data member of ToneMatrix
will store the pointer to this array.
You might be wondering how you would represent a two-dimensional grid using a one-dimensional array. This can be done by as follows: the first 16 elements of the array correspond to the first row of the matrix, the next 16 elements correspond to the second row of the matrix, etc. (4). Here's an example of this idea applied to a 3 × 3 grid; we're using a smaller grid simply so it's easier to see what's going on:
There is a nice formula that maps from a row/column position in the 2D grid to an index in the 1D array. Given an n × n grid, the entry in row r and column c is at index nr + c. One way to think about this: increasing or decreasing the column index simply shifts forward or backward one position in the array, while moving the column up or down requires jumping over n consecutive elements.
You will also need to initialize an array of StringInstruments
in the ToneMatrix
constructor. The Tone Matrix plays music by associating each row with a StringInstrument
. The _instruments
data member has type StringInstrument*
, perfect for pointing at an array of instruments. To determine the frequency of the instrument of a given row, you can look up the corresponding entry of the kFrequencies
array defined in ToneMatrix.cpp
.
Now, on to the destructor. As usual, this should simply clean up all memory allocated by your ToneMatrix
type.
Finally, let's talk about mousePressed
. This function is called by the graphics system whenever the mouse button is pressed inside the Tone Matrix. The function's arguments, mouseX
and mouseY
, give the position where the mouse is pressed, relative to the upper-left corner of the Tone Matrix. For example, if the mouse is pressed at the top-left corner of the Tone Matrix, mouseX
and mouseY
will be 0. If mouseX
is 137 and mouseY
is 106, it means that the mouse was pressed 137 pixels to the right of the top-left corner of the matrix and 106 pixels down.
Your mousePressed
function should do the following. First, you should figure out, based on mouseX
and mouseY
, which light in the grid the mouse was pressed on. As a hint, you know how big each cell is (it's a square whose size is _lightDimension
× _lightDimension
), and you should not need to do any complicated math. Next, you should toggle the state of that light: turn the light on if it's off, and turn the light off if it's on.
We have provided you with a battery of test cases you can use to validate your implementation. Because this is the first milestone in working with the ToneMatrix
type, you don't need to write any of your own test cases here, though we think you may find it useful to do so. We suggest you read over our test cases to see how we call the functions of ToneMatrix
to make changes to the grid of lights.
To summarize, here's what you need to do:
Milestone 1 Requirements
-
Implement the
ToneMatrix
constructor, destructor, andmousePressed
functions inToneMatrix.cpp
. -
Use our provided test cases to validate that your code works correctly. You should pass all the tests for Milestone One and fail all the other tests.
Some notes on this problem:
-
You must not use any container types (e.g.
Grid
,Vector
,string
, etc.) when coding up these functions. (More generally, these types are not permitted in this assignment.) -
Remember that arrays of
bool
s in C++ do not have thosebool
s default tofalse
. Rather, their values are unpredictable and based on whatever happened to be in memory at the spot where the array was created. -
Be careful working with row/column coordinates and (x, y) coordinates. In row/column coordinates, the row corresponds to the y coordinate and the column corresponds to the x coordinate. It's easy to get these backwards.
-
You can assume that the mouse is indeed hovering over a light; you don't need to worry about negative x or y coordinates, about the mouse being off the right or below the bottom of the Tone Matrix, etc.
-
You are welcome to add extra helper functions to the
ToneMatrix
type if you would like. However, for this milestone, do not add any new data members. -
Our SimpleTest framework is not capable of detecting memory errors with primitive types. Therefore, double- and triple-check your code to make sure that your destructor frees the array of
bool
s you allocate in the constructor, that you aren't allocating more arrays than you need, etc. Learning to check your code for memory leaks is an essential skill as a C++ programmer.
Milestone Two: Implement mouseDragged
Your next task is to implement ToneMatrix::mouseDragged
. This function is called by the graphics system whenever the mouse is moved over the Tone Matrix with the mouse button down. The mouseDragged
function is only called after an initial mousePressed
is called, and it's assumed that each mouseDragged
is part of a sequence of mouse movements that started with the most recent call to mousePressed
.
Like mousePressed
, mouseDragged
takes in two integers mouseX
and mouseY
indicating where the mouse was dragged within the Tone Matrix, relative to the Tone Matrix's upper-left corner. Like mousePressed
, mouseDragged
updates the state of the light directly under the mouse. However, the way mouseDragged
does this is different. Specifically:
-
If the last time
mousePressed
was called a light was turned on, then the light under the mouse inmouseDragged
turns on. -
If the last time
mousePressed
was called a light was turned off, then the light under the mouse inmouseDragged
turns off.
This has a nice, intuitive feel to it: if the user clicks on a light to turn it on and then drags the mouse around, every light they move the mouse over will turn on. If they click on a light to turn it off and drags the mouse around, then every light they move the mouse over will turn off.
In order to do this, mousePressed
will need to communicate information into mouseDragged
so mouseDragged
knows what to do. The proper way to do this is to add one or more additional data members (fields) to the ToneMatrix
type. The mousePressed
function can then write information to those data members that is then read later by mouseDragged
. We will leave it up to you to decide what information you want to remember this way; there are many good choices.
As part of completing this milestone, you will need to edit functions you have already written in the last milestone. You will need to update mousePressed
, and, depending on your implementation, you might need to update the constructor or destructor. (The two simplest strategies we know of do not require updates to the constructor or destructor, though.)
Because you are changing code you have already written, you may accidentally introduce bugs into previously working code. Such bugs are called regressions. Fortunately, we've provided a lot of test cases for Milestone One, so if your code changes break anything, there is a good chance that those test cases will flag the error. If you start failing tests for Milestone One, investigate and see if you can identify what change you made that caused the regression.
We have provided a few test cases for this milestone, but they're not as extensive as what we gave you for the first milestone. You will therefore need to write some test cases of your own to help check that your code works as intended. Specifically, you should write at least two new test cases: one that ensures mousePressed
updates the internal state of ToneMatrix
appropriately, and one that ensures that mouseDragged
performs as intended. You may want to refer to our provided tests for guidance on how to design such test cases.
To summarize, here's what you need to do:
Milestone 2 Requirements
-
Implement
mouseDragged
inToneMatrix.cpp
. In doing so, you will need to add one or more data members in theprivate
section ofToneMatrix.h
, and you will need to update your implementation ofmousePressed
from the previous milestone. -
Write at least one custom test case for
mousePressed
and at least one custom test case formouseDragged
, and ideally more. Use your tests, plus our provided test cases, to validate that your code works correctly. You should pass all the tests for the first two milestones and fail all tests for the later milestones.
Some notes on this problem:
-
You must not use any container types (e.g.
Grid
,Vector
,string
, etc.) when coding up these functions. -
Be aware of variable shadowing. If you declare a data member inside a
class
and then have a local variable with the same name inside a member function implementation, then the name of that variable will refer to the local variable, not the data member. For example, if you have a data member namednumSmiles
in theprivate
section ofToneMatrix
and declare a local variablenumSmiles
insidemousePressed
, then any code using the namenumSmiles
insidemousePressed
will refer to the local variablenumSmiles
rather than thenumSmiles
in theprivate
section of the class. -
You do not need to handle the case where
mouseDragged
is called beforemousePressed
is. However, your code formouseDragged
should work correctly if there were several different calls tomousePressed
that precede it. In that case, you should always focus on just the last call tomousePressed
. -
You should not need to write that much code inside
mousePressed
. If you find yourself having to significantly overhaulmousePressed
, it may indicate that you are overcomplicating the design. -
Feel free to add additional helper member functions to the
ToneMatrix
type. You may find that there is a good deal of code overlap betweenmousePressed
andmouseDragged
. -
The computer only periodically reads the state of the mouse and calls
mouseDragged
. This means that if the user moves the mouse quickly from one point to another, the mouse may appear to "jump" from the initial point to the destination. This can cause the mouse to skip over lights in the Tone Matrix. You do not need to worry about this; just havemouseDragged
focus on the light directly under the mouse when the function is called.
Milestone Three: Implement draw
Your next task is to implement ToneMatrix::draw()
, which draws the lights of the Tone Matrix. We have provided you with a function
void drawRectangle(const Rectangle& rect, Color color);
that draws the specified rectangle on-screen with the given color. The Rectangle
type is the one you're familiar with from Assigment 3:
struct Rectangle {
int x, y, width, height;
};
ToneMatrix::draw()
computes the rectangular bounding boxes for all the lights in the grid, then calls drawRectangle
passing in those rectangles and the appropriate colors. (We have provided you two color constants, kLightOnColor
and kLightOffColor
, for this purpose.) You should assume the upper-left corner of the Tone Matrix is at position (0, 0) and that each light is a perfect square whose size is _lightDimension
× _lightDimension
.
Because it's a bit tricky to test what rectangles were drawn on screen, we have provided you with test cases for this milestone. You do not need to write your own test cases, but you are welcome to do so if you'd like.
To summarize, here's what you need to do:
Milestone 3 Requirements
-
Implement
draw
inToneMatrix.cpp
. -
Use our provided test cases to validate that your code works correctly.
Some notes on this problem:
-
You must not use any container types (e.g.
Grid
,Vector
,string
, etc.) when coding up this function. -
You should not need to update any functions you've written previously, and you should not need to add any new data members.
-
You are welcome to add new member functions if it would be helpful.
Milestone Four: Implement nextSample()
Your final task is to get the Tone Matrix to actually send sound to the computer speakers.
The hardware inside computer speakers periodically asks the computer for samples to play. In this assignment, the speakers will call the function ToneMatrix::nextSample()
every time they need another sound sample to play. Whatever you return from this function will thus be played on your speakers. (5)
Like StringInstrument::nextSample()
, this function needs to both determine what sound sample gets sent to the speakers and make appropriate updates to the internal state of the Tone Matrix. At a high level, nextSample()
should do the following in the following order:
-
Determine whether it's time to pluck more strings and, if so, pluck the appropriate strings.
-
Add up the samples returned by all 16 strings and send that to the speakers.
We'll begin by discussing when to pluck the strings. As you saw from the constructor, the Tone Matrix maintains an array of StringInstrument
s, one per row of the matrix. Imagine there's an arrow at the bottom of the Tone Matrix, initially pointing at the leftmost column. Most of the time nextSample()
is called, that arrow remains in place. However, on the very first call, and every 8,192nd call after that (6), two things happen.
-
The Tone Matrix plucks strings. Specifically, it looks at the column pointed at by the arrow, finds all rows in that column that are lit up, and plucks the string corresponding to each of those rows.
-
The arrow moves one column to the right, wrapping around back to the start if necessary.
Next, let's talk about how nextSample()
determines its sound sample. After deciding whether to pluck any strings, the Tone Matrix calls nextSample
on each of its StringInstrument
, adds the values together, and returns the sum as the sample to play. (7) Because calling nextSample()
on an unplucked StringInstrument
always returns 0, strings that haven't been plucked will not contribute to the generated sound. Similarly, since vibrating strings quiet over time, strings that haven't been plucked in a long time will contribute negligibly to the overall sound.
To make this work, you will need to add at least one (and possibly more) data members to the ToneMatrix
type. We're going to leave the design up to you; there are many options and you're free to choose whichever one you'd like.
Because you will be adding new data members to ToneMatrix
, you will need to add at least one test case to make sure that the new data members are properly initialized. You will also need to write at least one test case to make sure that nextSample()
keeps track of the progress of time correctly and remembers what the current column is. We have provided some test cases that check whether the correct strings are plucked; the logic to check for this is much more involved and so we'll take care of it for you.
To summarize, here's what you need to do:
Milestone 4 Requirements
-
Implement
nextSample()
inToneMatrix.cpp
. This will require adding data members to theprivate
section ofToneMatrix.h
, and may require you to update your constructor. -
Write at least one custom test case that checks whether your new data members are initialized correctly when a new
ToneMatrix
is constructed. -
Write at least one custom test case that checks whether your implementation correctly keeps track of the current column and how many more calls to
nextSample()
need to elapse before the column advances. -
Use our provided test cases, plus your own custom tests, to validate that your code works correctly.
Some notes on this problem:
-
You must not use any container types (e.g.
Grid
,Vector
,string
, etc.) when coding up this function. -
This is probably the most challenging milestone of the assignment, but the good news is that you do not need to write much code. So if things are starting to seem complicated, pause, back up, and see if you can find an easier approach.
-
Feel free to write as many additional helper functions as you'd like.
(Optional) Milestone Five: Enjoy Your Creation!
Congratulations! You have just created a digital musical instrument from scratch. Everything you see on the screen, every way in which the program responds to the mouse, and all the sound emitting from your speakers is fully your own. You didn't rely on any ADTs in the course of coding this up, and aside from a little bit of glue code to connect ToneMatrix
to C++'s windowing and audio systems, we didn't do any of the heavy lifting for you. This really is your own handiwork!
So take some time to play around with what you made. Can you make any cool tunes or rhythms? Are there any patterns that sound particularly aesthetically pleasing?
(Optional) Part Four: Extensions!
We have barely scratched the surface of what you can do with audio processing. There are entire courses (e.g. Music 220a) dedicated to making music with computers. If you want to go above and beyond what's required here, go for it! Here are some suggestions to help get your creative juices flowing.
-
There are tons of different ways you can synthesize sound. FM synthesis, invented here at Stanford, is a remarkably powerful tool for creating interesting sounds, and is not that tricky to code up. Subtractive synthesis has been used extensively in the music industry since its invention. Envelope generation lets you make sounds that ease in and out naturally.
-
The visualization of the Tone Matrix that we asked you to code up just shows which lights are on and which are off. There's no indicator of which column is the active column, so it's tough to see what sounds are playing. Consider updating how you draw the squares of the Tone Matrix so that it's clearer what's happening. You could do that by highlighting the current column, or you could do something fancy like have each light emit a little wave pulse when it's played.
-
The Tone Matrix consists of an interface (how you play it and what draws on the screen) combined with sound sources. What other interfaces can you come up with? Could you invent new ones that include things like, say, the ability to control the speed / volume, change the shapes of the waves to make different types of sounds, etc.?
-
If you have some music theory training, you might recognize the sounds played by the Tone Matrix as a series of major pentatonic scales. We picked that because these tend to produce pleasing music regardless of which combination they're played in. Could you change the scales so that you get back different melodies with the same patterns of lights? Or could you make it so that each note controls a chord rather than a single note? Or something more creative than this?
-
Are you musically inclined? Record a video of a performance with your Tone Matrix that you think is artistically pleasing and share it with your SL.
-
Can you make the Tone Matrix automatically change the patterns of lights as time progresses? If so, how would you choose which lights turn on and off, at what times do they change, and what is the resulting sound pattern?
As usual, if you want to submit extensions, please submit two separate sets of code: one that meets all the baseline assignment requirements, and one that contains your freeform extensions.
Submission Instructions
Before you call it done, run through our submit checklist to be sure all your t
s are crossed and i
s are dotted. Make sure your code follows our style guide. Then upload your completed files to Paperless for grading.
Please submit only the files you edited; for this assignment, these files will be:
ShortAnswers.txt
StringInstrument.cpp
andStringInstrument.h
. (Don’t forget the header file!)ToneMatrix.cpp
andToneMatrix.h
. (Don’t forget the header file!)
You don't need to submit any of the other files in the project folder.
If you modified any other files that you modified in the course of coding up your solutions, submit those as well. And that’s it! You’re done!
Good luck, and have fun!
(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.
(4) This method of laying out a 2D grid inside a 1D array is called row-major order and is how our Grid
type is implemented.
(5) Here's the full technical lowdown of how this works. Computer speakers internally have a memory buffer, which is essentially just an array like the ones you get with new[]
and delete[]
, holding the next sound sample to play. As the speakers play, they consume what's in this array and generate sound. When the buffer is running out of samples, they call the operating system to request that a new buffer be filled up with samples. The OS in turn notifies your program that it needs to fill up the buffer. We have set up some glue code that fills the buffer by repeatedly calling ToneMatrix::nextSample()
. You can see how this works in AudioDevice.cpp
and ToneMatrixGUI.cpp
if you're curious.
(6) The number 8,192 is purely arbitrary here. We picked it because it's a nice round number (8,192 = 2^{13}) and because with a sampling rate of 44.1kHz, it's approximately four beats per second.
(7) When you add together multiple sound samples, the resulting value can be bigger than +1 or lower than -1. Since $\pm 1$ are the upper limits of the waveforms the speaker can produce, values above +1 get "clipped" to +1 and values below -1 get "clipped" to -1. This is called clipping and distorts the sound wave. Fortunately, you don't need to worry about that in this assignment: there are 16 total strings, and the amplitude of each waveform is 0.05, so the range of possible samples generated this was is -0.8 to +0.8.