[audio, speaker] Add support for decoding Ogg Opus files (#13967)

This commit is contained in:
Kevin Ahrendt
2026-02-18 21:51:33 -06:00
committed by GitHub
parent ba7134ee3f
commit eefad194d0
16 changed files with 222 additions and 46 deletions
+42
View File
@@ -1,10 +1,14 @@
from dataclasses import dataclass
import esphome.codegen as cg
from esphome.components.esp32 import add_idf_component, include_builtin_idf_component
import esphome.config_validation as cv
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
from esphome.core import CORE
import esphome.final_validate as fv
CODEOWNERS = ["@kahrendt"]
DOMAIN = "audio"
audio_ns = cg.esphome_ns.namespace("audio")
AudioFile = audio_ns.struct("AudioFile")
@@ -14,9 +18,38 @@ AUDIO_FILE_TYPE_ENUM = {
"WAV": AudioFileType.WAV,
"MP3": AudioFileType.MP3,
"FLAC": AudioFileType.FLAC,
"OPUS": AudioFileType.OPUS,
}
@dataclass
class AudioData:
flac_support: bool = False
mp3_support: bool = False
opus_support: bool = False
def _get_data() -> AudioData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = AudioData()
return CORE.data[DOMAIN]
def request_flac_support() -> None:
"""Request FLAC codec support for audio decoding."""
_get_data().flac_support = True
def request_mp3_support() -> None:
"""Request MP3 codec support for audio decoding."""
_get_data().mp3_support = True
def request_opus_support() -> None:
"""Request Opus codec support for audio decoding."""
_get_data().opus_support = True
CONF_MIN_BITS_PER_SAMPLE = "min_bits_per_sample"
CONF_MAX_BITS_PER_SAMPLE = "max_bits_per_sample"
CONF_MIN_CHANNELS = "min_channels"
@@ -173,3 +206,12 @@ async def to_code(config):
name="esphome/esp-audio-libs",
ref="2.0.3",
)
data = _get_data()
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
if data.mp3_support:
cg.add_define("USE_AUDIO_MP3_SUPPORT")
if data.opus_support:
cg.add_define("USE_AUDIO_OPUS_SUPPORT")
add_idf_component(name="esphome/micro-opus", ref="0.3.3")
+4
View File
@@ -46,6 +46,10 @@ const char *audio_file_type_to_string(AudioFileType file_type) {
#ifdef USE_AUDIO_MP3_SUPPORT
case AudioFileType::MP3:
return "MP3";
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
case AudioFileType::OPUS:
return "OPUS";
#endif
case AudioFileType::WAV:
return "WAV";
+3
View File
@@ -112,6 +112,9 @@ enum class AudioFileType : uint8_t {
#endif
#ifdef USE_AUDIO_MP3_SUPPORT
MP3,
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
OPUS,
#endif
WAV,
};
+58 -2
View File
@@ -3,10 +3,13 @@
#ifdef USE_ESP32
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
namespace esphome {
namespace audio {
static const char *const TAG = "audio.decoder";
static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration
static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data
@@ -79,6 +82,14 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) {
// Always reallocate the output transfer buffer to the smallest necessary size
this->output_transfer_buffer_->reallocate(this->free_buffer_required_);
break;
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
case AudioFileType::OPUS:
this->opus_decoder_ = make_unique<micro_opus::OggOpusDecoder>();
this->free_buffer_required_ =
this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
this->decoder_buffers_internally_ = true;
break;
#endif
case AudioFileType::WAV:
this->wav_decoder_ = make_unique<esp_audio_libs::wav_decoder::WAVDecoder>();
@@ -158,8 +169,9 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
// Decode more audio
// Only shift data on the first loop iteration to avoid unnecessary, slow moves
size_t bytes_read = this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS),
first_loop_iteration);
// If the decoder buffers internally, then never shift
size_t bytes_read = this->input_transfer_buffer_->transfer_data_from_source(
pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), first_loop_iteration && !this->decoder_buffers_internally_);
if (!first_loop_iteration && (this->input_transfer_buffer_->available() < bytes_processed)) {
// Less data is available than what was processed in last iteration, so don't attempt to decode.
@@ -195,6 +207,11 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
case AudioFileType::MP3:
state = this->decode_mp3_();
break;
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
case AudioFileType::OPUS:
state = this->decode_opus_();
break;
#endif
case AudioFileType::WAV:
state = this->decode_wav_();
@@ -339,6 +356,45 @@ FileDecoderState AudioDecoder::decode_mp3_() {
}
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
FileDecoderState AudioDecoder::decode_opus_() {
bool processed_header = this->opus_decoder_->is_initialized();
size_t bytes_consumed, samples_decoded;
micro_opus::OggOpusResult result = this->opus_decoder_->decode(
this->input_transfer_buffer_->get_buffer_start(), this->input_transfer_buffer_->available(),
this->output_transfer_buffer_->get_buffer_end(), this->output_transfer_buffer_->free(), bytes_consumed,
samples_decoded);
if (result == micro_opus::OGG_OPUS_OK) {
if (!processed_header && this->opus_decoder_->is_initialized()) {
// Header processed and stream info is available
this->audio_stream_info_ =
audio::AudioStreamInfo(this->opus_decoder_->get_bit_depth(), this->opus_decoder_->get_channels(),
this->opus_decoder_->get_sample_rate());
}
if (samples_decoded > 0 && this->audio_stream_info_.has_value()) {
// Some audio was processed
this->output_transfer_buffer_->increase_buffer_length(
this->audio_stream_info_.value().frames_to_bytes(samples_decoded));
}
this->input_transfer_buffer_->decrease_buffer_length(bytes_consumed);
} else if (result == micro_opus::OGG_OPUS_OUTPUT_BUFFER_TOO_SMALL) {
// Reallocate to decode the packet on the next call
this->free_buffer_required_ = this->opus_decoder_->get_required_output_buffer_size();
if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) {
// Couldn't reallocate output buffer
return FileDecoderState::FAILED;
}
} else {
ESP_LOGE(TAG, "Opus decoder failed: %" PRId8, result);
return FileDecoderState::POTENTIALLY_FAILED;
}
return FileDecoderState::MORE_TO_PROCESS;
}
#endif
FileDecoderState AudioDecoder::decode_wav_() {
if (!this->audio_stream_info_.has_value()) {
// Header hasn't been processed
+13 -2
View File
@@ -24,6 +24,11 @@
#endif
#include <wav_decoder.h>
// micro-opus
#ifdef USE_AUDIO_OPUS_SUPPORT
#include <micro_opus/ogg_opus_decoder.h>
#endif
namespace esphome {
namespace audio {
@@ -47,7 +52,7 @@ class AudioDecoder {
* @brief Class that facilitates decoding an audio file.
* The audio file is read from a ring buffer source, decoded, and sent to an audio sink (ring buffer or speaker
* component).
* Supports wav, flac, and mp3 formats.
* Supports wav, flac, mp3, and ogg opus formats.
*/
public:
/// @brief Allocates the input and output transfer buffers
@@ -55,7 +60,7 @@ class AudioDecoder {
/// @param output_buffer_size Size of the output transfer buffer in bytes.
AudioDecoder(size_t input_buffer_size, size_t output_buffer_size);
/// @brief Deallocates the MP3 decoder (the flac and wav decoders are deallocated automatically)
/// @brief Deallocates the MP3 decoder (the flac, opus, and wav decoders are deallocated automatically)
~AudioDecoder();
/// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr.
@@ -108,6 +113,10 @@ class AudioDecoder {
#ifdef USE_AUDIO_MP3_SUPPORT
FileDecoderState decode_mp3_();
esp_audio_libs::helix_decoder::HMP3Decoder mp3_decoder_;
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
FileDecoderState decode_opus_();
std::unique_ptr<micro_opus::OggOpusDecoder> opus_decoder_;
#endif
FileDecoderState decode_wav_();
@@ -124,6 +133,8 @@ class AudioDecoder {
bool end_of_file_{false};
bool wav_has_known_end_{false};
bool decoder_buffers_internally_{false};
bool pause_output_{false};
uint32_t accumulated_frames_written_{0};
+13
View File
@@ -197,6 +197,11 @@ esp_err_t AudioReader::start(const std::string &uri, AudioFileType &file_type) {
else if (str_endswith_ignore_case(url, ".flac")) {
file_type = AudioFileType::FLAC;
}
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
else if (str_endswith_ignore_case(url, ".opus")) {
file_type = AudioFileType::OPUS;
}
#endif
else {
file_type = AudioFileType::NONE;
@@ -241,6 +246,14 @@ AudioFileType AudioReader::get_audio_type(const char *content_type) {
if (strcasecmp(content_type, "audio/flac") == 0 || strcasecmp(content_type, "audio/x-flac") == 0) {
return AudioFileType::FLAC;
}
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
// Match "audio/ogg" with a codecs parameter containing "opus"
// Valid forms: audio/ogg;codecs=opus, audio/ogg; codecs="opus", etc.
// Plain "audio/ogg" without a codecs parameter is not matched, as those are almost always Ogg Vorbis streams
if (strncasecmp(content_type, "audio/ogg", 9) == 0 && strcasestr(content_type + 9, "opus") != nullptr) {
return AudioFileType::OPUS;
}
#endif
return AudioFileType::NONE;
}
@@ -26,7 +26,6 @@ from esphome.const import (
from esphome.core import CORE, HexInt
from esphome.core.entity_helpers import inherit_property_from
from esphome.external_files import download_content
from esphome.final_validate import full_config
_LOGGER = logging.getLogger(__name__)
@@ -37,6 +36,10 @@ DEPENDENCIES = ["network"]
CODEOWNERS = ["@kahrendt", "@synesthesiam"]
DOMAIN = "media_player"
CODEC_SUPPORT_ALL = "all"
CODEC_SUPPORT_NEEDED = "needed"
CODEC_SUPPORT_NONE = "none"
TYPE_LOCAL = "local"
TYPE_WEB = "web"
@@ -110,6 +113,8 @@ def _get_supported_format_struct(pipeline, type):
args.append(("format", "flac"))
elif pipeline[CONF_FORMAT] == "MP3":
args.append(("format", "mp3"))
elif pipeline[CONF_FORMAT] == "OPUS":
args.append(("format", "opus"))
elif pipeline[CONF_FORMAT] == "WAV":
args.append(("format", "wav"))
@@ -173,6 +178,13 @@ def _read_audio_file_and_type(file_config):
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"]
elif file_type in ("flac"):
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"]
elif (
file_type in ("ogg")
and len(data) >= 36
and data.startswith(b"OggS")
and data[28:36] == b"OpusHead"
):
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["OPUS"]
return data, media_file_type
@@ -199,6 +211,10 @@ def _validate_pipeline(config):
inherit_property_from(CONF_NUM_CHANNELS, CONF_SPEAKER)(config)
inherit_property_from(CONF_SAMPLE_RATE, CONF_SPEAKER)(config)
# Opus only supports 48 kHz
if config.get(CONF_FORMAT) == "OPUS" and config.get(CONF_SAMPLE_RATE) != 48000:
raise cv.Invalid("Opus only supports a sample rate of 48000 Hz")
# Validate the transcoder settings is compatible with the speaker
audio.final_validate_audio_schema(
"speaker media_player",
@@ -225,12 +241,27 @@ def _validate_repeated_speaker(config):
def _final_validate(config):
# Default to using codec if psram is enabled
if (use_codec := config.get(CONF_CODEC_SUPPORT_ENABLED)) is None:
use_codec = psram.DOMAIN in full_config.get()
conf_id = config[CONF_ID].id
core_data = CORE.data.setdefault(DOMAIN, {conf_id: {}})
core_data[conf_id][CONF_CODEC_SUPPORT_ENABLED] = use_codec
# Normalize boolean values to string equivalents
codec_mode = config[CONF_CODEC_SUPPORT_ENABLED]
if codec_mode is True:
codec_mode = CODEC_SUPPORT_ALL
elif codec_mode is False:
codec_mode = CODEC_SUPPORT_NONE
use_codec = codec_mode != CODEC_SUPPORT_NONE
# In "needed" mode, collect formats from pipelines and files
needed_formats = set()
need_all = False
if codec_mode == CODEC_SUPPORT_NEEDED:
for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE):
if pipeline := config.get(pipeline_key):
fmt = pipeline[CONF_FORMAT]
if fmt == "NONE":
# No preferred format means any format could arrive
need_all = True
else:
needed_formats.add(fmt)
for file_config in config.get(CONF_FILES, []):
_, media_file_type = _read_audio_file_and_type(file_config)
@@ -243,6 +274,26 @@ def _final_validate(config):
raise cv.Invalid(
f"Unsupported local media file type, set {CONF_CODEC_SUPPORT_ENABLED} to true or convert the media file to wav"
)
# In "needed" mode, add file format to needed codecs
if codec_mode == CODEC_SUPPORT_NEEDED:
for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items():
if str(media_file_type) == str(fmt_enum):
if fmt_name not in ("WAV", "NONE"):
needed_formats.add(fmt_name)
break
# Request codec support
if codec_mode == CODEC_SUPPORT_ALL or need_all:
audio.request_flac_support()
audio.request_mp3_support()
audio.request_opus_support()
elif codec_mode == CODEC_SUPPORT_NEEDED:
if "FLAC" in needed_formats:
audio.request_flac_support()
if "MP3" in needed_formats:
audio.request_mp3_support()
if "OPUS" in needed_formats:
audio.request_opus_support()
return config
@@ -307,7 +358,17 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(
min=4000, max=4000000
),
cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.boolean,
cv.Optional(
CONF_CODEC_SUPPORT_ENABLED, default=CODEC_SUPPORT_NEEDED
): cv.Any(
cv.boolean,
cv.one_of(
CODEC_SUPPORT_ALL,
CODEC_SUPPORT_NEEDED,
CODEC_SUPPORT_NONE,
lower=True,
),
),
cv.Optional(CONF_FILES): cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All(
cv.boolean, cv.requires_component(psram.DOMAIN)
@@ -340,11 +401,6 @@ FINAL_VALIDATE_SCHEMA = cv.All(
async def to_code(config):
if CORE.data[DOMAIN][config[CONF_ID].id][CONF_CODEC_SUPPORT_ENABLED]:
# Compile all supported audio codecs
cg.add_define("USE_AUDIO_FLAC_SUPPORT", True)
cg.add_define("USE_AUDIO_MP3_SUPPORT", True)
var = await media_player.new_media_player(config)
await cg.register_component(var, config)
@@ -13,7 +13,12 @@ namespace speaker {
static const uint32_t INITIAL_BUFFER_MS = 1000; // Start playback after buffering this duration of the file
static const uint32_t READ_TASK_STACK_SIZE = 5 * 1024;
// Opus decoding uses more stack than other codecs
#ifdef USE_AUDIO_OPUS_SUPPORT
static const uint32_t DECODE_TASK_STACK_SIZE = 5 * 1024;
#else
static const uint32_t DECODE_TASK_STACK_SIZE = 3 * 1024;
#endif
static const uint32_t INFO_ERROR_QUEUE_COUNT = 5;
@@ -552,6 +557,11 @@ void AudioPipeline::decode_task(void *params) {
case audio::AudioFileType::FLAC:
initial_bytes_to_buffer /= 2; // Estimate the FLAC compression factor is 2
break;
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
case audio::AudioFileType::OPUS:
initial_bytes_to_buffer /= 8; // Estimate the Opus compression factor is 8
break;
#endif
default:
break;
+1
View File
@@ -130,6 +130,7 @@
#define USE_AUDIO_DAC
#define USE_AUDIO_FLAC_SUPPORT
#define USE_AUDIO_MP3_SUPPORT
#define USE_AUDIO_OPUS_SUPPORT
#define USE_API
#define USE_API_CLIENT_CONNECTED_TRIGGER
#define USE_API_CLIENT_DISCONNECTED_TRIGGER
+2
View File
@@ -3,6 +3,8 @@ dependencies:
version: "7.4.2"
esphome/esp-audio-libs:
version: 2.0.3
esphome/micro-opus:
version: 0.3.3
espressif/esp-tflite-micro:
version: 1.3.3~1
espressif/esp32-camera:
@@ -1,10 +0,0 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO23
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
<<: !include common-audio_dac.yaml
@@ -1,5 +1,11 @@
<<: !include common.yaml
wifi:
ap:
psram:
mode: quad
media_player:
- platform: speaker
id: speaker_media_player_id
@@ -10,3 +16,4 @@ media_player:
volume_max: 0.95
volume_min: 0.0
task_stack_in_psram: true
codec_support_enabled: all
@@ -1,9 +0,0 @@
substitutions:
scl_pin: GPIO2
sda_pin: GPIO3
i2s_bclk_pin: GPIO4
i2s_lrclk_pin: GPIO5
i2s_mclk_pin: GPIO6
i2s_dout_pin: GPIO7
<<: !include common-media_player.yaml
@@ -1,10 +0,0 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO4
packages:
i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml
<<: !include common.yaml