Kevin Murphy

Software Developer

Revisiting Calling Sonic Pi From Ruby

Anyone Can Play Guitar Series 🔗

  1. Enumerating Musical Notes
  2. Revisiting Calling Sonic Pi From Ruby

Tweaking Amp Settings 🔗

Sonic Pi is software to make sounds and music driven by code. Sonic Pi comes with an IDE of sorts. You can program the composition you’d like to play in the IDE. With one button, you get immediate feedback hearing how your code sounds.

A few years ago I wrote about controlling Sonic Pi with Ruby code without needing to code in the IDE directly. That relied on the sonic-pi-cli gem. For my Anyone Can Play Guitar (With Ruby) talk, I took a different approach.

Updated Wiring 🔗

As of this writing, Sonic Pi has released version 4.3.0. It’s progressed significantly since version 3.2, which is the version that I used in my original post. One of the consequences is that the sonic-pi-cli gem does not appear to work with later versions of Sonic Pi. I couldn’t get earlier versions of Sonic Pi to work on my computer. I didn’t want to deal with virtualization to see if I could get an earlier version of Sonic Pi working with the gem. I also didn’t have the time to update the gem to work with later version of Sonic Pi. Sorry - I had a presentation to write!

I needed a quick way to achieve the same, or similar, results. So, I’ll admit - I cheated.

Input Jack 🔗

Sonic Pi reads an init file every time that the application boots. You might use this file for helper methods to recall in the application’s editor. Instead, I will store the code to play the song itself. It gets read when the application boots and starts playing the song.

It’s not the greatest long-term solution. You don’t want to hear the song every time you open the application forevermore. I know I don’t. But it is good enough for demonstration purposes. That said, I still needed to construct the code to play the song.

Speaker 🔗

Much like in my original version, I built an amplifier to communicate with Sonic Pi. In my original post, my guitar class knew how to generate its sound output to the amp. In this version, the amp knows how to do that.

class SonicPiAmplifier < Amplifier
  def sound_output(play_operation, duration: 0.25)
    [
      "with_synth :pluck do",
      "  #{play_operation}, release: #{duration})",
      "end",
      "sleep(#{duration})\n",
    ].join("\n")
  end
end

For each note to play, this is using Sonic Pi’s DSL to play a note with a synth patch that sounds a bit like a guitar. It plays the note for the provided duration, and then sleeps for that same duration. That’s so Sonic Pi won’t immediately play the next note on top of this one.

Power Amp 🔗

To simplify, let’s assume that amplifiers work by progressing sound through two components. Sound moves through a pre amp to a power amp. The power amp is what’s responsible for sending the sound to the speaker. That’s what will use the sound_output method we’ve built.

class SonicPiAmplifier < Amplifier
  def power_amp_stage(sound)
    play_operation = "play(:#{sound.to_s}"
    output = sound_output(play_operation, duration: sound.duration)
    @sounds << output

    output
  end
end

The play_operation string is again a command from Sonic Pi’s DSL. As you’d expect, it plays a sound. We retrieve the value of the sound to play from the Note class we constructed in a prior post. We pass this into our sound_output. We store the result in our list of @sounds that the amplifier projects, and return it as well.

Audio Loopback 🔗

The reason we’re keeping track of our @sounds is that we want to be able to replay them again after the fact. Our amplifier will use this ability to write the song it played to a file.

class SonicPiAmplifier < Amplifier
  def write_to_file(location)
    File.open(location, "w") do |file|
      @sounds.each do |sound|
        file.write(sound)
      end
    end
  end
end

We’ll write this to Sonic Pi’s init file. Then, when we open up the application, it will play the song from the amplifier on boot.

Output 🔗

Time to take this for a rip. We’ll grab a guitar, decide which song we want to play, plug in our amplifier we just built, and play the song.

def play
  guitar = Blues::Guitar.new
  guitar.restring(gauge_set: :srv)
  guitar.tune(:down_half_step)
  song = Blues::Shuffle.new(guitar)

  amp = Blues::SonicPiAmplifier.new(volume: 10, on: true)
  guitar.plug_in(amplifier: amp)

  song.play { |measure| measure.map { |sound| puts sound } }
  amp
end

Running this method in isolation won’t push any air through our speakers. Instead, we need our cheat. We’ll write all the sounds from the song to our init file, and then have our script open Sonic Pi in a subshell.

init = "#{Dir.home}/.sonic-pi/config/init.rb"
play.write_to_file(init)
`open "/Applications/Sonic\ Pi.app"`

Sonic Pi will start, read the init file with all the instructions to play our song, and start playing.

Coda 🔗

Sam Aaron very helpfully on Twitter suggested a more reasonable approach that highlights built-in Sonic Pi functionality. With a combination of live loops and OSC messages I could have avoided relying on the init file. Thanks to Sam for the tip - and for Sonic Pi!

One approach would be to implement some live_loop listeners to incoming OSC which you could then send to at your leisure from a separate pure Ruby process.

That way you can show off the best of both worlds!