Overview
You can listen to your favorite chiptune music with CircuitPython! In this project, you'll build a smol arcade cabinet that houses a Feather RP2350, I2S DAC, and TFT FeatherWing. The Feather runs CircuitPython code that uses the synthio module to emulate the AY-3-8910, a 3-voice programmable sound generator that was used in a lot of arcade video games.
You'll load your favorite video game music (.VGM/.VGZ) audio files onto a microSD card and enjoy all the vintage bleeps, bloops, and noise filters. These files can be found on video game archival sites or video game asset sites like itch.io. A lot of music tracker software also offers VGM file export for your original music. There is an example track included in the CircuitPython code folder for testing.
Page last edited July 01, 2026
Text editor powered by tinymce.
Circuit Diagram
- Board 3.3V to DAC VIN (red wire)
- Board GND to DAC GND (black wire)
- Board A1 to DAC BCK (yellow wire)
- Board A2 to DAC WSEL (green wire)
- Board A3 to DAC DIN (blue wire)
The Feather RP2350 is plugged into the TFT FeatherWing. The I2S DAC wires are plugged into the duplicate Feather pins on the FeatherWing.
Page last edited July 01, 2026
Text editor powered by tinymce.
3D Printing
CAD Parts
Individual 3MF files for 3D printing are oriented and ready to print on FDM machines using PLA filament. Original design source files may be downloaded using the links below.
Build Volume
The parts require a 3D printer with a minimum build volume of 168 (X) x 168 (Y) x 76mm (Z).
3D Models of Adafruit Parts
Electronic components like Adafruit's boards and more can be downloaded from the Adafruit CAD parts GitHub Repo.
Page last edited July 01, 2026
Text editor powered by tinymce.
Install CircuitPython
CircuitPython is a derivative of MicroPython designed to simplify experimentation and education on low-cost microcontrollers. It makes it easier than ever to get prototyping by requiring no upfront desktop software downloads. Simply copy and edit files on the CIRCUITPY drive to iterate.
CircuitPython Quickstart
Follow this step-by-step to quickly get CircuitPython running on your board.
Click the link above to download the latest CircuitPython UF2 file.
Save it wherever is convenient for you.
To enter the bootloader, hold down the BOOT/BOOTSEL button (highlighted in red above), and while continuing to hold it (don't let go!), press and release the reset button (highlighted in red or blue above). Continue to hold the BOOT/BOOTSEL button until the RP2350 drive appears!
If the drive does not appear, release all the buttons, and then repeat the process above.
You can also start with your board unplugged from USB, press and hold the BOOTSEL button (highlighted in red above), continue to hold it while plugging it into USB, and wait for the drive to appear before releasing the button.
A lot of people end up using charge-only USB cables and it is very frustrating! Make sure you have a USB cable you know is good for data sync.
You will see a new disk drive appear called RP2350.
Â
Drag the adafruit-circuitpython-boardname-language-version.uf2 file to RP2350.
The RP2350 drive will disappear and a new disk drive called CIRCUITPY will appear.
That's it, you're done! :)
Safe Mode
You want to edit your code.py or modify the files on your CIRCUITPY drive, but find that you can't. Perhaps your board has gotten into a state where CIRCUITPY is read-only. You may have turned off the CIRCUITPY drive altogether. Whatever the reason, safe mode can help.
Safe mode in CircuitPython does not run any user code on startup, and disables auto-reload. This means a few things. First, safe mode bypasses any code in boot.py (where you can set CIRCUITPY read-only or turn it off completely). Second, it does not run the code in code.py. And finally, it does not automatically soft-reload when data is written to the CIRCUITPY drive.
Therefore, whatever you may have done to put your board in a non-interactive state, safe mode gives you the opportunity to correct it without losing all of the data on the CIRCUITPY drive.
To enter safe mode when using CircuitPython, plug in your board or hit reset (highlighted in red above). Immediately after the board starts up or resets, it waits 1000ms. On some boards, the onboard status LED (highlighted in green above) will blink yellow during that time. If you press reset during that 1000ms, the board will start up in safe mode. It can be difficult to react to the yellow LED, so you may want to think of it simply as a slow double click of the reset button. (Remember, a fast double click of reset enters the bootloader.)
In Safe Mode
If you successfully enter safe mode on CircuitPython, the LED will intermittently blink yellow three times.
If you connect to the serial console, you'll find the following message.
Auto-reload is off. Running in safe mode! Not running saved code. CircuitPython is in safe mode because you pressed the reset button during boot. Press again to exit safe mode. Press any key to enter the REPL. Use CTRL-D to reload.
You can now edit the contents of the CIRCUITPY drive. Remember, your code will not run until you press the reset button, or unplug and plug in your board, to get out of safe mode.
Flash Resetting UF2
If your board ever gets into a really weird state and CIRCUITPY doesn't show up as a disk drive after installing CircuitPython, try loading this 'nuke' UF2 to RP2350. which will do a 'deep clean' on your Flash Memory. You will lose all the files on the board, but at least you'll be able to revive it! After loading this UF2, follow the steps above to re-install CircuitPython.
Page last edited July 01, 2026
Text editor powered by tinymce.
Code the Player
Once you've finished setting up your RP2350 Feather with CircuitPython, you can access the code and necessary libraries by downloading the Project Bundle.
To do this, click on the Download Project Bundle button in the window below. It will download to your computer as a zipped folder.
# SPDX-FileCopyrightText: 2026 Liz Clark for Adafruit Industries
#
# SPDX-License-Identifier: MIT
"""CircuitPython Chiptune Player
Uses AY8912 emulator helper library to play VGM files
through I2S DAC. TFT FeatherWing for touchscreen GUI"""
import time
import os
import gc
import board
import displayio
import fourwire
import storage
import sdcardio
from adafruit_hx8357 import HX8357
import simpleio
from adafruit_button import Button
import adafruit_tsc2007
import terminalio
from adafruit_display_text import label as text_label
import audiobusio
from adafruit_progressbar.horizontalprogressbar import (
HorizontalProgressBar,
HorizontalFillDirection,
)
from adafruit_ay8912.ay8912_emulator import AY8912
from adafruit_ay8912.vgm_player import VGMFile
displayio.release_displays()
spi = board.SPI()
SD_CS = board.D5
VGM_EXTS = (".vgm", ".vgz")
playlist = []
for attempt in range(3):
try:
sdcard = sdcardio.SDCard(spi, SD_CS)
vfs = storage.VfsFat(sdcard)
storage.mount(vfs, "/sd")
playlist = sorted(
(n for n in os.listdir("/sd") if n.lower().endswith(VGM_EXTS)),
key=lambda n: n.lower(),
)
print(f"Found {len(playlist)} VGM file(s) on SD")
break
except OSError as exc:
print(f"SD init attempt {attempt + 1} failed:", exc)
time.sleep(0.25)
else:
print("SD card not available - running with an empty playlist.")
# init display after sd card
tft_cs = board.D9
tft_dc = board.D10
display_bus = fourwire.FourWire(spi, command=tft_dc, chip_select=tft_cs)
display = HX8357(display_bus, width=320, height=480, rotation=90) # portrait
SCREEN_W = 320
SCREEN_H = 480
i2c = board.I2C()
tsc = adafruit_tsc2007.TSC2007(i2c, invert_x=True, invert_y=True)
audio = audiobusio.I2SOut(board.A1, board.A2, board.A3)
# touch screen buttons
OUTLINE = 0xFF00FF
LABEL_COLOR = 0x000000
# pylint: disable=global-statement, too-many-branches
def make_button(action, x, y, w, h, label, fill, label_color=LABEL_COLOR):
"""Build a Button and tag it with the action it triggers."""
b = Button(
x=x, y=y, width=w, height=h,
style=Button.ROUNDRECT,
fill_color=fill,
outline_color=OUTLINE,
label=label,
label_font=terminalio.FONT,
label_color=label_color,
)
return b, action
def fmt_time(seconds):
"""Seconds -> 'M:SS'."""
seconds = int(seconds)
return f"{seconds // 60}:{seconds % 60:02d}"
def fit(text, maxchars):
"""Truncate long strings so they don't overrun the screen."""
if len(text) > maxchars:
return text[:maxchars - 3] + "..."
return text
# ====================================================================
# PLAY VIEW
# ====================================================================
play_group = displayio.Group()
# Repeat toggle, top-left. Its "selected" state is the lit state:
# off -> black body + cyan text; on -> cyan body + black text.
repeat_button = Button(
x=6, y=8, width=76, height=34,
style=Button.ROUNDRECT,
fill_color=0x000000, outline_color=OUTLINE,
label="REPEAT", label_font=terminalio.FONT, label_color=0x00FFFF,
selected_fill=0x00FFFF, selected_label=0x000000,
)
play_group.append(repeat_button)
# Track info, centered in the open top area (filled in by load_track).
NP_X = SCREEN_W // 2
title_label = text_label.Label(terminalio.FONT, text="(no track)", color=0x00FFFF,
scale=2, anchor_point=(0.5, 0.5), anchored_position=(NP_X, 150))
author_label = text_label.Label(terminalio.FONT, text="", color=0xFFFF00,
anchor_point=(0.5, 0.5), anchored_position=(NP_X, 182))
game_label = text_label.Label(terminalio.FONT, text="", color=0xFF00FF,
anchor_point=(0.5, 0.5), anchored_position=(NP_X, 204))
play_group.append(title_label)
play_group.append(author_label)
play_group.append(game_label)
# Progress bar + time readout.
BAR_W, BAR_H = 288, 24
BAR_X = (SCREEN_W - BAR_W) // 2
BAR_Y = 360
progress_bar = HorizontalProgressBar(
(BAR_X, BAR_Y), (BAR_W, BAR_H),
min_value=0.0, max_value=1.0, value=0.0,
bar_color=0x00FFFF, outline_color=0xFF00FF, fill_color=0x000000,
direction=HorizontalFillDirection.LEFT_TO_RIGHT,
)
play_group.append(progress_bar)
time_label = text_label.Label(terminalio.FONT, text="0:00 / 0:00", color=0x00FFFF,
anchor_point=(1.0, 1.0), anchored_position=(BAR_X + BAR_W, BAR_Y - 4))
play_group.append(time_label)
# Transport row: STOP << PLAY/PAUSE >> MENU
ROW_Y = 400
BTN_H = 55
PLAY_W = 84
CTRL_W = 50
GAP = 6
play_x = (SCREEN_W - PLAY_W) // 2
rew_x = play_x - GAP - CTRL_W
stop_x = rew_x - GAP - CTRL_W
ffwd_x = play_x + PLAY_W + GAP
menu_x = ffwd_x + CTRL_W + GAP
play_buttons = [repeat_button]
play_actions = ["repeat"]
for btn, act in (
make_button("stop", stop_x, ROW_Y, CTRL_W, BTN_H, "STOP", 0xFF0000),
make_button("prev", rew_x, ROW_Y, CTRL_W, BTN_H, "<<", 0xFF00FF),
make_button("play", play_x, ROW_Y, PLAY_W, BTN_H, "PLAY", 0x00FFFF),
make_button("next", ffwd_x, ROW_Y, CTRL_W, BTN_H, ">>", 0xFFFF00),
make_button("show_menu", menu_x, ROW_Y, CTRL_W, BTN_H, "MENU", 0x000000, 0xFFFFFF),
):
play_group.append(btn)
play_buttons.append(btn)
play_actions.append(act)
play_button = play_buttons[3] # the PLAY/PAUSE button (for label toggling)
# ====================================================================
# MENU VIEW (for selecting tracks)
# ====================================================================
menu_group = displayio.Group()
MENU_X, MENU_W = 10, 300
MENU_ROW_H, MENU_ROW_GAP, MENU_TOP = 38, 4, 8
PER_PAGE = 9
# Persistent bottom nav row: [<] NOW PLAYING [>]
NAV_Y, NAV_H = 420, 52
PAGE_W = 56
prevpage_btn, _ = make_button("page_prev", MENU_X, NAV_Y, PAGE_W, NAV_H, "<", 0x000000, 0x00FFFF)
np_btn, _ = make_button("show_play", MENU_X + PAGE_W + GAP, NAV_Y,
MENU_W - 2 * (PAGE_W + GAP), NAV_H, "NOW PLAYING", 0x00FFFF)
nextpage_btn, _ = make_button("page_next", MENU_X + MENU_W - PAGE_W, NAV_Y,
PAGE_W, NAV_H, ">", 0x000000, 0x00FFFF)
for btn in (prevpage_btn, np_btn, nextpage_btn):
menu_group.append(btn)
page_label = text_label.Label(terminalio.FONT, text="", color=0x888893,
anchor_point=(0.5, 0.5), anchored_position=(NP_X, 404))
menu_group.append(page_label)
if not playlist:
menu_group.append(text_label.Label(
terminalio.FONT, text="No VGM files found on /sd", color=0xFFFFFF,
anchor_point=(0.5, 0.5), anchored_position=(NP_X, 180)))
file_buttons = []
menu_page = 0
menu_buttons = [prevpage_btn, np_btn, nextpage_btn]
menu_actions = ["page_prev", "show_play", "page_next"]
# ====================================================================
# AY8912 emulator setup
# ====================================================================
ay = AY8912(sample_rate=22050, clock_rate=1773400)
ay.begin(audio)
vgm = None
song_duration = 0.0
current_index = 0
is_playing = False
is_paused = False
repeat_mode = False
last_progress = -1.0
last_sec = -1
current_view = "play"
wait_release = False
def build_menu_page(page):
"""(Re)build the file buttons for the requested page, with wraparound."""
global menu_page, menu_buttons, menu_actions, file_buttons
if not playlist:
return
num_pages = (len(playlist) + PER_PAGE - 1) // PER_PAGE
menu_page = page % num_pages
for fb in file_buttons: # clear the previous page
menu_group.remove(fb)
file_buttons = []
fb_actions = []
gc.collect()
start = menu_page * PER_PAGE
for slot, idx in enumerate(range(start, min(start + PER_PAGE, len(playlist)))):
y = MENU_TOP + slot * (MENU_ROW_H + MENU_ROW_GAP)
b, _ = make_button(idx, MENU_X, y, MENU_W, MENU_ROW_H,
fit(playlist[idx], 30), 0x000000, 0x00FFFF)
menu_group.append(b)
file_buttons.append(b)
fb_actions.append(idx)
menu_buttons = file_buttons + [prevpage_btn, np_btn, nextpage_btn]
menu_actions = fb_actions + ["page_prev", "show_play", "page_next"]
page_label.text = f"page {menu_page + 1} / {num_pages}"
def load_track(track_index):
"""Load (but don't start) the track at playlist index"""
global vgm, song_duration, current_index, last_progress, last_sec
if not playlist:
return
current_index = track_index % len(playlist)
gc.collect()
display.auto_refresh = False
try:
vgm = VGMFile("/sd/" + playlist[current_index])
finally:
display.auto_refresh = True
ay.clock_rate = vgm.clock_hz
song_duration = vgm.duration
title_label.text = fit(vgm.title or playlist[current_index], 24)
author_label.text = fit(vgm.author, 40)
game_label.text = fit(vgm.game, 40)
progress_bar.value = 0.0
last_progress = -1.0
last_sec = -1
time_label.text = f"{fmt_time(0)} / {fmt_time(song_duration)}"
def play_track(track_index):
"""Load track index and start it from the beginning."""
global is_playing, is_paused
if not playlist:
return
load_track(track_index)
if vgm is None:
return
vgm.play(ay)
is_playing = True
is_paused = False
play_button.label = "PAUSE"
def change_track(delta):
"""Step the playlist with wraparound and play."""
if not playlist:
return
play_track((current_index + delta) % len(playlist))
def show_menu():
global current_view, wait_release
current_view = "menu"
display.root_group = menu_group
wait_release = True
def show_play():
global current_view, wait_release
current_view = "play"
display.root_group = play_group
wait_release = True
def dispatch(action):
"""Run once per fresh button tap (rising edge)."""
global is_playing, is_paused, repeat_mode, last_progress, last_sec
if action == "show_menu":
show_menu()
elif action == "show_play":
show_play()
elif action == "page_prev":
build_menu_page(menu_page - 1)
elif action == "page_next":
build_menu_page(menu_page + 1)
elif action == "repeat":
repeat_mode = not repeat_mode
repeat_button.selected = repeat_mode # lit when on
elif isinstance(action, int): # a file was picked in the menu
play_track(action)
show_play()
elif action == "play":
if vgm is None:
return
if not is_playing and not is_paused:
vgm.play(ay)
is_playing = True
play_button.label = "PAUSE"
last_progress = -1.0
last_sec = -1
elif is_playing:
is_playing = False # pause: stop pumping + mute
is_paused = True
play_button.label = "PLAY"
else:
is_paused = False # resume
is_playing = True
play_button.label = "PAUSE"
elif action == "stop":
if vgm is not None:
vgm.stop() # halts + resets the AY (silence)
is_playing = False
is_paused = False
play_button.label = "PLAY"
progress_bar.value = 0.0
last_progress = 0.0
time_label.text = f"{fmt_time(0)} / {fmt_time(song_duration)}"
last_sec = 0
elif action == "prev":
change_track(-1)
elif action == "next":
change_track(1)
def touched_index(the_btns):
"""Index of the button in btns under the touch, or None."""
if not tsc.touched:
return None
point = tsc.touch
if point["pressure"] < 100:
return None
p = (
simpleio.map_range(point['x'], 430, 3700, 0, 320),
simpleio.map_range(point['y'], 315, 3800, 0, 480),
)
for ind, the_btn in enumerate(the_btns):
if the_btn.contains(p):
return ind
return None
# --- Boot into the menu with the first track pre-loaded (stopped) ---
if playlist:
build_menu_page(0)
load_track(0)
show_menu()
# --- Main loop ---
POLL_INTERVAL = 0.02
last_index = None
last_poll = time.monotonic()
while True:
# Feed audio as tightly as possible, in any view.
if is_playing and vgm is not None:
vgm.update()
if not vgm.playing: # track reached its end
if repeat_mode:
vgm.play(ay) # replay the same track (no SD reload)
last_progress = -1.0
last_sec = -1
else:
change_track(1) # auto-advance, wraps at the end
elif vgm.loop_count >= 1 and not repeat_mode:
change_track(1) # looping file finished a pass; move on
now = time.monotonic()
if now - last_poll >= POLL_INTERVAL:
last_poll = now
btns = play_buttons if current_view == "play" else menu_buttons
acts = play_actions if current_view == "play" else menu_actions
index = touched_index(btns)
for i, btn in enumerate(btns):
if btn is repeat_button: # its lit state reflects repeat_mode, not touch
continue
btn.selected = i == index
if wait_release:
if index is None: # finger lifted; accept presses again
wait_release = False
last_index = index
else:
if index is not None and index != last_index:
dispatch(acts[index])
last_index = index
if is_playing and vgm is not None:
progress = vgm.progress
if abs(progress - last_progress) >= 0.01:
progress_bar.value = progress
last_progress = progress
sec = int(vgm.elapsed)
if sec != last_sec:
time_label.text = f"{fmt_time(sec)} / {fmt_time(song_duration)}"
last_sec = sec
Upload the Code and Libraries to the RP2350 Feather
After downloading the Project Bundle, plug your RP2350 Feather into the computer's USB port with a known good USB data+power cable. You should see a new flash drive appear in the computer's File Explorer or Finder (depending on your operating system) called CIRCUITPY. Unzip the folder and copy the following items to the RP2350 Feather's CIRCUITPY drive.
- lib folder
- code.py
Your RP2350 Feather CIRCUITPY drive should look like this after copying the lib folder and code.py file:
There is an example VGM track (computer_music.vgz) included in the bundle. You can copy this file to your microSD card for testing.
How the CircuitPython Code Works
After SPI is initialized, the SD card is mounted. A search is performed in the /sd directory for any video game music (.VGM) audio files. These files are added to the playlist list.
displayio.release_displays()
spi = board.SPI()
SD_CS = board.D5
VGM_EXTS = (".vgm", ".vgz")
playlist = []
for attempt in range(3):
try:
sdcard = sdcardio.SDCard(spi, SD_CS)
vfs = storage.VfsFat(sdcard)
storage.mount(vfs, "/sd")
playlist = sorted(
(n for n in os.listdir("/sd") if n.lower().endswith(VGM_EXTS)),
key=lambda n: n.lower(),
)
print(f"Found {len(playlist)} VGM file(s) on SD")
break
except OSError as exc:
print(f"SD init attempt {attempt + 1} failed:", exc)
time.sleep(0.25)
else:
print("SD card not available - running with an empty playlist.")
Display and Audio Init
Next is the display initialization with the TSC2007 touchscreen driver. I2S audio is initialized for the I2S DAC.
# init display after sd card tft_cs = board.D9 tft_dc = board.D10 display_bus = fourwire.FourWire(spi, command=tft_dc, chip_select=tft_cs) display = HX8357(display_bus, width=320, height=480, rotation=90) # portrait SCREEN_W = 320 SCREEN_H = 480 i2c = board.I2C() tsc = adafruit_tsc2007.TSC2007(i2c, invert_x=True, invert_y=True) audio = audiobusio.I2SOut(board.A1, board.A2, board.A3)
Graphics
There are two display groups:Â play_group and menu_group. menu_group shows the list of VGM files loaded from the SD card. Each file name is shown in a touch button to select for playback.Â
# ====================================================================
# MENU VIEW (for selecting tracks)
# ====================================================================
menu_group = displayio.Group()
MENU_X, MENU_W = 10, 300
MENU_ROW_H, MENU_ROW_GAP, MENU_TOP = 38, 4, 8
PER_PAGE = 9
# Persistent bottom nav row: [<] NOW PLAYING [>]
NAV_Y, NAV_H = 420, 52
PAGE_W = 56
prevpage_btn, _ = make_button("page_prev", MENU_X, NAV_Y, PAGE_W, NAV_H, "<", 0x000000, 0x00FFFF)
np_btn, _ = make_button("show_play", MENU_X + PAGE_W + GAP, NAV_Y,
MENU_W - 2 * (PAGE_W + GAP), NAV_H, "NOW PLAYING", 0x00FFFF)
nextpage_btn, _ = make_button("page_next", MENU_X + MENU_W - PAGE_W, NAV_Y,
PAGE_W, NAV_H, ">", 0x000000, 0x00FFFF)
for btn in (prevpage_btn, np_btn, nextpage_btn):
menu_group.append(btn)
page_label = text_label.Label(terminalio.FONT, text="", color=0x888893,
anchor_point=(0.5, 0.5), anchored_position=(NP_X, 404))
menu_group.append(page_label)
if not playlist:
menu_group.append(text_label.Label(
terminalio.FONT, text="No VGM files found on /sd", color=0xFFFFFF,
anchor_point=(0.5, 0.5), anchored_position=(NP_X, 180)))
file_buttons = []
menu_page = 0
menu_buttons = [prevpage_btn, np_btn, nextpage_btn]
menu_actions = ["page_prev", "show_play", "page_next"]
The play_group has all of the playback controls: stop, play, pause, forward, backward and repeat. The "now playing" info is shown in the middle of the display and a HorizontalProgressBar shows the playback progress.
# ====================================================================
# PLAY VIEW
# ====================================================================
play_group = displayio.Group()
# Repeat toggle, top-left. Its "selected" state is the lit state:
# off -> black body + cyan text; on -> cyan body + black text.
repeat_button = Button(
x=6, y=8, width=76, height=34,
style=Button.ROUNDRECT,
fill_color=0x000000, outline_color=OUTLINE,
label="REPEAT", label_font=terminalio.FONT, label_color=0x00FFFF,
selected_fill=0x00FFFF, selected_label=0x000000,
)
play_group.append(repeat_button)
# Track info, centered in the open top area (filled in by load_track).
NP_X = SCREEN_W // 2
title_label = text_label.Label(terminalio.FONT, text="(no track)", color=0x00FFFF,
scale=2, anchor_point=(0.5, 0.5), anchored_position=(NP_X, 150))
author_label = text_label.Label(terminalio.FONT, text="", color=0xFFFF00,
anchor_point=(0.5, 0.5), anchored_position=(NP_X, 182))
game_label = text_label.Label(terminalio.FONT, text="", color=0xFF00FF,
anchor_point=(0.5, 0.5), anchored_position=(NP_X, 204))
play_group.append(title_label)
play_group.append(author_label)
play_group.append(game_label)
# Progress bar + time readout.
BAR_W, BAR_H = 288, 24
BAR_X = (SCREEN_W - BAR_W) // 2
BAR_Y = 360
progress_bar = HorizontalProgressBar(
(BAR_X, BAR_Y), (BAR_W, BAR_H),
min_value=0.0, max_value=1.0, value=0.0,
bar_color=0x00FFFF, outline_color=0xFF00FF, fill_color=0x000000,
direction=HorizontalFillDirection.LEFT_TO_RIGHT,
)
play_group.append(progress_bar)
time_label = text_label.Label(terminalio.FONT, text="0:00 / 0:00", color=0x00FFFF,
anchor_point=(1.0, 1.0), anchored_position=(BAR_X + BAR_W, BAR_Y - 4))
play_group.append(time_label)
# Transport row: STOP << PLAY/PAUSE >> MENU
ROW_Y = 400
BTN_H = 55
PLAY_W = 84
CTRL_W = 50
GAP = 6
play_x = (SCREEN_W - PLAY_W) // 2
rew_x = play_x - GAP - CTRL_W
stop_x = rew_x - GAP - CTRL_W
ffwd_x = play_x + PLAY_W + GAP
menu_x = ffwd_x + CTRL_W + GAP
play_buttons = [repeat_button]
play_actions = ["repeat"]
for btn, act in (
make_button("stop", stop_x, ROW_Y, CTRL_W, BTN_H, "STOP", 0xFF0000),
make_button("prev", rew_x, ROW_Y, CTRL_W, BTN_H, "<<", 0xFF00FF),
make_button("play", play_x, ROW_Y, PLAY_W, BTN_H, "PLAY", 0x00FFFF),
make_button("next", ffwd_x, ROW_Y, CTRL_W, BTN_H, ">>", 0xFFFF00),
make_button("show_menu", menu_x, ROW_Y, CTRL_W, BTN_H, "MENU", 0x000000, 0xFFFFFF),
):
play_group.append(btn)
play_buttons.append(btn)
play_actions.append(act)
play_button = play_buttons[3] # the PLAY/PAUSE button (for label toggling)
# ==================================================================== # AY8912 emulator setup # ==================================================================== ay = AY8912(sample_rate=22050, clock_rate=1773400) ay.begin(audio) vgm = None song_duration = 0.0 current_index = 0 is_playing = False is_paused = False repeat_mode = False last_progress = -1.0 last_sec = -1 current_view = "play" wait_release = False
Helpers
There are a few helper functions:
-
build_menu_page()- builds out the file selection buttons in themenu_group. Handles overflow to multiple pages. -
load_track()- queues up the next VGM file for playback -
play_track()- plays the VGM file and handles play/pause controls -
change_track()- changes track based on the forward or backward button inputs -
show_menu()- switches to themenu_group -
show_play()- switches to theplay_group -
dispatch()- handle buttons inputs and change states or values depending on which button is pressed -
touched_index()- tracks if a button contains a touch point on the screen
while True:
# Feed audio as tightly as possible, in any view.
if is_playing and vgm is not None:
vgm.update()
if not vgm.playing: # track reached its end
if repeat_mode:
vgm.play(ay) # replay the same track (no SD reload)
last_progress = -1.0
last_sec = -1
else:
change_track(1) # auto-advance, wraps at the end
elif vgm.loop_count >= 1 and not repeat_mode:
change_track(1) # looping file finished a pass; move on
time.monotonic() is used as a non-blocking time tracker. During the POLL_INTERVAL, the buttons are checked for any inputs. If you are on the playback screen, the progress bar position is also updated.
now = time.monotonic()
if now - last_poll >= POLL_INTERVAL:
last_poll = now
btns = play_buttons if current_view == "play" else menu_buttons
acts = play_actions if current_view == "play" else menu_actions
index = touched_index(btns)
for i, btn in enumerate(btns):
if btn is repeat_button: # its lit state reflects repeat_mode, not touch
continue
btn.selected = i == index
if wait_release:
if index is None: # finger lifted; accept presses again
wait_release = False
last_index = index
else:
if index is not None and index != last_index:
dispatch(acts[index])
last_index = index
if is_playing and vgm is not None:
progress = vgm.progress
if abs(progress - last_progress) >= 0.01:
progress_bar.value = progress
last_progress = progress
sec = int(vgm.elapsed)
if sec != last_sec:
time_label.text = f"{fmt_time(sec)} / {fmt_time(song_duration)}"
last_sec = sec
Page last edited July 01, 2026
Text editor powered by tinymce.
Assembly
Solder Headers
Install the 12-pin and 16-pin headers onto the bottom of the Feather.
Use a pair of helping hands to assist while soldering.
Solder all of the pins on the Feather.
See the guide below on some tips how to do this.
Install Feather to TFT FeatherWing
Orient the Feather with the socket headers on the TFT FeatherWing.
Press the Feather into the sockets to install it onto the TFT FeatherWing.
Insert the microSD card (with preloaded VGM files) into the microSD card slot on the TFT FeatherWing.
Jumper Wires
Get five colored jumper wires from the pack. Red, black, two yellow, and a blue are suggested.
Prep Jumper Wires
Use wire cutters to remove one of the ends from all five jumper wires.
Use wire strippers to remove a bit of insulation from the cut ends.
Tin the exposed wire with a bit of solder.
Repeat this process for all five wires.
Solder Wires to PCM
Solder the jumper wires to the following pins on the PCM.
- Red wire to VCC on PCM
- Black wireto GND on PCM
- Yellow wireto WSEL on PCM
- Blue wire to DIN on PCM
- 2nd Yellow wire to BCK on PCM
Connect PCM to TFT FeatherWing
Plug in the connectors from the jumper wires to the following pins on the TFT FeatherWing socket headers.
- VCC to 3.3V on TFT FeatherWing
- GND to GND on TFT FeatherWing
- WSEL to A2 on TFT FeatherWing
- DIN to A3 on TFT FeatherWing
- BCK to A1 on TFT FeatherWing
Test Circuit
Connect 5V USB power to the Feather. Connect a pair of headphones (or powered speakers) to the PCM5102.
Ensure the enable switch on the back of the TFT FeatherWing is on the ON position.
Use the on-screen controls to play, pause, forward, etc. to test the circuit.
Secure PCM to Mount
Use 4x M2.5 x 6mm long machine screws to secure the PCM5102 to the PCB mount.
Orient the PCM5102 with the PCB Mount then place it over the four standoffs.
Insert and fasten the M2.5 screws to secure the PCB.
Secure TFT FeatherWing
Use 4x. M3 x 4mm long machine screws to secure the TFT FeatherWing to the TFT Mount.
Orient and place the TFT FeatherWing over the four standoffs.
Insert and fasten the M3 screws to secure the PCB.
Install Slide Switch
Carefully insert and fit the slide switch actuator into the cutout on the TFT Mount.
Press the slide switch the prongs are fitted over the actuator on the enable switch.
Test the switch by sliding it back and forth.
Install Left Side Panel
Place the TFT Mount next to the Left Side Panel and orient them.
Line up the left edge of the TFT Mount with the inset channel on the Left Side Panel.
Firmly press the two parts together to secure them in place.
Install Frame
Place the Frame with the Left Side Panel and orient them correctly.
Line up the left edge of the Frame with the inset channel on the Left Side Panel.
Firmly press the two parts together to secure them in place.
Secure PCM Mount to Side Panel
Insert and fit the PCM Mount into the clips on the Left Side Panel.
The TRS audio jack should line up with the circular cutout on the Frame.
The mounting tab should also line up with the mounting hole on the Left Side Panel.
Insert and fasten an M3 x 8mm long machine screw to secure the PCM Mount to the Left Side Panel.
Secure USB-C Cable
Remove the plastic hex nut from USB-C panel mount extension cable.
Insert the USB-C plug end of the cable through the large hole on the Frame.
Press the USB-C socket end of the cable so it sits flush with the Frame.
Insert the plastic hex nut back onto the USB-C cable and finger tighten to secure to the Frame.
Connect USB-C to Feather
Carefully fit the excess USB-C cable into the enclosure and connect the USB-C plug end to the USB-C port on the Feather.
Install Right Side Panel
Orient the Right Side Panel with the enclosure assembly so the right edges of the TFT mount and Frame are lined up correctly with the inset channel.
Firmly press the Right Side Panel to secure it to the two parts, ensuring all of the edges have been fully inserted into the channel.
Secure PCM Mount to Right Side Panel
Use another M3 x 8mm long machine screw to secure the PCM mount to the Right Side Panel.
Insert and fasten the M3 screw to secure the PCM mount to the Right Side Panel.
Connect Power, Audio and Play
Plug in USB-C 5V power to the USB-C extension cable on the back of the Frame.
Turn the slide switch to the ON position on TFT FeatherWings enable switch.
Connect a pair of headphones or powered speakers to the TRS audio jack on the PCM5102.
Final Build
Congratulations on your build! Check out the usage page for information on using the on-screen interface to play chiptunes.
Page last edited July 01, 2026
Text editor powered by tinymce.
Use
First, load your favorite VGM files onto a microSD card, then insert the card into the player. You'll have the best playback experience with files that are targeted for the AY-3-8910. There is an example track included in the CircuitPython code folder for testing.
Power up the player via a USB-C cable and turn the enable switch to the ON position. You'll see the VGM files listed for playback on the TFT.
Select the file that you want to play. This will bring you to the playback screen. You'll see the track info along with a playback progress bar and timestamp.
Page last edited July 01, 2026
Text editor powered by tinymce.