A pocket Navidrome player on a XIAO ESP32-C3

I wanted a music player I could pick up, press one button on, and hear something from my own library. No phone, no app, no account. The result is a box about the size of a large ice cube that streams from my home Navidrome server over Wi-Fi.

The finished player: a black 3D-printed box with two round buttons beside a small blue-white OLED showing Ebb and Flow, Final Fantasy III - Legend of the Eternal Wind, with an audio cable plugged into its side
The finished player, streaming from my library into a stereo through its line-out.
A CAD render of a small copper-coloured box with a blue lid, two buttons and a screen showing Artist - Song
The case in CAD: 54 × 54 × 35 mm, two buttons and the OLED window in the lid.

The parts

  • Seeed XIAO ESP32-C3. Wi-Fi and enough RAM to stream and decode MP3s on the chip.
  • TI PCM5102A DAC. Takes I²S audio from the XIAO and turns it into a line-level signal for headphones.
  • Adafruit 128×64 SSD1306 OLED. Shows the song title and album over I²C.
  • Two Omron B3F buttons. Play/Pause and Next.
  • A 450 mAh LiPo with a slide switch. The XIAO charges it over USB-C.

How it works

The firmware is plain C on ESP-IDF. On boot it joins Wi-Fi, logs in to Navidrome, and asks it for a random song. Then it streams the MP3 through a 24 KiB network buffer, decodes it on the chip with the Helix MP3 decoder, and sends the samples out over I²S to the DAC. When a song ends it picks another one. Next cancels the current song and does the same thing straight away.

The screen uses a tiny 5×7 font, 21 characters across and eight rows deep. Song titles word-wrap so you can read the whole thing, and only a title too long for the screen scrolls.

A cutaway CAD render of the case showing the two circuit boards on a holder, with the battery underneath
Inside: the XIAO and the DAC sit on a printed holder, with the battery tucked underneath.

The bug that made the screen go black

After one replug the screen stayed black, but the music kept playing. The serial log showed why: the firmware had checked for the screen once at boot, got no answer, and turned the display off for the rest of the session. The OLED has its own power-on reset circuit, and it most likely just wasn't ready yet.

The fix makes the firmware look for the screen again every two seconds. Every five seconds it also resends all of the screen's settings and redraws the page, so a screen that resets itself after a power dip comes back by itself. If an I²C transfer fails, it resets the bus and starts over.

What's left

I'm calling this one done, but it isn't perfect:

  • The Play/Pause button is stuck. Its line reads as pressed from the moment the board boots, so presses never register. That's a wiring fault, probably a short to ground, not a firmware bug. Next works fine.
  • Audio only comes out of the left side of my headphones. I haven't tracked down why yet.

Both are fixable with a soldering iron and some patience, and both are good reasons for the next version to be a proper circuit board instead of hand-wired modules.