From 5c5ea8824edebb74e80a3ff9306add66b76b4b95 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 5 Mar 2026 13:51:08 -0600 Subject: [PATCH] [audio_file] New component for embedding files into firmware (#14434) Co-authored-by: Claude Opus 4.6 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/audio_file/__init__.py | 255 ++++++++++++++++++ esphome/components/audio_file/audio_file.h | 28 ++ esphome/core/defines.h | 1 + script/ci-custom.py | 1 + tests/components/audio_file/common.yaml | 5 + .../components/audio_file/test.esp32-idf.yaml | 1 + tests/components/audio_file/test.wav | Bin 0 -> 46 bytes 8 files changed, 292 insertions(+) create mode 100644 esphome/components/audio_file/__init__.py create mode 100644 esphome/components/audio_file/audio_file.h create mode 100644 tests/components/audio_file/common.yaml create mode 100644 tests/components/audio_file/test.esp32-idf.yaml create mode 100644 tests/components/audio_file/test.wav diff --git a/CODEOWNERS b/CODEOWNERS index b22f85b71d..7c37b20e09 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -54,6 +54,7 @@ esphome/components/atm90e32/* @circuitsetup @descipher esphome/components/audio/* @kahrendt esphome/components/audio_adc/* @kbx81 esphome/components/audio_dac/* @kbx81 +esphome/components/audio_file/* @kahrendt esphome/components/axs15231/* @clydebarrow esphome/components/b_parasite/* @rbaron esphome/components/ballu/* @bazuchan diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py new file mode 100644 index 0000000000..3ed6c1cd92 --- /dev/null +++ b/esphome/components/audio_file/__init__.py @@ -0,0 +1,255 @@ +from dataclasses import dataclass, field +import hashlib +import logging +from pathlib import Path + +import puremagic + +from esphome import external_files +import esphome.codegen as cg +from esphome.components import audio +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_ID, + CONF_PATH, + CONF_RAW_DATA_ID, + CONF_TYPE, + CONF_URL, +) +from esphome.core import CORE, ID, HexInt +from esphome.cpp_generator import MockObj +from esphome.external_files import download_content +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CODEOWNERS = ["@kahrendt"] + +AUTO_LOAD = ["audio"] + +DOMAIN = "audio_file" + +audio_file_ns = cg.esphome_ns.namespace("audio_file") + +TYPE_LOCAL = "local" +TYPE_WEB = "web" + + +@dataclass +class AudioFileData: + file_ids: dict[str, ID] = field(default_factory=dict) + file_cache: dict[str, tuple[bytes, MockObj]] = field(default_factory=dict) + + +def _get_data() -> AudioFileData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = AudioFileData() + return CORE.data[DOMAIN] + + +def get_audio_file_ids() -> dict[str, ID]: + """Get all registered audio file IDs for cross-component access.""" + return _get_data().file_ids + + +def _compute_local_file_path(value: ConfigType) -> Path: + url = value[CONF_URL] + h = hashlib.new("sha256") + h.update(url.encode()) + key = h.hexdigest()[:8] + base_dir = external_files.compute_local_file_dir(DOMAIN) + _LOGGER.debug("_compute_local_file_path: base_dir=%s", base_dir / key) + return base_dir / key + + +def _download_web_file(value: ConfigType) -> ConfigType: + url = value[CONF_URL] + path = _compute_local_file_path(value) + + download_content(url, path) + _LOGGER.debug("download_web_file: path=%s", path) + return value + + +def _file_schema(value: ConfigType | str) -> ConfigType: + if isinstance(value, str): + return _validate_file_shorthand(value) + return TYPED_FILE_SCHEMA(value) + + +def _validate_file_shorthand(value: str) -> ConfigType: + value = cv.string_strict(value) + if value.startswith("http://") or value.startswith("https://"): + return _file_schema( + { + CONF_TYPE: TYPE_WEB, + CONF_URL: value, + } + ) + return _file_schema( + { + CONF_TYPE: TYPE_LOCAL, + CONF_PATH: value, + } + ) + + +def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]: + """Read an audio file and determine its type. Used by this component and media_source platform.""" + conf_file = file_config[CONF_FILE] + file_source = conf_file[CONF_TYPE] + if file_source == TYPE_LOCAL: + path = CORE.relative_config_path(conf_file[CONF_PATH]) + elif file_source == TYPE_WEB: + path = _compute_local_file_path(conf_file) + else: + raise cv.Invalid("Unsupported file source") + + with open(path, "rb") as f: + data = f.read() + + try: + file_type: str = puremagic.from_string(data) + file_type = file_type.removeprefix(".") + except puremagic.PureError as e: + raise cv.Invalid( + f"Unable to determine audio file type of '{path}'. " + f"Try re-encoding the file into a supported format. Details: {e}" + ) + + media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"] + if file_type == "wav": + media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"] + elif file_type in ("mp3", "mpeg", "mpga"): + media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"] + elif file_type == "flac": + media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"] + elif ( + file_type == "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 + + +LOCAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_PATH): cv.file_, + } +) + +WEB_SCHEMA = cv.All( + { + cv.Required(CONF_URL): cv.url, + }, + _download_web_file, +) + + +TYPED_FILE_SCHEMA = cv.typed_schema( + { + TYPE_LOCAL: LOCAL_SCHEMA, + TYPE_WEB: WEB_SCHEMA, + }, +) + + +MEDIA_FILE_TYPE_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(audio.AudioFile), + cv.Required(CONF_FILE): _file_schema, + cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), + } +) + + +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB + + +def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType]: + for file_config in config: + data, media_file_type = read_audio_file_and_type(file_config) + + if len(data) > MAX_FILE_SIZE: + file_info = file_config.get(CONF_FILE, {}) + source = ( + file_info.get(CONF_PATH) or file_info.get(CONF_URL) or "unknown source" + ) + raise cv.Invalid( + f"Audio file {source!r} is too large ({len(data)} bytes, max {MAX_FILE_SIZE} bytes)" + ) + + if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]): + file_info = file_config.get(CONF_FILE, {}) + source = ( + file_info.get(CONF_PATH) or file_info.get(CONF_URL) or "unknown source" + ) + raise cv.Invalid( + f"Unsupported media file from {source!r} (detected type: {media_file_type})" + ) + + # Cache the file data so to_code() doesn't need to re-read it + _get_data().file_cache[str(file_config[CONF_ID])] = (data, media_file_type) + + media_file_type_str = str(media_file_type) + if media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["FLAC"]): + audio.request_flac_support() + elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["MP3"]): + audio.request_mp3_support() + elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["OPUS"]): + audio.request_opus_support() + + return config + + +CONFIG_SCHEMA = cv.All( + cv.only_on_esp32, + cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA), + _validate_supported_local_file, +) + + +async def to_code(config: list[ConfigType]) -> None: + cache = _get_data().file_cache + + for file_config in config: + file_id = str(file_config[CONF_ID]) + data, media_file_type = cache[file_id] + + rhs = [HexInt(x) for x in data] + prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs) + + media_files_struct = cg.StructInitializer( + audio.AudioFile, + ( + "data", + prog_arr, + ), + ( + "length", + len(rhs), + ), + ( + "file_type", + media_file_type, + ), + ) + + cg.new_Pvariable( + file_config[CONF_ID], + media_files_struct, + ) + + # Store file ID for cross-component access + _get_data().file_ids[file_id] = file_config[CONF_ID] + + # Register all files in the shared C++ registry + cg.add_define("AUDIO_FILE_MAX_FILES", len(config)) + for file_config in config: + file_id = str(file_config[CONF_ID]) + file_var = await cg.get_variable(file_config[CONF_ID]) + cg.add(audio_file_ns.add_named_audio_file(file_var, file_id)) diff --git a/esphome/components/audio_file/audio_file.h b/esphome/components/audio_file/audio_file.h new file mode 100644 index 0000000000..537e19fb3c --- /dev/null +++ b/esphome/components/audio_file/audio_file.h @@ -0,0 +1,28 @@ +#pragma once + +#include "esphome/core/defines.h" + +#ifdef AUDIO_FILE_MAX_FILES + +#include "esphome/components/audio/audio.h" +#include "esphome/core/helpers.h" + +namespace esphome::audio_file { + +struct NamedAudioFile { + audio::AudioFile *file; + const char *file_id; +}; + +inline StaticVector + named_audio_files; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +inline void add_named_audio_file(audio::AudioFile *file, const char *file_id) { + named_audio_files.push_back({file, file_id}); +} + +inline const StaticVector &get_named_audio_files() { return named_audio_files; } + +} // namespace esphome::audio_file + +#endif // AUDIO_FILE_MAX_FILES diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 07afefd91a..1a6d9b3a80 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -137,6 +137,7 @@ // Feature flags which do not work for zephyr #ifndef USE_ZEPHYR +#define AUDIO_FILE_MAX_FILES 4 #define USE_AUDIO_DAC #define USE_AUDIO_FLAC_SUPPORT #define USE_AUDIO_MP3_SUPPORT diff --git a/script/ci-custom.py b/script/ci-custom.py index b60d7d7740..8e1652b505 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -72,6 +72,7 @@ ignore_types = ( ".gif", ".webp", ".bin", + ".wav", ) LINT_FILE_CHECKS = [] diff --git a/tests/components/audio_file/common.yaml b/tests/components/audio_file/common.yaml new file mode 100644 index 0000000000..9404208094 --- /dev/null +++ b/tests/components/audio_file/common.yaml @@ -0,0 +1,5 @@ +audio_file: + - id: test_audio + file: + type: local + path: $component_dir/test.wav diff --git a/tests/components/audio_file/test.esp32-idf.yaml b/tests/components/audio_file/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/audio_file/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/audio_file/test.wav b/tests/components/audio_file/test.wav new file mode 100644 index 0000000000000000000000000000000000000000..f9d07ef2238eb2fcb355055466d3789ee1a1fe0b GIT binary patch literal 46 vcmWIYbaPW