Function is messing with timing system and causing threads to fall behind

I have some code and whenever I use the function snap_midi_to_scale() all my threads (besides the thread that is calling the function) get killed because they fall to far behind. I have checked that the function is somewhat fast (prob. takes like 100ms at most to finish). I have no idea what is happening. Here’s my code:

# Welcome to Sonic Pi

use_midi_defaults port: "iac_driver_surgext", velocity: 70, channel: 1

surge = "iac_driver_surgext"
dexed = "iac_driver_dexed"

def play_notes(notes, length, velocity, port, spacing = 0.01)
  for n in notes do
    midi n, sustain: length - spacing, vel: velocity, port: port
  end
  
  sleep length
end

def play_note(note, length, velocity, port, spacing = 0.01)
  midi note, sustain: length - spacing, vel: velocity, port: port
  
  sleep length
end

def snap_midi_to_scale(scale_key, scale_type, note)
  if scale_key >= 12 then
    raise StandardError.new("Please use a scale key from 0 to 11")
  end
  
  out_note = note
  
  if scale_type == "major" then
    scale_intervals = [2, 4, 5, 7, 9, 11] # intervals for maj (all intervals in half steps from root)
  elsif scale_type == "minor" then
    scale_intervals = [2, 3, 5, 7, 8, 10] # intervals for min
  elsif scale_type == "harmonicminor" then
    scale_intervals = [2, 3, 5, 7, 8, 11] # intervals for harmonic min
  else
    raise StandardError.new("#{scale_type} is not a valid scale")
  end
  
  # NOTE: Not actual octave, just octaves from midi note zero
  note_octave = (note / 12).floor()
  # Generate two octaves to account for cases where scale starts above
  # inputed note
  note_octave -= 1
  
  scale_notes = [scale_key + note_octave * 12]
  
  for interval in scale_intervals do
    scale_notes.append( (scale_key + interval) + 12 * note_octave)
  end
  scale_notes.append(scale_key + 12 * (note_octave + 1))
  for interval in scale_intervals do
    scale_notes.append( (scale_key + interval) + 12 * (note_octave + 1))
  end
  
  while not scale_notes.include? out_note do
    out_note -= 1
  end
  
  return out_note
end

set :i, 0


in_thread(name: :clock) do
  loop do
    set :i, get[:i] + 1
    sleep 1
  end
end


in_thread(name: :chords) do
  notesring = range(48, 52, 1).ring()
  loop do
    sync :i
    
    key = notesring[(get :i)]
    chord_notes = chord(key, :minor)
    puts chord_notes
    snapped_notes = []
    for note in chord_notes do
      #puts snap_midi_to_scale(1, "major", i)
      snapped_notes.append(snap_midi_to_scale(0, "major", note))
    end
    
    
    play_notes(chord_notes, 3.95, 90, dexed)
    
    
  end
end

in_thread(name: :drums) do
  loop do
    sync :i
    4.times do
      sample :bd_ada, amp: 2
      sleep 0.5
      sample :hat_bdu, amp: 0.3
      sleep 0.495
    end
  end
end

Please respond ASAP I need to get my code working for a project

This is strange because I would have expected that if the function is taking too long it would be the thread that is calling it that would fall behind. But maybe this just an effect of the Sonic Pi timing system.

A couple of things that might help:

In Sonic Pi, you should define functions with define, not def. I think this will help with reporting exactly where errors are coming from. So use:
define :snap_midi_to_scale do |scale_key, scale_type, notes| instead of
def snap_midi_to_scale(scale_key, scale_type, note)
and similarly for play_note and play_notes.

Also, you are calling the snap_midi_to_scale function several times in succession, each time recalculating the scale_notes. It might be more efficient for it to take all of the chord notes and return a list of snapped notes so that the scale notes only have to be calculated once.

I cannot remember the exact details, @samaaron would have to chime in for that, but it is always a bit risky trying to do calculations like this if they are used in any live loops or threads that involve interacting with Sonic Pi’s time-state store, (ie get/set/sync), or that involve playing something or sleeping.

As I understand it, Sonic Pi measures time manually using a special ‘logical time’ clock rather than real-time ‘wall-clock’ to schedule its sounds and event processing. However, when running code that is pure ruby, the length of its execution duration is not managed by the internal scheduler and there is always a consideration that this ruby code needs to be as minimal as possible to reduce the chance that it runs too long for the other events to be scheduled according to Sonic Pi’s logical clock.

Re the snap_midi_to_scale possibly taking 100ms, even if it finished in 100ms here, as Emlyn mentions above it is being called once for each note in chord_notes, which would then mean 300ms added in total.

A side note: maybe this is just me misunderstanding how your snap_midi_to_scale function currently works, but I’m wondering whether it only snaps correctly half of the time?
For example, if we run the following two lines:

puts snap_midi_to_scale(5, "major", 47)
puts scale(39, :major)

It gives:

46
(ring <SonicPi::Scale :Eb :major [39, 41, 43, 44, 46, 48, 50, 51])

(Presumably because note 47 is in between 46 and 48, and to fit as the fifth note of this major scale, it needs to be snapped to 46).

However, there are multiple major scales that note 47 could fit in. It could either be snapped down to 46 as happens above, or it could fit exactly into the fifth note of E major

puts scale(40, :major)

(which produces:)

(ring <SonicPi::Scale :E :major [40, 42, 44, 45, 47, 49, 51, 52])

So does your algorithm also need to take into account the root note of the desired scale?

Either way, If you’re after a way to get this working, I don’t have a perfect solution off the top of my head.
It seems to me like you might need to just reduce the amount of calculation and list iterating that the function does.
I’ll agree with Emlyn’s comments as well about not calling it for each note.

A thought that comes to mind - if notesring is guaranteed to be known before the code starts playing (ie, is hard-coded, like it is currently), there are only three chords x three notes to choose to snap from. Maybe you could pre-calculate all possible scales that include those notes, store those in a hash/map and then look up the right scale somehow later :man_shrugging: (hash access being a quicker look up)

I came up with a function to round a note to an arbitrary scale.
Here it is:

define :closestnote do |thisnote, thisscale, **kwargs|
  foundnote = nil
  thisstep = 0
  thisneg = true
  while !foundnote
    thissign = (thisneg ? -1 : 1)
    if thisscale.include? thisnote + (thisstep * thissign)
      foundnote = thisnote + (thisstep * thissign)
    elsif thisneg
      thisstep += 1
    end #if foundnote or thisneg
    thisneg = !thisneg
  end #while
  return foundnote
end #define closestnote


You first populate thisscale as an array of notes, then pass it in. This way, this function can ignore the niceties of the scale, and you handle that outside the function. I think this also might be faster than your current code. You simply populate the scale (or chord, or whatever arbitrary collection of notes), and feed it in.
This function (in a somewhat more robust flavor) is available in my library of useful functions, YummyFillings.rb on github: