Stringing Code Together to Play Music

Kevin Murphy - Mar 9 '21 - - Dev Community

Setting the Stage

In our last post, I talked about how I built an interface to Sonic Pi when I was preparing my RubyConf 2020 talk about Ruby's Coverage module. At the end of that post, we could send sounds to Sonic Pi. Today, we'll have our code play the guitar, and send those sounds to our amplifier.

String Theory

A guitar is a string instrument, and each of those strings make a sound when you play them. For this example we'll focus on the happy path, which is that plucking the string plays the expected note. The code I built also considers that strings can break, and attempting to play broken strings won't work. You can look at the full implementation to see how that works.

Plucking an individual string creates a new sound.

class String
  def pluck(fret:)
    ...
    play_note(fret)
  end

  private

  def play_note(fret)
    StringSound.new(
      string_number: @number,
      tuning_note: tuning_note,
      fret_number: fret,
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

The @number variable is which string on the guitar it is, with index 0 being the low E, and index 5 being the high E, in standard tuning. The tuning_note is what note that string is tuned to, because any string can be tuned to any note. Again, for simplicity here, we'll assume standard tuning (EADGBE).

Our StringSound class converts that information into the command we'll send to Sonic Pi. All notes in Sonic Pi are represented with a number, and we can also use "traditional" note names, passed to it as a symbol. We can
use that to figure out the note our string would play if you plucked it without pressing down on a fret.

class StringSound
  def playable_note_root
    playable_note_key.dig(@string_number, @tuning_note)
  end

  def playable_note_key
    {
      0 => { e: :e2 },
      1 => { a: :a2 },
      2 => { d: :d3 },
      3 => { g: :g3 },
      4 => { b: :b3 },
      5 => { e: :e4 },
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

The number next to the note (the 2 in :e2 for the low E string) represents the octave.

A helpful thing here is that the note is still a number to Sonic Pi. We can add the fret number pressed on the string to the root note of the string and Sonic Pi will know what note that is. We'll construct a Sonic Pi command to send to our amplifier to play that note.

class StringSound
  def amp_value
    "(note(:#{playable_note_root}) + #{@fret_number})"
  end
end
Enter fullscreen mode Exit fullscreen mode

This is all in a string (the data type, not the part of the instrument), because we're going to pass it to Sonic Pi via the sonic-pi-cli gem. This is going to execute the note method in Sonic Pi to play that single tone.

Plucking a Single String

Our guitarist is interfacing with the guitar as a whole, which is composed of many strings. They'll first place their fingers on the neck of the guitar.

class FingerPlacement
  attr_reader :fret
  attr_reader :string_number
end
Enter fullscreen mode Exit fullscreen mode

And pluck an individual string with that placement.

class Guitar
  def pick(finger_placement, duration: 1)
    result = strings[finger_placement.string_number].pluck(fret: finger_placement.fret)
    @amplifier.play(sound_output("play #{result.amp_value}", duration: duration))
  end
end
Enter fullscreen mode Exit fullscreen mode

Here our guitar is adding details to the command that we'll send to Sonic Pi. We have the information about the note to play from the string, but now we want it to sound like a note from a guitar, and we'll rely on the guitarist to say how long to play the note for (the duration).

We can do this in Sonic Pi by specifying the synthesizer to use when playing the note, and we'll choose one that sounds like a guitar.

class Guitar
  def sound_output(play_operation, duration: 1)
    [
      "with_synth :pluck do",
      "#{play_operation}, release: #{duration}",
      "end",
    ].join("\n").strip
  end
end
Enter fullscreen mode Exit fullscreen mode

If you wanted to play this directly in Sonic Pi's IDE, it would look more familiar:

with_synth :pluck do
  play note(:e2 + 1), release: 1
end
Enter fullscreen mode Exit fullscreen mode

However, we need to package this all up in a string to then send that command over to Sonic Pi via the sonic-pi-cli gem.

Our amplifier, passed in via dependency injection, then takes that command and sends it to Sonic Pi, producing a sound!

Strike a Chord

Sonic Pi already knows how to play chords, so this could be a quick section; however, we're going to replicate that functionality a little differently. We're doing this because of the reality I mentioned when talking about strings - and that is, they can break. If a string is broken, the note in the chord that string would regularly play shouldn't be heard.

As such, we need to go string by string to determine the notes to play. Even though the reasoning is to handle broken strings, we're not going to consider that case in this explanation. You can view the full implementation to see how that's handled.

We first need to know which notes we should play:

class Guitar
  def strum(chord, duration: 1)
    notes = [
      strings[0].pluck(fret: chord.first_fret),
      strings[1].pluck(fret: chord.second_fret),
      strings[2].pluck(fret: chord.third_fret),
      strings[3].pluck(fret: chord.fourth_fret),
      strings[4].pluck(fret: chord.fifth_fret),
      strings[5].pluck(fret: chord.sixth_fret),
    ].map(&:amp_value)
  end
end
Enter fullscreen mode Exit fullscreen mode

We'll then take all of those notes and pass them to our amplifier, using Sonic Pi's play_pattern_timed method. This also allows us to define a time between each note, so we can place a small amount of time in between each to simulate the time it would take your hand to complete a downstroke across all the strings.

class Guitar
  def strum(chord, duration: 1)
    notes = [...].map(&:amp_value)

    @amplifier.play(
      sound_output(
        "play_pattern_timed [#{pattern_notes.join(", ")}], 0.05",
        duration: duration,
      )
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

The 0.05 is our amount of time it'll take to pluck from one string to the next when playing a chord.

Rock On

Combining a few key software design principles, we were able to create a flexible, extensible, and testable system for playing music over the course of a few blog posts.

We're now armed with an amplifier that knows how to communicate with Sonic Pi that's passed in to our guitar via dependency injection (but could send the notes anywhere as long as the injected class responds to the right methods). Our guitar is composed of various strings, each of which are responsible for knowing what sound to make.

Given a songwriter who knows how to consistently write for our band, we can play chords and individual notes on our guitar as the song requires.

If you listen closely at :14, you can hear a string break. Even with these principles in place, mistakes and errors happen. Make sure your system is prepared to handle errors in a fault-tolerant way - but that's a different blog series altogether. Thanks for joining me in this exploration.

This post originally published on The Gnar Company blog.

Learn more about how The Gnar builds Ruby on Rails applications.

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