How RPi Pico WAV Player Reads ID3v2 Tags and Displays Cover Art

The RPi Pico WAV Player extracts ID3v2 metadata and embedded JPEG cover art directly from WAV files using a lightweight tag parser, then renders the image on an ST7735S LCD via the picojpeg decoder without creating temporary files.

The RPi Pico WAV Player is an open-source audio firmware for the Raspberry Pi Pico that plays high-resolution WAV files from SD cards and displays track metadata on a small color LCD. Understanding how it parses ID3v2 tags and renders embedded cover art requires tracing the flow from the raw file bytes through the tag parser, UI logic, and JPEG decoding pipeline.

Parsing the ID3v2 Header and Frame List

The tag parsing logic lives in src/TagRead.cpp and src/TagRead.h. When a track is selected, UIPlayMode::play() (lines 66‑71 of src/UIMode.cpp) calls readTag(), which instantiates a TagRead object and invokes GetID3HeadersFull() (lines 91‑112).

Detecting the ID3v2 Header

GetID3HeadersFull() first attempts to read an ID3v1 tag, then calls ID32Detect() to handle the ID3v2 header. ID32Detect() reads the 10‑byte header (lines 189‑225) and populates an id32 structure containing the tag size, version (2.2, 2.3, or 2.4), and flags.

Walking the Frame List

Once the header is validated, the parser walks the frame list using a while loop (lines 332‑408, 426‑459, 473‑511) that iterates until the read position exceeds id32header->size. For each frame, it reads the 4‑byte frame ID (e.g., APIC for pictures), size, flags, and allocates an id32frame node linked to the header. The raw frame payload remains in the file; only metadata and pointers are stored in RAM.

Extracting Cover Art from APIC Frames

The UI does not need the entire payload in memory; it only needs the file offset and size of the JPEG data. TagRead::getPicturePos() (lines 578‑595 of src/TagRead.cpp) provides this interface:

  1. It calls getID32Picture(idx, mime, ptype, pos, size, isUnsynced) which scans the id32frame list for APIC (v2.3/v2.4) or PIC (v2.2) frames.
  2. It extracts:
    • mime – MIME type (e.g., image/jpeg)
    • ptype – picture type (front cover, back cover, etc.)
    • pos – absolute file offset of the picture data
    • size – length of the picture data in bytes
    • isUnsynced – flag indicating if the frame uses ID3 unsynchronisation (currently unsupported by the player)

The function returns true if a picture is found, allowing the UI to proceed with rendering.

UI Layer: Selecting the Image Source

The display logic resides in UIPlayMode::draw() (lines 25‑40 of src/UIMode.cpp). After rendering text tags (title, artist), the code checks for embedded cover art:

if (tag.getPicturePos(0, mime, ptype, pos, size, isUnsynced)) {
    if (!isUnsynced && mime == jpeg && size != tagImageSize) {
        file_menu_get_fname(vars->idx_play, str, sizeof(str));
        lcd->setImageJpeg(str, pos, size);   // pass filename + offset/size
        tagImageSize = size;
        loadImageFromDir = false;
    }
}

If no embedded picture exists, the UI falls back to scanning the same directory for a standalone .jpg or .jpeg file (lines 42‑52) and loads it using the same setImageJpeg() method, but without the offset and size parameters.

Decoding and Rendering the JPEG to LCD

The final rendering pipeline involves LcdCanvas, ImageFitter, and the picojpeg library.

Configuring the Image Pipeline

LcdCanvas::setImageJpeg() (lines 75‑85 of src/LcdCanvas.cpp) forwards the request to the singleton ImageFitter:

image.getImagePtr(&img_ptr, &w, &h);
imgFit.config(img_ptr, w, h);
imgFit.loadJpegFile(filename, pos, size);
imgFit.getSizeAfterFit(&w, &h);
image.setImageSize(w, h);
image.update();

Decoding from a File Region

ImageFitter::loadJpegFile() (lines 301‑324 of src/ImageFitter.cpp) uses the JPEGDecoder wrapper to decode the specific byte range:

bool ImageFitter::loadJpegFile(const char *filename,
                              const uint64_t pos,
                              const size_t size)
{
    int decoded = JpegDec.decodeSdFile(filename, pos, size, 0);
    if (decoded <= 0) return false;
    // ... optional reduction and mapping to LCD buffer
}

JPEGDecoder::decodeSdFile() (line 211 of lib/picojpeg/JPEGDecoder.cpp) opens the SD file via FatFS, seeks to pos, and reads exactly size bytes, feeding them to the picojpeg C decoder through the pjpeg_need_bytes_callback.

Scaling and Display

After decoding MCU blocks, ImageFitter::loadJpeg() (lines 65‑165) optionally shrinks the image using jpegMcu2sAccum and maps the decoded RGB565 pixels into the LCD frame buffer, respecting resizeFit, keepAspectRatio, and packHBlank options. The cover art is rendered directly from the byte stream stored inside the WAV file’s ID3v2 tag without temporary file creation.

Summary

  • TagRead (src/TagRead.cpp) parses the ID3v2 header and walks the frame list to locate APIC/PIC frames containing cover art, storing only file offsets and sizes to conserve RAM.
  • UIMode (src/UIMode.cpp) decides whether to use the embedded picture or a standalone JPEG from the same directory, then passes the filename and byte range to the LCD canvas.
  • ImageFitter (src/ImageFitter.cpp) and LcdCanvas (src/LcdCanvas.cpp) use the picojpeg library (lib/picojpeg/) to decode the specific file region, scale the image, and render it to the ST7735S LCD buffer.
  • The entire pipeline operates on the SD card file directly using offsets, eliminating the need to extract temporary files on the resource-constrained Raspberry Pi Pico.

Frequently Asked Questions

What ID3v2 versions does the RPi Pico WAV Player support?

The parser supports ID3v2.2, ID3v2.3, and ID3v2.4. The ID32Detect() function reads the version bytes from the header (lines 189‑225 of src/TagRead.cpp), and separate frame-walking loops handle the slightly different frame header sizes for each version (lines 332‑511).

Does the player support PNG cover art or only JPEG?

The player only supports JPEG images. The getPicturePos() function checks the MIME type extracted from the APIC frame (e.g., image/jpeg), and the downstream ImageFitter and picojpeg decoder are specifically designed for JPEG decompression. PNG is not implemented in the current codebase.

How does the player handle ID3v2 unsynchronisation?

The parser detects the unsynchronisation flag via the isUnsynced parameter returned by getPicturePos() (line 578‑595 of src/TagRead.cpp). However, the UI layer explicitly checks if (!isUnsynced) before attempting to render the image (lines 25‑40 of src/UIMode.cpp), meaning the current firmware does not support decoding unsynchronised APIC frames and will skip them.

Can the player display cover art from external JPEG files instead of embedded tags?

Yes. If no valid APIC frame is found in the ID3v2 tag, the UI falls back to scanning the same directory for standalone .jpg or .jpeg files (lines 42‑52 of src/UIMode.cpp). It loads these using the same LcdCanvas::setImageJpeg() method but without passing offset and size parameters, indicating the entire file should be decoded.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →