import numpy as np
import matplotlib.pyplot as plt
import IPython.display as ipd
import pandas as pd
from collections import OrderedDict
We saw at the third module that a vibrating string supports different frequencies that are integer multiples of a base frequency, known as "harmonics" or "overtones." Let's look at a few examples of base frequencies and their harmonics. We'll start with a base frequency of 220hz, and then we'll also look at a base frequency of 440z, which is known as its "octave" (the space in between these two frequencies is also referred to as an "octave"). We perceive these notes to be the same pitch, because we perceive pitch logarithmically in frequency. In other words, multiplying a sequence of frequencies by a constant amount will lead to a constant additive shift in our perception.
Finally, we'll look at frequencies at 330hz and 275hz, which are in 3/2 ratios and 5/4 ratios of 220hz, and which are known as fifths and third, respectively. And we look at another frequency, 313, which forms a "tri tone" with respect to 220hz. The plots below show notes with these base frequencies, along with their first 16 harmonics. Listen and notice that every harmonic of the octave 440hz is contained in the harmonics of 220hz, every other harmonic of 330 is contained in the harmonics of 220, and every fourth harmonic of 275 is contained in the harmonics of 220.
def make_html_audio(ys, sr, width=100):
clips = []
for y in ys:
audio = ipd.Audio(y, rate=sr)
audio_html = audio._repr_html_().replace('\n', '').strip()
audio_html = audio_html.replace('<audio ', '<audio style="width: {}px; "'.format(width))
clips.append(audio_html)
return clips
sr = 44100
t = np.linspace(0, 1, sr)
pd.set_option('display.max_colwidth', None)
tuples = []
summed = {}
for f0 in [220, 440, 330, 275, 313]:
ys = []
fs = (f0*np.arange(1, 17)).tolist()
all_together = np.zeros_like(t)
for f in fs:
y = np.cos(2*np.pi*f*t)
ys.append(y)
all_together += y/f # Put in less of the high frequencies
ys = [all_together] + ys
fs = ["All Together"] + fs
summed[f0] = all_together
clips = make_html_audio(ys, sr, width=50)
tuples += [("{} hz Harmonics".format(f0), fs), ("{} hz sinusoids".format(f0), clips)]
df = pd.DataFrame(OrderedDict(tuples))
ipd.HTML(df.to_html(escape=False, float_format='%.2f'))
If we add together a note and its octave, it sounds quite nice, because all of the harmonics of the octave are contained in the original note
y = summed[220] + 2*summed[440]
ipd.Audio(y, rate=sr)
The same is true about adding 220hz, 330hz, and 275 together, because they sall hare many harmonics. This is known as a major triad
y = summed[220] + summed[275] + summed[330]
ipd.Audio(y, rate=sr)
By contrast, if we add 220hz to 313hz, it sounds much less pleasing, because none of the harmonics line up perfectly, but many of them are close, so it creates a lot of beat frequencies that sound "dissonant"
y = summed[220] + summed[313]
ipd.Audio(y, rate=sr)
Now, you should construct 13 notes going in 3/2 intervals, starting at 440hz, but keep them in the interval between 440 and 880. So if the frequency goes above 880, simply halve it to go an octave down. This is known as the "circle of fifths"
f = 440
ys = []
freqs = []
for i in range(13):
freqs.append(f)
ys.append(np.cos(2*np.pi*f*t))
f = f*3/2
if f > 880:
f /= 2
tuples = [("Frequencies", freqs), ("Sinusoids", make_html_audio(ys, sr))]
df = pd.DataFrame(OrderedDict(tuples))
ipd.HTML(df.to_html(escape=False, float_format='%.2f'))
The circle of fifths is supposed to cycle through the 12 unique notes in an octave. But there is a discrepancy when we cycle all around, which is referred to as the Pythagorean comma.
There's another issue that arises using these notes when we want to "transpose" a tune, or move it up or down by a constant amount of notes. To see this, let's first sort the above notes so they are in frequency order.
idx = np.argsort(np.array(freqs[0:-1]))
freqs_sorted = np.array([freqs[i] for i in idx])
ys_sorted = [ys[i] for i in idx]
tuples = [("Frequencies", freqs_sorted), ("Sinusoids", make_html_audio(ys_sorted, sr))]
df = pd.DataFrame(OrderedDict(tuples))
ipd.HTML(df.to_html(escape=False, float_format='%.2f'))
Now let's construct the beginning of the happy birthday tune, starting at an A. So putting together the sinusoids at index 0, 2, 0, 5, 4
y = np.concatenate((ys_sorted[0], ys_sorted[2], ys_sorted[0], ys_sorted[5], ys_sorted[4]))
ipd.Audio(y, rate=sr)
If we want to go up by two notes, then we can use indices 2, 4, 2, 7, 6
y = np.concatenate((ys_sorted[2], ys_sorted[4], ys_sorted[2], ys_sorted[7], ys_sorted[6]))
ipd.Audio(y, rate=sr)
But there is a problem. If we look at the ratios of the corresponding frequencies from the first tune from the second tune, they are not all the same!
freqs_sorted[[2, 4, 2, 7, 6]] / freqs_sorted[[0, 2, 0, 5, 4]]
That fourth note is "flat" in the second tune with respect to the others! So they are technically not the same tune, even up to transposition. What all of this means is that it's impossible to construct all of the notes in a physically harmonic way while also being consistent! So we will have to settle for something close.
Let's now go back to the formula that we saw earlier for constructing notes on top of a base frequency. This time, we'll use 440 as our base frequency to be consistent with the above example. Let $p$ be how many halfsteps we are above the base frequency $f_0$. Then the formula is:
Notice now that if we move up by a constant amount of $k$, then the ratio between the notes is a constant $2^{k/12}$. So they really would be the same tune, because they are multiplied by a constant frequency for each note, and, as we said in the beginning, a constant multiple leads to a constant shift in our perception. For this reason, this way of constructing notes is known as "equal tempered." Let's follow this formula to compare the notes we constructed
freqs_formula = 440*(2**(np.arange(12)/12))
ys_formula = [np.cos(2*np.pi*f*t) for f in freqs_formula]
tuples = [("Our Frequencies", freqs_sorted), ("Our Sinusoids", make_html_audio(ys_sorted, sr))]
tuples += [("Formula Frequencies", freqs_formula), ("Formula Sinusoids", make_html_audio(ys_formula, sr))]
df = pd.DataFrame(OrderedDict(tuples))
ipd.HTML(df.to_html(escape=False, float_format='%.2f'))
Finally, it's worth it to take another look at the circle of fifths just using note numbers. What we see in the above table is that the fifth corresponds to note number 7. So we're jumping by 7 notes each time and wrapping around for octaves. This corresponds to using the operator mod 12. So the question is, do we return back to 0 after adding 7 repeatedly mod 12? Let's see:
notes = np.mod(np.arange(25)*7, 12)
print(notes)
Yes we do! Let's hear them all together as sinusoids one after the other using the formula for equal tempered frequencies
t = np.arange(int(sr/2))/sr
y = np.array([])
for p in notes:
f = 440*2**(p/12)
y = np.concatenate((y, np.cos(2*np.pi*f*t)))
ipd.Audio(y, rate=sr)