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,
]
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
We can search for where "C" is in our array using the index method.
notes.index(:c)
=> 4
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]
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]
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.