Skip to Content

The ABCs of Music Theory

The other day, I was trying to explain introductory music theory to one of my coworkers. In my opinion it's one of those things that is kind of like statistics: you either get it, or you don't. Or, option three, you find some really good explanation online that makes it click for you. I was lucky enough to go from option two to option three for a lot of my technical courses in graduate school, so I'm going to try and give back here. I want to present music theory in a way that may make more sense for people with a technical background [specifically, software]. Most of the implementation is going to live in Python land, just as an FYI. Without further ado, the Abstract Base Classes of Music Theory!

Goals

Ok, what are we really trying to do here? Without a defined goal, we'll just sit here writing forever. We'll try and implement the following base and child classes:

class MusicKey:
    def __init__(self, root: str):
        self.root = root
        self.scale = []
        self.chords = []

    def generate_scale(self):
        raise NotImplementedError

    def generate_chords(self):
        raise NotImplementedError

class MajorKey(MusicKey):
    pass

class MinorKey(MusicKey):
    pass

Definitions

Any good technical content usually relies on a few axioms1. I'm going to start with just one:

  • There are only 11 notes and they are [C, C#, D, D#, E, F, F#, G#, A, A#, B]2
  • The order of the notes matters
  • Once you hit the end of the list, you start over again: [..., A#, B, C, C#, ...]

Normally people start with a piano here, but I've always thought that was confusing. The piano visually has two types of notes (white keys and black keys) which are purely an implementation detail. They usually end up confusing people and really have nothing to do with the base theory. Let's start with a guitar fretboard instead.

Guitar fretboard

(Image source here)

You can play a whole lot of notes on that! Each fret (gap between two vertical metal bars) is a note in the list. There's some redundant notes in that list, but you can effectively play four copies of the list up and down. But as most people find out, just playing random notes usually comes out terribly. Luckily, people spent some time and found some good patterns of notes. Oh right, but code! Here's how we'll define our notes to start:

NOTES = [
    'C',
    'C#',
    'D',
    'D#',
    'E',
    'F',
    'F#',
    'G',
    'G#',
    'A',
    'A#',
    'B',
]

Next, we'll define how we go from one note to another. No worries, there's only two ways of doing this.

  • Incrementing by one is called a half step (or semitone)
  • Incrementing by two is called a whole step (or whole tone)

Let's add an index _idx to the current note to our class, and add some functions for this:

NUM_NOTES = len(NOTES)


class MusicKey:
    def __init__(self, root: str):
        self.root = root
        self._idx = 0

    def half_step(self) -> int:
        """Increments by a half step"""
        self._idx = (self._idx + 1) % NUM_NOTES
        return self._idx

    def whole_step(self) -> int:
        """Increments by a whole step"""
        self._idx = (self._idx + 2) % NUM_NOTES
        return self._idx

If you're coming at this from a non-coding background, the % symbol is the modulo operator. It implements the “remainder” function in division. If you're coming from a software background and wondering why I'm storing a class variable and returning it, give me a bit!

Major and Minor Scales

So it turns out just playing all the notes isn't that fun. Certain subsets sound good though, and that's what a scale is. It's a defined pattern that tells us which notes to play and in what order. Usually, they sound pretty good! They come in a lot of types, but here are some of the more popular ones:

  • major
  • minor
  • harmonic
  • blues
  • pentatonic

Major scales are probably the most popular, so let's start there. They have a well defined pattern. Start with one note, then go through the following intervals: whole step, whole step, half step, whole step, whole step, whole step, half step. After that, we're back where we started!

class MajorKey(MusicKey):
    def generate_scale(self):
        """Creates the major scale for a root note"""
        self.scale.append(NOTES[self._idx])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.half_step()])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.half_step()])

Now let's see if this really works.

Cmaj = MajorKey('C')
Cmaj.generate_scale()
print(Cmaj.scale)

And then we get the following output:

['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']

Wikipedia says we got it right, so that's a great start. Let's try the minor scale now! I'm not going to write out the pattern for those directly, but instead I'll go right to the code. It's still going to be the same length and end on the same note we started on, but with a different pattern in the middle.

class MinorKey(MusicKey):
    def generate_scale(self):
        """Creates the minor scale for a root note"""
        self.scale.append(NOTES[self._idx])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.half_step()])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.half_step()])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.whole_step()])

Let's see how we did:

Cmin = MinorKey('C')
Cmin.generate_scale()
print(Cmin.scale)

['C', 'D', 'D#', 'F', 'G', 'G#', 'A#', 'C']

Hmm, that one is a bit tough to read. The “easiest” one to read (least amount of sharps) is going to be A, so that let's check that instead:

Amin = MinorKey('A')
Amin.generate_scale()
print(Amin.scale)

['A', 'B', 'C', 'D', 'E', 'F', 'G', 'A']

Great! The basic scales are solidly in our grasp now. One thing you may have noticed is that the next note is always a half or whole tone away. That's a coincidence, not a rule! Additionally, not every scale needs eight notes. One of the most popular scales is the blues scale, which has the following pattern:

class BluesScale(MusicKey):
    def generate_scale(self):
        """Creates the blues scale for a root note"""
        self.scale.append(NOTES[self._idx])
        self.whole_step()
        self.scale.append(NOTES[self.half_step()])
        self.scale.append(NOTES[self.whole_step()])
        self.scale.append(NOTES[self.half_step()])
        self.scale.append(NOTES[self.half_step()])
        self.whole_step()
        self.scale.append(NOTESself.half_step()
        self.scale.append(NOTES[self.whole_step()])

Then we can go try it out:

Cblues = BluesScale('C')
Cblues.generate_scale()
print(Cblues.scale)
# ['C', 'D#', 'F', 'F#', 'G', 'A#', 'C']

Chords

Playing individual notes is fun, but what about multiple ones at a time? There are patterns for that too! If we were to index all the notes, we'd get this as our output:

for x, note in enumerate(Cmaj.scale):
    print(f"{x+1}: {note}")

1: C
2: D
3: E
4: F
5: G
6: A
7: B
8: C

Sorry for the +1, but the real world uses one-indexing, not zero-indexing3. They have some fancy names in music theory land (tonic, supertonic, etc) that we don't really need, so we're fine using numbers. Much like scales, we have a whole lot of different chords types:

  • major
  • minor
  • diminished
  • suspended
  • augmented

And the list goes on and on. We'll focus on the first three ones, because they fall quite cleanly in with our previou ideas. Each of those chords is three notes at once. How do we choose the notes?

  1. Choose the root note
  2. Move up the scale two notes and play it
  3. Move up the scale two more notes and play that too

That's it! No magic tricks here. Let's do that in our base class:

class MusicKey:
    def generate_chords(self):
        """Creates all chords in the scale"""
        for note in self.scale:
            chord_root_idx = self.scale.index(note)
            first_note = note
            third_note = self.scale[(chord_root_idx + 2) % 7]
            fifth_note = self.scale[(chord_root_idx + 4) % 7]
            self.chords.append([first_note, third_note, fifth_note])

Why did I call the variables third_ and fifth_? They're the 3rd and 5th notes, assuming the root is the 1st note. But why did I hard code mod 7 instead of using len(self.scale)? It's because the modulo operator gets messed up with the octave note4. Really, our scale should only be:

['C', 'D', 'E', 'F', 'G', 'A', 'B']

But it feels incomplete just sitting there without the octave note! Let's see how it ends up doing.

Cmaj = MajorKey('C')
Cmaj.generate_scale()
Cmaj.generate_chords()
for ch in Cmaj.chords:
    print(ch)

['C', 'E', 'G']
['D', 'F', 'A']
['E', 'G', 'B']
['F', 'A', 'C']
['G', 'B', 'D']
['A', 'C', 'E']
['B', 'D', 'F']
['C', 'E', 'G']

If you go to a piano and play all these, it will… kind of work? Some will sound good, some happy, some sad, some weird. Hm, I think we need a better pattern then this! We need a way to label them all. Guess what, the theorists already took care of that too. All chords are defined as distances between consecutive notes. We'll focus on a few here:

Major chords require:

  • four half steps between the first two notes
  • three half steps between the latter two notes

Minor chords require:

  • three half steps between the first two notes
  • four half steps between the latter two notes

Dimished chords require:

  • three half steps between the first two notes
  • three half steps between the latter two notes

As a reminder, a half step (or semitone) is an increment of one in our original 12 note list. Let's code it up!

class MusicKey:
    def __init__(self):
      ...
      self.chord_labels = []

    def label_chords(self):
        """Categorizes all chords in the scale"""
        for chord in self.chords:
            first_dist = NOTES.index(chord[1]) - NOTES.index(chord[0])
            if first_dist < 0:
                first_dist += NUM_NOTES

            second_dist = NOTES.index(chord[2]) - NOTES.index(chord[1])
            if second_dist < 0:
                second_dist += NUM_NOTES

            if (first_dist == 4) and (second_dist == 3):
                self.chord_labels.append('Major')
            elif (first_dist == 3) and (second_dist == 4):
                self.chord_labels.append('Minor')
            elif (first_dist == 3) and (second_dist == 3):
                self.chord_labels.append('Diminished')

We have to do the negative check because wrap-around issues; typical issue and typical solution with the modulo operator. In action:

Cmaj = MajorKey('C')
Cmaj.generate_scale()
Cmaj.generate_chords()
Cmaj.label_chords()
for label, ch in zip(Cmaj.chord_labels, Cmaj.chords):
    print(f"{label}: {ch}")

Major: ['C', 'E', 'G']
Minor: ['D', 'F', 'A']
Minor: ['E', 'G', 'B']
Major: ['F', 'A', 'C']
Major: ['G', 'B', 'D']
Minor: ['A', 'C', 'E']
Diminished: ['B', 'D', 'F']
Major: ['C', 'E', 'G']

Ahh, now things are finally starting to click. When we play all the major chords, they “feel” the same. When we play the minor chords, they do as well. But you know what's even cooler? We defined all of this in the base class, not an inherited one. This means that all MajorKey based items will have the same chord progression, since they have the same self.scale generation pattern! Let's see if that's true:

Gmaj = MajorKey('G')
Gmaj.generate_scale()
Gmaj.generate_chords()
Gmaj.label_chords()
for label, ch in zip(Gmaj.chord_labels, Gmaj.chords):
    print(f"{label}: {ch}")

Major: ['G', 'B', 'D']
Minor: ['A', 'C', 'E']
Minor: ['B', 'D', 'F#']
Major: ['C', 'E', 'G']
Major: ['D', 'F#', 'A']
Minor: ['E', 'G', 'B']
Diminished: ['F#', 'A', 'C']
Major: ['G', 'B', 'D']

Hey look, same pattern! Major minor minor Major Major minor diminished Major5. All major scales will have this pattern. What about minor scales?

Emin = MinorKey('E')
Emin.generate_scale()
Emin.generate_chords()
Emin.label_chords()
for label, ch in zip(Emin.chord_labels, Emin.chords):
    print(f"{label}: {ch}")

Minor: ['E', 'G', 'B']
Diminished: ['F#', 'A', 'C']
Major: ['G', 'B', 'D']
Minor: ['A', 'C', 'E']
Minor: ['B', 'D', 'F#']
Major: ['C', 'E', 'G']
Major: ['D', 'F#', 'A']
Minor: ['E', 'G', 'B']

Sure, it's a different pattern, but it's the same pattern we'll get for every minor scale: minor diminished Major minor minor Major Major minor.

Conclusions

We have a nifty scale and chord generator, and hopefully you're less afraid of music theory. If you're curious about messing around with the code, I've put it up on GitHub here.

There's even more interconnectedness to explore, but I think I'm done writing for today. We can actually abstract the whole scale and key creation away too, but I'll save that for a follow up post. If you're curious, I highly recommend you look up the Circle of Fifths. Just make sure you're sitting down! It gets pretty mind blowing pretty quickly once you “see it”.


  1. Axiom is just a fancy word for something I don't have to prove and you are forced to trust me on. ↩︎

  2. Flats are also fine, just not really needed here. ↩︎

  3. Haters gonna hate ↩︎

  4. Octave is a fancy word for the same note, but moved up or down by a list iteration. ↩︎

  5. No hate for diminished chords! Just needed a third formatter and Markdown is dumb. ↩︎