Midilib for Sonic Pi and Generating markov chains from midis

I created a fork of minilib that now runs with ruby 3.x and Sonic Pi.

Also included this ‘forgotten’ feature request to my fork which makes it possible to read the midi from the file and create a markov chain from the parsed melody directly in Sonic Pi:

Here is a neverending jazzy version of old Finnish folk tune:

# Path to midilib for Sonic Pi
load "~/midilib/sonicpi.rb"

# Path to example midi in the examples folder
test_midi = ENV['HOME']+'/midilib/examples/pinnin_valssi.mid'

use_bpm 180

# Parse to hash
midi_hash = midi_to_hash test_midi

melody = midi_hash[:tracks][0].map {|n| n[:note] }
chords = midi_hash[:tracks][1].map {|n| n[:note] }

sleep = midi_hash[:sleeps][0]
chord_sleep = midi_hash[:sleeps][1]

notes_and_lengths = melody.zip(sleep)
chords_and_lengths = chords.zip(chord_sleep)

# Create markov chains from the melody and the chords
melody_chain = markov(notes_and_lengths, 2)
chord_chain = markov(chords_and_lengths, 1)

with_fx :wobble, phase: 1.0, res: 0.5 do
  in_thread do
    loop do |n|
      a = melody_chain.next || melody_chain.reset
      synth :chiplead, note: a[0], attack: 0.1, release: 0.1, amp: 1
      sleep a[1]
    end
  end
  
  loop do |n|
    b = chord_chain.next || chord_chain.reset
    synth :chipbass, note: b[0], attack: 0.1, release: 0.1, amp: 1
    sleep b[1]
  end
end

To run this first “clone https://github.com/amiika/sonicmidilib.git” in your user folder and run the example in Sonic Pi (Or clone elsewhere and change the paths).

EDIT: Changed repository and cleaning up the fork to create a pull request to the original.

6 Likes

Thanks for this, looks great! It will take some time to understand all the code behind…

1 Like

My pull request to midilib went trough which means you can now use midilib with Sonic Pi also from the original repository:

jimm/midilib: Pure Ruby MIDI file and event manipulation library (github.com)

3 Likes

Hi @amiika, this looks awesome and I would like to try this out, so I’ve cloned the original midilib repository, and installed midilib as a gem. Then I had to add your ‘pinnin_valssi.mid and sonicpi.rb files, that where not included from your fork. I’ve changed the paths successfully, no problems there, but when evaluating the example I get stuck at the ‘#create markov chains’ part. I get this → undefined method `markov’ for Runtime:SonicPiLang.
Do I have to define the markov function somehow first to run this ? Or something else to get this going ? Cheers!

My original fork should still be working. I added the markov.rb from the Sonic Pi Feature request to lib folder in there.

I also made the pull request to the original, which means it’s working when required from the path. You don’t have to install the gem as those does not work with Sonic Pi.

Meanwhile … made some new changes to the midilib to include sleep and sustain, which would make it easier to use with Sonic Pi. These changes are in my new fork and don’t know yet if those will be accepted:

load "~/midilib/lib/midilib.rb"

use_bpm 180

# Path to midi
TEST_MIDI = ENV['HOME']+'/sonicmidilib/examples/pinnin_valssi.mid'

seq = MIDI::Sequence.new()

File.open(TEST_MIDI, 'rb') do |file|
  seq.read(file)
end

seq.each_with_index do |track,idx|
  track = track.select { |e| e.is_a?(MIDI::NoteOn) }
  in_thread do
    track.each do |e|
      play e.note, sustain: e.sustain*0.8, decay: e.sustain*0.2
      sleep e.sleep
    end
  end
end

Ok, thanks! I’ll try to use your forks instead and see if I get things going. Cheers!

One thing I noticed with your excellent port is that it can’t handle multiple parts if they start playing at different times from the start. EG in a fugue part 2 might come in 4 bars later than part 1. I deal with this with midi files exported from MuscScore by putting in dummy very high or very low notes each of a bars duration instead of each bar rest, then running a conversion script written in procesing to Sonic Pi code, then substituting the dummy notes back with bars rests. A bit tedious but it works.

2 Likes

@amiika Works from your original fork! Going to investigate further now . . .

1 Like

That should not be a problem, at least in my new fork, but I have to test it out. Could you post a link to some example from Musescore? You can get time_from_start value from the Events, so I quess you have to put a condition that sleeps in the beginning if time_from_start>0.

Sent you a message with a link
EDIT
also, some files have tempo changes within them. Will it handle those?

Great piece. I made some changes to my midilib fork to make it work. Renamed sleep to wait and switched the order around. It makes more sense this way.

Playing multiple parts:

load "~/midilib/lib/midilib.rb"

test_bpm = 200

use_bpm test_bpm

# Path to midi
TEST_MIDI = ENV['HOME']+'/midis/Danse_Macabre_Op.40.mid'

seq = MIDI::Sequence.new()

File.open(TEST_MIDI, 'rb') do |file|
  seq.read(file)
end

seq.each_with_index do |track,idx|
  track = track.select { |e| e.is_a?(MIDI::NoteOn) }
  in_thread do
    track.each do |e|
      with_synth :piano do
        sleep e.wait
        play e.note, release: e.sustain*0.8, decay: e.sustain*0.2
      end
    end
  end
end

Also added bpm function to tempo, which does the same thing as @theibbster did earlier with his script. Except im using separate threads for different types of events. Example with tempo changes:

load "~/midilib/lib/midilib.rb"

test_bpm = 80

use_bpm test_bpm

# Path to midi
TEST_MIDI = ENV['HOME']+'/midis/Tempo_Changes_test.mid'

seq = MIDI::Sequence.new()

File.open(TEST_MIDI, 'rb') do |file|
  seq.read(file)
end

seq.each_with_index do |track,idx|
  tempo = track.select { |e| e.is_a?(MIDI::Tempo) }
  track = track.select { |e| e.is_a?(MIDI::NoteOn) }
  in_thread do
    track.each do |e|
      with_bpm test_bpm do
        sleep e.wait
        play e.note, sustain: e.sustain*0.8, decay: e.sustain*0.2
      end
    end
  end
  in_thread do
    tempo.each do |t|
      sleep t.wait
      test_bpm = test_bpm+(t.bpm-test_bpm)
    end
  end
end

Potentially you could also have different events like Pitch bend, Program or Control change Events also.

Danse macabre sounds great! Well done.

I just wrote my own version of this. I thought of posting it to the forum and so I searched to see which category to post it to and then I found this post. I wish I knew about this before, but I am glad for the experience of writing my own version, especially because it re-familiarized me with Ruby. But I think yours might be better than mine. Here’s mine.

# This depends on https://github.com/jimm/midilib
#
# gem install midilib

midi_file = "/Users/jeng/Dropbox/Music/bach-midi/fuguecm.mid"

$LOAD_PATH.append("/Library/Ruby/Gems/2.6.0/gems/midilib-2.0.5/lib/")
require 'midilib/sequence'
require 'midilib/io/midifile'
seq = MIDI::Sequence.new
File.open(midi_file, 'rb') { | file | seq.read(file) }
seq.each_with_index do |track,ii|
	label = ("midi"+ii.to_s).to_sym
	puts "track name '#{track.name}', instrument name '#{track.instrument}', #{track.events.length} events, label #{label}"
	events = []
	time_divisor = 1000.0
	bpm = -1
	track.each_with_index do |event, jj|
		if MIDI::NoteEvent === event
			new_event = {
				:note => event.note,
				:time_from_start => event.time_from_start/time_divisor,
				:delta_time => event.delta_time/time_divisor,
				:status => event.status,
				:velocity => event.velocity
			}
			if events.length > 0
				events[events.length-1][:sustain] = event.delta_time/time_divisor
			end
			events.append(new_event)			
		elsif MIDI::Tempo === event
			new_event = {
				:bpm => MIDI::Tempo.mpq_to_bpm(event.tempo),
			}
			puts(event)
			events.append(new_event)
		else
			puts(event)
		end
	end
	begin
		thread_ii = 0
		if bpm > 0
			use_bpm bpm
		end
		live_loop label do
			use_synth :piano
			event = events[thread_ii]
			if event != nil and event.has_key? :status
				if event.has_key? :delta_time
					delta_time = event[:delta_time]
					if delta_time > 0
						sleep delta_time
					else
						sleep 0.000001
					end
				else
					puts("Missing delta_time")
					sleep 1
				end
				if event[:status] == 144
					play event[:note], velocity: event[:velocity], sustain: event[:sustain]
					puts("#{label} #{thread_ii} sleep #{delta_time} play #{event[:note]} velocity #{event[:velocity]} sustain #{event[:sustain]}")
				elsif event[:status] != 128
					puts(event[:status], "stop 2")
					stop
				end
			else
				sleep 0.001
				puts ("stop 3")
				stop
			end
			thread_ii+=1
			if thread_ii>=events.length
				puts ("stop 1")
				stop
			end
		end
	rescue
		events.each do |event|
			puts(event)
		end
	end
end

I am curious, your version of sonicmidilib says it’s compatible with Sonic Pi, but somehow I got the original project to work by adding the directory where it is installed to $LOAD_PATH. Is that why it wasn’t compatible? I was worried about compatibility problems between Ruby 2.6 and Ruby 3, but I didn’t notice any problems. I believe you also added some methods to make it easier to use with Sonic Pi.

Is there any chance something like these scripts can be documented so people don’t duplicate work? Or even better, it would be awesome if there was a Sonic Pi package manager that allowed downloading additions like this.

I’m not sure what to do with my script now. I had looked for midi file playback but I only found the midicsv and some other methods that I eventually decided not to do (but was grateful for the ideas). I will probably want to have an intermediate step that I can modify specifically for Sonic Pi, but for now I’d rather have Sonic Pi read and playback the midi file without any intermediate steps (like converting to csv).

I need this. I’m still learning Sonic Pi, but I believe I would have to play a note, save a reference to it, then modify that reference with control. It looks like midilib has these events (source).

MetaEvent

  • KeySig
  • Marker
  • Tempo
  • TimeSig

NoteEvent

  • NoteOff
  • NoteOn
  • PolyPressure

ChannelEvent

  • ChannelPressure
  • Controller
  • NoteEvent
  • PitchBend
  • ProgramChange

It looks like each synth has a different set of opts that can be controlled. So I’d have to have some sort of connecting or binding that would bind a midi control to the synth opt. Maybe the best way would be to use anonymous functions for the actual note playing, with parameters for the midi events.

I don’t see a list of sample opts but it appears there are many.

I’m guessing I’d want to be able to control every effect that is possible with with_fx. That’s a lot. I guess I’ve got a lot of fooling around to do to see if I can get this working.

Does anyone have a midi file that manipulates all of these midi controls?

At the time of the post the original project did not work. I made some fixes and did a pull request to the original so it works now. However, it does not include those helper methods i made for Sonic Pi.

For now, there is no such thing, but @samaaron has hinted that maybe later … might be something for elixir only but i don’t really know :slight_smile:

I found this project with lots of test midi files. I downloaded them and tried midilib against them all. I’m also testing for these note events: ChannelPressure, Controller, KeySig, Marker, NoteOn, PitchBend, ProgramChange, Tempo, TimeSig. Here’s my code. I run this script in the Terminal, not Sonic-Pi.

load "/Users/jeng/midilib/lib/midilib.rb"

def open_midi_file(midi_file)
	puts(midi_file)
	seq = MIDI::Sequence.new()
	File.open(midi_file, 'rb') do |file|
		seq.read(file)
	end
	test_bpm = 80
	seq.each_with_index do |track,idx|
		tempoTrack = track.select { |e| e.is_a?(MIDI::Tempo) }
		noteTrack = track.select { |e| e.is_a?(MIDI::NoteOn) }
		keySigTrack = track.select { |e| e.is_a?(MIDI::KeySig) }
		markerTrack = track.select { |e| e.is_a?(MIDI::Marker) }
		timeSigTrack = track.select { |e| e.is_a?(MIDI::TimeSig) }
		channelPressureTrack = track.select { |e| e.is_a?(MIDI::ChannelPressure) }
		controllerTrack = track.select { |e| e.is_a?(MIDI::Controller) }
		pitchBendTrack = track.select { |e| e.is_a?(MIDI::PitchBend) }
		programChangeTrack = track.select { |e| e.is_a?(MIDI::ProgramChange) }
		puts("------------------------")
		if(tempoTrack.length > 0)
			puts("tempoTrack")
			puts(tempoTrack)
		end
		if(noteTrack.length > 0)
			puts("noteTrack")
			puts(noteTrack.length)
		end
		if(keySigTrack.length > 0)
			puts("keySigTrack")
			puts(keySigTrack)
		end
		if(markerTrack.length > 0)
			puts("markerTrack")
			puts(markerTrack.length)
		end
		if(timeSigTrack.length > 0)
			puts("timeSigTrack")
			puts(timeSigTrack)
		end
		if(channelPressureTrack.length > 0)
			puts("channelPressureTrack")
			puts(channelPressureTrack.length)
		end
		if(controllerTrack.length > 0)
			puts("controllerTrack")
			puts(controllerTrack.length)
		end
		if(pitchBendTrack.length > 0)
			puts("pitchBendTrack")
			puts(pitchBendTrack.length)
		end
		if(programChangeTrack.length > 0)
			puts("programChangeTrack")
			puts(programChangeTrack.length)
		end
	end
end
midi_files = Dir["/Users/jeng/test-midi-files/midi/*"]
midi_files.sort.each { |midi_file|
	open_midi_file(midi_file)
}

It fails 19 tests, mainly tests involving corrupted midi files.

test-all-gm-percussion.mid
test-corrupt-file-missing-byte.mid
test-illegal-message-all.mid
test-illegal-message-f1-xx.mid
test-illegal-message-f2-xx-xx.mid
test-illegal-message-f3-xx.mid
test-illegal-message-f4.mid
test-illegal-message-f5.mid
test-illegal-message-f6.mid
test-illegal-message-f8.mid
test-illegal-message-f9.mid
test-illegal-message-fa.mid
test-illegal-message-fb.mid
test-illegal-message-fc.mid
test-illegal-message-fd.mid
test-illegal-message-fe.mid
test-non-midi-track.mid
test-sysex-7e-06-01-id-request.mid
test-syx-7e-06-01-id-request.syx

It passed 43 tests, though. These 2 files include the types of control I want to respond to.

test-rpn-00-00-pitch-bend-range.mid
test-rpn-00-05-modulation-depth-range.mid

Now I just need to figure out how to get Sonic-Pi to manipulate the signal based on the control data. I think I’d probably want to do something like this.

	in_thread do
		polyPressureTrack.each do |t|
			sleep t.wait
			control get(:v), amp: t.value/127.0
		end
	end
	with_fx :level,amp: 1 do |v|
		set :v,v
		in_thread do
			noteTrack.each do |e|
				with_bpm test_bpm do
					sleep e.wait
					play e.note, sustain: e.sustain*0.8, decay: e.sustain*0.2, amp: e.velocity/127.0
					# Do something with v
				end
			end
		end
	end

Using this file I was able to get a sort of pitch bend. It’s missing the slide and the values are wrong.

mypath="/Users/jeng/Dropbox/Github/sonic-pi/sonic-pi-magnusviri/"
load mypath+"day007 29/amiika/midilib/lib/midilib.rb"

def open_midi_file(midi_file)
  seq = MIDI::Sequence.new()
  File.open(midi_file, 'rb') do |file|
    seq.read(file)
  end
  seq.each do |track|
    noteTrack = track.select { |e| e.is_a?(MIDI::NoteOn) }
    pitchBendTrack = track.select { |e| e.is_a?(MIDI::PitchBend) }
    use_synth :piano
    with_fx :pitch_shift do |bend|
      live_loop :notes do
        noteTrack.each do |e|
          sleep e.wait
          if e.velocity > 0
            play e.note, sustain: e.sustain*0.8, decay: e.sustain*0.2, amp: e.velocity/127.0
          else
            play e.note, sustain: e.sustain*0.8, decay: e.sustain*0.2
          end
        end
      end
      if(pitchBendTrack.length > 0)
        live_loop :pitch_mod do
          pitchBendTrack.each do |t|
            sleep t.wait
            control bend, pitch: t.value/127.0
          end
        end
      end
    end
  end
  puts("end")
end

midi_file = "/Users/jeng/test-midi-files/midi/test-rpn-00-00-pitch-bend-range.mid"
open_midi_file(midi_file)

I think I’m done with this for now. I made a GitHub repo and put it there. Here’s the main function.

load ENV['HOME']+"/midilib/lib/midilib.rb"

def play_midi_file(midi_file, play_tracks_func)
  seq = MIDI::Sequence.new()
  File.open(midi_file, 'rb') do |file|
    seq.read(file)
  end
  seq.each do |track|
    play_tracks_func[{
      'channelPressureTrack'=>track.select { |e| e.is_a?(MIDI::ChannelPressure) },
      'controllerTrack'=>track.select { |e| e.is_a?(MIDI::Controller) },
      'keySigTrack'=>track.select { |e| e.is_a?(MIDI::KeySig) },
      'markerTrack'=>track.select { |e| e.is_a?(MIDI::Marker) },
      'noteTrack'=>track.select { |e| e.is_a?(MIDI::NoteOn) },
      'pitchBendTrack'=>track.select { |e| e.is_a?(MIDI::PitchBend) },
      'polyPressureTrack'=>track.select { |e| e.is_a?(MIDI::PolyPressure) },
      'programChangeTrack'=>track.select { |e| e.is_a?(MIDI::ProgramChange) },
      'tempoTrack'=>track.select { |e| e.is_a?(MIDI::Tempo) },
      'timeSigTrack'=>track.select { |e| e.is_a?(MIDI::TimeSig) }
    }]
  end
end

And this is how you use it.

midi_file = ENV['HOME']+"/test-midi-files/midi/test-rpn-00-00-pitch-bend-range.mid"

play_midi_file(midi_file, lambda{ |tracks|
  use_synth :saw
  with_fx :pitch_shift do |fx1|
    live_loop :notes do
      tracks['noteTrack'].each do |e|
        sleep e.wait
        play e.note, sustain: e.sustain*0.8, decay: e.sustain*0.2
      end
    end
    if(tracks['pitchBendTrack'].length > 0)
      live_loop :fx1 do
        tracks['pitchBendTrack'].each do |t|
          sleep t.wait
          control fx1, pitch: t.value/127.0
        end
      end
    end
  end
})

By putting the playback code in a lambda, it’s easier to ignore the midi code and focus on the Sonic Pi code. It would be nice to get all of the midi controls to do what they’re supposed to, but I’m still really new to Sonic Pi and I could spend weeks working on it. But I am only interested in a few of the midi parameters and fx so I’m not going to figure it all out. If someone else does, that would be awesome. Otherwise, I think I’m done with this for now. Thank you to everyone who has done the work that led to this.

1 Like

Great work! Thanks. I’m sure it will come useful one day for others as well :slight_smile:

I’v havent had much time to play with midi playback since most of my time has gone to developing Ziffers. I’ll come back to this at some point and one thing I was planning to do earlier was also support for writing midi from Sonic Pi. Ziffers to midi or saving other generated melodies from markov, lindermayer and other generative stuff would be great.