Build your own basic Raspberry Pi audio sampler
published: / last edited:Build your own basic audio sampler that can play mp3 sounds from a usb thumb drive, written in Python.
You can read more about this project here.

It uses Pimoronis Piano HAT, Drum HAT, pHAT Stack and Adafruits I2S Audio Bonnet (Pimoronis pHAT DAC also works; for direct output to speakers the HiFiBerry MiniAmp is a good option). A Raspberry Pi Zero W was soldered directly onto the pHAT Stack with enough spacing to put the Audio Bonnet on top. Only one micro USB cable is needed to power the sampler, so a power bank can be used to play on the go. One of those mobile phone OTG thumb drives is used (for example the Transcend JetFlash 880 is the perfect size to fit next to the power connector) because it plugs directly into the Pi Zeros micro USB interface, while also providing a USB Type-A connector.On the USB thumb drive are two directories: drums and piano. The drums folder can hold up to 8 samples which can be played via the Drum HAT, the piano folder can hold hundreds of samples which can be played via the Piano HAT; with the Octave Up / Octave Down buttons one can cycle through the samples in batches of 13.
When holding the Instrument button and pressing either Octave Up or Octave Down one can change the output volume. Holding Instrument and pressing the Drum HAT pad #8 two times will shut down the Pi.
The code is written in Python and is based on the Piano HATs simple-piano.py example and the Drum HATs drums.py example.
The code is written in Python:
#!/usr/bin/python
#############################
# Victoria
# by maxhaesslein, 2020-2022
# https://maxhaesslein.de
#############################
version = "2.0.0"
# this script gets started automatically on login via crontab.
# user 'crontab' -e and add:
# @reboot ~/victoria/victoria
import glob
import stat
import os
import re
import signal
import time
from sys import exit
import pygame
import pianohat
import drumhat
import buttonshim
BANK_PIANO = os.path.join(os.path.dirname(__file__), "sounds/piano")
BANK_DRUMS = os.path.join(os.path.dirname(__file__), "sounds/drums2")
MOUNT_PATH = '/mnt/victoria_usb'
MOUNT_VOLUME = '/dev/sda1'
FILETYPES = ['*.wav', '*.WAV', '*.ogg', '*.OGG']
use_drive = False # gets set to true if the volume is mounted
volume_step = 1.0/13.0
global_volume = 7.0/13.0
modus = "default"
buffer_size = 2048 # mixer buffer size. default: 512. smaller = less latency, but may have buffer underrun and scratchy sound. must be power of 2
running = True
update_channel = True
print("Press CTRL+C to exit.")
octave_piano = 0
octaves_piano = 0
instrument_button_down = False
shutdown_button_counter = 0
samples_piano = []
samples_drums = []
channels = []
options = {
'samplerate': 44100,
'folder_piano': '/piano',
'folder_drums': '/drums',
'channels': 32,
'use_display': True,
'fps': 4
}
display_content = [False, False]
pianohat.auto_leds(False)
drumhat.auto_leds = False
buttonshim.set_pixel(0x00,0x00,0xff)
def set_display( line1 = False, line2 = False ):
global display_content
display_content[0] = line1
display_content[1] = line2
def set_volume( direction ):
global global_volume
if direction > 0:
global_volume += volume_step
elif direction < 0:
global_volume -= volume_step
if global_volume >= 1:
global_volume = 1.0
elif global_volume <= 0:
global_volume = 0.0
max_led = int(round(global_volume * 13))
for i in range(0, 13):
pianohat.set_led(i, False)
for i in range(0, max_led):
pianohat.set_led(i, True)
print("set volume to "+str(global_volume))
set_display("","volume: "+str(round(global_volume*100))+"%")
for channel in channels:
channel['channel'].set_volume( global_volume )
def get_filename(path):
filename = os.path.basename(path)
return filename
def reset_playing_channel_leds():
for i in range(0, 13):
pianohat.set_led(i, False)
if modus == "default" or modus == "toggle":
for channel in channels:
sound = channel['channel'].get_sound()
if channel['hat'] != 'pianohat':
continue
if sound is not None:
print("playing led of this channel: "+str(channel['led']))
led = channel['led'] - (octave_piano*13)
if led >= 0 and led < 13:
pianohat.set_led( led, True )
def handle_note(channel_org, pressed):
channel = channel_org + (13 * octave_piano)
if pressed:
if channel < len(samples_piano):
pianohat.set_led( channel_org, True )
filename = samples_piano[channel]['filename']
print( "Piano #{}".format(channel) )
set_display( "Piano #{}".format(channel), get_filename(filename) )
play_sound( filename, 'pianohat', channel_org )
else:
print( 'Piano #'+str(channel)+' has no sound' )
set_display( 'Piano #'+str(channel)+' has no sound' )
def handle_instrument(channel, pressed):
global instrument_button_down
instrument_button_down = pressed
pianohat.set_led(13, False)
pianohat.set_led(14, False)
pianohat.set_led(15, False)
if pressed:
pianohat.set_led(channel, True)
max_led = int(round(global_volume * 13))
for i in range(0, 13):
pianohat.set_led(i, False)
for i in range(0, max_led):
pianohat.set_led(i, True)
else:
pianohat.set_led(channel, False)
global shutdown_button_counter
shutdown_button_counter = 0
for i in range(0, 16):
pianohat.set_led(i, False)
reset_playing_channel_leds()
def set_octave( direction ):
global octave_piano
if direction > 0:
if octave_piano <= (octaves_piano-1):
octave_piano += 1
print('piano set: {}'.format(octave_piano))
set_display( "piano set: {}".format(octave_piano+1) )
elif direction < 0:
if octave_piano > 0:
octave_piano -= 1
print('piano set: {}'.format(octave_piano))
set_display( "piano set: {}".format(octave_piano+1) )
reset_playing_channel_leds()
def handle_octave_up(channel, pressed):
pianohat.set_led(channel, pressed)
if instrument_button_down:
if pressed:
set_volume( +1 )
else:
if pressed:
set_octave( +1 )
else:
reset_playing_channel_leds()
def handle_octave_down(channel, pressed):
pianohat.set_led(channel, pressed)
if instrument_button_down:
if pressed:
set_volume( -1 )
else:
if pressed:
set_octave( -1 )
else:
reset_playing_channel_leds()
def handle_drums_hit(event):
# event.channel is a zero based channel index for each pad
# event.pad is the pad number from 1 to 8
# maybe handle shutdown:
if event.pad == 8 and instrument_button_down:
global shutdown_button_counter
shutdown_button_counter += 1
if shutdown_button_counter > 0:
for i in range(0, 16):
pianohat.set_led(i, True)
if shutdown_button_counter > 1:
buttonshim.set_pixel(0x00,0x00,0xff)
reset_led = True
drumhat.all_on()
for i in range(0, 16):
pianohat.set_led(i, False)
time.sleep(0.02)
drumhat.all_off()
shutdown_action( False )
return
try:
filename = samples_drums[event.channel]['filename']
drumhat.led_on(event.pad)
print( "Pad {}".format(event.pad) )
set_display( "Pad {}".format(event.pad), get_filename(filename) )
play_sound( filename, 'drumhat', event.pad )
except IndexError:
print("Pad {} has no sound".format(event.pad))
set_display( "Pad {} has no sound".format(event.pad) )
drumhat.led_off(event.pad)
def handle_drums_release():
pass
def play_sound( filename, hat, led ):
global update_channel
if modus == "default" or modus == "toggle":
for channel in channels:
if channel['hat'] != hat:
continue
if channel['hat'] == 'pianohat':
relative_led = channel['led'] - (octave_piano*13)
else:
relative_led = channel['led']
if relative_led == led:
if modus == "default":
print( ' this hat/channel already plays a sound')
set_display( '','already playing sound' )
return
elif modus == "toggle":
channel['channel'].fadeout(400)
print(" stopping this sound")
set_display( '','stopping sound' )
return
if len(channels) >= options['channels']:
print(" no free channel, abort playing")
if hat == "pianohat":
pianohat.set_led( led, False )
elif hat == "drumhat":
drumhat.led_off( led )
return
print( ' Playing Sound: {}'.format(filename))
sound = pygame.mixer.Sound( file=filename )
channelObj = pygame.mixer.find_channel()
loops_number = 0
if modus == "toggle":
loops_number = -1
channelObj.play( sound, loops=loops_number )
channelObj.set_volume( global_volume )
if hat == 'pianohat':
led_octave = led + (octave_piano*13)
else:
led_octave = led
channel = {
'filename': filename,
'hat': hat,
'led': led_octave,
'channel': channelObj
}
channels.append( channel )
update_channel = True
if modus == "chaos":
if hat == "pianohat":
pianohat.set_led( led, False )
elif hat == "drumhat":
drumhat.led_off( led )
def shutdown_action( skip_shutdown ):
global running
print( 'starting shut down ...')
buttonshim.set_pixel(0x00,0x00,0xff)
reset_led = True
running = False
time.sleep(0.4)
if options['use_display']:
draw.rectangle((0, 0, width, height), outline=0, fill=0)
text = "bye!"
(font_width, font_height) = font.getsize(text)
draw.text(
(disp.width // 2 - font_width // 2, disp.height // 2 - font_height // 2),
text,
font=font,
fill=255,
)
disp.image(image)
disp.show()
time.sleep(0.1)
print("mixer fadeout (1s)")
pygame.mixer.fadeout(1000)
time.sleep(1)
pygame.mixer.quit()
for i in range(0, 16):
pianohat.set_led(i, False)
drumhat.all_off()
if options['use_display']:
print("clear display")
# clear display:
draw.rectangle((0, 0, width, height), outline=0, fill=0)
disp.image(image)
disp.show()
if use_drive:
print( 'unmounting '+str(MOUNT_PATH) )
os.system( 'sudo umount '+str(MOUNT_PATH) )
buttonshim.set_pixel(0x00,0x00,0xff)
reset_led = True
if not skip_shutdown:
print("shut down system ...")
os.system('sudo shutdown now')
print("end program. bye.")
exit(0)
def sigint_handler(signal_received, frame):
print('SIGINT or CTRL-C detected. Exiting gracefully')
shutdown_action( True )
def disk_exists(path):
try:
return stat.S_ISBLK(os.stat(path).st_mode)
except:
return False
def set_modus( new_modus ):
global modus
modus = new_modus
print("set modus to "+str(new_modus))
set_display( "", "MODUS: "+str(new_modus) )
# check if usb thumb drive exists
print( 'check if '+str(MOUNT_VOLUME)+' exists')
if( disk_exists(MOUNT_VOLUME) ):
print(' yes')
# try to mount
os.system( 'sudo mount -o ro '+str(MOUNT_VOLUME)+' '+str(MOUNT_PATH) )
use_drive = True
BANK_PIANO = str(MOUNT_PATH)+options['folder_piano']
BANK_DRUMS = str(MOUNT_PATH)+options['folder_drums']
else:
print(' no')
if use_drive and os.path.isfile(str(MOUNT_PATH)+'/config.txt'):
import configparser
configParser = configparser.RawConfigParser()
configParser.read( str(MOUNT_PATH)+'/config.txt' )
for option in options:
if configParser.has_option('Victoria', option):
options[option] = configParser.get('Victoria', option)
if options[option] == 'False':
options[option] = False
if options[option] == 'True':
options[option] = True
if option == 'samplerate' or option == 'channels':
options[option] = int(options[option])
print( 'options:', options )
if options['use_display']:
# init the display
print( 'using display' )
import subprocess
from board import SCL, SDA
import busio
from PIL import Image, ImageDraw, ImageFont
import adafruit_ssd1306
i2c = busio.I2C(SCL, SDA)
disp = adafruit_ssd1306.SSD1306_I2C(128, 32, i2c)
disp.fill(0)
disp.show()
width = disp.width
height = disp.height
image = Image.new("1", (width, height))
draw = ImageDraw.Draw(image)
draw.rectangle((0, 0, width, height), outline=0, fill=0)
padding = -2
font = ImageFont.load_default()
draw.rectangle((0, 0, width, height), outline=0, fill=0)
text = "V I C T O R I A"
(font_width, font_height) = font.getsize(text)
draw.text(
(disp.width // 2 - font_width // 2, 5),
text,
font=font,
fill=255,
)
text = "v."+str(version)
(font_width, font_height) = font.getsize(text)
draw.text(
(disp.width // 2 - font_width // 2, disp.height - font_height),
text,
font=font,
fill=255,
)
disp.image(image)
disp.show()
time.sleep(0.1)
pygame.mixer.pre_init(options['samplerate'], -16, 2, buffer_size)
pygame.mixer.init()
pygame.mixer.set_num_channels(options['channels'])
files_piano = []
for filetype in FILETYPES:
files_piano.extend(glob.glob(os.path.join(BANK_PIANO, filetype)))
files_piano.sort()
octaves_piano = len(files_piano) / 13
files_drums = []
for filetype in FILETYPES:
files_drums.extend(glob.glob(os.path.join(BANK_DRUMS, filetype)))
files_drums.sort()
for filename in files_drums:
sample = {
'channel': False,
'filename': filename
}
samples_drums.append(sample)
for filename in files_piano:
sample = {
'channel': False,
'filename': filename
}
samples_piano.append(sample)
print( 'Piano: {} samples'.format(len(samples_piano)) )
print( 'Drums: {} samples'.format(len(samples_drums)) )
set_display( "Piano: "+str(len(samples_piano))+" samples", "DrumHat: "+str(len(samples_drums))+" samples")
for i in range(0, 16):
pianohat.set_led(i, False)
# ready-animation
for i in range(0, 13):
pianohat.set_led(i, True)
time.sleep(0.05)
time.sleep(0.2)
for i in range(0, 16):
pianohat.set_led(i, False)
drumhat.on_hit(drumhat.PADS, handle_drums_hit)
drumhat.on_release(drumhat.PADS, handle_drums_release)
pianohat.on_note(handle_note)
pianohat.on_octave_up(handle_octave_up)
pianohat.on_octave_down(handle_octave_down)
pianohat.on_instrument(handle_instrument)
@buttonshim.on_press(buttonshim.BUTTON_A)
def button_a(button, pressed):
buttonshim.set_pixel(0x00,0x00,0xff)
reset_led = True
set_volume( +1 )
@buttonshim.on_release(buttonshim.BUTTON_A)
def button_a_release(button, pressed):
handle_instrument(16, False)
@buttonshim.on_press(buttonshim.BUTTON_B)
def button_b(button, pressed):
buttonshim.set_pixel(0x00,0x00,0xff)
reset_led = True
set_volume( -1 )
@buttonshim.on_release(buttonshim.BUTTON_B)
def button_b_release(button, pressed):
handle_instrument(16, False)
@buttonshim.on_press(buttonshim.BUTTON_C)
def button_c(button, pressed):
buttonshim.set_pixel(0x00,0x00,0xff)
reset_led = True
set_modus( "default" )
@buttonshim.on_press(buttonshim.BUTTON_D)
def button_d(button, pressed):
buttonshim.set_pixel(0x00,0x00,0xff)
reset_led = True
set_modus( "toggle" )
@buttonshim.on_press(buttonshim.BUTTON_E)
def button_e(button, pressed):
buttonshim.set_pixel(0x00,0x00,0xff)
reset_led = True
set_modus( "chaos" )
signal.signal(signal.SIGINT, sigint_handler) # capture ctrl+c
target_time = 1/options['fps']
buttonshim.set_pixel(0xff,0xff,0xff)
change_led = True
CPU = ''
MemUsage = ''
last_line_1 = 'force refresh'
last_line_1_count = 12
last_line_2 = 'force refresh'
last_line_2_count = 12
cpumem_frame_counter = 10
screen_refresh = True
while running:
start_time = time.time()
if change_led:
if modus == "chaos":
buttonshim.set_pixel(0xff, 0x00, 0x00) # red
elif modus == "toggle":
buttonshim.set_pixel(0x00, 0xff, 0x00) # green
else: # default
buttonshim.set_pixel(0xff, 0xff, 0xff) # white
channel_removed = False
for channel in list(channels): # NOTE: we use list(channels) to iterate over a copy of the list, so we can remove elements from the original list
sound = channel['channel'].get_sound()
# if sound is "None" then no sound is playing
if sound is None:
channels.remove(channel)
channel_removed = True
if modus == "default" or modus == "toggle":
if channel['hat'] == 'pianohat':
led = channel['led'] - (octave_piano*13)
if led >= 0 and led < 13:
pianohat.set_led( led, False )
elif channel['hat'] == 'drumhat':
drumhat.led_off( channel['led'] )
if channel_removed:
update_channel = True
if options['use_display']:
if display_content[0]:
if last_line_1_count > 0:
last_line_1_count = last_line_1_count - 1
else:
display_content[0] = ''
if display_content[1]:
if last_line_2_count > 0:
last_line_2_count = last_line_2_count - 1
else:
display_content[1] = ''
if update_channel:
draw.rectangle((0, 8, width, 16), outline=0, fill=0)
draw.text((0, 8-1), 'Channels: '+str(len(channels))+'/'+str(options['channels']), font=font, fill=255)
update_channel = False
screen_refresh = True
if display_content[0] != last_line_1:
draw.rectangle((0, 16, width, 24), outline=0, fill=0)
if display_content[0]:
draw.text((0, 16-1), display_content[0], font=font, fill=255)
last_line_1 = display_content[0]
last_line_1_count = 12
screen_refresh = True
if display_content[1] != last_line_2:
draw.rectangle((0, 24, width, 32), outline=0, fill=0)
if display_content[1]:
draw.text((0, 24-1), display_content[1], font=font, fill=255)
last_line_2 = display_content[1]
last_line_2_count = 12
screen_refresh = True
if screen_refresh:
disp.image(image)
disp.show()
screen_refresh = False
else:
# cpu and mem update takes some time, so we only refresh it, if we don't update the screen this frame
if cpumem_frame_counter%12 == 0:
cmd = 'cut -f 1 -d " " /proc/loadavg'
CPU = str(subprocess.check_output(cmd, shell=True).decode("utf-8").strip())
draw.rectangle((0, 0, width, 8), outline=0, fill=0)
draw.text((0, 0-1), "CPU: " + CPU + ' / MEM: ' + MemUsage, font=font, fill=255)
# screen_refresh = True # next frame has a mem refresh, so we wait for that to happen before refreshing the creen
elif cpumem_frame_counter%12 == 1:
cmd = "free -m | awk 'NR==2{printf \"%.0f%%\", $3*100/$2 }'"
MemUsage = str(subprocess.check_output(cmd, shell=True).decode("utf-8").strip())
draw.rectangle((0, 0, width, 8), outline=0, fill=0)
draw.text((0, -1), "CPU: " + CPU + ' / MEM: ' + MemUsage, font=font, fill=255)
screen_refresh = True
cpumem_frame_counter = cpumem_frame_counter + 1
if cpumem_frame_counter > 24: # TODO: check what a good rollover point is
cpumem_frame_counter = 0
dif_time = time.time() - start_time
if dif_time < target_time:
sleep_time = target_time - dif_time
time.sleep(sleep_time)
else:
print( 'fps too low' )
signal.pause()
If you want to change some of the options on the fly, you can create a config.txt file in the root directory of the thumb drive to overwrite some options; it must look like this:
[Victoria]
samplerate = 48000
folder_piano = /my-piano-folder
folder_drums = /my-drums-folder
If you want to use this script make sure that:
- you have the Adafruit I2S Audio Bonnet script or the Pimoroni pHAT DAC script installed (whichever you want to use) or have audiooutput via HDMI or other means
- you have the pianohat and drumhat scripts installed
- you have pygame installed (should come with the drumhat/pianohat script)
- you have the Adafruit PiOLED display installed
- you have the Pimoroni Button Shim installed
- the directory /mnt/victoria_usb exists so the USB thumb drive can be mounted (you can change this with the
MOUNTPATH
variable) - the script assumes the path to thumb drive is /dev/sda1 (if not, change theMOUNT_VOLUME
variable) - the samples need to be .wav or .ogg files with stereo channel and the correct samplerate (see options)
- on the thumb drive there must be a folder called drums with up to 8 samples and a folder called piano with up to or more than 13 samples; the folder names can be changed in the
options
variable or via the config.txt file - the default sample rate is
44100
but can be changed in the options variable or via the config.txt file; all samples need to be in this sample rate. Samples that are in another samplerate will not be played correctly - there a two folders (piano and drums) with default samples if the USB thumb drive is not present, in the same folder as the script in a subfolder called sounds; change the variables
BANK_PIANO
andBANK_DRUMS
if you want to move them - auto start the script on log in (for example via
crontab -e
: add a line@reboot ~/victoria
) - you can activate the Overlay FS option in raspi-config to protect the SD-card from data loss
- the thumb drive gets mounted as read only (
-o ro
argument). This may be a problem with some drives as they will not mount correctly, so if you can't play the files from your drive, try removing the-o ro
argument - the I2S Audio Bonnet or pHAT DAC have a line out, not a headphone jack plug; if you use headphones the output volume will be too loud
- to find out which HATs work together you can use the pinout.xyz pHAT Stack Configurator
----------
Have a comment? Drop me an email!