A TouchOSC JukeBox for SonicPi

Recently I have been working with a pisound module plugged into a Raspberry Pi 3. This is a great audio card with stereo in/out as well as midi I/O interfaces. I have been  using Sonic Pi headless with this, and consequently wanted to have a convenient way to play Sonic Pi files. I resurrected a previous project I had written a couple of years ago which used a script to choose files from a folder and play them in Sonic Pi using the sonic pi cli gem written by Nick Johnstone. However, I updated it entirely, writing a python script this time, and using an interface screen I designed for TouchOSC to control it. This ran on either my iPhone or iPad, although  there is an Android version available too, but I have not tested this. Although the requirement for the jukebox interface was to expedite operation of Sonic Pi 3 on a pisound, the program will work with earlier version of Sonic Pi, and the host need not be a Raspberry Pi, but can be any computer running Sonic Pi, provided that it has python3 installed as well.

A picture of the TouchOSC interface is shown below:

As you can see it consists of ten fields, each of which contains the name of a Sonic Pi file which can be played. These files are contained in a specified folder on the computer which is running Sonic Pi. As you can see from the top of the display the interface program has ascertained that there are 167 files in this folder, and by using the left hand blue/cyan and right hand green buttons you can navigate through them ten at a time. In the photo, file names 61 to 70 are displayed. To play a file, you merely tap its name, and a hidden button underneath the filename transmits the filename number to the interface python script. There it selects the required file and uses the sonic pi cli to send a run_file command to Sonic Pi with the full pathname to the file. Selecting an different filename will send a stop command to Sonic Pi, followed by the details of the new file which will then start playing. Alternatively you can press the Red stop button at the top, which will stop all playing files. When a file is playing it is indicated by a red “LED” which is switched on beside the file name.

Sonic Pi 3 has exciting new features, including the ability to receive and send midi commands, and the ability to receive live audio feeds into the program which can be played as if they were a sample or the output of one of the internal synths, but can play continuously. I made a video of the system in use, employing TWO headless pisound boards, the second of which could receive a midi feed from Sonic Pi 3, play it using a selectable synth module, and then send the resultant audio back to Sonic Pi via this live_audio input, where it could be played. To enable this, there is a purple toggle button at the top left of the interface which switches on and off a live_audio feed in Sonic Pi 3, again by using commands sent via the sonic-pi-cli. With the Audio-In selected, we can play a midi file in Sonic Pi, and hear the result fed back into Sonic Pi as shown in the screen shot below.

Note the Audio-In button selected, and the Red “LED” indicating the Sonic Pi file with midi output which is playing.

The final button (top right) sends commands to clear Sonic PI, and to free up the memory used by any samples which have been loaded into Sonic Pi.
The python script is shown below:

#!/usr/bin/env python3
#TouchOSC jukebox interface for Sonic Pi by Robin
#Newman, Aug 2017
##################USER CONFIGURATION#########################
#specify full folder path containing playable SP3 files
SPfolder="/home/pi/Documents/SPfromXML/" #with trailing /
#specify full run path for sonic_pi Command Line Interface
c="/usr/local/bin/sonic_pi "
#################END OF USER CONFIGURATION############
from pythonosc import osc_message_builder
from pythonosc import udp_client
from pythonosc import dispatcher
from pythonosc import osc_server
import os,time
from os.path import isfile,join
import argparse
import sys

#optional addition to getserver IP addres automatically
import socket
import signal
AF_INET=2
SOCK_DGRAM=2
def my_ip():
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.connect(('172.24.1.1',1027))
    return s.getsockname()[0]


#Read file names from selected folder, ignorgin subfolders and hidden files
list=sorted([f for f in os.listdir(SPfolder) if not f.startswith('.') if isfile(join(SPfolder, f))], key=lambda f: f.lower())
print("\nTouchOSC controlled Jukebox server for Sonic Pi")
print("Written by Robin Newman, August 2017\n")
print("Folder is at ",SPfolder)
print("Nunber of files in folder ",len(list))
smax=len(list) #max setting for start

start=0;finish=9 #initialise start/finish pointers
audioFlag=0

def getargs(): #do getargs as a function which can be called from this namespace and from __main___
    try:
        #first set up and deal with input args when program starts
        parser = argparse.ArgumentParser()
        
        #This arg gets the server IP address to use. 127.0.0.1 or
        #The local IP address of the PI, required when using external TouchOSC
        parser.add_argument("--ip",
        default="127.0.0.1", help="The ip to listen on")
        
        #This is the port on which the server listens. Usually 8000 is OK
        #but you can specify a different one
        parser.add_argument("--sport",
              type=int, default=8000, help="The port the server listens on")
              
        #This is the IP address of the machine running TouchOSC if remote
        #or you can omit if using TouchOSC on the local Pi (very unlikely!!).
        parser.add_argument("--tip",
              default="127.0.0.1", help="The ip TouchOSC is on")
              
        #This is the port that TouchOSC is listening on. Usually 9000 is OK
        #but you can specify a differnt one
        parser.add_argument("--tport",
               type=int, default=9000, help="The port TouchOSC listens on")
               
        args = parser.parse_args()
        #return args #return without further processing if called
        if args.ip=="127.0.0.1" and args.tip !="127.0.0.1":
            #You must specify the local IP address of the Pi if trying to use
            #the program with a remote TouchOSC on an external computer
            raise AttributeError("--ip arg must specify actual local machine ip if using remote TouchOSC, not 127.0.0.1")
        #Provide feed back to the user on the setup being used    
        if args.tip == "127.0.0.1":
            touchip=args.tip
            print("local machine used for TouchOSC: unlikely this is what you want",touchip)  
        else:
            touchip=args.tip
            #print("remote_host for TouchOSC is",args.tip)
            touchport=args.tport
            #print("remote TouchOSC listent on port",touchport)
            #first two values needed by server, last two by sender
        return args#(args.ip,args.sport,touchip,tport)
    #Used the AttributeError to specify problems with the local ip address
    except AttributeError as err:
        print(err.args[0])
        sys.exit(0)
    
    
vals=getargs() #call getargs here to get touchip and touchport values for sender

touchip=(vals.tip)

#optional adjust if using auto select for server IP address
#you can uncomment and amend next four lines to suit your situation
##if my_ip()=='172.24.1.10':
##    touchip='172.24.1.130' #adjust for my iPhone client
##elif my_ip()=='172.24.1.89':
##    touchip='172.24.1.127' #adjust for my iPad client

touchport=int(vals.tport)        
print("Sending to TouchOSC on ('{}', {})".format(touchip,touchport))
sender=udp_client.SimpleUDPClient(touchip,touchport) #to send data to TouchOSC

sender.send_message('/jb/audio-in',0)

def update(): #updates display after one of the buttons is pushed
    i=1
    for n in range(start,start+10):
        #print('/jb/f'+str(i),[list[n]])
        if n<=smax-1:
            sender.send_message('/jb/n'+str(i),[list[n]]) #print valid file name 
        else:
            sender.send_message('/jb/n'+str(i)," ") #print blank filename
        i +=1
        time.sleep(0.01)
    sender.send_message('/jb/start',start+1) #update numeric values
    sender.send_message('/jb/finish',finish+1)
    sender.send_message('/jb/total',smax)

def updateleds(n): #update leds to reflect which file is playing
    for x in range(1,11):
        if x==n:
            sender.send_message('/jb/led'+str(x),1)
        else:
            sender.send_message('/jb/led'+str(x),0)
        
time.sleep(1)
update()
updateleds(0)


def handle_next(unused_addr,args,n): #deal with next button pushed
  global start,finish
  if n == 1: #only act on push, not release
      if start < smax-11: #inc start if more than 10 left
          start +=10
          finish = start+9 #update finish
          finish = min(finish,smax-1) #check if reached last file
          print("next",n,start,finish) #print updated values on terminal
      update() #update display
      updateleds(0) #clear all leds
      
def handle_prev(unused_addr,args,p): #deal with previous button pushed
  global start,finish
  if p ==1: #only react to push, not release
      start -= 10 #set new start value
      start = max(start,0) #check not back at the beginning of list
      finish=start+9 #set new finish value
      finish = min(finish,smax-1)
      print("prev",p,start,finish) #print new values on terminal
      update() #update display
      updateleds(0) #clear all leds

def handle_stop(unused_addr,args,s): # send stop signal to SP
  if s ==1: #only on press, not release
      os.system(c+"stop")
      os.system(c+"midi_sound_off")#in case of any oprphan midi notes
      updateleds(0) #clear all leds
      print("stopped") #on terminal window
      
def handle_f1(unused_addr,args,n): #deal with button f1 (under first filename) pushed
  global audioFlag
  if n == 1: #only push not release
    if start <= smax: #check there is a file there
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(1) #set led for position 1
        time.sleep(0.2)
        if audioFlag==1:
              os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start] #get full filename
        os.system(c+"\'"+"run_file"+"\""+f+"\"'") #send run_file command via sonic_pi cli

def handle_f2(unused_addr,args,n): # as per other buttons adjust for position 2
  global audioFlag
  if n == 1:
    if start+1 <= smax: #is it valid?
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(2)
        time.sleep(0.2)    
        if audioFlag==1:
             os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start+1]
        os.system(c+"\'"+"run_file"+"\""+f+"\"'")

def handle_f3(unused_addr,args,n):
  global audioFlag
  if n == 1:
    if start+2 <= smax:
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(3)
        time.sleep(0.2)    
        if audioFlag==1:
             os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start+2]
        os.system(c+"\'"+"run_file"+"\""+f+"\"'")

def handle_f4(unused_addr,args,n):
  global audioFlag
  if n == 1:
    if start+3 <= smax:
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(4)
        time.sleep(0.2)    
        if audioFlag==1:
              os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start+3]
        os.system(c+"\'"+"run_file"+"\""+f+"\"'")

def handle_f5(unused_addr,args,n):
  global audioFlag
  if n == 1:
    if start+4 <= smax:
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(5)
        time.sleep(0.2)    
        if audioFlag==1:
             os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start+4]
        os.system(c+"\'"+"run_file"+"\""+f+"\"'")

def handle_f6(unused_addr,args,n):
  global audioFlag
  if n == 1:
    if start+5 <= smax:
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(6)
        time.sleep(0.2)    
        if audioFlag==1:
             os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start+5]
        os.system(c+"\'"+"run_file"+"\""+f+"\"'")

def handle_f7(unused_addr,args,n):
  global audioFlag
  if n == 1:
    if start+6 <= smax:
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(7)
        time.sleep(0.2)    
        if audioFlag==1:
             os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start+6]
        os.system(c+"\'"+"run_file"+"\""+f+"\"'")

def handle_f8(unused_addr,args,n):
  global audioFlag
  if n == 1:
    if start+7 <= smax:
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(8)
        time.sleep(0.2)    
        if audioFlag==1:
              os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start+7]
        os.system(c+"\'"+"run_file"+"\""+f+"\"'")

def handle_f9(unused_addr,args,n):
  global audioFlag
  if n == 1:
    if start+8 <= smax:
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(9)
        time.sleep(0.2)    
        if audioFlag==1:
              os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start+8]
        os.system(c+"\'"+"run_file"+"\""+f+"\"'")

def handle_f10(unused_addr,args,n):
  global audioFlag
  if n == 1:
    if start+9 <= smax:
        if audioFlag==0:
            os.system(c+"stop") #stop previous file (if any)
        updateleds(10)
        time.sleep(0.2)    
        if audioFlag==1:
              os.system(c+"\'run_code \"with_fx :compressor,amp: 2 do;live_audio :sin;end\"\'")  
        f=SPfolder+list[start+9]
        os.system(c+"\'"+"run_file"+"\""+f+"\"'")

def handle_audioIn(unused_addr,args,a):
  global audioFlag
  if a == 1:
    audioFlag=1
    print("Audio Flag is ",audioFlag)
  if a == 0:
    audioFlag=0
    print("Audio Flag is ",audioFlag)
    os.system(c+"\'run_code \"live_audio :sin,:stop\"\'")

def handle_clear(unused_addr,args,cl):
  if cl == 1: #only on press, not release
    print("Clearing..")
    com="clear;sample_free_all"
    os.system(c+"\'"+"run_code"+"\""+com+"\"'")

#The main routine called when the program starts up follows
if __name__ == "__main__":
    try: #use try...except to handle possible errors
        args=getargs() #call args parsing to get ip and port for server
        sip=args.ip;sport=int(args.sport) #extract required itens

        #dispatcher reacts to incoming OSC messages and then allocates
        #different handler routines to deal with them
        dispatcher = dispatcher.Dispatcher()
        #set up the handler calls
        dispatcher.map("/jb/prev",handle_prev,"p")
        dispatcher.map("/jb/next",handle_next,"n")
        dispatcher.map("/jb/stop",handle_stop,"s")
        dispatcher.map("/jb/f1",handle_f1,"n")
        dispatcher.map("/jb/f2",handle_f2,"n")
        dispatcher.map("/jb/f3",handle_f3,"n")
        dispatcher.map("/jb/f4",handle_f4,"n")
        dispatcher.map("/jb/f5",handle_f5,"n")
        dispatcher.map("/jb/f6",handle_f6,"n")
        dispatcher.map("/jb/f7",handle_f7,"n")
        dispatcher.map("/jb/f8",handle_f8,"n")
        dispatcher.map("/jb/f9",handle_f9,"n")
        dispatcher.map("/jb/f10",handle_f10,"n")
        dispatcher.map("/jb/audio-in",handle_audioIn,"a")
        dispatcher.map("/jb/clear",handle_clear,"cl")
        #The following handler responds to the OSC message /testprint
        #and prints it plus any arguments (data) sent with the message
        #can be used for testing without doing anything
        dispatcher.map("/testprint",print)
        
        #Now set up and run the OSC server
        
        #optionally determine IP address of server: uncomment next line
        ##sip=my_ip() #overwrites sip value obtained from input args

        server = osc_server.ThreadingOSCUDPServer(
              (sip, sport), dispatcher)
        print("Serving messages from TouchOSC on {}".format(server.server_address))
        #run the server "forever" (till stopped by pressing ctrl-C)
        server.serve_forever()
    #deal with some error events
    except KeyboardInterrupt:
        print("\nServer stopped") #stop program with ctrl+C
    #handle errors generated by the server
    except OSError as err:
       print("OSC server error",	err.args)
    #anything else falls through

I am not going to discuss it in great detail here, but I give a description of its various parts in a video I have made of the system in use, which is linked at the end of the article.

In order to use it you require to install the sonic-pi-cli gem, and also the python-osc library.
sudo gem install sonic-pi-cli
sudo pip3 install python-osc
should install these.
The script is installed on the computer which is running Sonic Pi.
You need to edit the script to show the location of the folder containing the Sonic Pi files that you want to play, (in my case it was /home/pi/Documents/SPfromXML ) and also the full path to the binary for the sonic-pi-cli (on the Pi this is /usr/local/bin/sonic_pi )

You start the script running by typing:
./jb.py –ip a.b.c.d –tip p.q.r.s
where a.b.c.d is the IP address of the computer running Sonic Pi, and p.q.r.s is the IP address of the tablet/phone running TouchOSC
You can slightly modify the script as well so that it finds the IP address of the host machine automatically and has a look up for the associated IP address of the TouchOSC phone/tablet. This enables the script to be used headless with a computer that might be connected to different networks at different times. (I sometimes use the pisound with Sonic PI connected to an access point on the second pisound, and sometimes when it is running its own access point, on a different IP address. This lets me switch between them. To do this you uncomment a line near the end of the script which reads:
##sip=my_ip()
this will then automatically pick up the IP address for you (Be careful if you have multiple connected IP interfaces active on your machine..it may not be full proof, but OK for Pi use. Check and see if it gets it right).
You can then  if you wish uncomment and amend some lines near the beginning of the script which use if…elif…statements to find the associated tip IP address for your TouchOSC device.
##if my_ip()==’172.24.1.10′:
## touchip=’172.24.1.130′ #adjust for my iPhone client
##elif my_ip()==’172.24.1.89′:
## touchip=’172.24.1.127′ #adjust for my iPad client
You can make the first modification without the second if you wish. If you are using the auto IP feature then you must put in a dummy address like 1.2.3.4 for the –ip argument, otherwise the script will assume ‘127.0.0.1’ and it will then object to having a different address for tip if you enter that as an argument using –tip as it would not work. If you use a look up for tip, then it is OK to omit BOTH arguments entirely, as in this case the defaults ‘127.0.0.1’ will be used for both ip and tip arguments, and then substituted with the actual values, but teh argument parsing will be happy, although it will issue a couple of warning messages.

So to summarise:
With NO auto ip feature selected, and no lookup, you must specify the arguments on the command line: eg
./jb.py –ip 172.24.1.1 –tip 172.24.1.132
with auto IP but no lookup you must use a dummy for the ip entry eg
./jb.py –ip 1.2.3.4 –tip 172.24.1.132         (1.2.3.4) is a dummy address
with auto IP AND lookup you don’t need any arguments at startup
./jb.py
You can start Sonic Pi before or after the script is running.

At the TouchOSC end, you import the layout from the TouchOSC editor which will run on your Mac, Windows Computer, Linux Computer and is free to download from the TouchOSC website using the links above. You then export it to your iPad, iPhone or Android device (with the purchased TouchOSC app on it). You then set the iPad, iPhone or Android device to the same Wifi network as the machine running Sonic Pi and the jukebox script and set the address for the OSC host (your Sonic Pi computer) and note the local ip address for the hand held device, which you use for the tip address when starting the jb.py script.

 

As an addition to this article, I will add a few words on how I run Sonic Pi in a headless mode, and get it to automatically run the jb.py script once it has started.
I use a script called startsp.sh which is located in any convenient spot (I have mine on the Raspberry Pi desktop, and this is run using cron on startup by using crontab -e (II selected the nano editor when prompted on the first run) and adding a line to the end of the file after all the examples which reads
@reboot /home/pi/Desktop/startsp.sh

The script contains the following:

#!/bin/sh
Xvfb :1 & xvfb-run /usr/bin/sonic-pi 2 >/dev/null &

This uses the command Xvfb :1 which sets up a virtual frame buffer (which lets Sonic Pi think it is a graphical environment). This is followed after an & by xvfb-run /usr/bin/sonic-pi which runs Sonic Pi. This produces quite a lot of textual output, and also some graphics error messages because it is not running in a standard setup, so the line is completed with 2 >/dev/null & This ignores all text output, otherwise your terminal will be swamped by it. The final & means that the script exits as soon as the command has been issued.
This leaves us with another problem. How do we know ehn Sonic Pi has launched? Luckily Sonic Pi comes to the rescue. In the hidden sonic pi config folder which is located at ~/.sonic-pi is a file entitled init.rb This contains standard Sonic Pi code (just like one of the buffers you type in) which is run as soon as Sonic Pi initialises on startup. I put the following code in it:

# Sonic Pi init file
# Code in here will be evaluated on launch.
system('/home/pi/Desktop/jb.py --ip 172.24.1.89 --tip 172.24.1.127 >/dev/null 2>&1 &')
play 72,sustain: 2

What this does it first to run the jb.py script just as if we had typed the command in a terminal, and then plays a note pitch 72 for 2 beats to alter you that it is running. NB you should alter the address in the script to suit your own situation, although if you use the auto ip feature with lookup it won’t matter as they will be substituted.

So the net result is that the computer will boot up start Sonic Pi and start the JukeBox script ready to talk to your TouchOSC device.

You will find it helpful to view the video I have made using this setup, here

You can download the code for the jb.py script and the code for the TouchOSC screen layout here.