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.
· 41 min read
Music Theory for Software Developers #
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 puts scales, modes and chords on a fretboard and a circle of fifths at once, with everything updating as you change the tonic or the mode. Building it was how I intended to learn the theory. That worked, though not the way I planned: a player can leave a decision implicit and a computer will not, so every implicit decision in the domain turned into a question I could not answer.
The biggest one: I still could not have told you why there are twelve notes rather than eleven or nineteen, or why the major scale has the particular shape it has.
That is not a gap in practice. It is a gap in explanation. Almost every introduction to music theory presents the system as a set of conventions to absorb: here is the staff, here are the notes, this is the major scale, memorise the pattern. The reasons get left out, which is strange, 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, starting from a single number changing over time and stopping only when I had something that sounded like music. Nothing below is taken on faith.
1const ctx = new AudioContext();
2const out: AudioDestinationNode = ctx.destination;Sound and oscillators #
Sound is air pressure changing. A speaker produces it by moving a cone, 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 makes the list up as it goes.
The browser will generate the simplest possible one for you if you name a shape and a rate:
1const osc = ctx.createOscillator();
2osc.frequency.value = 440;
3osc.connect(out);
4osc.start();
5osc.stop(ctx.currentTime + 1);One second of a sine wave repeating 440 times a second. The only number in there carrying any musical content is 440, and even that is a convention: it is the frequency we agreed to call A. Change it to 300 and the code is equally correct, it just plays something else. The oscillator has no concept of a note. It knows a rate.
Higher frequency, higher pitch. That is the last one-to-one mapping in this article.
You may hear a click when it stops. That is not a bug in the browser, it is physics. The wave was cut mid-cycle, so the cone was displaced and then snapped back to zero instantly, and an instantaneous pressure change is what a click is.
The fix is a second number changing over time, this one controlling volume. Musicians call the shape of it 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 nearly nothing. Attack and decay. That is the difference between a test tone and something you would listen to twice. Stretch the attack out to half a second and the note stops sounding struck and starts sounding bowed, without the pitch moving by a single hertz. A great deal of what you hear as the character of an instrument lives in that curve. The full version has four stages (attack, decay, sustain, release); two is enough here.
Every snippet below uses this helper.
The harmonic series #
A sine wave is exactly one frequency, which is why it sounds like a hearing test and not like any instrument. A real string tuned to 220Hz does vibrate 220 times a second, but it also vibrates in halves, in thirds, and in quarters at the same time. Those extra motions produce 440, 660, 880, 1100 and on up.
That stack is the harmonic series: the fundamental multiplied by 1, 2, 3, 4. Nobody chose it. It follows from the geometry of a vibrating string or a column of air, so it comes out the same on every instrument built around one.
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]
Individually those are dull. What matters is that they always arrive as a package, and that the relative loudness of each one is the recipe that distinguishes a violin from a trumpet playing the same note. That recipe is timbre.
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 times, four different 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. The waveform is the harmonic recipe.
Keep the series in mind. It explains nearly everything that follows.
The octave #
Start with five frequencies, each one double the last.
1[110, 220, 440, 880, 1760].forEach((f, i) => note(f, i * 0.45, 0.4));Five different pitches that sound like the same note. Not similar: the same, at different brightness. Cultures with no contact with each other reached that conclusion 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 introduces no frequency the lower one was not already producing, so there is nothing new for the ear to find.
Two consequences carry through the rest of this article.
Pitch is multiplicative. An octave up means times two, not plus anything. The gap from 110 to 220 is 110Hz and 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.
There is only one octave to solve. Because doubling returns you to where you started, the question “which pitches should exist” collapses into “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 #
The obvious move is to divide the octave evenly and stop thinking about it. Nobody does that, because we do not hear all pairs of frequencies the same way.
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. The last one sounds like a car alarm. The pattern is not subtle: the simpler the fraction, the better it sounds. That is a suspiciously arithmetic result for something as subjective as “pleasant”, and 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. The higher produces 330, 660, 990, 1320. They share 660 and 1320 exactly. Two notes a fifth apart are not two independent sounds, they are two overlapping stacks reinforcing each other. Now try 220 against 311, which is close to √2. Nothing coincides, at any harmonic.
The second is roughness. Two frequencies that are close but not equal drift in and out of phase, and you hear the loudness pulsing:
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 disappears when they match, and its rate is exactly the difference between them: six hertz apart gives six pulses a second. This is called beating, and it is what an out-of-tune note actually is. When a ratio is complicated, the two harmonic stacks are littered with pairs sitting a few hertz apart, and every one of those pairs is beating.
Both mechanisms describe the same thing from different angles. Add two waves together, which is what an eardrum does, and ask how long before the combined shape repeats. For 2:1 it repeats immediately. For 16:15 it takes fifteen cycles. For √2 it never repeats at all, because √2 is irrational, so there is no period to lock onto.
Consonance is your ear finding a repeating pattern quickly. As far as I can tell, that is the whole of it.
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 well 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, and we are back near where we started but not on it. 223.00 instead of 220. Close enough to be audibly trying to be the same note, wrong enough to be unusable.
This is not floating point drift. It is structural, and it is clearer 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 makes the gap 23.46 cents: roughly a quarter of the distance between two adjacent piano keys. Audible, and ugly.
It also cannot be fixed, for a reason any programmer will recognise. Stacking n fifths multiplies by 3ⁿ / 2ⁿ. Stacking m octaves multiplies by 2ᵐ. For the two to ever 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 n. Not approximately never. Never.
The system everyone would want, where the octave is pure and the fifths are pure and the whole thing closes into a neat loop, does not exist and never did.
That is the single most useful thing I took from the exercise, and it is not really about music. It is a constraint, not an engineering failure, and once you accept it the design question changes shape entirely. It stops being “how do we get this right” and becomes “where do we put the error”. Every tuning system in history is a different answer: which intervals stay pure, which absorb the damage, and which keys become unusable as a result.
Equal temperament #
The modern answer gives up on pure ratios altogether. Divide the octave into twelve equal multiplicative steps and accept that nothing except the octave will ever be exactly right 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, because that is what a twelfth root does. Floating point adds its own small error on top, which is a separate and much less interesting problem.
The obvious question is why twelve, and it turns out to be answerable by search rather than by tradition. Divide the octave into n equal steps, find the step that lands closest to a real 3/2 fifth, and measure how close it gets:
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. Read the error column on a selection of them and twelve stops looking arbitrary:
| 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 inside about a tenth of a percent, and it is roughly eight times better than anything smaller. Twenty-four ties exactly, and so do thirty-six and forty-eight, for a boring reason: they contain twelve, so they contain its fifth. The first genuine improvement is 29, then 41, then 53, and nobody is building 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 the thirds come along with it.
How close, precisely:
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 fifth on every piano on Earth is two cents flat, and there is a slow beat in all of them if you listen for it. Everyone decided that being slightly wrong everywhere was better than being perfect in one key and unusable in the others.
The real payoff is representational. Notes are now integers. Pick one as zero and every other note is a whole number of steps away from it. The standard is MIDI numbering, 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
Everything from here on is arrays of integers. I stopped thinking about frequencies entirely.
Scales and modes #
Having twelve notes does not mean using twelve notes.
1for (let n = 60; n <= 72; n++) note(midiToFreq(n), (n - 60) * 0.18, 0.25);That sounds like a sound effect rather than music. Every step is identical, so nothing stands out, nothing sounds like home, and there is no way to 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. And a scale is best written as the gaps rather than as the 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 summing to 12, which is what makes the pattern close the octave exactly. The most familiar sound in Western music, expressed as a seven element array.
Change one number 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 are the same seven note idea with the gaps rearranged. The pentatonics drop two notes, which is why they are so forgiving and why every beginner guitar lesson starts there. The blues scale puts one deliberately awkward note back in.
Then the part that changed how I think about this. The modes, which I had always seen presented as seven exotic Greek names to be memorised, 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. Rotation 5 is the natural minor. Major and minor are not two systems, they are two offsets into the same one. Lydian sounds dreamy, Phrygian sounds Spanish, Locrian sounds broken, and all of that comes from which gap sits where relative to the note you started on.
This is the point where music theory stopped feeling arbitrary to me. Hold on to that rotate. It comes back three more times, in places that look nothing like scales.
Chords #
A chord is more than one note at once, which is not a useful definition, because most combinations sound bad. The useful question is which ones do not, and 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 is a C major chord: three notes at 0, 4 and 7 semitones above the root. As frequency ratios it is 1 : 1.2599 : 1.4983, very nearly 4 : 5 : 6. Three simple ratios sharing harmonics all over the place, which 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; stack two of a 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. That is the entire distance between the two emotional poles of Western music: I had absorbed the idea that they were deep categories, and they differ by a single semitone in the middle voice. Diminished compresses 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 sevenths, [0, 4, 7, 11], [0, 3, 7, 10] and [0, 4, 7, 10], which is where this starts sounding like music rather than a hymn.
Keys and Roman numerals #
Nothing says the process has to start on the first degree. Run it from each of the seven in turn, wrapping around the octave, and you get seven chords built entirely from the seven notes of one scale:
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, one diminished. Nobody picked that distribution. It is forced by the uneven gaps: start the process where the spacing is 4 then 3 and you get a major chord, 3 then 4 and you get a minor one.
Musicians write those seven as Roman numerals, capitals for major, lowercase for minor, 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 chords by their position in the scale rather than by their pitch, so V means the chord built on the fifth degree, whatever key you are in. That is relative addressing, which makes a chord chart key-independent source and transposing a matter of adding a constant.
Order matters too, and 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 is leaning on the door. The second is more interesting. 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 is precisely the irrational ratio that sounded like a car alarm earlier. The most unstable interval available is sitting inside the chord, and both of its notes resolve outward by one semitone when you move to I. The tension you hear releasing is an irrational ratio being exchanged for simple ones.
Progressions #
Seven chords is a vocabulary, not a grammar. What makes I vi IV V sound like a sentence and I V IV I sound like someone changed their mind halfway through is that the chords are not seven peers. They fall into four roles, and the roles have a transition table.
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. Dominant is maximum tension, and it has exactly one comfortable exit. The graph is close to a one-way cycle, and the reason the third example fails is that V to IV walks back against the arrow. Music theory calls that a retrogression, which is a better name than “invalid state transition” for the same thing.
That last example is worth dwelling on, because plenty of famous songs do exactly it. The rule is not a law, it 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.
The twelve-bar blues is the clearest case of 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 #
Here is the thing I had always been told was a matter of taste. When you move from one chord to the next, you are not moving a chord, you are moving 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 chooses 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 the long way round when that is closer.
The result for C to G is [59, 62, 67]: the top voice drops a semitone, the middle drops a tone, the bottom does not move at all. Total movement three semitones. That is 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 not subtle:
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 is six permutations, four voices is twenty-four, and both are fine to enumerate. If you wanted to do it properly at scale the answer is the Hungarian algorithm on a cost matrix, which is the same tool you would use to assign delivery drivers to routes. Composers were solving it by hand and by ear for four hundred years, and they arrived at the same objective function: minimise total motion.
Then there are the constraints, which is where it stops being pure optimisation. The strongest one is that two voices must not move in parallel fifths or parallel octaves. The reason is that voices moving in perfect consonance stop sounding like two people and start sounding like one person with a thick tone. The rule exists to preserve independence, and it is 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. Every rule in the chapter on part writing is of this shape: a predicate over two consecutive states. The whole 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 off the parallel fifths rule almost entirely. Renaissance counterpoint turns it up to maximum.
I had filed voice leading under “things musicians have an ear for”. It is a constrained optimisation with a published rule set.
Pitch-class sets #
This is the one I did not see coming, and it is the reason I ended up writing 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 something an augmented fifth rather than a minor sixth is a claim about where it is going to resolve, and in this music nothing resolves. Allen Forte’s answer, published in 1973, was to throw the names away and work with integers instead.
Which gives you the same representation this article arrived at from physics: twelve pitch classes, 0 to 11, and a chord is 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 real 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, and so, less obviously, is a C minor triad, because a minor triad is a major triad turned upside down. What you want is a function that maps all of those to one value, so that comparing ideas is comparing that value.
That is canonicalisation, and the procedure is exactly the one you would write.
Step one, normal form. Rotate the set into the ordering that spans the smallest distance, with 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 lexicographically. That is the textbook’s three tie-break rules (smallest span, then smallest span to the penultimate note, then lowest starting integer) collapsed into one comparator, because they are the same rule applied at decreasing scope. There it is prose with worked examples. Here it is a sort.
Step two, prime form. Transpose to zero, do the same to the inversion, 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]
Read the first three lines again. Major and minor triads, which the earlier section presented as the two emotional poles of the whole tradition, have the same prime form. The canonical form deliberately discards the distinction, because at this level of analysis “same intervals, one of them reflected” is the equivalence you want. Whether that is the right thing to throw away depends entirely on what question you are asking, which is true of every canonical form anyone has ever designed.
What is actually happening: the twelve transpositions and the twelve inversions form a group of 24 operations acting on the set of subsets of Z₁₂, prime form picks one representative per orbit, and the tie-break rules exist to make that choice deterministic. Musicians do not describe it that way. They describe a procedure, which is fine, because the procedure is the same procedure.
And since the space is finite, you can enumerate all of it. Twelve pitch classes means 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, is 224 things. Forte’s published catalogue covers the useful middle of that, the sets of three to nine notes, and comes to 208 entries, each with an identifier like 4-Z29. Those identifiers are interned strings for equivalence classes, and the catalogue is a lookup table somebody built by hand in the early 1970s and everyone has used since. You can regenerate it in a loop over 4096 integers.
There is one more piece, and it is the part I found genuinely funny. Canonicalising is not free, so analysts also use a cheap fingerprint: count how many times each of the six interval sizes occurs 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 cannot possibly be related, so you check the fingerprint first and only canonicalise when it matches.
It is 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 genuinely different set classes, identical fingerprints. Music theory has a name for this: they are Z-related, and the Z in Forte number 4-Z29 is a flag marking exactly these. A field with no interest in computing gave hash collisions their own notation, put the marker in the identifier, and has been arguing since the 1970s about whether Z-related sets sound alike. They contain the same intervals in the same quantities and cannot be transformed into one another. It is the clearest illustration I have found of what a lossy fingerprint actually is: equal hashes, unequal values, and a real question about whether the thing the hash kept is the thing you cared about.
Look at the interval vector of the major scale 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, and it is the same observation as the earlier one 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 use no note twice until all twelve have sounded. The sequence is called a row, and it is 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 are allowed to transform it in three ways. Reverse it. Invert it, meaning every interval that went up now goes down. Or 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();Four operations, 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, which is the orbit of one permutation under a group of order 48. And the size is not guaranteed: feed it 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. Rows with deliberate symmetry are a compositional choice, and what they are 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. It is built with 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 have a prime form. Right to left, a retrograde. A column downward is an inversion, upward a retrograde inversion. One O(n²) build, then every lookup is free.
That is a memoisation table. Analysts draw one by hand at the start of studying a piece, precisely because they are about to do the same lookup a few hundred times. Nobody taught them the word for it.
Rhythm and duration #
Everything so far has been pitch. The other axis got one chapter near the front of the book and I skipped past it, which was a mistake, because it is where the notation gets genuinely clever.
Durations are negative powers of two. A whole note, then half, quarter, eighth, sixteenth, each one half the last. A dot after the note adds half of it again, a second dot adds half of that, and the whole family is 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, and the notation writes it as punctuation. One dot gets you 1.5, two gets you 1.75, and you can see the series converging on 2 without ever reaching it, which is why nobody writes a triple dot.
The time signature is the two header fields that turn those into bars, and the decoding 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 tells you the beat. In 6/8 it does not: there are two beats, not six, and each one is a dotted quarter. The same two integers mean different things depending on whether the top one is over four and divisible by three. It is a tagged union with the tag computed from the payload, which is the kind of thing you would flag in review and which has survived four centuries of use because performers learn the rule once.
And then the escape hatch. Since every duration is a power of two, any grouping that is not a power of two is unrepresentable, so the format has an override: 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. It is the rhythmic equivalent of accidentals, which patch the seven-position staff when you need one of the twelve notes it cannot address. Both are the same design move: a mostly-adequate encoding plus a marker that overrides it, kept because the common case stays cheap.
Hear the difference between a division and 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 #
One more piece, and it is the one where a composer wrote code without a computer.
In 1972 Steve Reich wrote a piece called Clapping Music. Two performers, one twelve-unit pattern, no instruments. The first performer repeats the pattern unchanged for the entire 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 is the same rotate from the section on modes. There it turned a major scale into a minor one. Here it is 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 rotation by twelve is the identity. And what you hear at each shift is 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]
Eight claps in the pattern. At shift 0 all eight coincide, which is unison. At shifts 1, 6 and 11 only four coincide, which is the sparsest and busiest-sounding texture. The sequence is symmetric around the middle, because it is an autocorrelation and autocorrelations are.
Clapping Music is the autocorrelation of a twelve-bit word, performed. Reich did not compute it, he found it with tape loops of slightly different lengths drifting out of phase, noticed it was interesting, and wrote down the discrete version. 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 more like 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 is an off-by-one error, iterated deliberately, and it is the whole piece.
Notation #
None of the above needed a staff. It is integers and one function that turns them into frequencies. But notation is the format the entire literature of Western music is stored in, and once you know what it encodes it reads as a fairly reasonable design under very old constraints: written before printing was cheap, optimised for a human decoding it in real time with their hands occupied, and never revised because the install base was too large to migrate.
The clef declares the origin. Five lines pinned to no particular frequency, so a symbol at the front says which line is which pitch. The treble clef is a stylised G whose curl wraps the G line. A coordinate system with the origin marked in the margin.
The vertical axis is diatonic, not chromatic. Each line and space is one step up the scale, so consecutive positions are sometimes two semitones apart and sometimes one. It shows scale degrees dressed up as pitches, which is why the major scale looks like a plain run up the page. The format is optimised for the case it expects.
Accidentals are the escape hatch. Seven vertical positions per octave, twelve notes to represent. Sharps and flats patch the lossy encoding by shifting whatever the position would otherwise mean.
The key signature is a hoisted constant. In D major every F and C is sharp, so rather than marking each one you declare it once at the front of the line and it applies until something overrides it. Which is also why sheet music tells you the key before you have played a note: the key is a header field, not part of the body.
Durations are negative powers of two. Whole, half, quarter, eighth, each one half the last, and the notation encodes the exponent visually: an empty notehead, then a stem, then one flag per halving. A unary encoding of a binary exponent, which is a very medieval way to store a number and impossible to misread at a glance.
Tempo is the clock the format omits. Those durations are beats, not seconds, and become seconds only when a performer supplies a rate. 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, interpreted within the declared key: no pitches, no chord names, just offsets. 6 means put a note six scale steps up, and the key signature supplies whether that is six semitones or seven. The player reconstructs the harmony live.
That last one is the most aggressive design decision on the page. It stores deltas against a header-declared dictionary, it assumes a decoder that knows the conventions of the style, and it is lossy on purpose: two competent players realise the same figures differently and both are correct. The format encodes the constraints and leaves the rendering to the runtime.
Pitch on a diatonic axis with a declared origin and an escape hatch, duration as negative powers of two, a couple of header fields, and an optional compressed harmony track. Everything else on the page is performance annotation layered on top: how loud, how smoothly, which finger.
Putting it together #
Everything above 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 rather than 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 is 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 the voicings are no longer hand-chosen: each bar takes the assignment with the smallest total movement from the bar before.
Change the key and it moves. Change the gap array to [2,2,1,2,2,2,1] and the same tune turns cheerful. Change the melody array to anything at all and it still fits, because it indexes into the scale instead of picking frequencies. That constraint is what all of the theory above was for.
Open questions #
Plenty. 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 that is correct and dull, and the gap between correct and good is exactly 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 not, and everything I have read on the perception side is more tentative than the pitch material.
The set theory chapters raise a question they do not answer. Prime form tells you two passages belong to the same class. Whether a listener hears them as related is a separate claim, and 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. What it predicts about hearing is not.
And all of the above is one tradition’s answer. Plenty of music divides the octave differently, or does not treat the octave as the unit at all, and none of it is wrong.
But it has stopped being trivia. Twelve notes is what you get when you try to reconcile powers of two with powers of three, run into a proof about primes, and settle for a rounding error small enough to live with. A scale is a subset with gaps uneven enough to navigate by. A chord is a set of notes whose harmonics already overlap. A key is relative addressing. A progression is a state machine. Voice leading is an assignment problem with a lint config. Prime form is canonicalisation, the interval vector is the hash that goes with it, and a twelve-tone matrix is a memo table.
None of the people who built those constructions had a computer, and several of them had been dead for centuries before anyone wrote the word canonicalisation down. They were not anticipating anything. They had a finite space, an equivalence they cared about, and a need to compare things quickly, and that combination has exactly one shape.