In a previous post, I wrote about a representation of musical notes that hard-coded a list of notes, and (some of) their enharmonic equivalents. While this approach worked for what we were trying to accomplish, I wasn’t very extensible.

For example, imagine we’re working with the Db scale (Db, Eb, F, Gb, Ab, Bb, and C). A I IV plagal cadence in that key would be spelled I (Db, F, Ab), IV (Gb, Bb, Db). But changing that to a I iv progression requires we flatten the third of the IV chord, while keeping its note name (B), so we’d be left with a Bbb.

Our current Note implementation doesn’t support this. Let’s fix that.


First, let’s simplify our module attributes and only define the set of natural notes for convenience:

@naturals [
  {"C", 0},
  {"D", 2},
  {"E", 4},
  {"F", 5},
  {"G", 7},
  {"A", 9},
  {"B", 11}
]

The simplest case we can handle is creating a new note of length 1. We know that it will be a natural, so we can just look it up from our list:

def new(<<_::binary-size(1)>> = name) do
  %__MODULE__{
    name: name,
    pitch_class:
      @naturals
      |> Enum.find_value(fn
        {^name, pitch_class} -> pitch_class
        _ -> nil
      end)
  }
end

The next case is handling a note with one or more trailing accidentals:

def new(<<natural_name::binary-size(1), accidentals::binary>> = name) do
  ...
end

First, we’re create our base note, without accidentals:

note = new(natural_name)

Next, let’s count the number of sharps and flats we’ve been given:

sharps = accidentals |> String.replace(~r/[^#]/, "") |> String.length()
flats = accidentals |> String.replace(~r/[^b]/, "") |> String.length()

Now all we have to do is modify the name of the note we constructed to include the (reduced) accidentals, and increment or decrement the pitch_class of the note by the offset given by the sharps and flats we added up earlier:

%{
  note
  | name: String.replace(name, ~r/#b|b#/, ""),
    pitch_class: rem(note.pitch_class + 12 + sharps - flats, 12)
}

Note that the purpose of the + 12 and rem(..., 12) when modifying the pitch_class is to ensure that the pitch_class value stays between 0 and 11, wrapping in either direction when needed.


With those changes, we can happily work with any kind of note we can imagine:

assert [
         %Core.Note{name: "Gb", pitch_class: 6},
         %Core.Note{name: "Bbb", pitch_class: 9},
         %Core.Note{name: "Db", pitch_class: 1}
       ] =
         Core.Chord.get_notes(%Core.Chord{
           root: Core.Note.new("Gb"),
           quality: %Core.Quality{
             name: "m",
             degrees: ["1", "b3", "5"],
             quality: [0, 3, 7]
           }
         })
assert %Core.Note{name: "Ab", pitch_class: 8} = Core.Note.new("Ab#b#bb#")

Fantastic!