I think you understand. I accept that my proposals might be technically complex or impossible, I’m just trying to learn about the current architecture and design.
My mental here is basically a looper pedal. When the looper is syncing tracks, the main/first track sets the boundary and all other tracks are not permitted to exceed that length. So in Sonic Pi land it would be a fair trade off (conceptually) to kill off and restart dependent loops.
To clarify, I’m talking specifically about having two hypothetical loops:
Loop 1 - duration 1 beat
Loop 2 - duration 1.01 beat
Loop 1 and Loop 2 are started at the same time. My desire is to have both loops sync their runs together. After one run, Loop 2 will be 0.01 beats behind. After the second run it will be 0.02 beats behind, etc. This is what I mean by drift. My purpose in this discussion has been to explore and understand ways that a user could specify that Loop 2 should always start at the same time as Loop 1, even if that means part of Loop 2 is ‘cut off’ each run.
I would also appreciate if calling the function sync would explicitly and always mean: ‘sync now’.
Actually - as far as I see - this was the behaviour in version 2.1. I do not oversee the technical reasons in detail that led to the current implementation thought I am sure they are good ones.
As this is an issue which is constantly discussed (and which would make scenarios like the one you are describing possible after all), it seem to me a good candidate for a review.
My intuition matches that of @PiEaterAndPlayer - every cue should trigger all sync calls that blocked before the cue but should then persist for the “duration” of the “virtual time tick” and immediately unblock all sync calls that come in during the same time tick.
To illustrate:
live_loop :beat do
sleep 1
end
live_loop :synth do
sync :beat
play :c4
sleep 1
end
In this example, the “sync :beat” would trigger every beat, whatever the ordering happens to be (based on thread IDs, as discussed in the other post?).
The only downside I can imagine would be something like:
live_loop :beat do
sleep 1
end
live_loop :synth do
sync :beat
sync :beat
play :c4
sleep 1
end
I could imagine someone calling sync :beat twice in a row with the expectation to skip one signal. So maybe in addition for the cue just “persisting”, the number of cues emitted and syncs released (per thread) would need to be tracked too. But I’m not sure if that would be entirely necessary.
To be even more percise, I’d propose this behavior (pseudocode)
# For every new time tick, start accounting for cues and unblocks from scratch
def virtual_time_tick() do
@cues = {}
@unblocks = {}
end
def sync(name) do
# If there is a cue waiting for this virtual tick, consume it straight away.
if @cues[name] > @unblocks[name][get_thread_id()] then
@unblocks[name][thread_id] += 1
return
end
# No cues waiting, block until we see one.
@waiters[name].push(get_thread_id())
do_block_thread()
end
def cue(name) do
# Seen 1 cue at this time tick
@cues[name] += 1
# Unblock all threads already waiting for this cue
@waiters[name].each { |thread_id|
thread.unblock()
# Make a note of unblocking this thread once for this cue already.
@unblocks[name][thread_id] += 1
end
end
If we don’t worry about multiple sync calls at the same virtual time step, then the whole business with @unblocks could be omitted.
Given your example, I would say that my intuition then is that one cue in one tick should unblock at most one sync from a thread issued in the same tick or earlier.
So, if we have live_loop A, and live_loop B, and we have the following
live_loop :A do
cue :timer
sleep 4
end
live_loop :B do
sync :timer
sync :timer
print "Unblocked"
end
then live_loop :B should run every second time that live_loop :A does. As the first sync will be freed by the single cue, but the second sync will not be, resulting in live_loop :B sleeping for a bar until the next cue.
I think a test case in app/server/ruby/test/lang/core/test_cue_sync.rb would capture the main intention:
def test_loose_order
@lang.run do
assert_equal 0, vt
# Sync in early thread
in_thread do
sync :foo
assert_equal 0, vt
end
in_thread do
# Cue in the middle thread
cue :foo
sleep 0.1
cue :foo
end
# Sync in late thread
in_thread do
sync :foo
assert_equal 0, vt
end
# Sync in main thread
sync :foo
assert_equal 0, vt
end
end
I tried to read through the cue/sync code but it is pretty complicated-looking and multi-layered. It looks like EventHistory is used to communicate between cues and syncs, is that right, @samaaron? So does it sound right that find_next_event should be about the right place where to change this logic? Or maybe I’m misunderstanding what’s going on there and the history is used for other purpose?
live_loop :tock do
4.times do
cue :tick
sample :elec_ping
sleep 1
end
end
live_loop :synths, auto_cue: false do
sync :tock
3.times do
play :c4
sleep 1
end
play :c4
end
Thanks! This workaround works, but it gets trickier to apply correctly as you evolve your live loops.
I have been working at a patch, hopefully sould have something that could be tried out soon. So far it works with in_thread but not with live_loop and I’m not sure why exactly - I guess live_loop may do some extra accounting somewhere…
One other approach. Just work with one live-loop for staying sync
define :kick do
in_thread do
4.times do
sample :drum_heavy_kick
sleep 1
end
end
#sample :drum_heavy_kick
end
define :splach do
in_thread do
sample :drum_cymbal_hard
end
end
live_loop :main do
kick
splach
sleep 4
end```
I’ve cobbled together a change that allows me to get rid of “sleep asjustments” in “metronomes” in my projects. It doesn’t seem to have side effects based on my light testing. In particular, corner cases I tried to handle:
live_loop with no sleep calls, only sync calls
live_loop with 3x sleep 1.0/3 syncing to a sleep 1.0
live_loop syncing to another live_loop with 2x BPM
Please take a look and let me know if this violates any expectations:
live_loop :slave do
sync :master
4.times do
sample :elec_beep
sleep 0.9999
end
end
live_loop :master do
sleep 4
end
and it work.
I think the slave must have a slightly shorter duration to be available for the next cue.
And it makes sense. If the master starts 0 minutes 00 seconds, the slave starts with a slight delay like 1 millisecond, the 2 have the same duration, the slave did not finish its execution during the second cue and he waited for the 3rd
@rimkat1, I think both of your workarounds are fine. You can:
Omit the last sleep - only it is awkward if you have non-trivial logic for your patterns/sounds - like reperitions or calls out to defined functions that have sleeps in them.
Sleep a bit longer in the metronome loop or sleep a bit less in all other live_loops - this is what I’ve been doing so far (sleeping longer in metronome)
Use live_loop :foo, sync :click do that only syncs once - but you lose the ability to do sync_bpm and I think you might still miss the first run for loops when you start up, depending on the order you have laid out your live_loops.
And I’ve at least once wanted to synchronize a live loops in the middle of a run (not just syncing loop start times) for some sort of idea and if that gets into trouble for matching sleep times then it’s extremely annoying to work around. I’m sure it would be possible to build some kind of logic but honestly - we shouldn’t have to.
I hope my tweak to sync behaviour removes the need for the workarounds without negative side effects. If you’ve built sonic pi from git before, give my branch a try and see if it works for you!
live_loop :met do
sleep 1
end
def lop(*args, &blk)
live_loop(*args, sync: :met, &blk)
end
def lop8(*args, &blk)
lop(*args) do
tick
blk.call
sleep 0.25
end
end
So, lop (yes, bad name!) can be used like live_loop but will be synced to the metronome. The lop8 usecases are somewhat special, but you probably understand if they’d be handy for you.