mirror of
https://github.com/esphome/esphome.git
synced 2026-05-18 01:32:27 +08:00
[audio_file] New component for embedding files into firmware (#14434)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> 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 <nick@koston.org>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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<NamedAudioFile, AUDIO_FILE_MAX_FILES>
|
||||
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<NamedAudioFile, AUDIO_FILE_MAX_FILES> &get_named_audio_files() { return named_audio_files; }
|
||||
|
||||
} // namespace esphome::audio_file
|
||||
|
||||
#endif // AUDIO_FILE_MAX_FILES
|
||||
@@ -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
|
||||
|
||||
@@ -72,6 +72,7 @@ ignore_types = (
|
||||
".gif",
|
||||
".webp",
|
||||
".bin",
|
||||
".wav",
|
||||
)
|
||||
|
||||
LINT_FILE_CHECKS = []
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
audio_file:
|
||||
- id: test_audio
|
||||
file:
|
||||
type: local
|
||||
path: $component_dir/test.wav
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common.yaml
|
||||
Binary file not shown.
Reference in New Issue
Block a user