Example: superposition of sound waves
As discussed in SECTION 1.5, the simple audio model that we studied there needs to be embellished to create sound that resembles the sound produced by a musical instrument. Many different embellishments are possible; with functions, we can systematically apply them to produce sound waves that are far more complicated than the simple sine waves that we produced in SECTION 1.5. As an illustration of the effective use of functions to solve an interesting computational problem, we consider a program that has essentially the same functionality as playthattune.py (PROGRAM 1.5.8), but adds harmonic tones one octave above and one octave below each note to produce a more realistic sound.
Chords and harmonics
Notes like concert A have a pure sound that is not very musical, because the sounds that you are accustomed to hearing have many other components. The sound from a guitar string echoes off the wooden part of the instrument, the walls of the room that you are in, and so forth. You may think of such effects as modifying the basic sine wave. For example, most musical instruments produce harmonics (the same note in different octaves and not as loud), or you might play chords (multiple notes at the same time). To combine multiple sounds, we use superposition: simply add their waves together and rescale to make sure that all values stay between −1 and +1. As it turns out, when we superpose sine waves of different frequencies in this way, we can get arbitrarily complicated waves. Indeed, one of the triumphs of 19th-century mathematics was the development of the idea that any smooth periodic function can be expressed as a sum of sine and cosine waves, known as a Fourier series. This mathematical idea corresponds to the notion that we can create a large range of sounds with musical instruments or our vocal cords and that all sound consists of a composition of various oscillating curves. Any sound corresponds to a curve and any curve corresponds to a sound, so we can create arbitrarily complex curves with superposition.
Computing with sound waves
In SECTION 1.5, we saw how to represent sound waves by arrays of numbers that represent their values at the same sample points. Now, we will use such arrays as return values and arguments to functions to process such data. For example, the following function takes a frequency (in hertz) and a duration (in seconds) as arguments and returns a representation of a sound wave (more precisely, an array that contains values sampled from the specified wave at the standard 44,100 samples per second).
def tone(hz, duration, sps=44100): n = int(sps * duration) a = stdarray.create1D(n+1, 0.0) for i in range(n+1): a[i] = math.sin(2.0 * math.pi * i * hz / sps) return a
The size of the array returned depends on the duration: it contains about sps*duration floats (nearly half a million floats for 10 seconds). But we can now treat that array (the value returned from tone) as a single entity and compose code that processes sound waves, as we will soon see in PROGRAM 2.1.4.
Since we represent sound waves by arrays of numbers that represent their values at the same sample points, superposition is simple to implement: we add together their sample values at each sample point to produce the combined result. For greater control, we also specify a relative weight for each of the two waves to be superposed, with the following function:
def superpose(a, b, aWeight, bWeight): c = stdarray.create1D(len(a), 0.0) for i in range(len(a)): c[i] = aWeight*a[i] + bWeight*b[i] return c
(This code assumes that a and b are of the same length.) For example, if we have a sound represented by an array a that we want to have three times the effect of the sound represented by an array b, we would call superpose(a, b, 0.75, 0.25). The figure at the top of the next page shows the use of two calls on this function to add harmonics to a tone (we superpose the harmonics, then superpose the result with the original tone, which has the effect of giving the original tone twice the weight of each harmonic). As long as the weights are positive and sum to 1, superpose() preserves our convention of keeping the values of all waves between −1 and +1.
PROGRAM 2.1.4 (playthattunedeluxe.py) is an implementation that applies these concepts to produce a more realistic sound than that produced by PROGRAM 1.5.8. To do so, it makes use of functions to divide the computation into four parts:
- Given a frequency and duration, create a pure tone.
- Given two sound waves and relative weights, superpose them.
- Given a pitch and duration, create a note with harmonics.
- Read and play a sequence of pitch/duration pairs from standard input.
Program 2.1.4 Play that tune (revisited) (playthattunedeluxe.py)
These tasks are all amenable to implementation as functions, which depend on one another. Each function is well defined and straightforward to implement. All of them (and stdaudio) represent sound as a series of discrete values kept in an array, corresponding to sampling a sound wave at 44,100 samples per second.
Up to this point, our use of functions has been somewhat of a notational convenience. For example, the control flow in PROGRAM 2.1.1, PROGRAM 2.1.2, and PROGRAM 2.1.3 is simple—each function is called in just one place in the code. By contrast, PROGRAM 2.1.4 is a convincing example of the effectiveness of defining functions to organize a computation because each function is called multiple times. For example, as illustrated in the figure below, the function note() calls the function tone() three times and the function superpose() twice. Without functions, we would need multiple copies of the code in tone() and superpose(); with functions, we can deal directly with concepts close to the application. Like loops, functions have a simple but profound effect: one sequence of statements (those in the function definition) is executed multiple times during the execution of our program—once for each time the function is called in the control flow in the global code.
FUNCTIONS ARE IMPORTANT BECAUSE THEY GIVE us the ability to extend the Python language within a program. Having implemented and debugged functions such as harmonic(), pdf(), cdf(), mean(), exchange(), shuffle(), isPrime(), superpose(), tone(), and note(), we can use them almost as if they were built into Python. The flexibility to do so opens up a whole new world of programming. Before, you were safe in thinking about a Python program as a sequence of statements. Now you need to think of a Python program as a set of functions that can call one another. The statement-to-statement control flow to which you have been accustomed is still present within functions, but programs have a higher-level control flow defined by function calls and returns. This ability enables you to think in terms of operations called for by the application, not just the operations that are built into Python.
Whenever you can clearly separate tasks within a computation, you should do so. The examples in this section (and the programs throughout the rest of the book) clearly illustrate the benefits of adhering to this maxim. With functions, we can
- Divide a long sequence of statements into independent parts.
- Reuse code without having to copy it.
- Work with higher-level concepts (such as sound waves).
This point of view leads to code that is easier to understand, maintain, and debug compared to a long program composed solely of Python assignment, conditional, and loop statements. In the next section, we discuss the idea of using functions defined in other files, which again takes us to another level of programming.