Music Theory for Software Developers
I derived the twelve-note system in TypeScript to find out why it exists. Then I read the rest of the syllabus and found canonical forms, permutation groups and an assignment problem.
· 40 min read
“What I cannot create, I do not understand.” (Richard Feynman)
I have played guitar for years, and lately jazz has had me studying the theory properly for the first time.
It started with an app. I built Guitar Atlas, a music theory visualiser that shows scales, modes and chords on a fretboard and a circle of fifths at the same time, all updating as you change the tonic or the mode. The plan was to learn the theory by building it. That worked, just not the way I expected. A guitarist can leave a decision implicit and a computer can’t, so every implicit decision in the domain turned into a question I had to answer, and often couldn’t.
The biggest one was a little embarrassing. After all those years of playing, I couldn’t have told you why there are twelve notes rather than eleven or nineteen, or why the major scale has the shape it has.
Most introductions to music theory hand you the system as a set of conventions: here is the staff, here are the notes, here is the major scale, memorise the pattern. They skip the reasons, which is odd, because the reasons exist. They are physics and arithmetic, and most of them fit in a few lines of code.
So I derived the whole thing in TypeScript. I started from a single number changing over time, kept going until I had something that sounded like music, and tried not to take anything on faith along the way. Every snippet below shares one audio context:
1const ctx = new AudioContext();
2const out: AudioDestinationNode = ctx.destination;Sound and oscillators #
Sound is air pressure changing over time. A speaker makes it by moving a cone back and forth, and everything a computer does with audio comes down to deciding where that cone should be, tens of thousands of times a second. An audio file is that list of positions written down. A synthesiser computes the list as it goes.
The browser will generate the simplest possible list for you if you give it a rate:
1const osc = ctx.createOscillator();
2osc.frequency.value = 440;
3osc.connect(out);
4osc.start();
5osc.stop(ctx.currentTime + 1);That’s one second of a sine wave repeating 440 times a second. The only number in there with any musical content is 440, and even that is a convention: it’s the frequency we agreed to call A. Change it to 300 and the code is just as correct, it simply plays a different pitch. The oscillator has no idea what a note is. It knows a rate. Higher frequency means higher pitch, and I’d enjoy that while it lasts, because it’s the last one-to-one mapping in this article.
You may hear a click when it stops. The browser is behaving correctly. The wave gets cut mid-cycle, so the cone is displaced one instant and back at zero the next, and an instantaneous pressure change is exactly what a click is.
The fix is a second number changing over time, this one controlling volume. Musicians call its shape an envelope:
1function note(
2 freq: number,
3 start = 0,
4 length = 0.5,
5 wave: OscillatorType = "sine",
6): void {
7 const t = ctx.currentTime + start;
8 const osc = ctx.createOscillator();
9 const env = ctx.createGain();
10
11 osc.type = wave;
12 osc.frequency.value = freq;
13
14 env.gain.setValueAtTime(0, t);
15 env.gain.linearRampToValueAtTime(0.3, t + 0.01);
16 env.gain.exponentialRampToValueAtTime(0.001, t + length);
17
18 osc.connect(env).connect(out);
19 osc.start(t);
20 osc.stop(t + length);
21}Ten milliseconds to fade in, then an exponential decay to almost nothing: attack and decay. That curve is most of the difference between a test tone and something you would listen to twice. Stretch the attack to half a second and the note sounds bowed instead of struck, without the pitch moving by a single hertz, so a good part of what you hear as an instrument’s character lives in the envelope. The full version has four stages (attack, decay, sustain, release). Two are enough here, and every snippet from now on uses this helper.
The harmonic series #
A sine wave is exactly one frequency, which is why it sounds like a hearing test. A real string tuned to 220Hz does vibrate 220 times a second, but it also vibrates in halves, thirds and quarters at the same time, and those extra motions produce 440, 660, 880, 1100 and on up.
That stack is the harmonic series: the fundamental times 1, 2, 3, 4 and so on. Nobody chose it. It falls out of the geometry of a vibrating string or a column of air, so every instrument built around one produces the same series.
1const series = (base: number, count: number): number[] =>
2 Array.from({ length: count }, (_, i) => base * (i + 1));
3
4series(220, 8); // [220, 440, 660, 880, 1100, 1320, 1540, 1760]
On their own those numbers are dull. What matters is that they always arrive together, and that the relative loudness of each one is the recipe that tells a violin from a trumpet playing the same note. Musicians call that recipe timbre, and the browser ships four of them:
1(["sine", "triangle", "square", "sawtooth"] as OscillatorType[]).forEach(
2 (wave, i) => note(220, i * 0.7, 0.6, wave),
3);Same pitch, four characters. A square wave contains only the odd harmonics, which is why it sounds hollow and synthetic. A sawtooth contains all of them and sounds harsh. Picking a waveform means picking a harmonic recipe, and I’ll lean on the series for nearly everything that follows.
The octave #
Start with five frequencies, each double the last.
1[110, 220, 440, 880, 1760].forEach((f, i) => note(f, i * 0.45, 0.4));Five different frequencies, and they sound like one note at five different brightnesses. Cultures with no contact with each other noticed this independently and gave those frequencies a single name. We call the distance between them an octave.
The harmonic series explains why. The series of 440 is 440, 880, 1320, 1760, and every one of those already appears in the series of 220. The higher note adds no frequency the lower one wasn’t already producing, so the ear finds nothing new.
Two consequences carry through the rest of the article. First, pitch is multiplicative. An octave up means times two, and adding a fixed number of hertz gets you nowhere useful: the gap from 110 to 220 is 110Hz, the gap from 880 to 1760 is 880Hz, and they sound like exactly the same distance. Frequency space is logarithmic, and every interval in music is a ratio.
Second, there is only one octave to solve. Doubling brings you back to where you started, so the question “which pitches should exist?” shrinks to “how should the space between a frequency and twice that frequency be divided?” Answer it once and the answer repeats across the whole audible range.
Consonance and ratios #
Dividing the octave evenly would be the obvious move if we heard every pair of frequencies the same way. We don’t:
1const ratios: [string, number][] = [
2 ["octave 2/1", 2],
3 ["fifth 3/2", 3 / 2],
4 ["fourth 4/3", 4 / 3],
5 ["major third 5/4", 5 / 4],
6 ["semitone 16/15", 16 / 15],
7 ["irrational √2", Math.SQRT2],
8];
9
10ratios.forEach(([label, ratio], i) => {
11 note(220, i * 1.4, 1.2);
12 note(220 * ratio, i * 1.4, 1.2);
13 console.log(label, "->", (220 * ratio).toFixed(2), "Hz");
14});The first four sound like chords. The 16/15 sounds like a mistake, and the √2 sounds like a car alarm. The simpler the fraction, the better it sounds, which is a suspiciously arithmetic result for something as subjective as “pleasant”. There are two physical mechanisms behind it.
The first is the harmonic series again. Play 220 and 330, a 3:2 ratio. The lower note produces 220, 440, 660, 880, 1100, 1320, and the higher one produces 330, 660, 990, 1320. They share 660 and 1320 exactly, so two notes a fifth apart behave like two overlapping stacks that reinforce each other. Try 220 against 311, which is close to √2, and nothing coincides at any harmonic.
The second is roughness. Two frequencies that are close but unequal drift in and out of phase, and you hear the loudness pulse:
1[226, 223, 221, 220.5, 220].forEach((f, i) => {
2 note(220, i * 1.3, 1.2);
3 note(f, i * 1.3, 1.2);
4});The pulsing slows as the two converge and stops when they match. Its rate is the difference between them, so 226 against 220 pulses six times a second. This is called beating, and it’s what an out-of-tune note is. A complicated ratio litters the two harmonic stacks with pairs sitting a few hertz apart, and every one of those pairs beats.
Both mechanisms describe the same thing from different sides. Add two waves together, which is what your eardrum does, and ask how long it takes the combined shape to repeat. For 2:1 it repeats after a single cycle of the lower note. For 16:15 it takes fifteen. For √2 it never repeats, because √2 is irrational and there’s no period to lock onto. As far as I can tell, consonance is your ear finding a repeating pattern quickly, and that’s the whole story.
The Pythagorean comma #
After the octave, the simplest ratio available is 3/2. So try building a set of notes out of nothing but octaves and fifths: go up a fifth, halve whenever you leave the octave, repeat. This is roughly what Pythagoras did, and it works nicely for a while.
1let freq = 220;
2const pitches: number[] = [220];
3
4for (let i = 0; i < 12; i++) {
5 freq = (freq * 3) / 2;
6 while (freq >= 440) freq /= 2;
7 pitches.push(freq);
8}
9
10console.log(pitches[0], "->", pitches[12].toFixed(3)); // 220 -> 223.002
Twelve fifths bring us close to where we started, but we miss: 223.00 instead of 220. That’s close enough to sound like it’s trying to be the same note and far enough off to be unusable.
My first suspect was floating point. It’s innocent here. The gap is structural, and it’s easier to see without the octave folding:
1const twelveFifths = (3 / 2) ** 12; // 129.746337890625
2const sevenOctaves = 2 ** 7; // 128
3const comma = twelveFifths / sevenOctaves; // 1.0136432647705078
4
5console.log((1200 * Math.log2(comma)).toFixed(2), "cents"); // 23.46
Twelve pure fifths overshoot seven pure octaves by a factor of 1.0136. That gap is the Pythagorean comma. Pitch distance is conventionally measured in cents, 1200 to the octave, which puts the comma at 23.46 cents, roughly a quarter of the distance between two adjacent piano keys. You can hear it, and it’s ugly.
It also can’t be fixed, for a reason any programmer will enjoy. Stacking n fifths multiplies by 3ⁿ / 2ⁿ. Stacking m octaves multiplies by 2ᵐ. For the two to coincide you would need 3ⁿ = 2ⁿ⁺ᵐ, a power of three equal to a power of two. Three and two are both prime, so that never happens for any positive n, and no amount of searching will turn up a lucky exception.
So the tuning everyone would want, with pure octaves and pure fifths closing into a neat loop, has never existed.
This was the most useful thing I took from the whole exercise, and it applies well beyond music. Once you accept that the error has to go somewhere, the design question changes from “how do we get this right?” to “where do we put the error?” Every tuning system in history answers that question differently: which intervals stay pure, which ones absorb the damage, and which keys become unusable as a result.
Equal temperament #
The modern answer gives up on pure ratios entirely. Divide the octave into twelve equal multiplicative steps and accept that nothing except the octave will ever be exactly in tune again. One step is the twelfth root of two:
1const semitone = 2 ** (1 / 12); // 1.0594630943592953
2220 * semitone ** 12; // 440.00000000000017
The octave is exact by construction, since that’s what a twelfth root does. The trailing 17 is floating point adding its own small error, which is a separate and much less interesting problem.
Why twelve? I assumed the answer was tradition, but you can get it by search. Divide the octave into n equal steps, find the step that lands closest to a pure 3/2 fifth, and measure how far off it is:
1function bestFifth(n: number): { step: number; pct: number } {
2 let error = Infinity;
3 let step = 0;
4
5 for (let candidate = 1; candidate < n; candidate++) {
6 const off = Math.abs(2 ** (candidate / n) - 1.5);
7 if (off < error) {
8 error = off;
9 step = candidate;
10 }
11 }
12
13 return { step, pct: (error / 1.5) * 100 };
14}
15
16for (let n = 5; n <= 53; n++) {
17 const { step, pct } = bestFifth(n);
18 console.log(`${n} steps: best fifth is step ${step}, off by ${pct.toFixed(4)}%`);
19}That prints every division from 5 to 53. Here is a selection of the error column:
| Divisions | Best fifth | Error |
|---|---|---|
| 5 | step 3 | 1.0478% |
| 7 | step 4 | 0.9337% |
| 12 | step 7 | 0.1129% |
| 17 | step 10 | 0.2271% |
| 19 | step 11 | 0.4161% |
| 24 | step 14 | 0.1129% |
| 29 | step 17 | 0.0863% |
| 41 | step 24 | 0.0280% |
| 53 | step 31 | 0.0039% |
Twelve is the first division to get the fifth within about a tenth of a percent, and it’s roughly eight times more accurate than anything smaller. Twenty-four ties it exactly, as do 36 and 48, for a boring reason: they contain twelve, so they contain its fifth. The first real improvements are 29, 41 and 53, and I’m not about to learn an instrument with 53 keys per octave. Twelve is the cheapest division that buys a convincing fifth, and once the fifth is close, the fourth and (more loosely) the thirds come along with it.
How close, exactly:
1const pure = (220 * 3) / 2; // 330
2const tempered = 220 * 2 ** (7 / 12); // 329.6276
3
4console.log((1200 * Math.log2(tempered / pure)).toFixed(2), "cents"); // -1.96
Every equal-tempered fifth is about two cents flat, and if you listen for it there’s a slow beat in all of them. Musicians collectively decided that being slightly wrong in every key was better than being perfect in one key and unusable in the rest.
The bigger payoff is representational: notes become integers. Pick one as zero and every other note is a whole number of steps away from it. The standard numbering is MIDI, where 69 is the 440Hz A, and the conversion is one line:
1const midiToFreq = (n: number): number => 440 * 2 ** ((n - 69) / 12);
2
3midiToFreq(60); // 261.63, middle C
4midiToFreq(69); // 440
5midiToFreq(81); // 880
From here on everything is arrays of integers, and I more or less stopped thinking about frequencies.
Scales and modes #
Having twelve notes available doesn’t mean using all twelve.
1for (let n = 60; n <= 72; n++) note(midiToFreq(n), (n - 60) * 0.18, 0.25);That sounds like a sound effect. Every step is the same size, so no note stands out, nothing sounds like home, and you can’t tell where you are. Uniform spacing carries no information.
So music uses a subset, almost always seven of the twelve, chosen so that the gaps between them are uneven. A scale is best written as its gaps rather than its notes:
1const major = [2, 2, 1, 2, 2, 2, 1];
2
3const build = (root: number, gaps: number[]): number[] =>
4 gaps.reduce<number[]>(
5 (notes, gap) => [...notes, notes[notes.length - 1] + gap],
6 [root],
7 );
8
9build(60, major); // [60, 62, 64, 65, 67, 69, 71, 72]
Seven numbers that sum to 12, which is what makes the pattern close the octave exactly. The most familiar sound in Western music fits in a seven-element array.
Rearrange the gaps and the mood changes completely:
1const patterns: Record<string, number[]> = {
2 major: [2, 2, 1, 2, 2, 2, 1],
3 naturalMinor: [2, 1, 2, 2, 1, 2, 2],
4 majorPenta: [2, 2, 3, 2, 3],
5 minorPenta: [3, 2, 2, 3, 2],
6 blues: [3, 2, 1, 1, 3, 2],
7};Major and natural minor use the same seven gaps in a different order. The pentatonics drop two notes, which makes them forgiving and explains why every beginner guitar lesson starts there. The blues scale puts one deliberately awkward note back in.
Then came the part that changed how I think about all this. I had always seen the modes presented as seven exotic Greek names to memorise. They are one array, rotated:
1const rotate = <T,>(a: T[], by: number): T[] =>
2 a.map((_, i) => a[(i + by) % a.length]);
3
4rotate(major, 0); // [2, 2, 1, 2, 2, 2, 1] Ionian
5rotate(major, 5); // [2, 1, 2, 2, 1, 2, 2] Aeolian
Rotation 0 is the major scale and rotation 5 is the natural minor, so major and minor are two offsets into the same pattern. Lydian sounds dreamy, Phrygian sounds Spanish, Locrian sounds broken, and all of it comes from which gap sits where relative to the note you started on.
This is where music theory stopped feeling arbitrary to me. Keep rotate handy, because it turns up again later in places that look nothing like scales.
Chords #
A chord is more than one note at once. That definition is useless, because most combinations sound bad, and the useful question is which ones don’t. We already have the answer: notes whose harmonics overlap. Inside a scale those are the notes two degrees apart, a distance musicians call a third. So take a scale and grab every other note:
1const scale = build(60, major);
2const triad = [0, 2, 4].map((i) => scale[i]); // [60, 64, 67]
That’s a C major chord: three notes at 0, 4 and 7 semitones above the root. As frequency ratios it’s 1 : 1.2599 : 1.4983, very close to 4 : 5 : 6. Three simple ratios sharing harmonics all over the place is why it sounds so settled.
A third is either four semitones or three. Stack a four and a three in either order and you land on seven, and stack two of the same kind and you get the other two shapes:
1const shapes: Record<string, number[]> = {
2 major: [0, 4, 7],
3 minor: [0, 3, 7],
4 diminished: [0, 3, 6],
5 augmented: [0, 4, 8],
6};Major to minor is one array element moving by one. I had absorbed the idea that major and minor were deep categories, the two emotional poles of Western music, and they differ by a single semitone in the middle voice. Diminished squeezes both gaps and sounds unresolved. Augmented stretches both and sounds like something is about to go wrong in a film.
Add one more third and you get the seventh chords, [0, 4, 7, 11], [0, 3, 7, 10] and [0, 4, 7, 10], which is where this starts sounding like music instead of a hymn.
Keys and Roman numerals #
Nothing says the process has to start on the first degree. Run it from each of the seven degrees in turn, wrapping around the octave, and you get seven chords built entirely from one scale’s seven notes:
1const degrees = build(60, major).slice(0, 7);
2
3const chordOn = (degree: number): number[] =>
4 [0, 2, 4].map((step) => {
5 const i = degree + step;
6 return degrees[i % 7] + Math.floor(i / 7) * 12;
7 });Three come out major, three minor and one diminished. Nobody picked that distribution; the uneven gaps force it. Start where the spacing goes 4 then 3 and you get a major chord. Start where it goes 3 then 4 and you get a minor one.
Musicians write those seven as Roman numerals, with capitals for major, lowercase for minor and a small circle for the diminished one:
I ii iii IV V vi vii°
It took me an embarrassingly long time to notice what that notation is doing. It names each chord by its position in the scale, so V means the chord built on the fifth degree in whatever key you happen to be in. That’s relative addressing. A chord chart becomes key-independent source code, and transposing it means adding a constant.
Order matters too. The strongest pull in the system is V back to I, for two concrete reasons. The first is that the V chord contains the note one semitone below home, which sounds like it’s leaning on the door. The second is more fun. A dominant seventh contains two notes six semitones apart, and:
12 ** (6 / 12) === Math.SQRT2; // true
Six semitones is the tritone, exactly half an octave, and it’s precisely the irrational ratio that sounded like a car alarm earlier. The most unstable interval available sits inside the chord, and when you move to I its two notes each resolve by a semitone, in opposite directions. The tension you hear releasing is an irrational ratio being traded for simple ones.
Progressions #
Seven chords give you a vocabulary. The grammar comes from the fact that they aren’t seven equals: they fall into four roles, and the roles have a transition table. That’s why I vi IV V sounds like a sentence and I V IV I sounds like someone changed their mind halfway through.
1type Role = "tonic" | "prolongation" | "predominant" | "dominant";
2
3const roleOf: Record<string, Role> = {
4 I: "tonic",
5 ii: "predominant",
6 iii: "prolongation",
7 IV: "predominant",
8 V: "dominant",
9 vi: "prolongation",
10 "vii°": "dominant",
11};
12
13const nextRoles: Record<Role, Role[]> = {
14 tonic: ["tonic", "prolongation", "predominant", "dominant"],
15 prolongation: ["prolongation", "predominant", "dominant"],
16 predominant: ["predominant", "dominant"],
17 dominant: ["dominant", "tonic"],
18};
19
20const legal = (progression: string[]): boolean =>
21 progression
22 .slice(1)
23 .every((chord, i) => nextRoles[roleOf[progression[i]]].includes(roleOf[chord]));
24
25legal(["I", "vi", "IV", "V", "I"]); // true
26legal(["I", "IV", "V", "I"]); // true
27legal(["I", "V", "IV", "I"]); // false
Tonic is rest. Prolongation is still basically rest, dressed differently. Predominant is winding up, and dominant is maximum tension with exactly one comfortable exit. The graph is nearly a one-way cycle, and the third example fails because V to IV walks back against the arrow. Music theory calls that a retrogression, which is a nicer name than “invalid state transition” for the same thing.
That failing example deserves a second look, because plenty of famous songs do exactly that. The rule is a default, and breaking it is audible: V to IV is the sound of a blues turnaround refusing to resolve. A finite state machine with a well-known escape hatch is still a finite state machine, and the twelve-bar blues is the whole thing written out as data:
1const twelveBar = [
2 "I", "I", "I", "I",
3 "IV", "IV", "I", "I",
4 "V", "IV", "I", "I",
5];Twelve bars, three chords, one deliberate retrogression in bar 10, and a century of repertoire built on top.
Voice leading #
I had always been told voice leading was a matter of taste. When you move from one chord to the next, what actually moves is several independent voices, and which note each voice takes is a decision. C major to G major is three notes going to three notes, and there are six ways to assign them. Six is small enough to brute force:
1const nearest = (from: number, to: number): number => {
2 const d = (((to - from) % 12) + 12) % 12;
3 return d <= 6 ? d : d - 12;
4};
5
6const perms = <T,>(a: T[]): T[][] =>
7 a.length <= 1
8 ? [a]
9 : a.flatMap((x, i) =>
10 perms([...a.slice(0, i), ...a.slice(i + 1)]).map((p) => [x, ...p]),
11 );
12
13const bestVoicing = (from: number[], target: number[]) =>
14 perms(target)
15 .map((order) => order.map((pc, i) => from[i] + nearest(from[i], pc)))
16 .map((to) => ({
17 to,
18 cost: to.reduce((sum, n, i) => sum + Math.abs(n - from[i]), 0),
19 }))
20 .sort((a, b) => a.cost - b.cost)[0];
21
22bestVoicing([60, 64, 67], [7, 11, 2]); // { to: [59, 62, 67], cost: 3 }
23bestVoicing([60, 64, 67], [5, 9, 0]); // { to: [60, 65, 69], cost: 3 }
nearest is the important line. It picks the register that minimises movement, which is why the answer comes back as 59 rather than 71: each voice takes the shortest path to its assigned pitch class, wrapping around the octave when that’s closer.
For C to G the result is [59, 62, 67]. The bottom voice drops a semitone, the middle drops a whole tone, and the top doesn’t move at all, for three semitones of total movement. That’s the voicing a first-year harmony student is taught to write, and it falls out of minimising a sum. Play it against the naive version and the difference is obvious:
1const play = (chord: number[], at: number) =>
2 chord.forEach((n) => note(midiToFreq(n), at, 1.2, "triangle"));
3
4play([60, 64, 67], 0);
5play([67, 71, 74], 1.4); // naive: every voice jumps up
6play([60, 64, 67], 3.0);
7play([59, 62, 67], 4.4); // minimised: smallest total movement
This is an assignment problem. Three voices give six permutations and four give twenty-four, both fine to enumerate. To do it properly at scale you would run the Hungarian algorithm on a cost matrix, the same tool you’d use to assign delivery drivers to routes. Composers solved it by hand and by ear for about four hundred years and settled on the same objective function: minimise total motion.
Then there are the constraints, which is where it stops being pure optimisation. The strongest one forbids two voices from moving in parallel fifths or parallel octaves. Voices moving together in perfect consonance stop sounding like two people and start sounding like one person with a thick tone, so the rule protects their independence. It’s also mechanical enough to lint:
1const parallels = (before: number[], after: number[]): string[] => {
2 const gap = (a: number, b: number) => Math.abs(a - b) % 12;
3 const dir = (a: number, b: number) => Math.sign(b - a);
4 const found: string[] = [];
5
6 for (let i = 0; i < before.length; i++)
7 for (let j = i + 1; j < before.length; j++) {
8 const interval = gap(before[i], before[j]);
9 if (
10 interval === gap(after[i], after[j]) &&
11 (interval === 0 || interval === 7) &&
12 dir(before[i], after[i]) !== 0 &&
13 dir(before[i], after[i]) === dir(before[j], after[j])
14 )
15 found.push(
16 `voices ${i}+${j}: parallel ${interval === 0 ? "octaves" : "fifths"}`,
17 );
18 }
19
20 return found;
21};
22
23parallels([60, 64, 67], [62, 66, 69]); // ["voices 0+2: parallel fifths"]
24parallels([60, 64, 67], [59, 62, 67]); // []
Same interval, same direction, both voices moving: three conditions and a nested loop. Most rules in the part-writing chapter have this shape, a predicate over two consecutive states. Taken together, the discipline is a linter with about a dozen rules, an optimiser underneath it, and a genre-specific config file deciding which rules are errors and which are warnings. Jazz turns the parallel fifths rule off almost entirely. Renaissance counterpoint turns it up to maximum.
I had filed voice leading under “things musicians have an ear for”. It turns out to be constrained optimisation with a published rule set.
Pitch-class sets #
I didn’t see this one coming, and it’s the reason there is a second version of this article.
Around 1908 composers started writing music with no key and no chords in the traditional sense, and analysts had no vocabulary for it. The old labels all assume a key. Calling an interval an augmented fifth instead of a minor sixth is a claim about where it will resolve, and in this music nothing resolves. Allen Forte’s answer, published in 1973, was to throw the names away and work with integers.
That gives you the same representation this article reached from physics: twelve pitch classes, 0 to 11, with a chord as a set of them.
1type PC = number;
2
3const pcs = (notes: number[]): PC[] =>
4 [...new Set(notes.map((n) => ((n % 12) + 12) % 12))].sort((a, b) => a - b);
5
6pcs([60, 64, 67, 72]); // [0, 4, 7]
Now the interesting question. Two passages use different notes. Are they the same idea? A C major triad and an F♯ major triad are obviously “the same chord” in some sense. Less obviously, so is a C minor triad, because a minor triad is a major triad turned upside down. You want a function that maps all three to one value, so that comparing ideas means comparing values.
That’s canonicalisation, and the procedure is the one you would write yourself.
Step one is normal form: rotate the set into the ordering with the smallest span, using deterministic tie-breaks.
1const rotations = (set: PC[]): PC[][] =>
2 set.map((_, i) => set.map((_, j) => set[(i + j) % set.length]));
3
4const widths = (r: PC[]): number[] =>
5 r.slice(1).reverse().map((p) => (p - r[0] + 12) % 12);
6
7const tighter = (a: PC[], b: PC[]): number => {
8 const wa = widths(a);
9 const wb = widths(b);
10 for (let i = 0; i < wa.length; i++) if (wa[i] !== wb[i]) return wa[i] - wb[i];
11 return a[0] - b[0];
12};
13
14const normalForm = (notes: number[]): PC[] => rotations(pcs(notes)).sort(tighter)[0];
15
16normalForm([3, 11, 2]); // [11, 2, 3]
17normalForm([2, 3, 7, 11]); // [11, 2, 3, 7]
widths measures from the first note to the last, then to the second to last, and so on, and tighter compares those lists lexicographically. The textbook gives three tie-break rules (smallest span, then smallest span to the penultimate note, then lowest starting integer) as prose with worked examples. They are the same rule applied at decreasing scope, so in code they collapse into one comparator and a sort.
Step two is prime form: transpose to zero, do the same for the inversion, and keep whichever is smaller.
1const zero = (set: PC[]): PC[] => set.map((p) => (p - set[0] + 12) % 12);
2const flip = (set: PC[]): PC[] => set.map((p) => (12 - p) % 12);
3
4const lex = (a: PC[], b: PC[]): number => {
5 const i = a.findIndex((v, k) => v !== b[k]);
6 return i === -1 ? 0 : a[i] - b[i];
7};
8
9const primeForm = (notes: number[]): PC[] => {
10 const up = zero(normalForm(notes));
11 const down = zero(normalForm(flip(pcs(notes))));
12 return lex(up, down) <= 0 ? up : down;
13};
14
15primeForm([60, 64, 67]); // [0, 3, 7] C major
16primeForm([60, 63, 67]); // [0, 3, 7] C minor
17primeForm([66, 70, 73]); // [0, 3, 7] F# major
18primeForm([60, 63, 66]); // [0, 3, 6] diminished
19primeForm([0, 3, 6, 9]); // [0, 3, 6, 9]
Look at the first three results again. The major and minor triads I called the two emotional poles of the tradition a few sections ago have the same prime form. The canonical form throws that distinction away on purpose, because at this level of analysis “same intervals, one of them reflected” is the equivalence you want. Whether it’s the right thing to discard depends on the question you’re asking, which is true of every canonical form anyone has designed.
Underneath, the twelve transpositions and twelve inversions form a group of 24 operations acting on the subsets of Z₁₂. Prime form picks one representative per orbit, and the tie-break rules exist to make that pick deterministic. Musicians don’t describe it that way. They describe a procedure, and that works fine, because it’s the same procedure.
The space is finite, so you can enumerate all of it. Twelve pitch classes give 4096 subsets, one per 12-bit integer:
1const everything = new Set<string>();
2
3for (let bits = 0; bits < 4096; bits++) {
4 const set: PC[] = [];
5 for (let p = 0; p < 12; p++) if (bits & (1 << p)) set.push(p);
6 everything.add(set.length === 0 ? "" : primeForm(set).join(","));
7}
8
9everything.size; // 224
Every collection of notes available in Western music, up to transposition and inversion, comes to 224 classes. Forte’s published catalogue covers the useful middle of that, the sets of three to nine notes, which is 208 entries, each with an identifier like 4-Z29. Those identifiers are interned strings for equivalence classes. Someone built the lookup table by hand in the early 1970s, everyone has used it since, and you can regenerate it with a loop over 4096 integers.
The last piece is the one that made me laugh. Canonicalising isn’t free, so analysts also use a cheap fingerprint: count how many times each of the six interval sizes appears in a set.
1const intervalVector = (notes: number[]): number[] => {
2 const set = pcs(notes);
3 const v = [0, 0, 0, 0, 0, 0];
4
5 for (let i = 0; i < set.length; i++)
6 for (let j = i + 1; j < set.length; j++) {
7 const d = (set[j] - set[i] + 12) % 12;
8 v[Math.min(d, 12 - d) - 1]++;
9 }
10
11 return v;
12};
13
14intervalVector([0, 4, 7]); // [0, 0, 1, 1, 1, 0] major triad
15intervalVector([0, 2, 4, 5, 7, 9, 11]); // [2, 5, 4, 3, 6, 1] major scale
Six small numbers, invariant under transposition and inversion, computed in one pass. Two sets with different vectors can’t be related, so you check the fingerprint first and only canonicalise when it matches.
It’s a hash, so it collides:
1primeForm([0, 1, 4, 6]); // [0, 1, 4, 6]
2primeForm([0, 1, 3, 7]); // [0, 1, 3, 7]
3intervalVector([0, 1, 4, 6]); // [1, 1, 1, 1, 1, 1]
4intervalVector([0, 1, 3, 7]); // [1, 1, 1, 1, 1, 1]
Two different set classes with identical fingerprints. Music theory calls them Z-related, and the Z in Forte number 4-Z29 is a flag marking exactly these sets. A field with no interest in computing gave hash collisions their own notation, put the marker in the identifier, and has argued since the 1970s about whether Z-related sets sound alike. They contain the same intervals in the same quantities, yet no transposition or inversion turns one into the other. It’s the cleanest example I’ve found of what a lossy fingerprint is: equal hashes, unequal values, and an open question about whether the hash kept the thing you cared about.
Now look at the major scale’s interval vector again: [2, 5, 4, 3, 6, 1]. Every interval class appears a different number of times, and the tritone appears exactly once. That uniqueness is why you can always tell where you are in a major scale. It’s the earlier observation about uneven gaps carrying information, arrived at from the other end.
Twelve-tone rows #
Schoenberg’s answer to the same problem was to fix an order. Take all twelve pitch classes, arrange them in a sequence, and don’t repeat a note until all twelve have sounded. The sequence is called a row, and it’s a permutation of 0..11:
1const row = [4, 5, 7, 1, 6, 3, 8, 2, 11, 0, 9, 10]; // Schoenberg, Op. 25: E F G Db Gb Eb Ab D B C A Bb
You can transform it three ways: reverse it, invert it (every interval that went up now goes down), or do both.
1const T = (n: number) => (r: number[]): number[] => r.map((p) => (p + n) % 12);
2const I = (r: number[]): number[] => r.map((p) => (12 - p) % 12);
3const R = (r: number[]): number[] => [...r].reverse();Counting the untouched original, that’s four operations with twelve transpositions of each, and the piece is built entirely from the results:
1const forms = new Set<string>();
2
3for (let n = 0; n < 12; n++)
4 for (const op of [(r: number[]) => r, R, I, (r: number[]) => R(I(r))])
5 forms.add(T(n)(op(row)).join(","));
6
7forms.size; // 48
Forty-eight row forms, the orbit of one permutation under a group of order 48. The size isn’t guaranteed, though. Feed in a row with internal symmetry and the orbit collapses, because different operations land on the same result:
1const chromatic = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
2// same loop with `chromatic` gives 24, not 48
Composers know this. A row with deliberate symmetry is a compositional choice, and what they’re choosing is a smaller orbit, which means more repetition in the material whether or not anyone phrases it that way.
Analysts work from a twelve-by-twelve grid called the matrix, which holds all 48 forms at once. Building it takes one subtraction:
1const matrix = (r: number[]): number[][] =>
2 r.map((first) => r.map((p) => (p - first + 12) % 12));Read a row left to right and you get a prime form, right to left a retrograde. Read a column downward for an inversion and upward for a retrograde inversion. It’s one O(n²) build, and after that every lookup is free. In other words it’s a memoisation table, and analysts draw one by hand before studying a piece because they know they’re about to do the same lookup a few hundred times.
Rhythm and duration #
Everything so far has been pitch. Rhythm got one chapter near the front of the book and I skipped past it, which was a mistake, because the notation there is clever.
Durations are negative powers of two: whole note, half, quarter, eighth, sixteenth, each half the last. A dot after a note adds half its value again, a second dot adds half of that, and the whole family fits in one expression:
1const beats = (denom: number, dots = 0): number => (4 / denom) * (2 - 2 ** -dots);
2
3beats(4); // 1 quarter note
4beats(4, 1); // 1.5 dotted quarter
5beats(4, 2); // 1.75 double dotted quarter
6beats(1); // 4 whole note
2 - 2**-dots is the partial sum of a geometric series, written in the score as punctuation. One dot gets you 1.5 and two get you 1.75, and you can watch the series creep toward 2 without reaching it, which probably explains why triple dots are so rare.
The time signature is the pair of header fields that turns durations into bars, and decoding it is less obvious than it looks:
1const meter = (top: number, bottom: number) => {
2 const compound = top > 4 && top % 3 === 0;
3 return {
4 beats: compound ? top / 3 : top,
5 division: compound ? 3 : 2,
6 beatLength: (compound ? 3 : 1) / bottom,
7 };
8};
9
10meter(4, 4); // { beats: 4, division: 2, beatLength: 0.25 }
11meter(6, 8); // { beats: 2, division: 3, beatLength: 0.375 }
12meter(9, 8); // { beats: 3, division: 3, beatLength: 0.375 }
In 4/4 the bottom number gives you the beat. In 6/8 it doesn’t: there are two beats, and each one is a dotted quarter. The same two integers mean different things depending on whether the top one is greater than four and divisible by three. That’s a tagged union with the tag computed from the payload, the kind of thing I’d flag in code review, and it has survived four centuries because performers only have to learn the rule once.
Then there’s the escape hatch. Every duration is a power of two, so any grouping that isn’t one can’t be represented, and the format has an override for that: write the notes anyway and put a number over them.
1const tuplet = (n: number, span: number): number[] =>
2 Array.from({ length: n }, () => span / n);
3
4tuplet(3, 1); // [0.333…, 0.333…, 0.333…] triplet in one beat
5tuplet(5, 1); // [0.2, 0.2, 0.2, 0.2, 0.2] quintuplet
A triplet is a local escape from base two into base three. Accidentals do the same job for pitch, patching the seven-position staff when you need one of the twelve notes it can’t address. Both follow the same design: a mostly adequate encoding plus a marker that overrides it, kept because the common case stays cheap. Here’s a plain division next to an override:
1[0, 0.5, 1, 1.5].forEach((t) => note(midiToFreq(72), t, 0.2)); // four
2[0, 1 / 3, 2 / 3, 1, 4 / 3, 5 / 3].forEach((t) => note(midiToFreq(72), t + 3, 0.2)); // triplets
Phase music #
In this last piece, a composer wrote code without a computer.
In 1972 Steve Reich wrote Clapping Music for two performers, one twelve-unit pattern and no instruments. The first performer repeats the pattern unchanged for the whole piece. The second plays it rotated by one position, then after some repetitions by two, and so on, until the rotation comes back around and both are in unison again.
1const clap = [1, 1, 1, 0, 1, 1, 0, 1, 0, 1, 1, 0];
2
3const rotate = <T,>(a: T[], by: number): T[] =>
4 a.map((_, i) => a[(i + by) % a.length]);That’s the same rotate from the section on modes. There it turned a major scale into a minor one. Here it’s the entire compositional process of a canonical piece of twentieth-century music.
The piece is twelve shifts long because the pattern is twelve units long and rotating by twelve is the identity. What you hear at each shift comes down to a number:
1const alignment = (shift: number): number => {
2 const other = rotate(clap, shift);
3 return clap.reduce((n, x, i) => n + (x && other[i] ? 1 : 0), 0);
4};
5
6Array.from({ length: 12 }, (_, s) => alignment(s));
7// [8, 4, 5, 6, 5, 6, 4, 6, 5, 6, 5, 4]
The pattern has eight claps. At shift 0 all eight coincide, which is unison. At shifts 1, 6 and 11 only four coincide, which gives the sparsest and busiest-sounding texture. The sequence is symmetric around the middle because it’s an autocorrelation, and autocorrelations are symmetric.
So Clapping Music is the autocorrelation of a twelve-bit word, performed. Reich didn’t compute it. He had found phasing years before with tape loops of slightly different lengths drifting out of sync, and this piece writes down the discrete version of that drift. Play the array and you can hear the shape:
1[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].forEach((shift) => {
2 const other = rotate(clap, shift);
3 const at = shift * 12 * 0.16;
4 clap.forEach((x, i) => {
5 if (x) note(midiToFreq(84), at + i * 0.16, 0.06, "square");
6 if (other[i]) note(midiToFreq(77), at + i * 0.16, 0.06, "square");
7 });
8});Philip Glass’s version of the same instinct is even closer to code. In Two Pages a short figure repeats, and each repetition adds or removes a single note, so the figure grows and shrinks one element at a time. It’s an off-by-one, iterated on purpose for the length of a piece.
Notation #
None of the above needed a staff. It’s integers plus one function that turns them into frequencies. But notation is the format the whole literature of Western music is stored in, and once you know what it encodes, it reads like a reasonable design under very old constraints. It was written before printing was cheap, optimised for a human decoding it in real time with both hands busy, and never revised because the install base was too large to migrate.
The clef declares the origin. The five lines aren’t pinned to any frequency, so a symbol at the front says which line is which pitch. The treble clef is a stylised G whose curl wraps around the G line, which makes it a coordinate system with the origin marked in the margin.
The vertical axis is diatonic. Each line and space is one scale step up, so neighbouring positions are sometimes two semitones apart and sometimes one. The staff shows scale degrees dressed up as pitches, which is why a major scale looks like a plain run up the page: the format is optimised for the case it expects.
Accidentals are the escape hatch. There are seven vertical positions per octave and twelve notes to represent, so sharps and flats patch the lossy encoding by shifting whatever a position would otherwise mean.
The key signature is a hoisted constant. In D major every F and C is sharp, so instead of marking each one you declare it once at the start of the line and it applies until something overrides it. That’s also why sheet music tells you the key before you’ve played a note: the key lives in the header.
Durations, as the rhythm section showed, are negative powers of two, and the notation draws the exponent: an empty notehead, then a stem, then one flag per halving. That’s a unary encoding of a binary exponent, a very medieval way to store a number and impossible to misread at a glance.
Tempo is the clock the format leaves out. Durations are measured in beats and only become seconds when a performer supplies a rate, and that separation is what makes a score portable.
Figured bass is a compressed body. For most of the Baroque period, keyboard players were handed a bass line with small numbers under it and nothing else. The numbers are intervals above the bass, read within the declared key, with no pitches or chord names. A 6 means a sixth above the bass, and the key signature decides whether that sixth is eight semitones or nine. The player reconstructs the harmony live.
Figured bass is the most aggressive design decision on the page. It stores deltas against a dictionary declared in the header, it assumes a decoder that knows the conventions of the style, and it’s lossy on purpose: two competent players realise the same figures differently and both are right. The format encodes the constraints and leaves the rendering to the runtime. Everything else on a score is performance annotation layered on top: how loud, how smoothly, which finger.
Putting it together #
Here’s all of it in one place: a key, its diatonic chords, a progression that respects the state machine, voicings chosen by minimising motion, and a melody that indexes into the scale instead of choosing pitches.
1const key = 57; // A
2const scale = build(key, [2, 1, 2, 2, 1, 2, 2]).slice(0, 7); // natural minor
3const beat = 60 / 104;
4
5const chord = (degree: number): number[] =>
6 [0, 2, 4].map((step) => {
7 const i = degree - 1 + step;
8 return scale[i % 7] + Math.floor(i / 7) * 12;
9 });
10
11const progression = [1, 6, 3, 7];
12const melody = [0, 2, 4, 2, 3, 2, 1, 0, 4, 3, 2, 1, 0, 2, 1, 0];
13
14let voices = chord(progression[0]);
15
16progression.forEach((degree, bar) => {
17 const at = bar * 4 * beat;
18 const target = chord(degree).map((n) => ((n % 12) + 12) % 12);
19 voices = bar === 0 ? voices : bestVoicing(voices, target).to;
20
21 // Bass note on the downbeat.
22 note(midiToFreq(voices[0] - 12), at, beat * 3.6, "triangle");
23
24 // Arpeggio: up, down, up, across the bar.
25 [0, 1, 2, 1, 0, 1, 2, 1].forEach((which, i) => {
26 note(midiToFreq(voices[which]), at + i * beat * 0.5, beat * 0.45);
27 });
28
29 // Melody, four notes per bar, always from the scale.
30 melody.slice(bar * 4, bar * 4 + 4).forEach((step, i) => {
31 note(midiToFreq(scale[step % 7] + 12), at + i * beat, beat * 0.9, "triangle");
32 });
33});Every constant in there traces back to something derived above. 57 is an A because of a twelfth root and a tuning fork. [2,1,2,2,1,2,2] is the minor scale because it’s the major scale rotated five places. [0,2,4] is a chord because harmonics overlap when notes sit two scale degrees apart. [1,6,3,7] goes somewhere because of where the tension falls. And nobody picks the voicings by hand anymore: each bar takes the assignment with the smallest total movement from the bar before.
Change the key and the whole thing moves. Swap the gaps for [2,2,1,2,2,2,1] and the same tune turns cheerful. Replace the melody array with anything at all and it still fits, because it indexes into the scale instead of picking frequencies. That constraint is what all the theory above was for.
What I still don’t know #
Quite a lot. This covers pitch thoroughly, rhythm lightly, and almost nothing else.
Why a melody wants to land where it lands is still mostly beyond me. I can write the scale, the chords and the voice leading and get something correct and dull, and the gap between correct and good is the part with no procedure in it.
Rhythm goes deeper than the durations section suggests. Encoding a rhythm is arithmetic. Why a particular pattern makes people move is something else, and everything I’ve read on the perception side is more tentative than the pitch material.
The set theory raises a question it doesn’t answer. Prime form tells you two passages belong to the same class, and whether a listener hears them as related is a separate claim. The Z-relation is where the field admits it: those pairs have identical fingerprints, and analysts still disagree about whether they sound alike. The canonicalisation is exact, and what it predicts about hearing isn’t.
And all of this is one tradition’s answer. Plenty of music divides the octave differently, or doesn’t treat the octave as the unit at all, and none of it is wrong.
The twelve notes have stopped being trivia for me, though. They’re what you get when you try to reconcile powers of two with powers of three, run into a fact about primes, and settle for a rounding error small enough to live with. None of the people who built the rest of the structure had a computer, and several had been dead for centuries before anyone wrote down the word canonicalisation. They had a finite space, an equivalence they cared about and a need to compare things quickly, and they ended up with the same tools a programmer would reach for. I find that oddly reassuring.