Enumerating Musical Notes

Kevin Murphy - Oct 17 '22 - - Dev Community

I'm teaching a computer how to play the guitar for RubyConf Mini 2022. Let's understand some basics before focusing on the guitar. Guitars can play many musical notes - actually, all of them! In this post, we'll output which note we play with the help of Ruby's Array class and Enumerable module.

Note Notation

There are only so many notes in music. 12 in fact. This is not going to be a lesson in music theory, so I'll spare you the details. We'll store each of the notes in an array.

notes = [
  :a_flat,
  :a,
  :b_flat,
  :b,
  :c,
  :d_flat,
  :d,
  :e_flat,
  :e,
  :f,
  :g_flat,
  :g,
]
Enter fullscreen mode Exit fullscreen mode

Chromatic Scale

Putting all these notes together forms the Chromatic scale. Most often, you'll see the scale start with the "C" note.

Our collection of notes doesn't start with "C" though.

notes.first
=> :a_flat
Enter fullscreen mode Exit fullscreen mode

We can search for where "C" is in our array using the index method.

notes.index(:c)
=> 4
Enter fullscreen mode Exit fullscreen mode

The scale starts at the fifth note in our collection. Ruby arrays are zero-indexed; the first element is at position, or index, zero. Index four, where our "C" is, is the fifth element. We'd like it to be the first, and have all the other notes follow in the expected order. We can use rotate with our index to accomplish this.

chromatic = notes.rotate(4)
=> [:c, :d_flat, :d, :e_flat, :e, :f, :g_flat, :g, :a_flat, :a, :b_flat, :b]
Enter fullscreen mode Exit fullscreen mode

To make it a little more clear where that 4 came from, we can use the return from our call to index as the argument to rotate.

chromatic = notes.rotate(notes.index(:c))
=> [:c, :d_flat, :d, :e_flat, :e, :f, :g_flat, :g, :a_flat, :a, :b_flat, :b]
Enter fullscreen mode Exit fullscreen mode

Octaves

Aren't there are more than 12 sounds that comprise the entirety of possible music? Yes! These notes repeat themselves at a higher frequency. The same note with double the frequency is an octave.

Our collection of notes has a definitive end though.

chromatic.last
=> :b
Enter fullscreen mode Exit fullscreen mode

We'd like to repeat the collection to play the next octave. We'll treat our notes as a series that we can cycle through again and again.

note_cycle = chromatic.cycle
Enter fullscreen mode Exit fullscreen mode

Calling cycle with no block and not providing a value for the number of times to cycle returns an Enumerator that cycles forever.

To demonstrate, let's ask for 40 elements from our cycle.

result = []
40.times { result << note_cycle.next }
result.count(:c)
=> 3
Enter fullscreen mode Exit fullscreen mode

Our notes array only has 12 elements. By calling cycle on it this way, we infinitely repeat this progression. "C" shows up many times in our result, as we progress from one octave to the next.

Classical Music

Let's build a Note class to encapsulate this behavior. We'll accept a starting point that we'll call the starting note. We'll find the starting note in our notes collection, and start our infinite cycle with that note.

class Note
  def initialize(starting_note:)
    @note_cycle = notes.rotate(notes.index(starting_note)).cycle
  end
end
Enter fullscreen mode Exit fullscreen mode

Our Note class will also accept an offset - a number representing how far from our starting note we'll stray. This is how far into our cycle we should reach to determine which note value we're representing.

class Note
  def initialize(starting_note:, offset:)
    @note_cycle = notes.rotate(notes.index(starting_note)).cycle
    @offset = offset
  end
end
Enter fullscreen mode Exit fullscreen mode

We need to find our final note. We'll take the collection of the cycle we traverse to move from the starting note to our offset. We'll return the last element of that progression as the value of our note.

class Note
  def note_progression
    @note_cycle.take(@offset + 1)
  end

  def value
    note_progression.last
  end
end
Enter fullscreen mode Exit fullscreen mode

While we don't need the full progression now, it'll come in handy soon.

Stringing Together Notes

Let's bring this back to our goal - playing a note on a guitar. We tune each string on a guitar to a particular note. Plucking the string and letting it ring out will play the note it's tuned to - its starting note.

We'll get that value by setting the starting note of our Note class to be the note the guitar string is tuned to with an offset of 0.

Note.new(starting_note: :a, offset: 0).value
=> :a
Enter fullscreen mode Exit fullscreen mode

Letting the A string ring out on a guitar plays an A note.

You can play more notes on a guitar string than that. A guitar's neck has many sections, called frets. Pressing down on the string on a fret and plucking the string produces a higher frequency sound. That's the next note in our cycle.

Each fret will be a different offset from the same starting note. We can determine which note we play on a string tuned to "A", pressing down on the third fret, with our Note class.

Note.new(starting_note: :a, offset: 3).value
=> :c
Enter fullscreen mode Exit fullscreen mode

Playing the A string on a guitar with your finger on the third fret plays a C note.

Fretting About Octaves

The number of frets on a guitar varies. A typical guitar may have between 18-24. That means that a string will be able to play the same note over again.

Note.new(starting_note: :a, offset: 0).value
=> :a
Note.new(starting_note: :a, offset: 12).value
=> :a
Enter fullscreen mode Exit fullscreen mode

These are both "A" notes, but at different octaves. We want our Note class to keep track of the octave so these don't seem to be playing exactly the same frequency.

To do this, we first need to know the octave number of our starting note. We'll change our constructor again.

class Note
  def initialize(starting_note:, starting_octave:, offset:)
    @note_cycle = notes.rotate(notes.index(starting_note)).cycle
    @starting_octave = starting_octave
    @offset = offset
  end
end
Enter fullscreen mode Exit fullscreen mode

We progress to the next octave when we repeat the Chromatic scale - starting at a "C" note. This is where the full progression of notes from the starting note to our resulting value will help. We'll count the number of "C" notes we encounter in our progression. That's how many octaves we traversed.

class Note
  def octave
    @starting_octave + note_progression.count(:c)
  end
end
Enter fullscreen mode Exit fullscreen mode

Playing the starting note will have the same octave as is passed in. Playing the same note 12 frets up is the next octave.

Note.new(starting_note: :a, starting_octave: 1, offset: 0).octave
=> 1
Note.new(starting_note: :a, starting_octave: 1, offset: 12).octave
=> 2
Enter fullscreen mode Exit fullscreen mode

Can you "C" an error?

Our implementation is a bit naive. If we have a string tuned to "C", and we play the starting note, we expect it to return the same octave passed in. However, it does not.

Note.new(starting_note: :c, starting_octave: 1, offset: 0).octave
=> 2
Enter fullscreen mode Exit fullscreen mode

We're counting the number of "C" notes we encounter in our progression. When we start with "C", we're guaranteed to get one, but we haven't changed octaves.

We need to handle this special case. We'll check if our starting note is "C" and exclude the starting note from our octave count if that's the case.

class Note
  def c_start?
    @note_cycle.first == :c
  end

  def octaves_progressed
    cs_passed = note_progression.count(:c)

    if c_start?
      cs_passed -= 1
    end

    cs_passed
  end

  def octave
    @starting_octave + octaves_progressed
  end
end
Enter fullscreen mode Exit fullscreen mode

Now we account for this edge case, while still working for other starting notes.

Note.new(starting_note: :c, starting_octave: 1, offset: 0).octave
=> 1
Note.new(starting_note: :c, starting_octave: 1, offset: 12).octave
=> 2

Note.new(starting_note: :a, starting_octave: 1, offset: 0).octave
=> 1
Note.new(starting_note: :a, starting_octave: 1, offset: 12).octave
=> 2
Enter fullscreen mode Exit fullscreen mode

Closing Notes

We now have a class that can tell us which note we play on any string of our guitar. We leveraged Ruby's standard library to handle most of our logic. The Array class and Enumerable module worked in concert with our domain knowledge. We didn't need to write any algorithms or complex transformations ourselves. And now our computer knows how to play musical notes.

That sounds great to me.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .