How WAV Playback Is Handled Internally by the PlayWav Library on Raspberry Pi Pico

The PlayWav library handles WAV playback by parsing RIFF headers in skipToDataChunk(), converting raw PCM or IEEE-float samples to 32-bit integers in a DMA-driven decode loop, and streaming buffers to the I²S driver while maintaining RMS level meters.

The elehobica/rpi_pico_wav_player repository provides a dedicated PlayWav class for WAV file playback on the Raspberry Pi Pico. Understanding how WAV playback is handled internally by the PlayWav library reveals a layered architecture that separates file parsing, sample conversion, and hardware abstraction into distinct, testable components.

Architecture of the PlayWav Class

Inheritance and Static Instance Management

PlayWav is declared in lib/PlayAudio/PlayWav.h and inherits from the abstract PlayAudio base class. This design allows the WAV-specific decoder to leverage generic buffering and I²S output logic while focusing exclusively on RIFF container parsing and sample format conversion.

The implementation uses a static singleton pattern to bridge C-style callback interfaces with the C++ class structure. The static member g_inst holds a pointer to the active PlayWav instance, and the static method decode_func() forwards interrupt-driven decode requests to the instance method decode()https://github.com/elehobica/rpi_pico_wav_player/blob/main/lib/PlayAudio/PlayWav.cpp#L21-L25】.

Integration with the Generic Audio Stack

PlayWav interacts with three core subsystems provided by the base class and associated libraries:

  • ReadBuffer – Supplies buffered file I/O and tracks the current position within the WAV data chunk. Accessed via the protected rdbuf member inherited from PlayAudio.
  • I²S Driver – The base class owns an audio_t *ap handle. PlayWav obtains DMA buffers via take_audio_buffer() and releases them with give_audio_buffer(), abstracting hardware-specific DMA handling.
  • Volume Control – Per-sample volume scaling uses a pre-computed vol_table lookup, applying the current volume level and DAC offset during the conversion stage.

WAV Playback Lifecycle

Initialization and Header Parsing

When playback begins via play(), the method first resets RMS level-meter accumulators to zero, then delegates file opening and buffer setup to PlayAudio::play()https://github.com/elehobica/rpi_pico_wav_player/blob/main/lib/PlayAudio/PlayWav.cpp#L36-L42】.

The critical parsing step occurs in skipToDataChunk(), which scans the RIFF container structure to locate the fmt and data chunks. This method extracts essential parameters including sample rate, channel count, bits-per-sample, and audio format (PCM or IEEE float). It also records the size of the data chunk and positions the ReadBuffer at the start of the raw PCM data【https://github.com/elehobica/rpi_pico_wav_player/blob/main/lib/PlayAudio/PlayWav.cpp#L44-L70】.

The Decode Loop and Buffer Management

Actual audio output is driven by the decode() method, invoked periodically from the I²S DMA interrupt context via the static decode_func() trampoline. The loop follows this sequence:

  1. Acquire Buffer – Obtain an empty audio buffer from the DMA pool using take_audio_buffer().
  2. Fill and Convert – Read raw bytes from the ReadBuffer, assemble them into samples according to the detected format (PCM 16/24/32-bit or IEEE float), convert to signed 32-bit integers, apply volume scaling via vol_table, and add the DAC offset.
  3. Release Buffer – Return the filled buffer to the I²S driver with give_audio_buffer().
  4. Advance and Check – Increment sample counters, update the read buffer position, and terminate playback if the remaining data size reaches zero【https://github.com/elehobica/rpi_pico_wav_player/blob/main/lib/PlayAudio/PlayWav.cpp#L83-L129】.

Sample Conversion and Format Handling

Supported Audio Formats

PlayWav supports multiple WAV sub-formats through conditional conversion logic in the decode loop:

  • PCM 16-bit – Sign-extended to 32-bit.
  • PCM 24-bit – Assembled from three bytes and sign-extended.
  • PCM 32-bit – Used directly.
  • IEEE Float – Converted from float to signed 32-bit integer representation.

All formats ultimately normalize to a signed 32-bit integer sample before volume scaling and DAC offset application【https://github.com/elehobica/rpi_pico_wav_player/blob/main/lib/PlayAudio/PlayWav.cpp#L100-L115】.

Volume Control and Level Metering

Volume adjustment uses a pre-calculated vol_table that maps volume levels to scaling factors. During decoding, each sample is multiplied by the current volume factor and then offset for the DAC hardware.

Simultaneously, the decoder maintains RMS-style level-meter accumulators. Every approximately 576 milliseconds (adjusted for 44.1 kHz sample rate), the accumulated values are converted to a level indicator and the accumulators reset, providing real-time audio level feedback without impacting the decode loop performance【https://github.com/elehobica/rpi_pico_wav_player/blob/main/lib/PlayAudio/PlayWav.cpp#L115-L126】.

Seeking and Duration Calculation

For sample-accurate seeking, parseSetPos() first ensures the file pointer is positioned at the start of the data chunk by calling skipToDataChunk(), then delegates the byte-offset calculation to PlayAudio::parseSetPos()https://github.com/elehobica/rpi_pico_wav_player/blob/main/lib/PlayAudio/PlayWav.cpp#L76-L81】.

Total track duration is computed by totalMillis(), which calculates the full playback time from the data chunk size, sample rate, channel count, and bits per sample. The method includes a safeguard to never return a duration smaller than the already-elapsed playback time【https://github.com/elehobica/rpi_pico_wav_player/blob/main/lib/PlayAudio/PlayWav.cpp#L138-L144】.

Practical Implementation Examples

Basic WAV Playback

#include "PlayAudio/PlayWav.h"

PlayWav player;

void setup() {
    // Initialize SD card and I2S hardware via PlayAudio base class
    player.init();
}

void loop() {
    // Play entire file from beginning
    player.play("/sdcard/music.wav");
    
    // Playback runs asynchronously via DMA interrupt
    while (player.isPlaying()) {
        delay(10);
    }
}

Seeking to a Specific Time Offset

// Seek to 30 seconds into the track
size_t samplePos = 30 * player.getSampleRate();
player.play("/sdcard/track.wav", samplePos);

The play() method invokes parseSetPos() internally, which ensures the ReadBuffer is positioned at the data chunk boundary before calculating the byte offset for the requested sample position.

Summary

  • PlayWav extends PlayAudio to provide WAV-specific decoding while inheriting generic I²S and buffering infrastructure.
  • Header parsing occurs in skipToDataChunk(), which validates RIFF structure, extracts fmt parameters (sample rate, bit depth, channels), and locates the data chunk.
  • Decode loop runs in interrupt context via decode(), filling DMA buffers by converting PCM 16/24/32-bit or IEEE float samples to 32-bit integers with volume scaling and DAC offset.
  • Buffer management uses take_audio_buffer() and give_audio_buffer() to interface with the RP2040 I²S DMA driver without blocking the audio callback.
  • Seeking is supported through parseSetPos(), which repositions the ReadBuffer and calculates byte offsets for sample-accurate jumps.
  • Level metering accumulates RMS values during decoding, updating visual indicators every ~576 ms without impacting real-time performance.

Frequently Asked Questions

How does PlayWav handle different WAV bit depths?

PlayWav supports PCM 16-bit, 24-bit, 32-bit, and IEEE float formats through conditional assembly logic in the decode loop. Raw bytes are read from the ReadBuffer and assembled into signed 32-bit integers regardless of source format, then normalized with volume scaling and DAC offset before output to the I²S driver.

What is the role of the static decode_func() in PlayWav?

The static method decode_func() serves as a C-compatible trampoline that forwards interrupt-driven decode requests to the singleton instance stored in g_inst. This design allows the RP2040 I²S DMA callback to invoke the decoder using a function pointer while maintaining full access to the PlayWav class context and state.

How does seeking work within a WAV file?

Seeking is implemented in parseSetPos(), which first ensures the file pointer is positioned at the start of the data chunk via skipToDataChunk(), then delegates byte-offset calculations to PlayAudio::parseSetPos(). This approach enables sample-accurate seeking by converting the requested sample position into byte offsets relative to the data chunk boundary.

Why does the decode loop use take_audio_buffer and give_audio_buffer?

These methods abstract the RP2040's I²S DMA hardware interface, allowing decode() to acquire empty DMA buffers, fill them with converted PCM samples, and return them to the driver without managing hardware registers directly. This zero-copy approach minimizes latency and prevents buffer underruns during real-time audio playback.

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 →