Help with understanding cues/syncs

Hi, new to Sonic Pi and very keen to use it. I’ve been through the tutorials and got very excited about using cues and syncs for controlling many arps and voices…but…clearly something I don’t understand about the model. Without a solid mechanism for doing this I can’t build what I need. If anyone could point me in the right direction I’d be very grateful.

In the simplified code (at the end of this post) I have one live loop that cycles between two chords and plays the current one, and another loop that plays the current chord. I use the ‘set’ command to both set the chord globally and create cue to play it.

What I thought should happen (in pseudo code) is

in loop 'changechord'
  choose a chord
  set it
  send out the cue

in loop 'playchord'
  receive the cue, so start running code...
  get the new chord
  play it

BUT I found this only works if I put a short delay ‘sleep 0.001’ in the ‘playchord’ loop - otherwise it doesn’t pick up new chord.

I know that the loops are running concurrently, but don’t the statements within a loop run sequentially? What’s wrong my with understanding of the model. Is this a valid workaround?

use_bpm 60

chord1 = [:C4, :Eb4, :G4]
chord2 = [:F4, :A4, :C5]
seq    = [chord1, chord2].ring

live_loop "playchord" do
  sync "/set/currentchord"
  ###########
  sleep 0.001
  ###########
  c = get[:currentchord]
  sleep 1
  puts "b " + c.to_s
  play c
end

live_loop "changechord" do
  c = seq.tick
  puts "a " + c.to_s
  set :currentchord, c
  use_synth :tri
  play c
  4.times do
    sample :drum_cymbal_closed, amp: 0.3
    sleep 1
  end
end

Try reversing the order of the live loops, i.e. place playchord after changechord.

When the script first starts, the time is 0. Let’s issue you don’t have your cheeky call to sleep 0.001.

At time 0, you’re doing two things simultaneously across two different live loops:

  1. You’re trying to get the value of :currentchord in playchord live loop.
  2. You are also trying to set the value of :currentchord in the changechord live loop.

In normal programming environments this is a race condition. Which goes first is typically non-deterministic and depends on which way the wind is blowing.

Sonic Pi attempts to be deterministic wherever possible. The current approach to doing this is to give each thread a priority depending on when it was started. Threads started first have a higher priority than those started later.

Therefore, playchord has a higher priority than changechord.

This means that the get of playchord takes priority to the set of changechord given that they are both happening at time 0. This is why it fails - you’re trying to read a value that hasn’t been set yet. The fact it fails every single time is by design - it’s deterministic. This is much better than it failing some times and not others.

By adding the cheeky sleep, you are ensuring that the call to get always happens after the call to set which is why that fudge fixes it (despite it now making playchord drift out of time).

By swapping the live loops round you’ll switch the priorities and that should make things work for you :slight_smile:

I notice you’re also calling sync which is also fixes the situation by forcing the playchord live loop to wait until the value is set before it continues. This means that you don’t need the sleep for it to work but it will also force the playchord live loop to spin round at the same slower rate as the changechord live loop which is probably not what you want. (You can circumnavigate this by using the sync: opt to the live loop such as:

live_loop :playchord, sync: "/set/currentchord" do

This will force the playchord live loop to wait until the set happens before it starts, but not to wait for it again.

Hope that this helps!

Many thanks for this. That’s very helpful about the thread priorities - I had noticed the different results when swapping the loop order, and that explains it.

I thought that I had avoided the race condition because my sync statement in ‘playchord’ would make the thread stop and wait and not do its ‘get’ until after ‘changechord’ had run its first ‘set’. But it doesn’t and I’ll have to experiment more to understand why.

Just to say, lovely piece of work! Thank you so much!

EDIT btw the 1ms sleep doesn’t make ‘playchord’ drift, just always plays 1ms late which you can’t hear. But I don’t want to rely on a fudge.

EDIT2 I have feeling that in conventional programming my thing might be right, but here it’s something around this amazing time-scheduling-prediction model you have. Some ‘puts’ statements show me that these sequential lines within a thread end up being genuinely concurrent. Light is shining through…

Actually it may make some sense to very briefly explain the different behaviours of the 3 main functions which operate on Time State:

  • get - this simply retrieves the latest value at the current time (or no value if none is found). This does not block the current thread in any way and is assumed to take 0 time.
  • set - this puts a value at the current time into the Time State. It does not block the current thread and is assumed to take 0 time.
  • sync this retrieves the next value from the current time and if no value is already there and waiting it will block the current thread. The amount of time this takes depends on when the next value is available. You can therefore treat sync like a special kind of sleep where you don’t specify the time explicitly - rather it’s just the time of the next matching event.

Actually, this does make the playchord live loop drift. After 1000 iterations, it will be 1 second later than other other thread. 0.001 is only a small amount but it slowly adds up over time. If you don’t want drift like this, consider at and time_warp.

I’m not sure there is no right in conventional programming. It depends entirely if you want non-deterministic behaviour or not. Sonic Pi attempts to ensure that the same code always produces the same sounds as this makes collaboration and re-creation possible which I believe to be extremely powerful for production.

Indeed. The puts statements send the values to be printed to the GUI. This part of the system is non-deterministic - it sends messages over the local loopback network via UDP so there are no guarantees on order of delivery which is why you see the print outs in different orders each time you run. However all the internal events within the language are deterministic - it’s just the GUI that’s not :slight_smile:

Hope that this helps!

Thanks again, yes that’s super helpful. I think I’ve got it now. Well maybe :smile: My model is now that I have to make sure everything is properly scheduled in time using sleeps and syncs.

Even if I’m wrong, it’s working for me at the moment…deep joy…here’s the kind of thing I wanted to do, and I’m happily varying it live. It doesn’t rely on the order of the loops. The wheels haven’t fallen off yet.

There’s still one minor mystery. Why I have to run the bar’s worth of notes in each ARP loop inside another in_thread otherwise they skip a bar. They are set up to play exactly 1-bar’s worth, but it feels like they don’t finish quite in time ready for the next sync. My current theory is if I put them in a thread then they can just complete when they want, meanwhile the loop has gone straight to wait for the next sync. As a theory, consistent with the observations - doesn’t mean it’s right ha ha. Anyway, I’m just going to do it for now.

Further evidence. If I play only 31 1/32nd notes, not in_thread, it works Ok.

use_bpm 100

c1 = [:C4, :Eb4, :G4]
c2 = [:C4, :F4,  :A4]
c3 = [:B3, :D5,  :G4]


live_loop "main" do
  #Cue each 4/4 bar
  #Set the chord to use for each bar
  sample :perc_bell, amp: 0.2
  seq = [c1, c1, c2, c3].ring
  set :currentchord, seq.tick
  sleep 4
  cue "bar"
end

live_loop "arp_bass" do
  sync "bar"
  c = get[:currentchord]
  in_thread do
    12.times do
      with_fx :lpf, cutoff: :c5 do
        synth :saw, note: c.tick-24, release: 0.3, amp: 0.3
        synth :saw, note: c.look-12, release: 0.3, amp: 0.1
        synth :tri, note: c.look, release: 0.3, amp: 0.5
        sleep 4.0/12.0
      end
    end
  end
end

live_loop "arp_high" do
  sync "bar"
  c = get[:currentchord]
  use_synth :dtri
  cutoff = [60,80,100,110,120,110,100,80,60].ring
  with_fx :lpf, cutoff: cutoff.tick do
    in_thread do
      32.times do
        play c.choose, amp: 0.2, release: 0.1
        sleep 4.0/32.0
      end
    end
  end
end

live_loop "drums" do
  sync "bar"
  pattern = [1,1,1,2].ring
  case pattern.tick
  when 1
    accent = [0.5, 0.2, 0.2, 0.2]
  when 2
    accent = [0.5, 0.5, 0.0, 0.5]
  end
  #Hihat
  in_thread do
    16.times do
      sample :drum_cymbal_closed, amp: accent.tick
      sleep 4.0/16.0
    end
  end
  #Kick & Snare
  case pattern.look
  when 1
    in_thread do
      2.times do
        sample :drum_bass_soft
        sleep 1
        sample :drum_snare_soft
        sleep 1
      end
    end
  when 2
    in_thread do
      1.times do
        sample :drum_bass_soft
        sleep 1
        sample :drum_snare_soft
        sleep 1
        sample :drum_bass_soft
        
        #Flam
        sleep 1-1.0/16
        sample :drum_snare_soft, amp: 0.5
        sleep 1.0/16
        sample :drum_snare_soft, amp: 2.0
        sleep 1.0
      end
    end
  end
end

Hi Soxsa,

maybe this thread will help to furthermore solves you question.

Thank you Martin, that’s a very clear and helpful diagram, I found this yesterday while searching for info.

With your first mode (sync in the live_loop line) it definitely works without needing an extra in_thread, and I get why - the arp loop is just going off independently.

I would like to understand my setup though (sync inside the live_loop).

Sorry to jump onto this thread, but the OP’s original kludge fix for this - adding a call to sleep 0.0001 - reminded me of a situation I got into recently where I had to do the same. (And this was after making sure that thread start up order led to optimal thread priority.) In my case I used sleep 1.0e-64. I’ve written multithreaded code on other platforms and had the option of using sleep(0), or an explicit ‘threadswitch’ call which just makes sure all other threads get a chance to run before coming back to the current thread. Is there a reason why sleep 0 isn’t honoured in SP?

If you want to really understand the precise semantics of Sonic Pi’s concurrency and state model (and it’s prudent to point out that this isn’t necessary to use it effectively) then it’s important to understand that state and thread execution are independent.

The following are worth noting:

  • The state model (so-called “Time State”) is nothing more than a timestamped series of immutable data values accessible by all threads.
  • Each thread can be scheduled in arbitrary order and Sonic Pi will still be deterministic.
  • get, set and sync all operate on the Time State using their current thread’s time and not execution order.
  • In the case of a tie (when two or more threads are attempting to operate at the same time), then the thread priority kicks in to guarantee deterministic behaviour.
  • Sleep advances the thread’s time (and possibly calls POSIX-style sleep if it’s necessary to stop the thread racing too far ahead).

Given the above, it should be possible to see that sleep 0 is essentially a null operation with respect to the thread’s notion of time - as it adds 0 to it. It also may cause the thread to actually POSIX sleep if it’s too far ahead of itself (this is just to ensure that the actual temporal execution of the threads is within some acceptable bounds).

I hope that this helps.

1 Like

I does help, I think. The trouble with prior knowlege is that it often brings expectations and assumptions rather than useful understanding. I think I just need to forget a few more things and learn to think more in the way that you were when setting all this up :slight_smile:

1 Like

I agree with you on that. With hindsight I see I had made some wrong assumptions. Now I get the time state idea (without knowing how it’s actually done) everything has been making sense. Almost everything.

I wonder if other statements like simple assignments or function calls are concurrent. No need to answer that, I’ll experiment.

1 Like