revised simpler polyphonic gated synth using Sonic Pi 3

further simplepolysynth2.rb version added to the code link for this article for use with keyboards with separate note_on note_off midi commands, rather than those using note_on with velocity 0 for note_off  (April 2018)

Back in August I developed quite a complex program to play 8 note polyphony using Sonic Pi synths. The program worked well, but it was too demanding on resources to work on a Raspberry Pi. Following on from a thread in the Sonic Pi in-thread.sonic-pi.net site, I decided to look at the problem again, and this time I ended up with a simpler, shorter program which was capable of playing multi-note polyphony using a midi controller to drive Sonic Pi 3 which also worked OK on a Raspberry Pi 3, as well as more powerful machines like a Mac.

First of all, why would you want such a program? In a standard DAW midi note inputs can be played, starting a note sounding when a note_on signal is received, and keeping the note going until a corresponding note_off (or more usually a note_on with zero velocity value) is received. On Sonic Pi, synths work slightly differently. You need to specify the pitch to play, but also the duration of the note as it starts. This is no problem if all the notes have the same duration, or if you know the duration of the note when you start it playing, but it can’t handle keyboard input where the player can change the length of the notes at will depending on how long the input key is depressed. The DAW uses what is called a gated synth which can be switched on and off at will by the start and stop signals. What this program does is to simulate that by starting a “long” note of the required pitch playing when the midi note_on signal is received, and then waiting until the note_off (or note_on with zero velocity) signal is received, when the note is killed, thus achieving the same effect.

if you only have one note at a time playing this is fairly easy to do, but if you have more than one note (polyphony) then you have to keep track of notes that are playing and choose the right one to kill. Another feature I wanted to include was the ability to apply pitch-bend to any playing note, which means that you had to be able to control the note while it was playing.

The final program to do all of this is shown below. To a certain extent it breaks the rules of how to pass data between running live_loops, but it does seem to work pretty well.

#polyphonic midi input program with sustained notes
#experimental program by Robin Newman, November 2017
#pitchbend can be applied to notes at any time while they are sounding
use_debug false

set :synth,:tb303 #initial value
set :pb,0 #pitchbend initial value
kill_list=[] #list to contain notes to be killed
on_notes=[] #list of notes currently playing
ns=[] #array to store note playing references
nv=[0]*128 #array to store state of note for a particlar pitch 1=on, 0=off

128.times do |i|
  ns[i]=("n"+i.to_s).to_sym #set up array of symbols :n0 ...:n127
end
#puts ns #for testing

define :sv do |sym| #extract numeric value associated with symbol eg :n64 => 64
  return sym.to_s[1..-1].to_i
end
#puts sv(ns[64]) #for testing

live_loop :choose_synth do
  b= sync "/midi/*/*/*/control_change" #use wild cards to works with any controller
  if b[0]==10 #adjust control number to suit your controller
    sc=(b[1].to_f/127*3 ).to_i
    set :synth,[:tri,:saw,:tb303,:fm][sc] #can change synth list if you wish
    puts "Synth #{get(:synth)} selected"
  end
end

live_loop :pb do #get current pitchbend value adjusted in range -12 to +12 (octave)
  b = sync "/midi/*/*/*/pitch_bend" #change to match your controller
  set :pb,(b[0]-8192).to_f/8192*12
end
with_fx :reverb,room: 0.8,mix: 0.6 do #add some reverb
  
  live_loop :midi_note_on do #this loop starts 100 second notes for specified pitches and stores reference
    use_real_time
    note, on = sync "/midi/*/*/*/note_on" #change to match your controller
    if on >0
      if nv[note]==0 #check if new start for the note
        puts "setting note #{note} on"
        vn=on.to_f/127
        nv[note]=1 #mark note as started for this pitch
        use_synth get(:synth)
        x = play note+get(:pb),attack: 0.01, sustain: 100,amp: vn #start playing note
        set ns[note],x #store reference to note in ns array
        on_notes.push [note,vn] #add note to list of notes playing
      end
    else
      if nv[note]==1 #check if this pitch is on
        nv[note]=0 #set this pitch off
        kill_list.push note #add note to list of notes to kill
      end
    end
  end
  
  live_loop :processnote,auto_cue: false,delay: 0.4 do # this applies pitchbend if any to note as it plays
    #delayed start helps reduce timing errors
    use_real_time
    if on_notes.length > 0 #check if any notes on
      k=on_notes.pop #get next note from "on" list
      puts "processing note #{k[0]}"
      in_thread do #start a thread to apply pitchbend to the note every 0.05 seconds
        v=get(ns[k[0]]) #retrieve control value for the note
        while nv[k[0]]==1 #while the note is still merked as on
          control v,note: k[0]+get(:pb),note_slide: 0.05,amp: k[1]
          sleep 0.05
        end
        #belt and braces kill here as well as in notekill liveloop: catches any that miss
        control v,amp: 0,amp_slide: 0.02 #fade note out in 0.02 seconds
        sleep 0.02
        puts "backup kill note #{k[0]}"
        kill v #kill the note referred to in ns array
      end
    end
    sleep 0.08 #so that the loop sleeps if no notes on
  end
  
  live_loop :notekill,auto_cue: false,delay: 0.3 do # this loop kills released notes
    #delayed start helps reduce timing errors
    use_real_time
    while kill_list.length > 0 #check if there are notes to be killed
      k=kill_list.pop #get next note to kill
      puts "killing note #{k}"
      v=get(ns[k]) #retrieve reference to the note
      control v,amp: 0,amp_slide: 0.02 #fade note out in 0.02 seconds
      sleep 0.02
      kill v #kill the note referred to in ns array
    end
    sleep 0.01 #so that the loop sleeps if no notes to be killed
  end
end #reverb

The program starts by initialising various variables.

:synth is set to contains the current synth to use, which can optionally be changed if your input controller supports a suitable input device. In my case I used an M-Audio Oxygen8 v2 keyboard with a series of rotary potentiometer inputs, and used one of these to select the synth.

:pb is set to contain the current pitchbend offset (scaled in a range +12 to -12 ie up or down an octave. Later in the program it is altered by the output of the pichbend wheel on the keyboard.

four lists hold data used to select notes to be operated upon. kill-list contains a list of notes which have stopped being played and which need to be killed off. on-notes contains a list of notes which are currently playing together with their volume settings. ns is a list which contains references to the control value for notes which are playing, indexed by the note value. It has 128 entries, one for each possible midi note 0-127.Finally nv is a list which contains a 1 for a note which is playing and a 0 if it is not. This list has 128 values corresponding to midi values 0-127

The ns list is filled with symbols :n0, :n1….:n127 Thse are used with a set command to store the control value when a particular note is played. A corresponjding function sv converts a symbol back to the numeric midi value with which it is associated. Thus sv(:n64)=>64

The first live loop :choose_synth detects a control change on the selected channel (10 for my rotary control) and reads the rotary position in range 0-127. It converts this to an integer range 0-3 which is then used to select a synth from a list and store it using a set :synth command.

live_loop :pb is triggered by changes in the pitchbend input, and scales the input 0-16384 in the range -12 to +12 and stores the result using set :pb

The following live_loops are responsible for producing sounds, and they are wrapped in a with_fx :reverb command. The first live_loop :midi_note_on is set to use real time for a fast response, and waits to detect a note_on signal, storing the note value concerned in the variable note. The second parameter on stores the associated velocity. This will be > 0 if the note is starting and 0 if it is finishing. If on is > 0 the loop tests if the note is already playing by checking the entry in nv[note] this will be 0 if a note of that pitch is not playing and 1 if it is. If it is not already playing it prints a message saying the note is starting, and calculates the note volume by scaling on  to the range 0->1 storing the result in vn. It sets nv[note] to 1 to signify the note is playing, then retrieves and sets the current synth to use, and starts a “long” note of duration 100 playing at the designated pitch (modified by any pb value). So that the note can be controlled a control value x is set and stored, pointed to by the appropriate symbol in the ns list. The note value is added to the list of notes playing notes_on together with its volume vn.
So that this live_loop can continue as quickly as possible, further action on the note is controlled by separate live_loops :processinote and :notekill
Finally, the :midi_note_on live loop also deals with the case where on is zero, ie a key on the keyboard input has been released. In this case, it checks to see if nv[note] is 1 ie the note is playing and if so sets nv[note] to 0 to signify it is being stopped, and adds the note value to the kill_list. Again it leaves the kill process to a different live_loop so that the midi_note_on loop is ready to process the next keyboard input as soon as possible.

live_loop :processnote is used to control the note while it is playing, by starting a thread to control the pitch of the note if the pitchbend value in :pb is altered. Again this live_loop works in real_time to give a rapid response. First it checks to see if there are any playing notes by looking at the length of the on_notes list. If there are notes there it extracts the first note value and its volume to a list variable k, and prints a message on the screen to signify this. In the thread it adjusts the pitch of the note if necessary while the value of nv[k[0]] is still 1, ie the note is still playing. When the value of nv[k[0]] changes to 0, it passes on to fade the note to 0 volume and then kill it. This is belt and braces, as the note should be killed by the :notekill live_loop more quickly. However I found that occasionally this could be missed, and so this second backup ensured that the note was killed. Sonic Pi objects with a message saying it can’t kill a sound that has already been killed, but otherwise it seems happy. If you look at the output log you will see that occasionally it is the :processnote loop that kills the note rather than the :notekill one.

live_loop :notekill is purposed to kill a note as quickly as possible after it has been released by the midi keyboard, ie as soon as possible after a note_on with 0 velocity parameter has been detected. Again it works in real time to make it as responsive as possible. It looks at the length of the kill_list and if there are entries there it retrieves the next one. It puts a message on the screen, and then retrieves the control value for the given note. It fades the note to zero volume, to avoid clicks and then kills it

You will notice that there are start delays on the :processnote and :notekill live_loops. This was found to reduce the loading when running on a raspberry pi and therefore reduce the incidence of timing errors warnings.

One final but of “Ruby” that I have used is to use the syntax #{variablename} when printing a variable value using the puts command. It gives a slightly cleaner output.

An accompanying video is here

The code can be downloaded here

3 thoughts on “revised simpler polyphonic gated synth using Sonic Pi 3

    • You haven’t overlooked anything. The latency is an audio latency in Sonic Pi on the Raspberry Pi. With the standard settings it is about 139mS which is very noticeable when you are playing a keyboard midi input. IT is possible to tune the settings in the jackd connection and reduce this to about 64mS which gives an acceptable response I find when playing. To get even lower you can install the PiSound card made by blokas (blokas.io) although this is rather expensive. I got mine when it was still a kick-starter project. I run about 42mS with this.
      You can alter the settings in the jackd connection without going into the code, by using the program qjackctl, which should be on your system. If not you can easily install it with apt-get. Make sure Sonic Pi is NOT running, and that there is no left over jackd running (killall jackd in a terminal window). Start up qjackctl in the terminal window using qjackctl. Stop the instance of jackd that it starts, and in then select the setup window. In the Parameters tab set the Sample rate to 48000, the Frames/period to 1024 and the periods/buffer to 3. (It should show a Latency value of 64 msec in the bottom right of the window. Click OK to exit and then click the green start arrow. Leave this running, and start Sonic Pi running from the Programming Menu in the usual way. It will pick up and use the already running instance of jackd. You should find that the response to your keyboard is better.

      I have boosted yout code up to 8 note polyphony and it works well. A couple of comments. You used separate, note_on and note_off live_loops. This may suit your keyboard, but many keyboards just use note_on and signify note off by using note_on with the vel parameter = 0. This should work for any keyboard and so is probably a better way to go. You merely use an if vel=0 or if vel>0 to detect which is taking place.
      Secondly, you can make it independent of the midi input by using wild card * characters, so you get
      key, vel = sync “/midi/*/*/*/note_on”
      if vel > 0
      #code follows
      end

      for note_on matching and the same with if vel == 0 for note off matching.

      Hope this helps

Leave a comment