diff --git a/_quarto.yml b/_quarto.yml new file mode 100644 index 0000000..cda1583 --- /dev/null +++ b/_quarto.yml @@ -0,0 +1,3 @@ +format: + gfm: + variant: +yaml_metadata_block diff --git a/content/blog/music_theory/index.qmd b/content/blog/music_theory/index.qmd new file mode 100644 index 0000000..f101f99 --- /dev/null +++ b/content/blog/music_theory/index.qmd @@ -0,0 +1,430 @@ +--- +title: "Some Music Theory From (Computational) First Principles" +date: 2025-09-20T18:36:28-07:00 +draft: true +filters: ["./to-parens.lua"] +--- + +Sound is a perturbation in air pressure that our ear recognizes and interprets. +A note, which is a fundamental building block of music, is a perturbation +that can be described by a sine wave. All sine waves have a specific and +unique frequency. The frequency of a note determines how it sounds (its +_pitch_). Pitch is a matter of our perception; however, it happens to +correlate with frequency, such that notes with higher frequencies +are perceived as higher pitches. + +Let's encode a frequency as a class in Python. + +```{python} +#| echo: false +import math +import colorsys +``` + +```{python} +class Frequency: + def __init__(self, hz): + self.hz = hz +``` + +```{python} +#| echo: false + +def points_to_polyline(points, color): + return """""".format( + color=color, + points_str=" ".join(f"{x},{y}" for x, y in points) + ) + +def wrap_svg(inner_svg, width, height): + return f""" + {inner_svg} + """ + +OVERLAY_HUE = 0 +class Superimpose: + def __init__(self, *args): + global OVERLAY_HUE + + self.args = args + self.color = hex_from_hsv(OVERLAY_HUE, 1, 1) + OVERLAY_HUE = (OVERLAY_HUE + 1.618033988) % 1 + + def points(self, width, height): + points = [] + if not self.args: + return [0 for _ in range(width)] + + first_points = [(x, y - height/2) for (x, y) in self.args[0].points(width, height)] + for thing in self.args[1:]: + other_points = thing.points(width, height) + for (i, ((x1, y1), (x2, y2))) in enumerate(zip(first_points, other_points)): + assert(x1 == x2) + first_points[i] = (x1, y1 + (y2 - height/2)) + + # normalize + max_y = max(abs(y) for x, y in first_points) + if max_y > height / 2: + first_points = [(x, y * (0.9 * height / 2) / max_y) for x, y in first_points] + + return [(x, height/2 + y) for x, y in first_points] + + def get_color(self): + return self.color + + def _repr_svg_(self): + width = 720 + height = 100 + + points = self.points(width, height) + return wrap_svg( + points_to_polyline(points, self.get_color()), + width, height + ) + +class VerticalStack: + def __init__(self, *args): + self.args = args + + def _repr_svg_(self): + width = 720 + height = 100 + buffer = 10 + + polylines = [] + for (i, arg) in enumerate(self.args): + offset = i * (height + buffer) + points = [(x, y+offset) for (x,y) in arg.points(width, height)] + polylines.append(points_to_polyline(points, arg.get_color())) + + return wrap_svg( + "".join(polylines), + width, len(self.args) * (height + buffer) + ) + +def hex_from_hsv(h, s, v): + r, g, b = colorsys.hsv_to_rgb(h, s, v) + return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}" + +def color_for_frequency(hz): + while hz < 261.63: + hz *= 2 + while hz > 523.25: + hz /= 2 + + hue = (math.log2(hz / 261.63) * 360) + return hex_from_hsv(hue / 360, 1, 1) + +def Frequency_points(self, width=720, height=100): + # let 261.63 Hz be 5 periods in the width + points = [] + period = width / 5 + for x in range(width): + y = 0.9 * height / 2 * math.sin(x/period * self.hz / 261.63 * 2 * math.pi) + points.append((x, height/2 - y)) + return points + +def Frequency_get_color(self): + return color_for_frequency(self.hz) + +def Frequency_repr_svg_(self): + # the container on the blog is 720 pixels wide. Use that. + width = 720 + height = 100 + + points = self.points(width, height) + points_str = " ".join(f"{x},{y}" for x, y in points) + return wrap_svg( + points_to_polyline(points, self.get_color()), + width, height + ) + +Frequency.points = Frequency_points +Frequency.get_color = Frequency_get_color +Frequency._repr_svg_ = Frequency_repr_svg_ +``` + +Let's take a look at a particular frequency. For reason that are historical +and not particularly interesting to me, this frequency is called "middle C". + +```{python} +middleC = Frequency(261.63) +middleC +``` + +Great! Now, if you're a composer, you can play this note and make music out +of it. Except, music made with just one note is a bit boring, just like saying +the same word over and over again won't make for an interesting story. +No big deal -- we can construct a whole variety of notes by picking any +other frequency. + +```{python} +g4 = Frequency(392.445) +g4 + +``` +```{python} +fSharp4 = Frequency(370.000694) # we write this F# +fSharp4 +``` + +This is pretty cool. You can start making melodies with these notes, and sing +some jingles. However, if your friend sings along with you, and happens to +sing F# while you're singing the middle C, it's going to sound pretty awful. +So awful does it sound that somewhere around the 18th century, people started +calling it _diabolus in musica_ (the devil in music). + +Why does it sound so bad? Let's take a look at the +{{< sidenote "right" "superposition-note" "superposition" >}} +When waves combine, they follow the principle of superposition. One way +to explain this is that their graphs are added to each other. In practice, +what this means is that two peaks in the same spot combine to a larger +peak, as do two troughs; on the other hand, a peak and a trough "cancel out" +and produce a "flatter" line. +{{< /sidenote >}} of these two +notes, which is what happens when they are played at the same time. For reason I'm +going to explain later, I will multiply each frequency by 4. These frequencies +still sound bad together, but playing them higher lets me "zoom out" and +show you the bigger picture. + +```{python} +Superimpose(Frequency(middleC.hz*4), Frequency(fSharp4.hz*4)) +``` + +Looking at this picture, we can see that it's far more disordered than the +pure sine waves we've been looking at so far. There's not much of a pattern +to the peaks. This is interpreted by our brain as unpleasant. + +{{< dialog >}} +{{< message "question" "reader" >}} +So there's no fundamental reason why these notes sound bad together? +{{< /message >}} +{{< message "answer" "Daniel" >}} +That's right. We might objectively characterize the combination of these +two notes as having a less clear periodicity, but that doesn't mean +that fundamentally it should sound bad. Them sounding good is a purely human +judgement. +{{< /message >}} +{{< /dialog >}} + +If picking two notes whose frequencies don't combine into a nice pattern +makes for a bad sound, then to make a good sound we ought to pick two notes +whose frequencies *do* combine into a nice pattern. +Playing the same frequency twice at the same time certainly will do it, +because both waves will have the exact same peaks and troughs. + +```{python} +Superimpose(middleC, middleC) +``` + +In fact, this is just like playing one note, but louder. The fact of the matter +is that *playing any other frequency will mean that not all extremes of the graph align*. +We'll get graphs that are at least a little bink wonky. Intuitively, let's say +that our wonky-ish graph has a nice pattern when they repeat quickly. That way, +there's less time for the graph to do other, unpredictable things. + +What's the soonest we can get our combined graph to repeat? It can't +repeat any sooner than either one of the individual notes --- how could it? +We can work with that, though. If we make one note have exactly twice +the frequency of the other, then exactly at the moment the less frequent +note completes its first repetition, the more frequent note will complete +its second. That puts us right back where we started. Here's what this looks +like graphically: + +```{python} +twiceMiddleC = Frequency(middleC.hz*2) +VerticalStack( + middleC, + twiceMiddleC, + Superimpose(middleC, twiceMiddleC) +) +``` + +You can easily inspect the new graph to verify that it has a repeating pattern, +and that this pattern repeats exactly as frequently as the lower-frequency +note at the top. Indeed, these two notes sound quite good together. It turns +out, our brains consider them the same in some sense. If you have ever tried to +sing a song that was outside of your range (like me singing along to Taylor Swift), +chances are you sang notes that had half the frequency of the original. +We say that these notes are _in the same pitch class_. While only the first +of the two notes I showed above was the _middle_ C, we call both notes C. +To distinguish different-frequency notes of the same pitch class, we sometimes +number them. The ones in this example were C4 and C5. +We can keep applying this trick to get C6, C7, and so on. + +```{python} +C = {} +note = middleC +for i in range(4, 10): + C[i] = note + note = Frequency(note.hz * 2) + +C[4] +``` + +To get C3 from C4, we do the reverse, and halve the frequency. +```{python} +note = middleC +for i in range(4, 0, -1): + C[i] = note + note = Frequency(note.hz / 2) + +C[1] +``` + +You might've already noticed, but I set up this page so that individual +sine waves in the same pitch class have the same color. + +All of this puts us almost right back where we started. We might have +different pithes, but we've only got one pitch _class_. Let's try again. +Previously, we made it so the second repeat of one note lined up with the +first repeat of another. What if we pick another note that repeats _three_ +times as often instead? + +```{python} +thriceMiddleC = Frequency(middleC.hz*3) +VerticalStack( + middleC, + thriceMiddleC, + Superimpose(middleC, thriceMiddleC) +) +``` + +That's not bad! These two sound good together as well, but they are not +in the same pitch class. There's only one problem: these notes are a bit +far apart in terms of pitch. Wait a minute --- weren't we just talking about +singing notes that were too high at half their original frequency? +We can do that here. The result is a note we've already seen: + +```{python} +print(thriceMiddleC.hz/2) +print(g4.hz) +``` + +In the end, we got G4 by multiplying our original frequency by $3/2$. What if +we keep applying this process to find more notes? Let's not even worry +about the specific frequencies (like `261.63`) for a moment. We'll start +with a frequency of $1$. This makes our next frequency $3/2$. Taking this +new frequency and again multiplying it by $3/2$, we get $9/4$. But that +again puts us a little bit high: $9/4 > 2$. We can apply our earlier trick +and divide the result, getting $9/16$. + +```{python} +from fractions import Fraction + +note = Fraction(1, 1) +seen = {note} +while len(seen) < 6: + new_note = note * 3 / 2 + if new_note > 2: + new_note = new_note / 2 + + seen.add(new_note) + note = new_note + + +# Throw in one more by going *backwards*. More on that in a bit. +seen.add(Fraction(2/3) * 2) + +fractions = sorted(list(seen)) +frequencies = [middleC.hz * float(frac) for frac in fractions] +frequencies +``` + +```{python} +steps = [frequencies[i+1]/frequencies[i] for i in range(len(frequencies)-1)] +minstep = min(steps) +print([math.log(step)/math.log(minstep) for step in steps]) +``` + +Since peaks and troughs +in the final result arise when peaks and troughs in the individual waves align, +we want to pick two frequencies that align with a nice pattern. + +But that begs the question: what determines +how quickly the pattern of two notes played together repeats? + +We can look at things geometrically, by thinking about the distance +between two successive peaks in a single note. This is called the +*wavelength* of a wave. Take a wave with some wavelength $w$. +If we start at a peak, then travel a distance of $w$ from where we started, +there ought to be another peak. Arriving at a distance of $2w$ (still counting +from where we started), we'll see another peak. Continuing in the same pattern, +we'll see peaks at distances of $3w$, $4w$, and so on. For a second wave +with wavelength $v$, the same will be true: peaks at $v$, $2v$, $3v$, and so on. + +If we travel a distance that happens to be a multiple of both $w$ and $v$, +then we'll have a place where both peaks are present. At that point, the +pattern starts again. Mathematically, we can +state this as follows: + +$$ +nw = mv,\ \text{for some integers}\ n, m +$$ + +As we decided above, we'll try to find a combination of wavelengths/frequencies +where the repetition happens early. + +__TODO:__ Follow the logic from Digit Sum Patterns + +The number of iterations of the smaller wave before we reach a cycle is: + +$$ +k = \frac{w}{\text{gcd}(w, v)} +$$ + + +```{python} +Superimpose(Frequency(261.63), Frequency(261.63*2)) +``` + +```{python} +Superimpose(Frequency(261.63*4), Frequency(392.445*4)) +``` + +```{python} +VerticalStack( + Frequency(440*8), + Frequency(450*8), + Superimpose(Frequency(440*8), Frequency(450*8)) +) +``` + + +```{python} +from enum import Enum +class Note(Enum): + C = 0 + Cs = 1 + D = 2 + Ds = 3 + E = 4 + F = 5 + Fs = 6 + G = 7 + Gs = 8 + A = 9 + As = 10 + B = 11 + + def __add__(self, other): + return Note((self.value + other.value) % 12) + + def __sub__(self, other): + return Interval((self.value - other.value + 12) % 12) +``` + +```{python, echo=false} +class MyClass: + def _repr_svg_(self): + return """ + + """ + +MyClass() +``` diff --git a/content/blog/music_theory/to-parens.lua b/content/blog/music_theory/to-parens.lua new file mode 100644 index 0000000..00aa011 --- /dev/null +++ b/content/blog/music_theory/to-parens.lua @@ -0,0 +1,5 @@ +function Math(el) + if el.mathtype == "InlineMath" then + return pandoc.RawInline("markdown", "\\(" .. el.text .. "\\)") + end +end