TuneYard is a lightweight gem providing a Sonic Pi remote, and letting you embed snippets like their provided examples inside any arbitrary Ruby code. My intention is to use this to power an upcoming Iron Yard class project that I’m particularly excited about, but let me know if you have something else in mind; I’d love to support more general use.
Big shout outs to Sam Aaron for developing Sonic Pi and Overtone, all the Sonic Pi contributors for all their work, and tUnE-yArDs for the naming inspiration and development soundtrack.
Motivation
As mentioned last time, I’m taking a bottom-up approach with my next Ruby class. Brit1 and I have been batting around concepts for a large project focusing on understanding controllers, and came up with one that I can’t wait to try out. The rough idea is to have students pair off and create a script-instrument to play a server-song.
There are a few things that I find really exciting about this project:
- Controllers are really hard to practice with in isolation, since we often think of them as the glue that connects models and views. If we rule out rendering views, what can we do? Well, we can make sound.
- I’ve been listening to some John Luther Adams lately, and will be working on a piece with a nod to The Place Where You Go to Listen, changing sounds based on data pulled from external APIs.
- Sonic Pi is an amazing project, and I’m hugely excited about the work they’re doing on the educational front. I’d love to be able to contribute to that effort.
- It helps dispel that pernicious “Ruby is only good for Rails” mindset.
- It’s just cool.
Exploring Sonic Pi - An After Action Report
I was completely unfamiliar with the internals of Sonic Pi before starting on this (other than having used SuperCollider and Overtone a bit in the past). What follows was my process in exploring it. It almost certainly isn’t the smartest. Get at me with how you’d do better.
Where Are We?
I started off by doing a standard OSX install of Sonic Pi from dmg
, which installs everything to /Applications/Sonic Pi.app
:
/Applications/Sonic Pi.app ⊩ tree -L 2
.
├── Contents
│ ├── Frameworks
│ ├── Info.plist
│ ├── MacOS
│ ├── PkgInfo
│ ├── PlugIns
│ └── Resources
├── app
│ └── server
├── etc
│ ├── doc
│ ├── examples
│ ├── samples
│ └── synthdefs
└── server -> app/server
A little poking through the app
directory and I landed pretty quickly on a few items of interest: the library code in server/sonicpi
and the scripts in server/bin
- especially server/bin/sonic-pi-server.rb
, which looked to be a potential entry point.
sonic-pi-server.rb
appears to spin up an OSC server. Fortunately, I was somewhat familiar with those after an abortive attempt at getting scruby up to date 2. So my rough game plan at the moment is: verify that this code is actually what’s running, then try to reverse engineer the OSC messages, and write my own client to produce similar messages. That plan didn’t really hold up, but it was a reasonable place to start.
Inspect What You Expect
First things first: we need some way of inspecting code in-flight. Were this a standard command-line app, I’d pry
liberally, but since this is a standalone application, it’s trickier. remote-pry
might be an option, but I’m not really sure how the bundled Ruby interacts with rbenv
or where to install it, so let’s start with something more basic: logging. Again, I have no idea where a puts
would end up (if anywhere), but can rig up something:
# In app/server/bin/sonic-pi-server.rb
def _log text
File.open("/tmp/log", "a") { |f| f.puts text }
end
_log "Here. We. Go."
_log "Program name: #{$PROGRAM_NAME}"
_log "Path: #{__FILE__}"
Now we can tail -f /tmp/log
and start up the Sonic Pi app and see:
Here. We. Go.
Program name: /Applications/Sonic Pi.app/Contents/MacOS/../../server/bin/sonic-pi-server.rb
Path: /Applications/Sonic Pi.app/Contents/MacOS/../../server/bin/sonic-pi-server.rb
So, confirmed: the app runs this script directly.
Aside: at some point, I realized that ~/.sonic-pi/log
was a thing and started tail
ing that as well, which was helpful but not essential.
Messaging
Next up, let’s see what messages we’re passing:
def osc_server.dispatch_message msg
_log "osc_server got message: #{msg.address} / #{msg.to_a}"
super msg
end
def gui.send_raw msg
_log "gui send_raw: #{msg}"
super msg
end
Aside: did you realize you could define methods on individual instances this way? (Hint: think about the def self.stuff
class method pattern.)
With that message logging in place, and after an app restart, we can poke around the GUI and see what messages are fired. Really, we want to figure out what happens when we click “play”, since that’s what we want to be able to replecate (but with our own Ruby injected). There are some /load-buffer
and /exit
calls, but the magic looks to be /save-and-run-buffer
:
osc_server got message: /save-and-run-buffer / ["workspace_one", "use_arg_checks true #__nosave__ set by Qt GUI user preferences.\nuse_debug true #__nosave__ set by Qt GUI user preferences.\nloop do\n sample :perc_bell, rate: (rrand 0.125, 1.5)\n sleep rrand(0.1, 2)\nend", "Workspace 1"]
That’s certainly the “song” that I’m composing in the GUI, along with some other information, so we’ll investigate there further.
Aside: as best I can tell, the gui.send_raw
calls are just to feed results back to the GUI. We can support that guess by commenting it out and noticing that the in-GUI log doesn’t update any more.
Eval’ing Buffers - A Closer Look
Digging into the /save-and-run-buffer
handler, it looks like the magic is in
sp.__spider_eval code, {workspace: workspace}
where code
is the code in the window buffer (plus the use_arg_checks
and use_debug
lines), and workspace
is the workspace name (a string), as we can confirm by log_
ing. So maybe there’s a more direct approach here: rather than using OSC to pass messages, maybe we can __spider_eval
things ourselves.
Looking over the definition of sp
, it looks like we should be able to extract the business logic to something like:
class Player < SonicPi::Spider
include SonicPi::SpiderAPI
include SonicPi::Mods::Sound
end
p = Player.new "localhost", 4556, Queue.new, 5, Module.new
sleep 1 # just being defensive about race conditions
p.__spider_eval %{
use_arg_checks true #__nosave__ set by Qt GUI user preferences.
use_debug true #__nosave__ set by Qt GUI user preferences.
loop do
sample :perc_bell, rate: (rrand 0.125, 1.5)
sleep rrand(0.1, 2)
end
}, workspace: 'Workspace 1'
sleep 2
p.__stop_jobs
If we include that near the top of the server script, we get a couple pleasant chimes every time Sonic Pi boots (and an unresponsive UI because sleep
s, but hey, it’s a start).
Sure enough, once we figure out how to require
the referenced internal Sonic Pi libraries, we should be able to extract all this to an external script. Take a peek at the gem implementation if you’re curious about that bit.
The Evils of Eval
So, we’ve got good progress (boops!), but we’ve got eval
problems.
First off, we’re calling __spider_eval
with a literal string, which isn’t terribly user friendly. That’s an easy enough fix using sourcify
and some string munging.
The more confounding problem is that - unless we rewrite the Sonic Pi internals (which I have neither the clout nor wisdom to do) - we’re going to call eval(code, nil)
somewhere deep inside of __spider_eval
(in app/server/sonicpi/lib/sonicpi/spider.rb
) and such an eval won’t close over variables. So … what can we do?
Warning: gross hack ahead. We want to be able to locate variables and functions that are in scope when the song block is defined, but they aren’t present when we eval
on our Player
instance. So, let’s “define” them on the player.
Metaprogramming is like violence: if it doesn’t solve your problem, you aren’t using enough of it.
class Player
def run &block
@_outer_block = block.binding
__spider_eval block.to_source(strip_enclosure: true), workspace: __FILE__
end
def method_missing name, *args
if @_outer_binding.local_variable_defined? name
@_outer_binding.local_variable_get name
else
@_outer_binding.send name, *args
end
end
end
It’s certainly not perfect (instance variables, for instance), but it’s Good Enough™ for an afternoon hack - and is at least fairly simple and short.
Result
The finished (well, not finished finished, but useable) product is available on RubyGems and Github. I’ll be building on it in class and pushing improvements as I do. Let me know if you’re using this for a project; I’d love to get some idea of what to support, and to have an excuse to dig into Sonic Pi further and extract out a gem properly down the road.
1) My dear friend and colleague, who can be found on the internet, the Twitter, and teaching Rails at the Iron Yard in Atlanta.↩
2) The original scruby looks pretty solid, if a bit heavy on global state and metaprogramming. Ultimately, I decided leveraging and contributing to Sonic Pi would be a better use of time.↩