Let's make a music teacher, part 2: Wishful thinking and Accidentals

Tjeerd in 't Veen

Tjeerd in 't Veen

— 20 min read

In the previous article, we bootstrapped a Command Line Tool. Then we covered a little bit of music theory to make our program support the C major scale and the C major chord.

In this article, we’ll expand on its feature-set. We’ll implement support for all notes and major scales by using a programming technique called wishful thinking. You’ll learn about data modeling, parsing and handling strings.

In the midst of all this, we will cover accidentals, a crucial part of music. So that we can expand our program.

Wishful thinking

Follow along! View the source on Github.

To implement our code, we’ll use a technique called wishful thinking. A top-down programming practice where we refer to code that doesn’t exist yet, and pretend that it already exists. The further we get, the more in detail we get and implement the code. The compiler will scream at us at first, but it will all work out in the end.

Wishful thinking ensures that we don’t get lost by the details as much, so we can focus on the core concepts. Because it’s a top-down approach, it allows us to break down seemingly complex tasks into smaller problems that are easier to solve.

Wishful thinking is a technique originated from the renowned SICP book.
Alternatively: Browser-friendly version.

You can refer chapter 2.1 for more information.

Updating the program’s entry point

As you may remember from the previous article, the Music struct is the entry point of our program, and its main method run() is where our program starts.

First we’ll update the run() method to initialize a new Note type and a new notesIn(scale:) function.

Remember, we’re programming in a wishful thinking style, this means that we pretend notesIn(scale:) and Note already exist. This won’t compile yet, not until we’re done.

/// The main entrypoint in the application
struct Music: ParsableCommand {

    mutating func run() throws {
        switch (scale, note)) {
        case (true, let noteStr?): // If we have a scale and a note string...
            if let note = Note(string: noteStr) { // ...we make a new Note..
              print(notesIn(scale: note)) // ... and print its major scale.
            } else {
              // The note couldn't be initialized.
              // Maybe a weird character is passed.
              print("\(noteStr) is not a proper note")
            }

    // ... snip
}

This tells us that we need a Note type, and we need to support an initializer to turn a string into a Note. The brain of this program is the notesIn(scale:) function that we also need to make.

But before we can implement all of that, we need to understand a music theory element called accidentals. Long story short: Accidentals are a few extra notes we need to learn so that we can make complete scales.

Let’s go over a quick explanation, and then we’ll implement the notesIn(scale:) function and the Note type.

Already comfortable with accidentals? Or want to skip straight to the code? Feel free to skip to the next section.

Accidentals

Earlier we’ve seen how we can make a C major scale, by starting at C, and moving to the next note using either a whole step (two keys to the right) or a half step (one key to the right).

As a reminder, the major scale pattern is: Whole step, Whole step, Half step, Whole step, Whole step, Whole step, Half step.

In the C major scale, this gives us the notes CDEFGAB and back at C — this scale is always the same on any instrument, we just use the piano for visualization.

The C major scale is the only scale that uses strictly white keys.

But what about other scales? Let’s get the G major scale by applying this pattern. We’ll also add the names of the black keys.

Interesting, we landed on a black key. This one is called F♯ (pronounced F-Sharp). All the black keys represent “in-between” notes, called accidentals. Here we indicate them with a symbol.

Let’s say we grab the F key. If we raise it (we go up) by a half step, we land on the black key on the right of it. That means we will add a sharp. Thus a raised F becomes F♯.

This means that the G major scale is G A B C D E F♯.

All keys — except B and E — work this way. If we start on a C, and raise the C note in pitch by a half step, we get a C♯.

So the notes we have are (if we start from A): A, A♯, B, C, C♯, D, D♯, E, F, F♯, G, G♯.

Accidentals actually have multiple names, which we’ll cover later.

The lack of accidentals

Perhaps you already noticed that there are no black keys between the B and C notes, and between the E and F notes. There are no accidentals between these notes for historical reasons. This makes learning music theory a bit more complicated, and will be the cause of edge-cases in our program.

That was all the theory for today, let’s get started with implementing all the scales.

Implementing notesIn(scale:)

Below you’ll find the implementation of notesIn(scale:).

First, we get all notes . Which are a, aSharp, b, c, cSharp, d, dSharp, e, f, fSharp, g, gSharp.

We write the sharps out as Sharp since we can’t use # or ♯ in code.

Then we need to rotate the notes. For example, If we want the scale of C, we need to start at C. After rotating all the notes, they become c, cSharp, d, dSharp, e, f, fSharp, g, gSharp, a, aSharp, b.

Then we define the pattern of the major scale as an array of whole steps and half steps.

Once we have all the rotated notes, we need to grab the major scale and select the notes following the major scale pattern. We’ll iterate over the scale’s steps, and move an index forward by one (for a half step) or two (for a whole step). Then we collect the corresponding notes and return that as the scale.

/// Get the notes from a major scale.
/// - Parameter scale: A scale
/// - Returns: An array of notes folliowing a major scale pattern
func notesIn(scale: Scale) -> [Note] {

    // Gives us: a, aSharp, b, c, d, dSharp, e, f, fSharp, g, gSharp
    let notes = Note.allCases

    let majorScalePattern: [Step] = [.whole, .whole, .half, .whole, .whole, .whole, .half]

    // Rotate to the note. E.g. rotating to c becomes
    // c, cSharp, d, dSharp, e, f, fSharp, g, gSharp, a, aSharp, b
    let rotatedNotes = notes.rotated(to: scale)

    // grab notes based on steps
    var index = 0
    var collectedNotes = [rotatedNotes[0]]
    // dropLast here: We don't want to end on the note that we start with.
    for step in majorScalePattern.dropLast() {
        // For a whole step we skip a note
        if step == .whole {
            index += 2
        // For a half step we go to the next note
        } else if step == .half {
            index += 1
        }

        collectedNotes.append(rotatedNotes[index])
    }

    return collectedNotes
}

We use dropLast because we don’t want to end on the note we start with. For instance, for the C scale we want CDEFGAB, we won’t end on another C. Otherwise the index would exceed the number of available notes in our array. If we want to support another C, we need to support multiple octaves first.

Implementing Step

Step is nothing more than a tiny enum.

/// A step in a scale
enum Step {
    case whole
    case half
}

Implementing rotated(to:)

We need to add support for the rotated(to:) method, used in notesIn(scale:). We make an extension on Array with elements contrained to Equatable (so we can check for equality between them), and then once we have the index, we make a new array rotated to the index.

If no index is found, we’ll return the array as is.


extension Array where Element: Equatable {

    /// Rotate the elements in an array to its element.
    /// The target element becomes the first element. Elements _before_ the passed element will be appended on the end.
    /// - Parameter element: The target element to rotate to.
    /// - Returns: A new array with `element` as the first element.
    ///
    /// Example: We rotate the characters to a "c".
    ///
    ///
    ///     let rotatedElements = Array("abcdefg").rotated(to: "c")
    ///     print(rotatedElements) // ["c", "d", "e", "f", "g", "a", "b"]
    ///
    func rotated(to element: Element) -> [Element] {
        guard let index = firstIndex(of: element) else {
            return self
        }

        return Array(self[index...] + self[..<index])
    }
}

Implementing Note and Scale

We can use enums to define Note, because we have a fixed amount of unique notes. Notice how we make Note conform to CaseIterable so we get the allCases property that we use inside notesIn(scale:). This gives us all the notes as an array.

On top of that, we made Note conform to Equatable to support the equality checks inside rotated(to:).

/// A representation of a musical note
enum Note: String, Equatable, CaseIterable {
    case a, aSharp
    case b
    case c, cSharp
    case d, dSharp
    case e
    case f, fSharp
    case g, gSharp

    /// Turn a string representation, such as  `F` or `C#` into a `Note` type.
    init?(string: String) {
        let rawValue = string.replacingOccurrences(of: "#", with: "Sharp")
        self.init(rawValue: rawValue)
    }
}

I can almost hear you think “Adding a doc comment to explain that a note is a note? That’s silly!”. But don’t underestimate documentation. You may hear “note” and think “music”, your coworker may hear “note” and think “a small piece of text”. Second, doc comments (quick help) show up nicely when using DocC.

And since a note — such as C — can also mean a scale, we’ll make a typealias. This way we don’t have to make another Scale type with the same cases as Note.

typealias Scale = Note // E.g. C can be note C or the C major scale

Parsing a Note from a String

Inside the run() method, we convert a note string representation to a Note type — such as turning "D♯" to Note.dSharp. To support this parsing operation, we’ll give Note a raw value of String, by adding : String to its declaration.

Then we add a custom initializer where we replace # with Sharp. For instance, "c#" turns into cSharp. Then we can pass this to the initializer that accepts a rawValue. If conversion fails, the initializer returns nil.

// We make Note a `String` rawvalue type.
enum Note: String, Equatable, CaseIterable {

    // ... snip

    // We add a custom initializer
    init?(string: String) {
        let rawValue = string.replacingOccurrences(of: "#", with: "Sharp")
        self.init(rawValue: rawValue)
    }
}

You may be wondering why we’re not implementing ExpressibleByStringLiteral to initialize Note from a String. We’re not using the protocol because we want our initializer to be able to fail, which is something that ExpressibleByStringLiteral doesn’t allow. The second reason is these notes will be made at runtime, which isn’t really compatible with the protocol.

Creating clean output

Even though we used wishful thinking, our program is now complete and can compile.

Let’s give our program a test-run

% swift run music --scale c
[music.Note.c, music.Note.d, music.Note.e, music.Note.f, music.Note.g, music.Note.a, music.Note.b]

% swift run music --scale g
[music.Note.g, music.Note.a, music.Note.b, music.Note.c, music.Note.d, music.Note.e, music.Note.fSharp]

Hooray it works! But yuck, it’s not very readable. Let’s solve this problem by making notes such as gSharp show up as G♯.

Implementing CustomStringConvertible

We’ll give a custom string representation for Note that’s more readable, by conforming to CustomStringConvertible and implementing the description property.

We convert the word Sharp to a proper sharp symbol “♯”. We’ll also uppercase the note to make it look nicer.

extension Note: CustomStringConvertible {
    var description: String {
        self.rawValue
            .replacingOccurrences(of: "Sharp", with: "♯")
            .uppercased()
    }
}

This time, whenever we print a note, it will use this custom representation.

Now let’s try our program again.

% swift run music --scale g
[G, A, B, C, D, E, F♯]
% swift run music --scale c
[C, D, E, F, G, A, B]

Much better!

But the notes are still returned as an array, let’s return the notes as a single string.

Turning notes into a string

We’ll update the run() method inside of Music. We’ll iterate over the notes, then grab their descriptions (which we just implemented), and then join them into one string.

Using the description property of Note (which we just implemented) is discouraged by the CustomStringConvertible protocol. I am not sure why, but the documentation advises to use String(describing:) instead.

/// The main entrypoint in the application
struct Music: ParsableCommand {

    mutating func run() throws {
        switch (scale, note?.lowercased()) {
        case (true, let noteStr?):
            if let note = Note(string: noteStr) {
                let notes = notesIn(scale: note)
                    // We grab each description...
                    .map { note in String(describing: note) }
                    // ... and join them into one string...
                    // ... separated by spaces.
                    .joined(separator: " ")

                print(notes) // Finally we print the string


      // ... snip
}

Final test-run

Let’s try our program one more time:

% swift run music --scale c
C D E F G A B

% swift run music --scale g
G A B C D E F♯

Great, it works! Our output is now clean.

No program is perfect

We’ve come a long way, and have glued a lot of code together. We are now able to show the major scales. Unfortunately, our program actually isn’t working correctly!

The C scale and G♯ scale output the notes just fine, but some other scales are not correct. For instance, if we check the F scale, it will return an invalid scale.

% swift run music --scale f
F G A A♯ C D E

Technically it’s a correct scale, it follows the WWHWWWH steps. However, the note A appears twice (once as a regular A and once as an A♯) and the B is missing. There’s this pesky music theory rule that repeating letters isn’t allowed. Music theory states that in a scale every letter must appear only once. I apologize for not telling you this sooner, but doing so would have doubled the music theory part in this article.

To make sure our scales work correctly, we need to support another accidental called a flat, which you may have seen before, it looks like a ♭. Next time, we’re going to solve this problem and we’ll refactor with the help of test-cases.

Want to learn more?

From the author of Swift in Depth

Buy the Mobile System Design Book.

Learn about:

  • Passing system design interviews
  • Large app architectures
  • Delivering reusable components
  • How to avoid overengineering
  • Dependency injection without fancy frameworks
  • Saving time by delivering features faster
  • And much more!

Suited for mobile engineers of all mobile platforms.

Book cover of Swift in Depth

Conclusion

We used wishful thinking to model our program and decomposed bigger problems into smaller problems that are easier to solve. We parsed our input and turned it back into strings again using CustomStringConvertible. And, we learned about accidentals, a crucial part of music theory.

We have a good skeleton to build upon. Once we fix our scale problem, we can easily support multiple chords and scales, and many other things we haven’t covered yet.

However, our program isn’t perfect. Besides the major scale bug, there are already three main problems that I see.

  • We don’t yet support error handling in a unix-friendly way, we just print messages. This makes our tool harder to use in combination with other command line tools.

  • We’re making a music teacher. But currently our tool is very much a passive tool: we ask it for information, which I think is already quite useful to help quiz ourselves. But wouldn’t it be nice if it starts to quiz us instead, perhaps measure progress?

  • There are no tests.

Let’s continue next time, where we make our program more robust and expand on its feature-set.

Continue to part 3

Written by

Tjeerd in 't Veen has a background in product development inside startups, agencies, and enterprises. His roles included being a staff engineer at Twitter 1.0 and iOS Tech Lead at ING Bank.