Streaming MP3 decoder for embedded devices. Fixed-point decoder forked from OpenCore with frame synchronization, PSRAM-aware allocation, and lazy initialization. Supports MPEG 1, 2, and 2.5 Layer III.
- Streaming decode: Decodes directly from the caller's buffer when a complete MP3 frame is available, avoiding an intermediate copy. Falls back to internal buffering only when frames span chunk boundaries.
- MP3 frame synchronization: Built-in frame header parsing handles sync-word detection and frame-boundary alignment. No external demuxer needed.
- Metadata tag skipping: Automatically detects and skips ID3v2 tags (leading or between chained tracks) and trailing ID3v1/APE tags, even when they span chunk boundaries.
- Gapless trimming: Automatically removes encoder delay and end padding from files with a Xing/Info/LAME header, so output length matches the source audio.
- PSRAM-aware allocation: Configurable memory placement with automatic fallback.
- MPEG version support: MPEG 1 (44.1/48/32kHz), MPEG 2 (22.05/24/16kHz), and MPEG 2.5 (11.025/12/8kHz) Layer III
- VBR compatible: Bitrate reported per-frame from decoded header data
- Probe on first frame: Stream format (sample rate, channel count, bitrate) determined automatically from the first decoded frame before any PCM is written to the caller's buffer
- Built-in equalizer: 7 preset EQ modes (flat, bass boost, rock, pop, jazz, classical, talk) applied in the frequency domain. Switchable per-frame.
#include "micro_mp3/mp3_decoder.h"
micro_mp3::Mp3Decoder decoder; // Constructor always succeeds (lazy init)
// Heap-allocate on embedded targets to avoid stack overflow
int16_t* pcm_buffer = new int16_t[micro_mp3::MP3_MAX_SAMPLES_PER_FRAME *
micro_mp3::MP3_MAX_OUTPUT_CHANNELS]; // 4608 bytes
while (have_data) {
size_t consumed = 0, samples = 0;
micro_mp3::Mp3Result result = decoder.decode(
input_ptr, input_len,
reinterpret_cast<uint8_t*>(pcm_buffer),
micro_mp3::MP3_MIN_OUTPUT_BUFFER_BYTES,
consumed, samples
);
input_ptr += consumed;
input_len -= consumed;
if (result == micro_mp3::MP3_STREAM_INFO_READY ||
result == micro_mp3::MP3_STREAM_INFO_CHANGED) {
// Format parsed (first frame) or changed mid-stream; no PCM yet
// Reconfigure the pipeline, then call again to decode the frame
setup_pipeline(decoder.get_sample_rate(), decoder.get_channels());
continue;
}
if (result == micro_mp3::MP3_DECODE_ERROR) {
continue; // Skip corrupt frame, recoverable
}
if (result < 0) {
break; // Fatal error (allocation failure, invalid input, etc.)
}
if (samples > 0) {
// samples is per-channel; total int16_t values = samples * channels
process_audio(pcm_buffer, samples, decoder.get_channels());
}
}
delete[] pcm_buffer;See the decode benchmark example for a complete working example.
A standalone mp3_to_wav converter is included for testing on macOS/Linux:
cd host_examples/mp3_to_wav
mkdir build && cd build && cmake .. && make
./mp3_to_wav input.mp3 output.wav| Member | Description |
|---|---|
Mp3Decoder() |
Constructor, always succeeds, no allocations |
~Mp3Decoder() |
Destructor, frees all resources |
decode(input, input_len, output, output_size, bytes_consumed, samples_decoded) |
Decode one MP3 frame; see result codes below |
get_sample_rate() |
Sample rate in Hz (0 until first successful decode) |
get_channels() |
Output channel count: 1 (mono) or 2 (stereo); 0 until first decode |
get_bit_depth() |
Always 16 |
get_bytes_per_sample() |
Always 2 |
get_bitrate() |
Bitrate in kbps (e.g., 128); may vary frame-to-frame for VBR |
get_version() |
MPEG version: MP3_MPEG1, MP3_MPEG2, or MP3_MPEG2_5 |
get_samples_per_frame() |
PCM samples per channel per frame (1152 for MPEG1, 576 for MPEG2/2.5; 0 until first decode) |
get_min_output_buffer_bytes() |
Always MP3_MIN_OUTPUT_BUFFER_BYTES (4608) |
set_equalizer(eq) |
Set equalizer preset; takes effect on next decode() call |
get_equalizer() |
Current Mp3Equalizer preset |
is_initialized() |
True once decoder memory has been allocated |
reset() |
Free all state; next decode() call re-initializes |
| Code | Value | Meaning |
|---|---|---|
MP3_OK |
0 | Success; check samples_decoded |
MP3_NEED_MORE_DATA |
1 | Partial frame buffered; feed more data and call again |
MP3_STREAM_INFO_READY |
2 | Stream format parsed from header; no PCM yet; advance by bytes_consumed |
MP3_INPUT_INVALID |
-1 | Null pointer or bad input |
MP3_ALLOCATION_FAILED |
-2 | Memory allocation failed |
MP3_OUTPUT_BUFFER_TOO_SMALL |
-3 | Output buffer too small for the decoded frame (recoverable; enlarge it and call again, the frame is retained). MP3_MIN_OUTPUT_BUFFER_BYTES always suffices |
MP3_DECODE_ERROR |
-4 | Corrupt/invalid frame (recoverable; advance by bytes_consumed) |
MP3_STREAM_INFO_CHANGED |
-5 | Sample rate, channels, or MPEG version changed mid-stream (recoverable; no PCM). Re-read the accessors, reconfigure, and call again |
Use result < 0 to check for any error. Use result >= 0 for non-error (success or informational). MP3_DECODE_ERROR, MP3_STREAM_INFO_CHANGED, and MP3_OUTPUT_BUFFER_TOO_SMALL are negative but recoverable. Handle them before a generic result < 0 bail.
| Constant | Value | Description |
|---|---|---|
MP3_MAX_SAMPLES_PER_FRAME |
1152 | Max PCM samples per channel per frame (MPEG1) |
MP3_MAX_OUTPUT_CHANNELS |
2 | Max output channels |
MP3_MIN_OUTPUT_BUFFER_BYTES |
4608 | Min output buffer size (1152 x 2ch x 2 bytes) |
MP3_INPUT_BUFFER_SIZE |
1536 | Internal input buffer size |
| Preset | Description |
|---|---|
MP3_EQ_FLAT |
No equalization (default) |
MP3_EQ_BASS_BOOST |
Boost low frequencies relative to high |
MP3_EQ_ROCK |
Bass + mid emphasis |
MP3_EQ_POP |
Mid-high cut |
MP3_EQ_JAZZ |
Low-mid emphasis |
MP3_EQ_CLASSICAL |
Low emphasis |
MP3_EQ_TALK |
Mid emphasis, high cut |
The equalizer operates on 32 subbands in the frequency domain during decode. All non-flat presets only attenuate (gains ≤ 0 dB), so they will not introduce clipping but may reduce overall volume. The preset can be changed between decode() calls and takes effect on the next decode() call:
decoder.set_equalizer(micro_mp3::MP3_EQ_BASS_BOOST);
// ... subsequent decode() calls use bass boost
decoder.set_equalizer(micro_mp3::MP3_EQ_FLAT);
// ... back to flatidf.py menuconfig
# Navigate to: Component config, microMP3 DecoderDecoder state memory can be configured with four placement options. The three PSRAM options require SPIRAM and only appear in menuconfig when it is enabled:
| Option | Description |
|---|---|
CONFIG_MICRO_MP3_PREFER_PSRAM |
Try PSRAM first, fall back to internal RAM (requires SPIRAM) |
CONFIG_MICRO_MP3_PREFER_INTERNAL |
Try internal RAM first, fall back to PSRAM (requires SPIRAM) |
CONFIG_MICRO_MP3_PSRAM_ONLY |
Strict PSRAM; fails if unavailable (requires SPIRAM) |
CONFIG_MICRO_MP3_INTERNAL_ONLY |
Never use PSRAM |
The default is CONFIG_MICRO_MP3_PREFER_PSRAM when SPIRAM is enabled and CONFIG_MICRO_MP3_INTERNAL_ONLY otherwise. Prefer PSRAM conserves internal RAM at a slight performance cost; prefer internal RAM for better decode throughput when RAM is plentiful.
Decoding 48 kHz stereo MP3, single stream (128 kbps clip):
| Chip | Clock | Wall-clock (30s audio) | Per-task RTF | Throughput |
|---|---|---|---|---|
| ESP32 | 240 MHz | 9.4s | 0.315 | 3.2x real-time |
| ESP32-S3 | 240 MHz | 2.3s | 0.076 | 13.1x real-time |
| ESP32-P4 | 360 MHz | 1.5s | 0.049 | 20.5x real-time |
Decoder state stays in PSRAM by default on every target (the measured footprint lands entirely in PSRAM). Even on the original ESP32, where PSRAM is much slower than internal SRAM, a single MP3 stream clears real-time. The ESP32-P4 decodes about 1.6x faster per stream than the ESP32-S3. Decode cost scales with bitrate: the 320 kbps clip runs roughly 55% slower per frame than the 64 kbps clip.
The dual-core ESP32-S3 also scales across concurrent streams, each decoder instance on its own task and core (128 kbps clip):
| Concurrent tasks | Wall-clock (30s audio) | Per-task RTF | Throughput |
|---|---|---|---|
| 1 | 2.3s | 0.076 | 13.1x real-time |
| 2 (one per core) | 2.7s | 0.088 | 11.3x real-time each |
| 3 | 5.3s | 0.176 | 5.7x real-time |
| 4 | 6.5s | 0.215 | 4.7x real-time |
Each stream decodes on a single thread. Running a second stream on the other core barely raises wall-clock time (2.7s against 2.3s) while nearly doubling combined throughput, but the two cores share one PSRAM bus, so adding a third and fourth task slows every task under contention. See examples/decode_benchmark/README.md for per-frame timing statistics across all three bitrates, the test clip details, and instructions for running your own benchmark.
| Allocation | Size | Notes |
|---|---|---|
| Decoder state | ~21.3KB | Allocated via pvmp3_decoderMemRequirements(); PSRAM preferred by default |
| Internal input buffer | 1.5KB | MP3 frame accumulation (MP3_INPUT_BUFFER_SIZE, 1536 bytes) |
| PCM output buffer | 4.5KB | User-provided; MP3_MIN_OUTPUT_BUFFER_BYTES (4608 bytes) |
Total internal allocation: ~22.8KB (decoder state + input buffer). The PCM output buffer is caller-owned.
The wrapper is covered by a ctest suite (tests/) that decodes ffmpeg-generated fixtures and checks output against self-consistent references: the stream-info probe contract, chunked streaming at adversarial chunk sizes, decode accuracy, reset and reuse, error codes, corrupt-frame recovery, leading-garbage resync, ID3v2 tag skipping, and gapless (Xing/LAME) trimming.
cd tests
cmake -DENABLE_SANITIZERS=ON -B build && cmake --build build
(cd build && ctest --output-on-failure)Fixtures are checked into tests/data/; regenerate them with tests/generate_test_data.sh (needs ffmpeg with libmp3lame). The host tool can also be run directly under AddressSanitizer and UBSan:
cd host_examples/mp3_to_wav
cmake -DENABLE_SANITIZERS=ON -B build && cmake --build build
./build/mp3_to_wav input.mp3 output.wavAdd -DENABLE_WERROR=ON to either cmake command to treat warnings as errors (off by default). A libFuzzer harness lives in tests/fuzz/ for fuzzing the streaming decoder; see its README for build and run instructions. An ISO conformance gate lives in tests/conformance/, decoding the ISO/IEC 11172-4 and 13818-4 Layer III vectors and comparing PCM against the reference output shipped with each vector at a full-accuracy PSNR and peak threshold; see its README for the accuracy criteria and how to run it.
