12 input touch keyboard for Sonic Pi

This project is something I’ve been meaning to try for some time, but have at last got round to doing so. It is a culmination of many other things I have done with Sonic Pi, especially following the introduction of OSC messaging added to Sonic Pi 3.I have done several projects interfacing hardware devices such as push buttons, and a ps3 games controller to Sonic Pi. These heve used python to interface to the hardware, and I have completed the link to Sonic Pi by utilising the python-osc library using it to send signals generated by the hardware to Sonic Pi in the form of OSC messages.

I was attracted by Adafruit’s MPR121 12 channel capacitive input board which was available from the PiHut for £8, and which had good documentation supporting it on the Adafruit Site. The documentation shows you how to mount the board on a breadboard, and connect it to a raspberry pi. I used both a Pizero, and a Pi3, as it is possible to use the software I developed to connect either to a local copy of Sonic Pi (running on a Pi3) or to a remote copy of Sonic Pi on a different device, in which case the Pizero may be more appropriate and is certainly cheaper. A small amount of soldering is required to mount the header pins on the board, but this is not too difficult to do. There is a separate PiHat version of the board for £15, if you want to mount it directly on the Pi. In fact I used the standalone board and mounted it on a Rasp.iO ProHat that I already had, which made it very easy to wire things up. I used a 63 row breadboard to make a 12 key “keyboard”

I followed the instructions from Adafruit to install their library, but I modified things slightly so that I could use it with python3 which is required for the python-osc library.

I installed on the latest stretch raspbian, using the following steps:
(First open a terminal window)

sudo apt-get update
sudo apt-get install build-essential python3-dev python3-smbus python3-pip git
cd ~
git clone https://github.com/adfruit/Adafruit_Python_MPR121
sudo python3 setup.py install

Notes: I think all of the install packages in the second line should already be there, but this shows what is needed, and there is no harm in using the line to check

In the Adafruit documentation a problem is flagged concerning the driver for the i2c system. You should install the workaround as specified by editing the config.txt file in the boot partiti

sudo nano /boot/config.txt

Scroll to the bottom of the file and add the line:

dtoverlay=i2c-bcm2708

The exit the editor using ctrl+X selecting to save the changes in the file.
You should make sure that i2c is enabled in the Raspberry Pi Confugration. Either use the utility under Preferences on the main window in the Desktop, or from the terminal run

sudo raspi-config

select number 5 Interfacing Options and enable I2C. Also, make sure that your boot option boots to the Desktop, the default setup.

Now reboot the Raspberry Pi so that the changed boot options take effect.

The Hardware

The Adafruit documentation has several screens, or you can download a pdf. The Hardware Page shows the connections between the Pi GPIO pins and the MPR121 board schematically. I used a Rasp.iO ProHat board plugged into a Pi3. This board contains a small embedded breadboard large enough to plug in the MPR121 board, and it also has all the GPIO pins arranged in numerical order, making it easy to make the connections uinsg male to male jumper leads. I used 5 of these to connect the 3v3, GND, SCL and SDA pins as specified, and also the IRQ pin to GPIO pin 26, used in one of the Adafruit example files Raspberry Pi virtual keyboard (to which I made some modifications to it would work on Python3), although that is not part of the full project I develop below, so you can miss it out if you wish. The two pictures below shows these connections.

wiringTop

SDA-SDL

The twelve capacitative inputs are on the over side of the MPR121 board, and I connected each of these using slightly longer male to male jumper leads to a 63 column breadboard, and used short wire links to produce the input “keys”.

keyboard

This is only one way to make an input “device” but I found it worked well in practice. If you wish you could make something with slightly larger keys, maybe using aluminium foil. Strips of this could be stuck down onto a piece of wood, and then wired back to the ProHat breadboard, but the wiring would be a little trickier to achieve.

So much for the hardware. What about the software? My main aim in doing this project was to develop a touch keyboard that could be used with Sonic Pi. However before developing the software for that, it is prudent to check the operation of the MPR121 board. Adafruit include several example files that can be utilised, but some of them will need to be modified to work under Python3. However the first one, simpletest.py will work unmodified. To try this out start a terminal and navigate to the Adafruit examples folder:

cd ~ython_MPR121/examples
sudo python3 simpletest.py

Touch and release each “key” in turn, and you should see printed output on the terminal screen like this:

0 touched!
0 released!
1 touched!
1 released!
...
...
11 touched!
11 released!

You can stop the program running by typing ctrl+C

The remaining two example files are not required by my project, but for the sake of completeness if you want to try out the playtest.py and keyboard.py files you need to modify them slightly. Otherwise you can ignore the following section, and move onto the section describing the software I developed.

for playtest.py file make the following changes:

change line 30 from

print 'Adafruit MPR121 Capacitance Touch Audio Player Test'

to

print('Adafruit MPR121 Capacitance Touch Audio Player Test')

Also change line 89 from

for key, soundfile in SOUND_MAPPING.iteritems():

to

for key, soundfile in SOUND_MAPPING.items():

I did this and saved the new version as playtest3.py. You can then try it out using

sudo python3 playtest3.py

For the file keyboard.py change line 136 from

for pin, key in KEY_MAPPING.iteritems():

to

for pin, key in KEY_MAPPING.items():

also, in the requirements for the file listed at the top it states:

sudo apt-get install build-essential python-dev python-pip libudev-dev
sudo pip install python-uinput

These should be changed (and acted upon) to:

sudo apt-get install build-essential python3-dev python-pip3 libudev-dev
sudo pip3 install python-uinput

You should have already installed the Adafruit library for python3, and when you run the keyboard script you should use

sudo python3 keyboard3.py

I renamed the altered file to keyboard3.py

Using the Adafruit MPR121 with Sonic Pi.

We have seen in the simpletest.py script that the MPR121 board can detect keypresses and print the results on teh terminal screen. How do we interface this to Sonic Pi? The intermediate “glue” is to make use of OSC (Open Sound Control) messages. These can be generated using an add-on python library, and version 3 and upwards of Sonic Pi can detect them, and then utilise them to control a program running in Sonic Pi.

The Python library I utilise is called python-osc. This can be installed using the command below, which currently gives version 1.6.6 (Note there is a bug in version 1.6.5)

sudo pip3 install python-osc

I developed a script based on Adafruit’s simpletest.py script to which I added the extra code to enable OSC support, and to add message sedning of the state of each “touch key” when it was changed. I also have a PS3 games controller, which I had previously used with OSC support, and I added support for one of the joysticks on this which I utilised in my program. The program below has the Joystick support lines commented out, but you can add them in again if you wish to use this.

#!/usr/bin/env python3

#driver for 12 pad touch keys and ps3 wireless controller by Robin Newman
#April 2018
#uses library for Adafruit MPR121 board

# Copyright (c) 2014 Adafruit Industries
# Author: Tony DiCola
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# NB Install Adafruit Library and python-osc library before use
import sys
import time
import pygame

import Adafruit_MPR121.MPR121 as MPR121

from pythonosc import osc_message_builder #add library for OSC support
from pythonosc import udp_client
import argparse
pygame.display.init()
##### uncomment next three lines to add joystick support
#pygame.joystick.init() #add support for PS3 controller
#ps3=pygame.joystick.Joystick(0)
#ps3.init()

def control(spip): #This is called once __main__ has set up the Sonic Pi ip address
    gate = 0.1 #dead area used for PS3 joysticks
    sender=udp_client.SimpleUDPClient(spip,4559) #set up OSC link to Sonic Pi


    print('Adafruit MPR121 Capacitive Touch keypads')

    # Create MPR121 instance.
    cap = MPR121.MPR121()

    # Initialize communication with MPR121 using default I2C bus of device, and
    # default I2C address (0x5A).  On BeagleBone Black will default to I2C bus 0.
    if not cap.begin():
        print('Error initializing MPR121.  Check your wiring!')
        sys.exit(1)

    # Alternatively, specify a custom I2C address such as 0x5B (ADDR tied to 3.3V),
    # 0x5C (ADDR tied to SDA), or 0x5D (ADDR tied to SCL).
    #cap.begin(address=0x5B)

    # Also you can specify an optional I2C bus with the bus keyword parameter.
    #cap.begin(busnum=1)

    pygame.init()


    # Main loop to print a message every time a pin is touched.
    print('Press Ctrl-C to quit.')
    last_touched = cap.touched()
    while True:
        try:
            ##### uncomment next section to add joystick support #############
            #pygame.event.pump()
            #rud=ps3.get_axis(3)
            #rlr=ps3.get_axis(2)

            #if abs(rud) > gate:
                #sender.send_message('/rud',-rud)
                #print("/rud, "+str(-rud))
            #if abs(rlr) > gate:
                #sender.send_message('/rlr',rlr)
                #print("/rlr, "+str(-rlr))
                ###### end of section to uncomment for joystick support ###########
            current_touched = cap.touched()
            # Check each pin's last and current state to see if it was pressed or released.
            for i in range(12):
                # Each pin is represented by a bit in the touched value.  A value of 1
                # means the pin is being touched, and 0 means it is not being touched.
                pin_bit = 1 << i
                # First check if transitioned from not touched to touched.
                if current_touched & pin_bit and not last_touched & pin_bit:
                    print('{0} touched!'.format(i))
                    print("/key"+str(i)+',1')
                    sender.send_message('/key'+str(i) ,1)
                if not current_touched & pin_bit and last_touched & pin_bit:
                    print('{0} released!'.format(i))
                    print('/key'+str(i)+',0')
                    sender.send_message('/key'+str(i) ,0)
            

            # Update last state and wait a short period before repeating.
            last_touched = current_touched
            time.sleep(0.1)

        except KeyboardInterrupt:
            print("\nExiting")
            sys.exit()

if __name__=="__main__":
    parser = argparse.ArgumentParser() #setup retrieving arg (if any) for --sp
    parser.add_argument("--sp",
    default="127.0.0.1", help="The ip on which Sonic Pi listens")
    args = parser.parse_args()
    spip=args.sp
    print("Sonic Pi on ip",spip)
    control(spip)

This file is saved as osckey.py and should be set to be executable using

chmod +x osckey.py

Depending upon whether you want to control Sonic Pi on the Raspberry Pi, or on a different external computer you start this file running in a terminal window using

sudo ./osckey.py

to run it on the local Raspberry Pi computer and with

sudo ./osckey.py --sp a.b.c.d

where a.b.c.d is the IP address of the computer running Sonic Pi (on a connected local network). If you now start Sonic Pi running (on the appropriate computer), then, if you touch some of the keys on the touch keyboard, you should see OSC messages showing up in the cues log on Sonic Pi.oscmessages

Now we can write some Sonic Pi test code to use these OSC messages.

Below is a simple test program which detects the incoming OSC messages from the keyboard and uses them to play the notes in a scale, and to allow the scale to be shifted up and down over several octaves. It also uses two keys to choose the synths to be used. Note, that when Sonic Pi receives an incoming osc message of the form /key1 it modies it to /osc/key1  This is so that it can be distiguished from cues coming in from other sources, eg midi or generated by Sonic Pi cue commands.

#Test program to receive OSC messages from touch keyboard
#written by Robin Newman, April 2018
#keys 0 to 7 olay a scale
#keys 8 and 9 shift up and down by an octave
#keys 10 and 11 choose sdiffernt synths

#the followng function detects value of wild card in osc address
define:parse_sync_address do |address|
  v= get_event(address).to_s.split(",")[6]
  if v != nil
    return v[3..-2].split("/")
  else
    return ["error"]
  end
end

use_synth :pluck #set initial synth
set :base,60 #set initial octave base note

live_loop :getkey do
  use_real_time
  b = sync "/osc/key*" #detect osc messages from ANY key using wild card *
  if b[0] == 1 #if the key has been pressed
    res = parse_sync_address "/osc/key*" #decode wild card value
    puts "Decoded address information is",res
    key = res[1][3..-1].to_i #extract key from res list and convert to integer
    puts "key pressed was",key
    if key<8 #key <8 used to play a note
      scaleOffset=[0,2,4,5,7,9,11,12]
      play note get(:base) + scaleOffset[key]
    elsif key==8 #key 8 moves down an octave
      base=get(:base)
      base=[36,base-12].max #limits minimum value of base to 36
      set :base,base
    elsif key==9 #key 9 moves up an octave
      base=get(:base)
      base=[84,base+12].min #limits max value of base to 84
      set :base,base
    elsif key==10 #key 10 chooses :pluck synth
      use_synth :pluck
    elsif key==11 #key 11 chooses :piano synth
      use_synth :piano
    end
  end
end

The live_loop :getkey is at the heart of the program. First it sets use_real_time to minimise any delay in the program responding and to give the quickest response to a key being pressed. Then it uses a sync command to wait for an incoming osc message with address “/osc/key*” This will match ANY of the keys /key1 to /key11 being pressed. It checks whether the message received is a 1, and if so (the key is pressed) it uses a function to parse the synced address and to retrieve the value of the wild card * which matched the key being pressed. The rest of the loop then uses the key number to either play a note (keys 0 to 7) or to shift the octave range (kesy 8 and 9) or to change the synth being used (keys 10 and 11). The initial base note is set to 60 but is increased or decreased in steps of 12 when keys 8 and 9 re pushed, with minimum and maximum values set to 36 and 84.

previously I had written a program called Sonic Pi Loop Controller which used TouchOSC to control Sonic Pi as shown in this video I decided to convert this to work with the touch keyboard instead. To this end, I added an extra feature to the osckey.py file enabling ot also to hadle input from a wireless PS3 games controller which I hd previously used with Sonic Pi here  I cu thsi down to only look at the input from the righthand joystick on the controller. This was used to adjust the cutoff value when playing notes with the :tb303 synth (left-right movement) and to adjust the volume of the percussion loops and the tb303 synth (up down motion). The changes to the osckey.py file involved uncommenting two sections of code, the resulting file being osckeyps3.py, which I haven’t documented here, but which can  be retrieved via the download link at the end of the article, as can the associated file to run on Sonic Pi which is shown below.

#Sonic Pi Loop Controller by Robin Newman
#VERSION 2 ADDS CONTROL OF BACKGROUND PULSE AND SCREEN
#THIS VERSION TESTED ON Pi3 B and Pi3 B+
#in particular uses some new :loop samples only in version 3.1
#These can be copied to the samples folder on version 3.0.1 if required
#or you can specify different ones.
#This version uses 11 push buttons on TouchOSC for input and 1 LED on TouchOSC for output
#Can be modified to use any suitable OSC source that can give on/off signals when a button is pressed
use_real_time #use_sched_ahead_time 0.2  #if problems
use_debug false
use_osc_logging false
use_osc "localhost",4559
#prime ps3 vol and cutoff
osc "/rud",0
osc "/rlr",0

use_bpm 120
path="~/Desktop/samples/"
#turn background sound off
set :bg,0

use_debug false
use_osc_logging false
#input on and off live_loops to detect inputs

define:parse_sync_address do |address|
  v= get_event(address).to_s.split(",")[6]#[address.length+1..-2].to_i
  if v != nil
    return v[3..-2].split("/")
  else
    return ["error"]
  end
end

define :toggleBG do
  b=get(:bg)
  if b==1
    set :bg,0
  else
    set :bg,1
  end
end

live_loop :pon do
  b = sync "/osc/key*"
  if b[0]==1
    r=parse_sync_address "/osc/key*"
    ns= r[1][3..-1]
    set ("c"+ns).to_sym,1
    doCommandSelect(ns .to_i)
  end
end
live_loop :poff do
  b = sync "/osc/key*"
  if b[0]==0
    r=parse_sync_address "/osc/key*"
    ns= r[1][3..-1]
    set ("c"+ns).to_sym,0
  end
end

define :doCommandSelect do |n|
  puts n
  case n
  when 0
    toggleBG
  when 1
    doLoop 1,0.5,:loop_amen,4 #parameters: channel,vol,samplename,beatstrech value
  when 2
    doLoop 2,0.5,:loop_garzul,16
  when 3
    doLoop 3,0.8,:loop_compus,16
  when 4
    doLoop 4,0.9,:loop_mehackit1,4
  when 5
    doLongNote 5,0.5,:fm,:c3,4 #parameters channel, vol,synth,note,repeat duration*****
    #*** can add an optiona beat_stretch, but probably not required
  when 6
    doLoopSequence 6,0.3,:tb303 #parameters channel, vol,synth
  when 7
    doLoop 7,0.7,:loop_mika, 16
  when 8
    doLoop 8,0.7, :loop_weirdo, 4
  when 9
    doLoop 9,0.9,:loop_safari,16 #doSingleSample
  when 10
    doLoop 10,0.7, :loop_mehackit2,4
  when 11
    doOneShot 11,4,path+"testsample.flac" #parameters channel,vol,sample
    #as a singleShot plays once so only sync the start
  else
    puts "nothing"
  end
end


#general function to set up stoppable live_loop
define :doLoop do |n,vol,sampleName,bs,|
  set ("kill"+n.to_s).to_sym,false
  ln=("name"+n.to_s).to_sym
  in_thread do
    loop do
      if get( ("c"+n.to_s).to_sym)==0
        s= get( ("s"+n.to_s).to_sym)
        kill s
        set ("kill"+n.to_s).to_sym, true
        stop
      end
      sleep 0.2
    end
  end
  live_loop  ln, sync: :metro do
    s=sample sampleName,beat_stretch: bs,amp: vol
    set ("s"+n.to_s).to_sym,s
    k=(bs/0.25).to_i
    k.times do
      sleep bs.to_f/k
      control s, amp: vol*((get "/osc/rud")[0]+1),amp_slide: 0.05
      stop if get( ("kill"++n.to_s).to_sym)
    end
  end
end

#general function to start stoppable single shot sample
define :doOneShot do |n,vol,sampleName,bs=0|
  sync :metro
  if bs >0
    s=sample sampleName,beat_stretch: bs,amp: vol
  else
    s=sample sampleName,amp: vol
  end
  in_thread do
    loop do
      if get( ("c"++n.to_s).to_sym)==0
        kill s
        stop
      end
      sleep 0.2
    end
  end
end

#general function to set up long repeating note
define :doLongNote do |n,vol,synth,note,dur|
  set ("kill"+n.to_s).to_sym,false
  ln=("name"+n.to_s).to_sym
  in_thread do
    loop do
      if get( ("c"+n.to_s).to_sym)==0
        s= get( ("s"+n.to_s).to_sym)
        control s,amp: 0,amp_slide: 0.05
        sleep 0.05
        kill s
        set ("kill"+n.to_s).to_sym, true
        stop
      end
      sleep 0.2
    end
  end
  live_loop  ln, sync: :metro do
    use_synth synth
    s=play note,sustain: dur-1,release: 1,cutoff: 100,amp: vol
    set ("s"+n.to_s).to_sym,s
    k=(dur/0.25).to_i
    k.times do
      sleep dur.to_f/k
      stop if get( ("kill"++n.to_s).to_sym)
    end
  end
end

#function to setup sequence of notes (stoppable)
define :doLoopSequence do |n,vol,synth|
  set ("kill"+n.to_s).to_sym,false
  ln=("name"+n.to_s).to_sym
  in_thread do
    loop do
      if get( ("c"+n.to_s).to_sym)==0
        set ("kill"+n.to_s).to_sym, true
        stop
      end
      sleep 0.1
    end
  end
  live_loop  ln, sync: :metro do
    use_synth synth
    co=play scale(:c3,:minor_pentatonic,num_octaves: 2).choose,release: 0.20,amp: vol*((get "/osc/rud")[0]+1),cutoff: 80#rrand_i(60,120)
    control co,cutoff: (get "/osc/rlr")[0]*40+80,cutoff_slide: 0.05,amp: vol*((get "/osc/rud")[0]+1),amp_slide: 0.05
    2.times do #do this to get a quicker response time
      sleep 0.25 / 2
      stop if get(("kill"+n.to_s).to_sym)
    end
  end
end

live_loop :metro do #metronome to sync stuff together
  sleep 1
end

#Optional repeating pulse
with_fx :gverb,room: 15,mix: 0.8,amp: 0.5 do
  live_loop :alwaysplaying,sync: :metro do #runs continuously playing
    use_synth :fm
    if get(:bg)==1
      play :c3,release: 0.9,amp: 1,pan: -0.8
      play :c4,release: 1.8,amp: 0.2,pan: -0.8
    end
    sleep 2
    if get(:bg)==1
      play :c3,release: 1.8,amp: 1,pan: 0.8
    end
    sleep 2
  end
end #fx

Note this program works even if you drive it with the osckey.py of the keyboard software, and it sets default values for the cutoff and volume values which will apply in the absence of the ps3 controller.

Both programs work OK on a Pi3. However, there is some latency, which is much reduced if you just use the Pi to send the osc messages to Sonic Pi running on a separate Mac.

I hope that these examples will inspire you to build a similar keyboard. It is not expensive to do, and, as the Sonic Pi programs show, it makes it possible to control Sonic Pi with a wide range of possibilities.

A video showing the keyboard running on a Pizero W driving Sonic Pi on a Mac is available here.

The software can be downloaded for my gist site here.