Sonic Pi has an elegant and well-thought temporal semantics. Using the following program as an example,
sample :drum_bass_hard play :c sleep 1 sample :drum_cowbell
The drum sample and note can be thought of as starting simultaneously, and the cowbell will play precisely one second1 after.
In other words,
sleep there isn't actually POSIX's
sleep - it can be thought of as delimiting a section on a virtual timeline, a section which does not include the time taken to start playing the sample and note.
This enables a simple and declarative programming model. Setting up two live loops like the following works as you would expect, i.e. they won't drift out of sync. If the sound card is overloaded, some sounds may not play, but live loops will remain in phase.
live_loop :hihat do sample :drum_cymbal_closed sleep 0.25 end live_loop :drums do sample :drum_bass_hard sleep 1 end
Well, mostly. Live loops are Ruby threads, which are scheduled nondeterministically. As a result, events across threads within one time "partition" are unordered.
Consider this simplified example, which features communication across live loops. It's a fairly common scenario: we have a control loop that determines the key we're playing in, and one or more loops which adapt accordingly.
get for deterministic (timeline-synced) state updates, and the
sync: parameter to ensure that
melody only starts when receiving a cue from
control. Cues are sent every time a live loop begins executing its block.
live_loop :control do set :n, [:c, :d, :e].tick sleep 1 end live_loop :melody, sync: :control do play (get :n) sleep 1 end
We observe two surprising things.
- The first note we hear is always
:d, and always after a second of silence
- The notes thereafter are some random subsequence of the ring
[:c, :d, :e]
The fix for the first issue is to make the control loop start after a short delay.
live_loop :control, delay: 0.1 do set :n, [:c, :d, :e].tick sleep 1 end
This happens because of Ruby's imperative nature and nondeterministic thread scheduling: when the first live loop starts executing in a thread, the cue it subsequently sends is unordered with respect to the second
live_loop call (which executes on the main thread). Most of the time the cue is sent before the melody loop starts, causing it to miss playing the first note.
Thus the cue should be delayed until after the melody loop has (presumably2) started.
To address the second issue of notes missing, we could remove the
sync: parameter and try using
sync instead of
get, which makes melody loop wait for the next note.
live_loop :melody do play (sync :n) sleep 1 end
Now, however, we hear only every other note. The problem is that it is possible for the melody loop to miss cues while it is sleeping. I'm not sure if this is due to the time abstraction leaking (where we can observe one thread sleeping while another acts, despite the time "partition" being the same), or the ordering of cues not being well-defined with respect to other events in live loops.
Nevertheless, the solution is to ensure that the melody loop is not asleep when control loop cues. A simple way is to make sure there is always less sleep time in the former loop.
live_loop :control, delay: 0.1 do set :n, [:c, :d, :e].tick sleep 1 end live_loop :melody do play (sync :n) # we can do other things here, as long as we sleep < 1 end
These are awfully subtle issues for beginners to debug. I wish Sonic Pi had a simpler, more synchronous concurrency model, or at least provided more guarantees about the interleaving of events within a time partition. It's quite likely that this is difficult to implement efficiently with its wealth of features, though.