MIDI loop/recording


#1

Hi all,

First time posting here!

I was experimenting with MID and fooling around. I looked for a feature such as ‘with_fx :recorder’ but for midi events and didn’t find anything of the like. So I’ve started playing with it to see what I could do, I’ve managed something dirty so far and I’m not sure as well how I could save the data between runs (short of saving these as .csv file somewhere).

I have several questions:

  • I’m looking for any general ideas of improvements and as well how to write these ‘helpers’ more easily than within the interface buffer, is there a “library” folder? Or can I simply do some ‘require’ at the top of the buffer?
  • Also wondering why I can’t use the ‘live_loop’ method within a class, is there a specific reason?
  • Last, in order to avoid symbol collisions, is there a better way to get random temporary name for live_loop or cue/etc. outside of generating a name as random as possible?

Code behavior is quite simple, it wait for you to play a note on a MIDI interface, then record every note_on and note_off until you stop playing for at least 4 beat. Then it’ll loop over what has been recorded.

Best,
Cyprien

def MIDILoop(input: , output:, bar_length: 4.0, bpm: 100.0)
  # To store the notes on/off
  buffer = []
  # controlling state
  done = false
  start = nil
  
  real_time_bar = (bar_length * 60.0 / bpm)
  
  # simple metronome sound
  live_loop :bpm do use_debug(false)
    stop if done
    use_bpm bpm
    bar_length.times do
      |i|
      if i == 0 then
        sample :drum_cymbal_pedal
      else
        sample :drum_cymbal_soft
      end
      sleep 1.0/bar_length
    end
  end
  
  # capturing note_on
  live_loop :internal_on do use_debug(false)
    use_real_time
    args = sync "#{input}/note_on"
    
    stop if done
    
    # start on first note
    if start.nil? then
      start = Time.now
    end
    
    # if a note after a bar, stop recording and start looping
    if !buffer.empty? && Time.now - buffer.last[:t] > real_time_bar then
      done = true
      cue :done
    else
      # push information to buffer
      buffer.push({on: true, t: Time.now, data: args})
    end
  end
  
  # capture note_off
  live_loop :internal_off do use_debug(false)
    use_real_time
    args = sync "#{input}/note_off"
    
    stop if done
    
    # push event
    buffer.push({on: false, t: Time.now, data: args})
  end
  
  # looping the recorded buffer
  live_loop :loop do use_debug(false)
    # wait for record
    if !done then
      sync :done
    end
    current_time = start
    # going through event
    buffer.each {
      |event|
      # sleep time before event
      sleep event[:t] - current_time
      current_time = event[:t]
      # note_on or off accordingly
      n, v = event[:data]
      if event[:on] then
        midi_note_on n, vel_f: v/127.0 ,port: output
      else
        midi_note_off n, vel_f: v/127.0 ,port: output
      end
    }
    total_duration = current_time - start
    sleep real_time_bar - (total_duration % real_time_bar)
  end
end

MIDILoop(input: "/midi/sv1_1_keyboard/*/*", output: "sv1_1_sound", bpm: 90)

#2

Hi all,

I’ve played more with what I did so far, here’s the WIP. It’s fun to play with already :slight_smile:
Completely noob, so probably lots of stuff to improve or do differently.

The above code does the following:

  • 10 times (10 buffer) will record note_on and note_off messages until there’s at least 4 seconds of blank (triggered on next note)
  • each time one recording is done it’ll start replaying it (on my korg as midi) in a loop with either lower/higher notes
  • the recording are saved in ~/sonic-pi-midi-record/ as csv files for re-use
  • it’s quite generic and should work with OSC messages to, and it’s possible to provide any ‘output’ method as a callback, so it’s easy to change it with play note or sample instead of anything, the method output can be chained (array) so it’s possible to modify the event data (for instance modify the note)

Any feedback welcome!

Best,
Cyprien

def record(syncs: , record:, silence_stop: 4.0)
  require 'csv'
  require 'fileutils'
  # To store the notes on/off
  buffer = []
  # controlling state
  done = false
  start = nil
  
  loops = syncs.map {
    |input|
    
    # capturing the input
    live_loop "/recorder/#{record}/input/#{input}" do use_debug(false)
      use_real_time
      args = sync "#{input}"
      
      if done then
        stop
      end
      
      # start on first event
      if start.nil? then
        start = Time.now
      end
      
      # if a note after an empty bar, stop recording and start looping
      if !buffer.empty? && (Time.now - buffer.last[:t] - start) > silence_stop then
        done = true
        cue "/recorder/#{record}/done_internal"
        stop
      end
      
      # push information to buffer
      buffer.push({input: input, t: Time.now - start, data: args})
    end
  }
  path = File.expand_path("~/sonic-pi-midi-record/")
  sync "/recorder/#{record}/done_internal"
  FileUtils::mkdir_p path
  CSV.open("#{path}/#{record}.csv", File::CREAT|File::TRUNC|File::RDWR) do |csv|
    buffer.each {
      |e|
      csv << [e[:t], e[:input], Marshal::dump(e[:data])]
    }
  end
  puts "done recording in #{path}/#{record}.csv"
  cue "/recorder/#{record}/done"
end

def replay(record:, outputs:, from: 0, to: nil)
  require 'csv'
  
  unless outputs.is_a? Array then
    outputs = [outputs]
  end
  
  buffer = []
  path = File.expand_path("~/sonic-pi-midi-record/")
  if ! File.exists?("#{path}/#{record}.csv") then
    sync "/recorder/#{record}/done"
  end
  CSV.foreach("#{path}/#{record}.csv") do |row|
    # use row here...
    buffer << {
      t: row[0].to_f,
      input: row[1],
      data: Marshal::load(row[2])
    }
  end
  puts "play record #{record} - duration #{buffer.last[:t]} - events #{buffer.length}"
  in_thread do
    current_time = 0
    buffer.each {
      |event|
      if event[:t] < from || (to && event[:t] > to) then
        next
      end
      #sleep time before event
      sleep event[:t] - current_time
      current_time = event[:t]
      outputs.each {
        |output|
        event = output.call(input: event[:input], data: event[:data])
      }
    }
  end
  return buffer.last[:t]
end


# Various attempt at playing back differently, these methods can be chained
def midi_playback(input:, data:)
  # note_on or off accordingly
  n, v = data
  if input.match /note_on/ then
    midi_note_on n, vel_f: v/127.0, release: 0.125, port: "sv1_1_sound"
  elsif input.match /note_off/ then
    midi_note_off n, vel_f: v/127.0, release: 0.125, port: "sv1_1_sound"
  end
end

def midi_add_octave(input:, data:)
  n, v = data
  if input.match /note_on/ then
    return {input: input, data: [n + 12, v]}
  elsif input.match /note_off/ then
    return {input: input, data: [n + 12, v]}
  end
end

def midi_remove_octave(input:, data:)
  # note_on or off accordingly
  n, v = data
  if input.match /note_on/ then
    return {input: input, data: [n - 12, v]}
  elsif input.match /note_off/ then
    return {input: input, data: [n - 12, v]}
  end
end

def midi_lower_velocity(input:, data:)
  # note_on or off accordingly
  n, v = data
  if input.match /note_on/ then
    return {input: input, data: [n, v / 4.0]}
  elsif input.match /note_off/ then
    return {input: input, data: [n, v / 4.0]}
  end
end


def midi_higher_velocity(input:, data:)
  # note_on or off accordingly
  n, v = data
  if input.match /note_on/ then
    return {input: input, data: [n, v * 4.0]}
  elsif input.match /note_off/ then
    return {input: input, data: [n, v * 4.0]}
  end
end

midi_all_notes_off

set_audio_latency! 175
# simple metronome sound
live_loop :beat do use_debug(true)
  4.times do
    |i|
    if i == 0 then
      sample :drum_cymbal_pedal
    else
      sample :drum_cymbal_soft
    end
    sleep 1.0/4.0
  end
end

10.times do
  |i|
  # Uncomment to start recording events
  record(syncs: ["/midi/sv1_1_keyboard/*/*/note_on", "/midi/sv1_1_keyboard/*/*/note_off"], record: "loop_#{i}")
  
  live_loop "/record/loop/#{i}" do
    use_real_time
    sync :beat
    #use_bpm 60
    duration = replay(record: "loop_#{i}", outputs: [[method(:midi_add_octave)], [method(:midi_remove_octave)]].choose + [method(:midi_playback)])
    sleep duration
  end
end

#3

Hi @cyprien,

you’re working on something fun - I’m looking forward to watching your progress.

However, be aware that ‘all of standard Ruby’ is not supported in Sonic Pi. Although Sonic Pi is built-on-top-of Ruby, it isn’t Ruby and not all of Ruby works well with Sonic Pi’s design.

For the record, only the things documented in the help section are officially supported. Everything else may or may not work and today and that situation may change without notice in the future. In other words, if you stray out of the confines of the documentation you’re in your own unprotected and unsupported Wild West :slight_smile:

As a concrete example, standard Ruby classes and methods are not supported. As you have discovered, Sonic Pi’s DSL design doesn’t mesh particularly well with Ruby classes and the official way of creating a function is with define, not def. This will allow us to do some very important and interesting things with determinism and threading in the future.

We definitely do need to put some thought and consideration into what an extension system would look like. However, for now, it’s best represented as a file of calls to define and/or set which you can load with run_file.

If what you’re looking for isn’t implementable with this approach, then please do let me know, and we can start using this exploration as a way of specifying an extension system that can both work for this project and other people’s projects :slight_smile:


Can't access printer? it's *here*
A question about Ruby and Sonic Pi
#4

Hi @samaaron

Thanks for your nice reply.

Quick questions that came to my mind:

Using only methods defined in help section how can I persist data between run? Short of using OSC message to send it to another process and requesting it back.

Is it possible to use ‘define’ as callbacks? Or can I use the block or proc base types for my define arguments?

Also two things that would be tremendously helpful:
Possibility to sync on “/a/b | /c/d” for instance to wait on both a midi/osc command OR a “stop/cue” event. Otherwise I’m not sure how to stop a loop waiting on a /midi sync.
Also it would be tremendously helpful when using pattern matching (e.g. /midi/*/something/) to know what string has been matched (e.g. note_on or note_off). Actually I do create on loop per pattern and it doesn’t seems very efficient.

As well general interest, if I wanted to write with_fx like code that affects midi_note call, how should I proceed?

Best,
Cyprien


#5

This thread was recently referenced in a related reply from Sam Aaron. I thought this addition might add to it, even though the thread is quite old.
Hi cyprien

To answer one of your questions, you can retrieve the info that synced to a wild card using an undocumented call getEvent in Sonic Pi. I developed the routine below which does this, returning a list of the elements of the /midi “address”
You can uncomment the lines in the parse_sync_address routine to follow how it works.

define :parse_sync_address do |address|
  #puts get_event(address) #normally comment out
  v= get_event(address).to_s.split(",")[6]
  #puts v #normally comment out
  if v != nil
    #puts  v[3..-2].split("/") #normally comment out
    return v[3..-2].split("/")
  else
    return ["error"]
  end
end

live_loop :getControl do
  cntrl,value = sync "/midi/*/*/*/control_change"
  r=parse_sync_address("/midi/*/*/*/control_change")
  puts #blank line
  puts "Control #{cntrl} value #{value}"
  puts "synced data", r
end