A handheld music player I built on the Adafruit Feather RP2040. Touchscreen, physical buttons, album art, and proper I2S audio output. Started as a CircuitPython prototype, ended up as a full C++ rewrite (PlatformIO + arduino-pico) when I hit the limits of what CircuitPython could do for audio.
- MP3 playback via I2S DAC (PCM5102A)
- Touchscreen Now Playing view with album art, track info, and volume control
- Queue browser: reorder, insert, remove, and save tracks
- Playlist support via
.m3ufiles on the SD card - Screen timeout and wake source settings, persisted across reboots
- Physical PREV / PAUSE / NEXT buttons
- Battery percentage indicator (color-coded)
- Restores saved queue on reboot; shuffles all songs if no saved queue exists
| Component | Details | Qty | Cost |
|---|---|---|---|
| MCU | Adafruit Feather RP2040 (dual-core, 133 MHz) | 1 | $18.0 |
| Display | ILI9341 SPI TFT, 240x320 portrait | 1 | $15.3 |
| Touchscreen | XPT2046 (often integrated with display) | 1 | incl^ |
| Audio DAC | PCM5102A I2S DAC module | 1 | $4.91 |
| Storage | MicroSD card (FAT32, any size) | 1 | $13.5 |
| Battery | LiPo cell (3.7V) | 1 | $9.00 |
| Buttons | Momentary tactile push buttons | 3 | $0.50 |
| Misc | Wires, headers, resistors (voltage divider for battery ADC) | - | $0.50 |
| Case | 3D printed, ~7 revisions to get right | - | $1.50 |
Total: ~$63
All pins are defined in lyra/src/Constants.h.
| Role | Feather Pin | GPIO |
|---|---|---|
| Display SPI1 SCK | D10 | 10 |
| Display SPI1 MOSI | D11 | 11 |
| Display SPI1 MISO | D12 | 12 |
| TFT CS | D9 | 9 |
| TFT DC | D6 | 8 |
| TFT RST | D5 | 7 |
| Backlight PWM | A2 | 28 |
| Touch CS (XPT2046) | SDA | 2 |
| Touch IRQ (XPT2046) | SCL | 3 |
| SD SPI0 SCK | SCK | 18 |
| SD SPI0 MOSI | MOSI | 19 |
| SD SPI0 MISO | MISO | 20 |
| SD CS | RX | 1 |
| I2S BCLK | D24 | 24 |
| I2S LRCLK | D25 | 25 |
| I2S DIN | A3 | 29 |
| PREV button | TX | 0 |
| PAUSE button | D4 | 6 |
| NEXT button | D13 | 13 |
| Battery ADC | A1 | 27 |
D5 -> GPIO7 and D6 -> GPIO8. If the display is blank after first flash, check these two pins first.
Format as FAT32 with MBR partition table.
/music/ # MP3 files (subdirectories OK, e.g. /music/Artist/Song.mp3)
/images/ # album art JPEGs, pre-scaled to 110x110 (generated by sync_music.py)
/playlists/ # .m3u playlist files
/state/ # internal state (queue.m3u, auto-created)
/music_list.txt # file index cache (auto-built on first boot)
/music_meta.txt # metadata cache: path, artist, title, album, duration (written by sync_music.py)
Only MP3 files are played. Re-encode any .wav files before copying.
cd lyra
pio runpio run --target upload- Hold
BOOTSELwhile plugging in USB, or double-tapRESET - Board appears as
RPI-RP2drive - Drag
.pio/build/adafruit_feather_rp2040/firmware.uf2to the drive - Board reboots
Run on your computer, not on the device.
| Script | What it does |
|---|---|
tools/sync_music.py |
Copies MP3s from a local source to the SD card, generates album art in /images/, writes /music_list.txt and /music_meta.txt |
tools/playlist_manager.py |
Creates and edits .m3u playlists on the SD card |
music_meta.txt format: path \t artist \t title \t album \t duration_ms
- Artist, title, album, prev/next track context
- 110x110 album art (loaded asynchronously from
/images/, cached in RAM) - Vertical volume bar + V-/V+ buttons
- << / Play/Pause / >> buttons
- Current song progress
- Battery % (green >50%, yellow >20%, red <=20%)
- Settings button
Toggle between Queue and All Songs.
- Queue: reorder (Move Up/Down), Remove, Save to SD
- All Songs: insert any track after the current position
Lists .m3u files from /playlists/. Tap to load, replaces current queue.
- Screen timeout: Off / 5s / 10s / 30s / 1m
- Wake source: Touch+Buttons / Buttons Only
- Persists to flash on pause
| Button | Short press | Long press |
|---|---|---|
| PREV | Previous track | Volume up (>0.5 s) |
| NEXT | Next track | Volume down (>0.5 s) |
| PAUSE | Toggle play/pause (no screen wake) | Toggle screen (>2 s) |
The RP2040 has two cores, split cleanly:
- Core 0: UI: display, touch, buttons, battery, EEPROM
- Core 1: Audio: MP3 decode (Helix) + I2S output
Cross-core state uses volatile globals. The SD bus (SPI0) is owned by Core 1 during playback, so Core 0 requests a yield via g_sdYieldRequest before any SD access (album art load, queue save). This took a while to get right.
State (volume, track, settings) lives in EEPROM flash. EEPROM.commit() freezes both cores for ~100ms, which empties the I2S buffer and causes a screech, so commits only happen when audio is already paused.