Deadline: We need it tonight
We have just eight hours until our classic rock band rehearsal session tonight, and we need some pre-recorded sound effects for three songs. The required sound effects (SFX) playback machine should be super easy to use, powered by a battery, and have a stereo output for the band’s PA system. Sound quality is important, but since the sound effect recordings aren’t actual music, we can be flexible with the bit rate to save space on the storage.
The band retired six years ago and, at the time, it didn’t seem like there would be a reunion tour. So, I stripped the old sound effects machine (the FXM-8 shown above) for parts and gave it a new life. The old unit used a special SFX board (Adafruit #2220) with .ogg sound files stored on-board. It worked and sounded good, but it was a pain to update the files. The simple and obvious FXM-8 tactile interface was also pretty cool in hindsight.
And of course, the band’s plans changed. They asked us to come out of retirement and play one more gig.
So, we’re going to need a new SFX machine.
The Requirements
Here's the list of requirements for the new SFX machine:
- Choose, play, and stop up to four selectable sound effect files.
- Utilize a simple one-click play/stop button.
- Accommodate 16-bit 22.050kps stereo wave files stored on a removable micro SD card.
- Provide an obvious flashing light when playing that can be seen from across the stage.
- Battery powered continuous operation for at least 3 hours. USB power connector for charging/wall wart use.
Most importantly, it needs to be coded and tested in fewer than eight hours.
Given that several of my existing projects incorporate code snippets for interfacing with the display and playing sounds, it was a logical assumption that CircuitPython would be the preferred platform for the new device. Additionally, I have a spare PyGamer device available, which would be ideal for the stereo output due to its 3.5mm TRS earphone jack, vibrant display, gamepad buttons and joystick, SD card, and demonstrated ability to play high-quality sound from wave files.
Time’s ticking, so let’s gather those code snippets and make sure this project idea is a hit!
The CircuitPython Code
The implementation of the audio output, keypad decoding, and SD card file reader was relatively straightforward. The file selection interface was constructed using simple displayio.Label objects, which were controlled by a list of wave file properties. After some initial troubleshooting and minor adjustments to the positioning of graphics objects, the primary functionality, including the joystick and button interface, was successfully implemented.
Next, the 44.100kps 24-bit stereo source wave files were converted to 22.050kps 16-bit using the free version of Audacity, placed on a micro SD card and the code was retested. Everything was working nicely.
The final modifications to the code included enabling the on-board speaker, which is deactivated when the earphone jack is utilized, and incorporating a moving worm effect onto the five Neopixels to indicate the playback status of a sound file.
Here's the final version of the CircuitPython code including a battery charge icon graphic that was recently added. The code and bitmap files can be found in the code folder of the H12 SFX Machine GitHub repository.
# SPDX-FileCopyrightText: 2024 JG for Cedar Grove Maker Studios
#
# SPDX-License-Identifier: MIT
"""A custom sound effects player for The Highway 12 Band"""
import time
import board
from analogio import AnalogIn
from digitalio import DigitalInOut
import sdcardio
import storage
import audiocore
import audioio
import audiomixer
import neopixel
from simpleio import map_range
import terminalio
import keypad
import displayio
from adafruit_display_text.label import Label
from adafruit_bitmap_font import bitmap_font
import adafruit_imageload
# Instantiate the integral display and define its size
display = board.DISPLAY
display.brightness = 0.6
WIDTH = display.width
HEIGHT = display.height
# Display splash graphics first before instantiating everything
track_group = displayio.Group(scale=1)
splash_group = displayio.Group(scale=1)
sign_bright = displayio.OnDiskBitmap("/HW12_sign_bw_2012-11-17_v00.bmp")
sign_faded = displayio.OnDiskBitmap("/HW12_sign_bw_2012-11-17_v01_faded.bmp")
splash_group.append(
displayio.TileGrid(sign_faded, pixel_shader=sign_faded.pixel_shader)
)
splash_group.append(
displayio.TileGrid(sign_bright, pixel_shader=sign_bright.pixel_shader)
)
display.root_group = splash_group
# Instantiate and clear the NeoPixels
pixels = neopixel.NeoPixel(board.NEOPIXEL, 5, pixel_order=neopixel.GRB)
pixels.brightness = 0.06
pixels.fill(0x202020)
# Battery indicator
battery = AnalogIn(board.A6)
sprite_sheet, battery_palette = adafruit_imageload.load(
"/batt_sprite_sheet.bmp", bitmap=displayio.Bitmap, palette=displayio.Palette
)
battery_icon = displayio.TileGrid(
sprite_sheet,
pixel_shader=battery_palette,
width=1,
height=1,
tile_width=16,
tile_height=16,
)
battery_icon.x = WIDTH - 16
battery_icon.y = 1
battery_palette.make_transparent(0) # Set icon background to transparent
# Define track titles and associated SD card file names
TRACK_TITLE = [
["409 REV", "409_22.wav"],
["Jet START", "jet_start_22.wav"],
["Jet END", "jet_start_22.wav"],
["Wipeout", "wipeout_22.wav"],
]
CURRENT_TRACK = 0
# Define the state machine states
class State:
INIT = "INIT"
PLAYING = "PLAY"
PAUSED = "PAUSE"
STOPPED = "STOP"
STATE = State.INIT
print(f"STATE: {STATE}")
# Enable the speaker
DigitalInOut(board.SPEAKER_ENABLE).switch_to_output(value=True)
# Instantiate PyGamer joystick
joystick_x = AnalogIn(board.JOYSTICK_X)
joystick_y = AnalogIn(board.JOYSTICK_Y)
# Initialize ShiftRegisterKeys to read PyGamer buttons
panel = keypad.ShiftRegisterKeys(
clock=board.BUTTON_CLOCK,
data=board.BUTTON_OUT,
latch=board.BUTTON_LATCH,
key_count=8,
value_when_pressed=True,
)
# Define front panel button event values
BUTTON_SELECT = 3 # Not used
BUTTON_START = 2 # Not used
BUTTON_A = 1 # Player START/STOP
BUTTON_B = 0 # Not used
# Instantiate the SD card storage system
sd = sdcardio.SDCard(board.SPI(), board.SD_CS)
vfs = storage.VfsFat(sd)
storage.mount(vfs, "/sd")
# print(os.listdir('/sd/SFX'))
# Instantiate the mixer and audio output stream
audio_out = audioio.AudioOut(
left_channel=board.A0, right_channel=board.A1, quiescent_value=32_768
)
mixer = audiomixer.Mixer(
voice_count=1,
buffer_size=2048,
sample_rate=22050,
channel_count=2,
bits_per_sample=16,
samples_signed=True,
)
audio_out.play(mixer)
# Create a list of opened wave files from TRACK_TITLE list
wave_files = [open("sd/SFX/" + t[1], "rb") for t in TRACK_TITLE]
# Create a list of wave objects from the opened wave files list
waves = [audiocore.WaveFile(f) for f in wave_files]
# Define track labels
font_0 = bitmap_font.load_font("/fonts/helvB14.bdf")
label_padding = 7
track_0_label = Label(
font_0,
text=TRACK_TITLE[0][0],
color=0xFFFFFF,
padding_top=label_padding,
padding_bottom=label_padding,
padding_left=label_padding,
padding_right=label_padding,
)
track_0_label.anchor_point = (0.5, 0.5)
track_0_label.anchored_position = ((WIDTH // 2), 1 * HEIGHT // 5)
track_group.append(track_0_label)
track_1_label = Label(
font_0,
text=TRACK_TITLE[1][0],
color=0xFFFFFF,
padding_top=label_padding,
padding_bottom=label_padding,
padding_left=label_padding,
padding_right=label_padding,
)
track_1_label.anchor_point = (0.5, 0.5)
track_1_label.anchored_position = ((WIDTH // 2), 2 * HEIGHT // 5)
track_group.append(track_1_label)
track_2_label = Label(
font_0,
text=TRACK_TITLE[2][0],
color=0xFFFFFF,
padding_top=label_padding,
padding_bottom=label_padding,
padding_left=label_padding,
padding_right=label_padding,
)
track_2_label.anchor_point = (0.5, 0.5)
track_2_label.anchored_position = ((WIDTH // 2), 3 * HEIGHT // 5)
track_group.append(track_2_label)
track_3_label = Label(
font_0,
text=TRACK_TITLE[3][0],
color=0xFFFFFF,
padding_top=label_padding,
padding_bottom=label_padding,
padding_left=label_padding,
padding_right=label_padding,
)
track_3_label.anchor_point = (0.5, 0.5)
track_3_label.anchored_position = ((WIDTH // 2), 4 * HEIGHT // 5)
track_group.append(track_3_label)
state_status = Label(font_0, text=" ", color=0x404040)
state_status.anchor_point = (0, 1.0)
state_status.anchored_position = (0, 128)
track_group.append(state_status)
battery_status = Label(terminalio.FONT, text=" ", color=0x808080)
battery_status.anchor_point = (1.0, 0)
battery_status.anchored_position = (WIDTH, 12)
track_group.append(battery_status)
# Reveal faded sign bitmap image, battery icon and show tracks
display.root_group.pop()
display.root_group.append(track_group)
splash_group.append(battery_icon)
# FUNCTIONS
def stop():
# Stop track playback
global STATE, CURRENT_TRACK
mixer.voice[0].stop()
mixer.voice[0].level = 0.0
STATE = State.STOPPED
track_group[CURRENT_TRACK].color = 0x000000
track_group[CURRENT_TRACK].background_color = 0xFFFF00
pixels.fill(0x000000)
print(f"STATE: {STATE}")
def play():
# Start track playback
global STATE, CURRENT_TRACK, pixel_lighted
STATE = State.PLAYING
mixer.voice[0].level = 1.0
mixer.voice[0].play(waves[CURRENT_TRACK])
track_group[CURRENT_TRACK].color = 0x000000
track_group[CURRENT_TRACK].background_color = 0x00FF00
pixels.fill(0x00FF00)
pixel_lighted = 0
print(f"STATE: {STATE}")
def get_joystick():
# Read the joystick and interpret as up/down buttons
if joystick_y.value < 20000:
return 1 # UP
if joystick_y.value > 44000:
return -1 # DOWN
return 0
def display_battery_level():
"""Display the battery level."""
battery_volts = round(battery.value * 6.6 / 0xFFF0, 1)
# battery_status.text = f"{battery_volts:3.1f}"
battery_level = int(map_range(battery_volts, 3.1, 3.6, 0, 5))
battery_icon[0] = battery_level
# Initialization completed
joystick_hold = False
pixel_lighted = 0
stop() # Stop mixer/audio output
display_battery_level()
# Main Code Loop
while True:
if time.monotonic() % 10 == 0:
# Update the battery display every 10 seconds
display_battery_level()
state_status.text = STATE
if STATE == State.STOPPED:
# Select next track to play only when stopped
joystick_delta = get_joystick()
if joystick_delta != 0:
if not joystick_hold:
track_group[CURRENT_TRACK].color = 0xFFFFFF
track_group[CURRENT_TRACK].background_color = None
CURRENT_TRACK = CURRENT_TRACK - joystick_delta
CURRENT_TRACK = min(max(0, CURRENT_TRACK), 3)
joystick_hold = True
else:
joystick_hold = False
track_group[CURRENT_TRACK].color = 0x000000
track_group[CURRENT_TRACK].background_color = 0xFFFF00
buttons = panel.events.get()
if buttons and buttons.pressed:
if buttons.key_number == BUTTON_A: # START/STOP button pressed
play() # This is non-blocking
if STATE == State.PLAYING:
# Watch for STOP button press only when playing
buttons = panel.events.get()
if buttons and buttons.pressed:
if buttons.key_number == BUTTON_A: # START/STOP button pressed
stop()
if mixer.playing:
if (
pixels[pixel_lighted][0] * 0x010000
+ pixels[pixel_lighted][1] * 0x000100
+ pixels[pixel_lighted][2]
) == 0x00FF00:
pixels[pixel_lighted] = 0x002000
else:
pixels[pixel_lighted] = 0x00FF00
time.sleep(0.05)
pixel_lighted += 1
if pixel_lighted > 4:
pixel_lighted = 0
else:
stop()
The Rehearsal
The SFX player was ready just in time for a lovely family dinner before our band rehearsal. We played two songs that needed the sounds of an automobile engine and a jet airplane. The rehearsal was a huge success!
If you want to use the code for your own SFX player, you’ll need to update the TRACK_TITLE list for your specific effect titles and SD card .wav files. You can also change the background graphics to show your band’s logo.
CircuitPython was the perfect choice for getting the project up and running quickly because there were already functional examples of the code that would make this project work.
Next Steps
Perhaps in the future, the code will be altered to automatically scan the SD card and list any stored .wav file. It would also be helpful if the playback length in seconds was also displayed next to the sound effect name. What else might you add?
Â
This page (The Highway 12 Band SFX Machine) was last updated on August 17, 2025.
Text editor powered by tinymce.