Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

PA 1 part 1: Simulating a string instrument

Your first step in building the Tone Matrix is writing a StringInstrument class that runs a simplified physical simulation of a plucked string, like a guitar or a harp. Let’s start with a brief overview of how computers make sound, then dig into the code.

How computers make and process sound

Sound is a “wave” traveling in the air. When a speaker plays a note, a small cone inside it vibrates, rapidly pushing air forward and back to make a pressure wave. That wave travels to your ear, and you hear a sound.

The shape of that wave is what you hear. A computer controls the speaker cone by sending it a rapid sequence of numbers. Each number is the cone’s displacement at that instant: 0.0 means rest, a positive value pushes the cone forward, and a negative value pulls it back. The bigger the number, the farther the cone moves, and the louder the sound.

There are two key audio processing concepts that will occur throughout your implementation: sampling and frequency.

Sampling

A computer has trouble storing a smooth, continuous wave, so it stores snapshots of the wave’s height taken at a steady rate. Each snapshot is called a sample, as pictured below:

Sampling a wave: at each step we read the wave’s height as a number and store it in a box. The row of boxes is what our computer will actually store as an array. This is similar to how video frames are stored on our computers for animations or films: each frame is a snapshot of the video at a particular moment in time.

Sampling a wave: at each step we read the wave’s height as a number and store it in a box. The row of boxes is what our computer will actually store as an array. This is similar to how video frames are stored on our computers for animations or films: each frame is a snapshot of the video at a particular moment in time.

The standard sampling rate for computer audio processing is 44,100 samples every second (you can read more about this here). Since this is a number that will be used throughout your implementation, you’ll see in the starter code that we define it as a constant that cannot be changed using the final keyword. By convention, constants are written in all uppercase letters:

// Constant representing the number of samples per second for the audio sample
private final int SAMPLE_RATE = 44100;

Frequency

A note’s pitch is set by its frequency, which is how many complete back-and-forth cycles the wave makes per second. Higher pitched notes have higher frequencies and have waves that are more “squished” to get more cycles into a shorter amount of time:

The low note fits several samples into each cycle; the high note packs its cycles closer together, so each one gets fewer samples.

The low note fits several samples into each cycle; the high note packs its cycles closer together, so each one gets fewer samples.

Frequency is measured in Hertz (Hz). The note A4 (the A above middle C) has a frequency of 440 Hz, which means it has 440 full cycles every second.

By putting the concept of sampling and frequency together, we can ask: how many samples does one cycle of the wave take? If the speaker is playing the note A4 (440 Hz) and we’re taking 44,100 samples per second, then one cycle spans:

SAMPLE_RATEfrequency=44,100440≈100 samples per cycle\frac{\text{SAMPLE\_RATE}}{\text{frequency}} = \frac{44{,}100}{440} \approx 100 \text{ samples per cycle}

That means to process one cycle of the wave on our computers, we need to store about 100 numbers. Processing a lower note at 100 Hz requires 44,100100=441\frac{44{,}100}{100} = 441 numbers, while a higher note at 880 Hz takes 44,100880≈50\frac{44{,}100}{880} \approx 50 numbers. Higher notes pack more cycles into each second, so each cycle gets fewer samples.

To represent a musical note, we’ll store exactly one cycle of the wave in an array of floats called waveSample. Its length is the number of samples in a single cycle, which is the formula above. If the number of samples is not a whole number, we’ll round down to the nearest whole number by casting the float to an int using the (int) syntax:

// Example of casting a float to an int
int number = (int) 1.5f; 
System.out.println(number); // prints 1, as we round down to the nearest whole number

Set up the StringInstrument class

Let’s set up the class. Below is a snippet of the starter code for the StringInstrument class showing the constants and instance variables (as TODOs) you will use in your implementation:

public class StringInstrument {
    // number of audio samples per second for waveSample array initialization
    private final int SAMPLE_RATE = 44100;

    // decay factor for the Karplus-Strong algorithm in nextSample()
    private final float DECAY_FACTOR = 0.995f;

    // TODO: declare waveSample as array of floats 

    // TODO: declare waveIndex as an integer index

    // rest of class below...
}

In particular, you will need to implement:

[Task] Complete the constructor

Your constructor should set up the instance variables as follows:

Understand and implement sound generation methods

We split the work of generating sound samples of a musical note into three small methods, so let’s understand and implement them one by one.

[Task] pluck() method

The pluck() method shapes the starting wave. When we want to play a note on a plucked string instrument, we pull the string to one side and let go. We model that approximately by filling the first half of waveSample with +0.05 and the second half with -0.05, then setting waveIndex back to 0 so we always start reading from the front. That gives us a simple wave to start from. This method does not return anything, but updates the waveSample array and the waveIndex variable in the following ways:

Note that we’ll see longer arrays with lower frequencies/pitches and shorter arrays with higher frequencies/pitches. This is because the number of samples in a single cycle of the wave is determined by the frequency, and the longer the array, the more samples we need to store.

[Task] nextSample() method

The nextSample() method produces one sample of sound that is designed to mimic a string instrument. A real string doesn’t ring forever; it gradually settles and fades. So, we need some way for the numbers in the waveSample array to decay and get smaller over time.

To accomplish this, we’ll use the Karplus-Strong algorithm, which was developed in the 1970s to simulate the sound of a string instrument. The Karplus-Strong algorithm updates the state of the wave at a given waveIndex by taking the average of the current value and the value at the next index, and then multiplying the result by the DECAY_FACTOR:

new_sample=(current_sample+next_sample)2∗DECAY_FACTOR\text{new\_sample} = \frac{(\text{current\_sample} + \text{next\_sample})}{2} * \text{DECAY\_FACTOR}

The decay factor is a constant less than 1 that controls the rate of decay. The smaller the decay factor, the faster the sound will fade away. In the starter code, the decay factor is set to 0.995f. This new sample is then stored in the waveSample array at the current waveIndex, and waveIndex is incremented by 1.

Surprisingly, this is all that’s needed to simulate a sound that fades away over time. Something to pay attention to in your implementation is that our waveSample array has a fixed length, so when we reach the end of the array, we need to wrap around to the beginning for the next sample.

Two examples of the Karplus-Strong algorithm update on an example array. Each cell becomes the average of itself and its neighbor, then shrinks by the DECAY_FACTOR. On the left, the neighbor is simply the
next cell over. On the right, the last cell wraps around, so its neighbor is the first cell (index 0).

Two examples of the Karplus-Strong algorithm update on an example array. Each cell becomes the average of itself and its neighbor, then shrinks by the DECAY_FACTOR. On the left, the neighbor is simply the next cell over. On the right, the last cell wraps around, so its neighbor is the first cell (index 0).

Bringing this all together, the nextSample() method has four steps:

  1. Save the sound sample at the current waveIndex.

  2. Update the sound sample stored in the waveSample array at the current waveIndex with the new sample using the Karplus-Strong algorithm, wrapping around to the beginning of the array if necessary.

  3. Increment waveIndex by 1, also wrapping around if necessary.

  4. Return the saved sound sample from step 1.

[Task] Drawing the waveSample array

To better visualize how these methods work together, draw the waveSample array after each indicated step of the code.

Now that you’ve drawn the waveSample array, use your understanding and visualization to implement the nextSample() method.

[Task] generateSoundArray(float seconds) method

Finally, the generateSoundArray(float seconds) method is used to return an array of sound samples for a given number of seconds. It does this by using both the pluck() and nextSample() methods to shape the starting wave and produce the sound samples. The method has four steps:

  1. Call pluck() to begin a newly plucked string.

  2. Create a new array with the correct number of slots based on SAMPLE_RATE and the number of seconds.

  3. Loop over the array, calling nextSample() to fill each slot.

  4. Return the newly created array of sound samples.

You’ll notice that for any note played, the length of the sound array that we’re generating is going to be the same for the set number of seconds. 6 seconds with a SAMPLE_RATE of 44,100 is always going to mean that our sound array is going to have a length of 264,600. The difference in the final sound we hear comes from the fact that in pluck(), we made it so thatwaveSample has a different length based on the pitch of the note we desire. Remember, the higher the pitch, the shorter the waveSample array, which means we’ll be cycling through the entirety of waveSample more often when collecting enough samples to fill our final sound array. The more cycles we manage in a given period of time means a higher Hz, resulting in a higher pitch!

[Task] Test your implementation with testCode()

How do we know our code is behaving as we expect? We test it. Testing is a critical (though often overlooked) part of computer science, so we’ll start building testing habits now.

We’ll use a simple testing framework by providing a static testCode() method in your StringInstrument.java file. Since this method is static, we can call it in the main() method:

public static void main(String[] args) {
    // Tests the StringInstrument class
    testCode();
}

Your task here is write code inside the testCode() method to have confidence that your implementation is working correctly. Most tests follow the same three steps:

Let’s start out by testing your implementation against the answers you already worked out by hand in the drawing exercise.

Copy the small example code we provided in the earlier section into the testCode() method. This code sets up a small, known example and runs your code on it.

Then, add code to check your implementation against the answers you already worked out by hand in the drawing exercise. To do this, you can write if statements to compare the actual output to the expected output:

// Initialize the instrument with a frequency of 11025 Hz
StringInstrument instrument1 = new StringInstrument(11025f);

instrument1.pluck();

float sample1 = instrument1.nextSample();
float sample2 = instrument1.nextSample();

if (sample1 != 0.05f) {
    System.out.println("Failed test for sample1: want 0.05 but got " + sample1);
}

In addition to checking the output of nextSample() through sample1 and sample2, write a test for the behavior of your generateSoundArray(float seconds) method. To make things easier to reason about, you can call generateSoundArray with 6f / 44100f to get an sound output array with 6 samples:

// *should* give us an array of length 6
float[] soundArray = instrument1.generateSoundArray(6f / 44100f);

Demo your implementation with StringInstrumentMain.java

Once you’ve tested your implementation with testCode(), you can hear your string instruments in action with StringInstrumentMain.java. This code is a Processing sketch that previews some of the UI concepts we’ll cover in the coming weeks. All you need to do is open the file and click the play ▶️ button in VSCodium -- if your implementation is working correctly, you should see a window pop up. Every time you click the window, you should hear a note that’s bright at first, then fades away:

When your tests pass and you can hear notes played, you’ve finished Part 1. Congratulations! You’ve built a musical instrument using only for loops and arrays.

Part 1 task checklist

The checkpoint autograder will grade your implementation of:

It will also provide feedback on your implementation of: