An Ogg Vorbis audio decoder optimized for embedded devices. Fixed-point decoder forked from Tremor (libvorbisidec), based on its lowmem branch, with an arena allocator for decode-path allocation. Designed as an ESP-IDF component with PSRAM support and Xtensa assembly optimizations. For internal architecture see src/README.md; for the fork's changes relative to upstream Tremor see src/tremor/CHANGES.md.
- Fixed-point decoding: Uses a forked Tremor (libvorbisidec); no FPU required
- Arena allocator: Single pre-sized arena per decode block eliminates per-frame heap allocations
- PSRAM support: Configurable memory placement with automatic fallback
- Streaming decode: Minimal internal buffering; reduces per-stream memory footprint
- Zero-copy Ogg parsing: Most Ogg packets parsed without copying via microOggDemuxer
- Channel output: Smart-downmix to mono or stereo (ITU-R BS.775), or raw per-role selection of up to 8 channels
Requires ESP-IDF v5.0 or later. Add via the ESP Component Registry by listing it in your project's idf_component.yml:
dependencies:
esphome/micro-vorbis: "^0.1.0"Then configure via menuconfig (Component config, microVorbis Decoder).
Add to platformio.ini:
[env:esp32dev]
platform = espressif32
framework = espidf
lib_deps =
esphome/micro-vorbiscd host_examples/vorbis_to_wav
cmake -B build && cmake --build build
./build/vorbis_to_wav input.ogg output.wav#include "micro_vorbis/ogg_vorbis_decoder.h"
#include "esp_log.h"
#include <cinttypes> // PRIu32 for the uint32_t PcmFormat fields
static const char *TAG = "vorbis_example";
void vorbis_decode_example(const uint8_t *ogg_data, size_t ogg_size) {
micro_vorbis::OggVorbisDecoder decoder;
int16_t pcm_buffer[4096 * 2]; // Large enough for any stereo stream (see max_output_bytes())
const uint8_t *input_ptr = ogg_data;
size_t input_len = ogg_size;
while (input_len > 0) {
size_t bytes_consumed, bytes_written;
micro_vorbis::OggVorbisResult result = decoder.decode(
input_ptr, input_len,
reinterpret_cast<uint8_t*>(pcm_buffer), sizeof(pcm_buffer),
bytes_consumed, bytes_written
);
// result < 0 is an error; result >= 0 is success/informational. pcm_buffer is big enough
// for any stereo stream, so OGG_VORBIS_DECODER_ERROR_OUTPUT_BUFFER_TOO_SMALL never fires
// here; size buffers smaller than max_output_bytes() to handle that recoverable error.
if (result < 0) {
ESP_LOGE(TAG, "Decode error: %d", result);
break;
}
// Process any decoded PCM first (bytes_written is the total across all channels)
if (bytes_written > 0) {
const auto& fmt = decoder.get_pcm_format();
ESP_LOGI(TAG, "Decoded %zu bytes at %" PRIu32 " Hz, %" PRIu32 " channels",
bytes_written,
fmt.sample_rate(),
fmt.num_channels());
// Send to I2S DAC, save to file, etc.
}
// ...then handle end-of-stream
if (result == micro_vorbis::OGG_VORBIS_DECODER_END_OF_STREAM) {
break; // Stream fully decoded
}
input_ptr += bytes_consumed;
input_len -= bytes_consumed;
}
}See the decode benchmark example for a complete ESP-IDF example with performance measurements.
See the vorbis_to_wav example for a complete working example that converts Ogg Vorbis files to WAV format.
| Method | Description |
|---|---|
OggVorbisDecoder(uint8_t channels = 0, bool enable_crc = false) |
Smart-downmix decoder. channels: 0 = stream default, 1 = mono, 2 = stereo; any other value uses the stream's channel count. |
OggVorbisDecoder(const SpeakerRole* roles, uint8_t count, bool enable_crc = false) |
Raw channel-selecting decoder; output channel i is fed by the source channel matching roles[i]. |
OggVorbisResult decode(const uint8_t* input, size_t input_len, uint8_t* output, size_t output_size_bytes, size_t& bytes_consumed, size_t& bytes_written) |
Decode input bytes to interleaved 16-bit PCM; returns an informational code (>= 0) or an error (< 0). |
void reset() |
Reset decoder state for a new stream. Configuration and the demuxer buffer are kept; the next stream re-allocates Tremor state. |
size_t get_required_output_bytes() const |
Exact byte count (all channels) the last audio packet needs; 0 before the first audio packet. |
const PcmFormat& get_pcm_format() const |
Output PCM format (sample rate, channels, bit depth) after OGG_VORBIS_DECODER_STREAM_INFO_READY. |
Both constructors defer all allocations to decode(). The OggDemuxer buffer is allocated on the first decode() call; the Tremor decoder state and codebook tables are allocated as the three header packets are processed, which can span several decode() calls when input arrives in small chunks. The smart-downmix constructor folds surround layouts to stereo per ITU-R BS.775: center/surround at -3 dB relative to the fronts, LFE dropped, and the mix normalized so a full-scale fold cannot clip (4.6-9.9 dB of attenuation, depending on the source layout). The raw channel-selecting constructor feeds each output channel unmixed at unity gain; count is 1-8 (an invalid count falls back to the stream's channel count), and a role absent from the file's layout emits silence.
Buffer sizing is expressed in bytes, matching the uint8_t* output buffer: output_size_bytes and bytes_written share that unit directly. bytes_written is the total across all channels (a stereo block of 1024 frames yields 2048 samples => 4096 bytes). When the output buffer is at least get_pcm_format().max_output_bytes() bytes, decode() never returns OGG_VORBIS_DECODER_ERROR_OUTPUT_BUFFER_TOO_SMALL; after that error, get_required_output_bytes() gives the exact byte count the last packet needs before retrying (0 before the first audio packet). Use get_pcm_format().is_valid() to check whether the format has been parsed.
OggVorbisDecoder is not thread-safe. Use each instance from a single task, or add external synchronization.
Describes the decoder's output (after any downmix or channel selection), available once decode() returns OGG_VORBIS_DECODER_STREAM_INFO_READY via decoder.get_pcm_format():
| Method | Description |
|---|---|
bits_per_sample() |
Output bit depth (always 16; Tremor emits 16-bit signed PCM) |
bytes_per_sample() |
Output bytes per sample (always 2) |
max_output_bytes() |
Safe output buffer size, in bytes, for one decode() call (all channels) |
num_channels() |
Output channel count (1 = mono, 2 = stereo, etc.) |
sample_rate() |
Sample rate in Hz (e.g., 44100, 48000) |
is_valid() |
Whether the format has been parsed |
Non-negative values (>= 0) are success/informational states; negative values are errors. Check result < 0 to detect errors. One error, OGG_VORBIS_DECODER_ERROR_OUTPUT_BUFFER_TOO_SMALL, is recoverable: resize the output buffer and retry.
| Code | Value | Description |
|---|---|---|
OGG_VORBIS_DECODER_SUCCESS |
0 |
An audio frame was decoded. Check bytes_written for the byte count. |
OGG_VORBIS_DECODER_STREAM_INFO_READY |
1 |
All three Vorbis headers parsed; the PCM format (sample rate, channels) is available. |
OGG_VORBIS_DECODER_NEED_MORE_DATA |
2 |
No audio produced this call; feed more input data. |
OGG_VORBIS_DECODER_END_OF_STREAM |
3 |
Stream fully decoded; no more audio frames. |
OGG_VORBIS_DECODER_ERROR_OUTPUT_BUFFER_TOO_SMALL |
-1 |
Output buffer too small (recoverable). Call get_required_output_bytes() and retry. |
OGG_VORBIS_DECODER_ERROR_INPUT_INVALID |
-2 |
Invalid Ogg or Vorbis stream structure. |
OGG_VORBIS_DECODER_ERROR_ALLOCATION_FAILED |
-3 |
Memory allocation failed. |
OGG_VORBIS_DECODER_ERROR_DECODE_FAILED |
-4 |
Vorbis decode failed (corrupted or invalid packet). |
Only ERROR_OUTPUT_BUFFER_TOO_SMALL is recoverable in place. After any other error, call reset() and feed the stream again from its first page.
idf.py menuconfig
# Navigate to: Component config, microVorbis DecoderEach memory type can be configured independently with four placement options:
Prefer PSRAM: Try PSRAM first, fall back to internal RAMPrefer internal RAM: Try internal RAM first, fall back to PSRAMPSRAM only: Strict PSRAM placement, fails if unavailableInternal only: Strict internal RAM placement, fails if unavailable
| Memory Type | Kconfig Choice | Default | Notes |
|---|---|---|---|
Decoder state |
MICRO_VORBIS_STATE_MEMORY_PREFERENCE |
Prefer PSRAM | Tremor decoder state and persistent buffers |
Demuxer buffers |
MICRO_VORBIS_OGG_DECODER_MEMORY_PREFERENCE |
Prefer PSRAM | Ogg packet assembly (most packets use zero-copy) |
Codebook tables |
MICRO_VORBIS_CODEBOOK_MEMORY_PREFERENCE |
Prefer internal RAM | Huffman decode trees; random-access in the decode hot path, so internal RAM is recommended |
The defaults above apply when PSRAM is enabled (CONFIG_SPIRAM=y). Without PSRAM, the PSRAM-related options are hidden and every choice defaults to Internal only.
Decoding 48 kHz stereo Ogg Vorbis (~128 kbit/s VBR), single stream:
| Chip | Clock | Working buffers | Wall-clock (30s audio) | Per-task RTF | Throughput |
|---|---|---|---|---|---|
| ESP32 | 240 MHz | Internal RAM | 6.3s | 0.21 | 4.8x real-time |
| ESP32-S3 | 240 MHz | PSRAM | 3.2s | 0.11 | 9.5x real-time |
| ESP32-P4 | 360 MHz | PSRAM | 1.9s | 0.062 | 16.2x real-time |
On the original ESP32, PSRAM access is much slower than internal SRAM, so the benchmark places decoder state and demuxer buffers in internal RAM (CONFIG_MICRO_VORBIS_STATE_PREFER_INTERNAL=y, CONFIG_MICRO_VORBIS_OGG_DECODER_PREFER_INTERNAL=y); the ESP32-S3 and ESP32-P4 leave them in PSRAM (the default) with negligible penalty.
The dual-core ESP32-S3 also scales across concurrent streams, each decoder instance on its own task and core:
| Concurrent tasks | Wall-clock (30s audio) | Per-task RTF | Throughput |
|---|---|---|---|
| 1 | 3.2s | 0.11 | 9.5x real-time |
| 2 (one per core) | 5.4s | 0.18 | 5.6x real-time each |
| 3 | 9.5s | 0.32 | 3.2x real-time |
| 4 | 11.3s | 0.37 | 2.7x real-time |
Each stream decodes on a single thread. Running a second stream on the other core raises combined throughput to about 11x real-time, but the two cores share one PSRAM bus, so each task slows down under contention: two concurrent streams take 5.4s against 3.2s for one. See examples/decode_benchmark/README.md for per-frame timing statistics, the test clip details, and instructions for running your own benchmark.
Heap use depends heavily on the encoded file: sample rate, channel count, and the codebooks the encoder chose all move it. As a reference point, the 48kHz stereo ~128kbit/s clip in the benchmark example draws about 76KB total (roughly 20KB internal RAM and 56KB PSRAM) for decoder state, codebook tables, demuxer buffers, and the output buffer combined. Smaller streams use less. Use PSRAM when internal RAM is insufficient.
| Allocation | Size | Notes |
|---|---|---|
Decoder state |
~10-40KB | Tremor state: PCM history and decode/setup arenas. ~10KB mono/low-rate, ~40KB 48kHz stereo, more for multichannel; scales with block size and channels (PCM history dominates) |
Demuxer buffers |
~5-10KB typical | Ogg packet assembly (zero-copy when possible); starts at 1KB, grows on demand to a 128KB cap |
Codebook tables |
~5-20KB typical | Compact Huffman decode trees and quantized value arrays; scales with codebook complexity. Placed separately via MICRO_VORBIS_CODEBOOK_MEMORY_PREFERENCE |
Output buffer |
User-provided | Typically 2-8KB for stereo |
The default constructor smart-downmixes surround streams to mono or stereo per ITU-R BS.775 (center/surround at -3 dB relative to the fronts, LFE dropped, normalized against clipping). To instead route specific source channels to the output unmixed at unity gain, construct the decoder with an array of SpeakerRole values, one per output channel:
// Extract the unmixed front pair from a surround stream
micro_vorbis::SpeakerRole roles[] = {micro_vorbis::SpeakerRole::FL,
micro_vorbis::SpeakerRole::FR};
micro_vorbis::OggVorbisDecoder decoder(roles, 2);Output channel i is fed by the source channel matching roles[i], resolved against the file's Vorbis channel layout; e.g., quad has no center; 7.1 uses the side channels SL/SR. A role absent from the file's layout emits silence, except that mono sources route all three front roles (FL, FR, FC) to their single channel. The selected count becomes the channel count reported by get_pcm_format().num_channels(). Available roles: FL, FR, FC, LFE, RL, RR, SL, SR, BC.
This is useful for sound systems where each device plays one channel of a multichannel stream, or for skipping the downmix when feeding an external mixer.
The wrapper is covered by a ctest suite (tests/) that decodes oggenc-generated fixtures and checks output against self-consistent references: chunked streaming at adversarial chunk sizes, buffer-too-small recovery, reset, smart-downmix, raw channel selection, error codes, and CRC validation.
cd tests
cmake -DENABLE_SANITIZERS=ON -B build && cmake --build build
ctest --test-dir build --output-on-failureFixtures are checked into tests/data/; regenerate them with tests/generate_test_data.sh (needs ffmpeg and oggenc). The host decoder can also be run directly under AddressSanitizer and UBSan:
cd host_examples/vorbis_to_wav
cmake -DENABLE_SANITIZERS=ON -B build && cmake --build build
./build/vorbis_to_wav input.ogg 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.
- No seeking support: Decoder is designed for streaming, not random access
- No metadata retrieval: Vorbis comment tags (title, artist, and similar) are parsed and discarded, not exposed through the API
- No concatenated streams: Only the first logical bitstream is decoded; chained Ogg streams (multiple bitstreams in one file) are not supported
- microVorbis wrapper code (examples, OggVorbisDecoder, host tools): Apache License 2.0
- Tremor fork (in
src/tremor/): BSD-3-Clause license; see src/tremor/COPYING. Modifications relative to upstream are summarized in src/tremor/CHANGES.md. - See NOTICE for third-party attributions and downstream redistribution obligations.
- Tremor: Fixed-point Vorbis decoder (libvorbisidec), forked into
src/tremor/with arena allocator and Xtensa optimizations - microOggDemuxer: Zero-copy Ogg page parser
- Vorbis Official Site
- Tremor Documentation
- Ogg Container Format
