Synchronization between `sync` and `sleep`

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.

I’ll have to do some research and get back to you :slight_smile:

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.

Ah, I see. Yes, then you’ll get a drift.

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.

1 Like

… and thank you for your examples! I’ve looked at and learned from some of your other codes but hadn’t seen that yet. Cheers

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.

2 Likes

@slimphh

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.

1 Like

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?

1 Like

@siimphh
try this

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…

1 Like

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```
1 Like

@PiEaterAndPlayer, @Martin, @samaaron:

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:

  1. live_loop with no sleep calls, only sync calls
  2. live_loop with 3x sleep 1.0/3 syncing to a sleep 1.0
  3. live_loop syncing to another live_loop with 2x BPM

Please take a look and let me know if this violates any expectations:

1 Like

I have trying this

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:

  1. 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.
  2. 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)
  3. 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!

1 Like

I found me using this pattern recently:

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.