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.
We're using set
and 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
Conclusion
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.