Building a Pocket Music Player with a Pi Zero and Pirate Audio
pirateplayer turns a Raspberry Pi Zero, a Pimoroni Pirate Audio HAT and a small UPS battery into a self-contained, pocketable music player. No phone, no streaming, no subscription: just your MP3, FLAC and WAV files in folders, a 240×240 screen, and four buttons.
I wanted something that behaves like an old-school MP3 player. Everything I tried off the shelf either lacked a decent interface or the Bluetooth kept dropping out. I wanted something that you turn it on, it picks up where you left off, and it gets out of the way. Everything here is written in Python and runs comfortably on a Pi Zero.
The hardware
The build is three boards stacked together. The Pirate Audio HAT does most of the work: it gives you a DAC for line or headphone output, an ST7789 colour LCD and four tactile buttons.
| Part | Role |
|---|---|
| Raspberry Pi Zero | Runs the player, mpv and the UI |
| Pimoroni Pirate Audio HAT | 240×240 ST7789 screen over SPI, I2S DAC, buttons A, B, X, Y |
| Waveshare UPS HAT (C) | Battery, read directly over I2C (INA219 at 0x43) |
With battery = "auto" the player probes for either UPS on startup. Supporting another board means adding one class with a read() method to battery.py.
Features
The player is small, but it covers everything I wanted from a daily-use device.
- Plays MP3, FLAC and WAV from
~/Music, organised in folders however you like. - Folder browser that lists folders first, then tracks, in natural order, so “Track 2” sorts before “Track 10”. Hidden files are skipped.
- Now-playing screen with cover art, title, artist, album, a progress bar, elapsed and total time, volume, and the track’s position in the playlist.
- Cover art from art embedded in the file, or from a
cover,folder,frontoralbumimage (JPG or PNG) in the album folder. FLAC front covers are preferred when a file has several pictures. - Tag reading from ID3 for MP3, Vorbis comments for FLAC, and either ID3 or RIFF INFO chunks for WAV. Files without tags fall back to their file name.
- Resumes where you left off. The playlist, track, position and volume are saved every 30 seconds while playing and on every pause. After a reboot it resumes paused, because a player shouldn’t start making noise just because it booted.
- Screen sleep. The backlight turns off after 30 seconds without input. The first press only wakes the screen, so you never trigger an action you can’t see.
- Battery gauge in the header, with a green fill while charging and a red warning below 15%.
- Safe low-battery shutdown. After three consecutive readings at or below 5% on battery, it saves state and powers the Pi off cleanly, protecting the SD card.
- Power-off combo. Hold A and B together for 3 seconds to shut down.
- Skips unplayable files automatically, without looping forever if a whole folder is broken.
- Plays recursively. Long-press a folder to queue every track inside it, subfolders included.
Four buttons, two screens
Four buttons cover everything because each one has a tap and a hold, and their meaning changes with the screen. A press becomes a hold after 0.6 seconds; holding A or B in the browser keeps scrolling. On the board, A is top-left, B bottom-left, X top-right and Y bottom-right.
| Button | Browser | Now playing |
|---|---|---|
| A | Up (hold to scroll) | Volume up |
| B | Down (hold to scroll) | Volume down |
| X | Open folder, or play the folder from this track | Play / pause |
| X hold | Play the whole folder, including subfolders | Next track |
| Y | Parent folder (at the top: go to now playing) | Back to the browser, current track selected |
| Y hold | Go to now playing | Previous track, or restart if more than 3 s in |
| A + B hold 3 s | Power off | Power off |
The Y behaviour on the now-playing screen is a small detail I like: going back to the browser opens the current track’s folder with that track highlighted, so you can pick a neighbour without hunting for it.
Under the hood
The code is a single main loop that ticks every 50 ms: read buttons, handle player events, check the battery, and redraw only when something changed. A few pieces are worth calling out.
- mpv as the audio engine. Playback runs in a headless mpv process driven over its JSON IPC socket. It starts with no config, no scripts, no video and no default key bindings to keep RAM use down. A reader thread turns mpv’s events into a queue the main loop drains, and a flag ignores stale end-of-file events from the previous track when a new one loads.
- Patient startup. mpv can take 7 seconds or more to start on a Pi Zero, so the screen shows “Starting…” and the player waits up to 60 seconds for the socket.
- Button handling. A small
PressTrackerturns sampled pressed/released states into tap, hold and repeat events, plus the A+B combo. Presses are latched by GPIO callbacks, so a quick tap isn’t lost while the loop is busy drawing. - Rendering with Pillow. Each screen is drawn into a 240×240 image with DejaVu fonts and pushed to the ST7789 over SPI at 80 MHz. Long names are trimmed with an ellipsis, and resized cover art is cached so it isn’t rescaled every second.
- Battery smoothing. For the Waveshare board, voltage is averaged over the last five readings, since it sags under load. The INA219 calibration is rewritten before each read in case a brown-out reset it.
- Robust shutdown. SIGTERM from
systemctl stopbecomes a normal exit so state is always saved. If mpv dies, the app exits and systemd restarts it after 3 seconds.
A desktop simulator
You don’t need the hardware to try it. Run python3 -m pirateplayer --sim and a pygame window shows the board, with the screen in the middle and the four buttons on either side. Each button’s label updates to show what it does on the current screen.
Only the screen, buttons, battery and audio are simulated; the browser, playlists, state saving and UI are the same code that runs on the Pi. That made developing on a laptop much faster than redeploying to a Pi Zero for every change.
| Control | Action |
|---|---|
| Click a button, or A/Up, B/Down, X/Enter/Right, Y/Esc/Left | Press it; hold for a long press |
| A and B held for 3 s | Power off (closes the window) |
| C | Connect or disconnect the charger |
- / = |
Lower or raise the battery by 10% |
Audio plays through pygame by default. Add --mpv to use the Pi’s real mpv playback code, --scale 3 for a bigger screen, or --music DIR to point it at another folder.
Getting it running
On Raspberry Pi OS Bookworm or later (Lite is fine), copy the project to the Pi and run the installer, then reboot:
./install.sh
sudo reboot
The script does the fiddly setup for you:
- installs mpv, the Python libraries and the ST7789 driver into a virtualenv;
- enables SPI, I2C and the Pirate Audio DAC in
config.txt, turns off onboard audio and keeps HDMI from grabbing the default sound card; - frees GPIO 9 from SPI, because the display uses it as its data/command line;
- lets your user run
poweroffwithout a password, for the button combo and low-battery shutdown; - installs and enables a
pirateplayersystemd service that starts at boot.
Then copy music into ~/Music, for example with rsync or scp. Logs are one command away with journalctl -u pirateplayer -f.
To change settings, copy config.example.toml to ~/.config/pirateplayer/config.toml. You can set the music folder, starting volume, screen timeout (0 keeps it on), display rotation, battery type, low-battery threshold and mpv audio device. If button Y does nothing, you have an early board: set y_pin = 20.
Wrapping up
pirateplayer is a focused little device: put files in folders, press X, and listen. It remembers where you were, saves its own battery, and shuts down safely when the charge runs out.
One honest caveat: battery percentage is estimated from voltage, so treat it as a rough guide rather than a precise reading. If you build one, or add support for another UPS board, I’d love to hear about it.