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:

----------
Have a comment? Drop me an email!