Skip to main content

Audio Pipeline Overview

Five chapters cover the audio engine; this one is the map. The pipeline moves bytes through four stages, each with exactly one owner:

Audio pipeline dataflow
SOURCE TRANSPORT DECODE OUTPUT
SD file (fread) ──► read buffer ──► codec (MP3/AAC/WAV) ──► I2S DMA ──► amp
HTTP stream ──► ring buffer ──► PCM frames ──► 44.1 kHz, 16 bit
(stream_worker) (16 KB) (drain contract) stereo

The one loop that matters

Everything converges in audio_task (components/audio_player/src/audio_player.c), a single loop that:

  1. Polls its command queue (play, pause, seek, stop) without blocking.
  2. Pulls the next chunk of compressed bytes from the source (file read or stream ring buffer).
  3. Feeds the codec and drains every PCM frame the codec can produce.
  4. Applies software volume and writes the PCM to the I2S driver, which blocks until the DAC has room.

Step 4 is the metronome. The I2S DMA consumes exactly 176,400 bytes per second (44.1 kHz x 16 bit x stereo); when its buffers are full, the write blocks, and that blocking is what paces the entire pipeline to real time. Nothing in the system contains a timer that says "decode now": the hardware's appetite is the clock. This is the standard design for embedded audio because it is unfalsifiable; a measured output rate of precisely 176.6 KB/s on the bench confirmed it.

The invariants

Every design decision in the next four chapters serves one of these:

  1. Never shear a frame. Compressed audio is a sequence of frames; delivering a partial frame to a decoder, or dropping bytes mid-frame, desynchronizes everything after it. Both the codec layer and the transport enforce this, and both carry scars from learning it.
  2. Backpressure, never buffer bloat. When the source is faster than real time (a bursting radio server, a fast SD card), the excess must wait at the source (TCP window, file position), not accumulate in RAM.
  3. The audio task never waits on anyone slower than the DAC. Publishing state changes is async, UI work happens elsewhere, and even a full event queue drops rather than stalls the loop.
  4. Position is derived from PCM, not wall time. Elapsed time = PCM bytes written / byte rate. It survives pauses, buffering, and seeks without drift, because it measures what was actually played.

Formats

FormatContainerDecoderWhere used
MP3raw MPEG streamminimp3 (vendored, single header)Files and streams
AAC-LC, HE-AACADTSespressif/esp_audio_codecStreams, and .aac files
WAVRIFF~100 lines in-houseFiles
M4A/MP4not supportedRejected deliberately: the MP4 container demands random access and a box parser, poor fit for a streaming pipeline; ADTS AAC covers the same codec

All decoders sit behind one interface (codec_interface.h) so audio_task is codec-agnostic; the codec chapter dissects it, including the drain contract that keeps buffers bounded.

Command and control

The player's public API (audio_player.h) queues commands to the task; nothing external ever touches the pipeline directly:

audio_player_play_file(path); // also: play_file_at(path, start_ms)
audio_player_play_stream(url);
audio_player_pause(); / resume(); / stop();
audio_player_seek(position_ms);
audio_player_set_volume(0..100);

State flows back out as events (EVENT_AUDIO_PLAY_START, EVENT_AUDIO_UNDERRUN, ...) that the display, WebSocket, and persistence layers consume without the player knowing they exist.

Reading order

I2S and DMA first (the clock of the system), then codecs, then streaming; seek and resume stands alone on top.