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:
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:
- Polls its command queue (play, pause, seek, stop) without blocking.
- Pulls the next chunk of compressed bytes from the source (file read or stream ring buffer).
- Feeds the codec and drains every PCM frame the codec can produce.
- 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:
- 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.
- 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.
- 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.
- 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
| Format | Container | Decoder | Where used |
|---|---|---|---|
| MP3 | raw MPEG stream | minimp3 (vendored, single header) | Files and streams |
| AAC-LC, HE-AAC | ADTS | espressif/esp_audio_codec | Streams, and .aac files |
| WAV | RIFF | ~100 lines in-house | Files |
| M4A/MP4 | not supported | Rejected 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.