[micro_wake_word] Support adding and removing wake word models at runtime (#17927)

This commit is contained in:
Kevin Ahrendt
2026-08-05 10:24:46 -04:00
committed by GitHub
parent 3964ef61f8
commit 64f0e38a51
8 changed files with 461 additions and 4 deletions
@@ -432,7 +432,7 @@ CONFIG_SCHEMA = cv.All(
min_channels=1,
max_channels=1,
),
cv.Required(CONF_MODELS): cv.ensure_list(
cv.Optional(CONF_MODELS, default=[]): cv.ensure_list(
cv.maybe_simple_value(MODEL_SCHEMA, key=CONF_MODEL)
),
cv.Optional(CONF_ON_WAKE_WORD_DETECTED): automation.validate_automation(
@@ -555,6 +555,9 @@ async def to_code(config):
# Use the general model loading code for the VAD codegen
config[CONF_MODELS].append(vad_model)
# Default feature step size for runtime models
feature_step_size = 10
for i, model_parameters in enumerate(config[CONF_MODELS]):
model_config = model_parameters.get(CONF_MODEL)
data = []
@@ -573,6 +576,9 @@ async def to_code(config):
manifest[KEY_MICRO][CONF_SLIDING_WINDOW_SIZE],
)
# Update feature step size from manifest
feature_step_size = manifest[KEY_MICRO][CONF_FEATURE_STEP_SIZE]
if manifest[KEY_WAKE_WORD] == "vad":
cg.add(
var.add_vad_model(
@@ -602,7 +608,7 @@ async def to_code(config):
cg.add(var.add_wake_word_model(wake_word_model))
cg.add(var.set_features_step_size(manifest[KEY_MICRO][CONF_FEATURE_STEP_SIZE]))
cg.add(var.set_features_step_size(feature_step_size))
cg.add(var.set_stop_after_detection(config[CONF_STOP_AFTER_DETECTION]))
if on_wake_word_detection_config := config.get(CONF_ON_WAKE_WORD_DETECTED):
@@ -9,6 +9,8 @@
#include "esphome/components/audio/audio_transfer_buffer.h"
#include <algorithm>
#ifdef USE_OTA
#include "esphome/components/ota/ota_backend.h"
#endif
@@ -35,21 +37,34 @@ static const UBaseType_t INFERENCE_TASK_PRIORITY = 3;
enum EventGroupBits : uint32_t {
COMMAND_STOP = (1 << 0), // Signals the inference task should stop
COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio
COMMAND_PAUSE_MODELS = (1 << 2), // Asks the inference task to pause at a safe point so the model lists can be
// mutated from the main loop
TASK_STARTING = (1 << 3),
TASK_RUNNING = (1 << 4),
TASK_STOPPING = (1 << 5),
TASK_STOPPED = (1 << 6),
MODELS_PAUSED = (1 << 7), // Inference task acknowledges it is paused and holds no iterators
COMMAND_RESUME_MODELS = (1 << 8), // Main loop signals the inference task it may resume iterating
ERROR_MEMORY = (1 << 9),
ERROR_INFERENCE = (1 << 10),
WARNING_FULL_RING_BUFFER = (1 << 13),
WARNING_MODELS_RESUME_TIMEOUT = (1 << 14), // The paused inference task gave up waiting to be released
ERROR_BITS = ERROR_MEMORY | ERROR_INFERENCE,
ALL_BITS = 0xfffff, // 24 total bits available in an event group
};
// How long the main loop waits for the inference task to acknowledge a pause request before giving up.
// The task checks for the command at the top of its loop, which runs at least every DATA_TIMEOUT_MS.
static const uint32_t MODELS_PAUSE_TIMEOUT_MS = 500;
// How long the paused inference task waits to be resumed before rechecking on its own. Only reached if
// the main loop abandoned the handshake (e.g. it timed out first), so recovery just needs to be bounded.
static const uint32_t MODELS_RESUME_TIMEOUT_MS = 1000;
float MicroWakeWord::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; }
static const LogString *micro_wake_word_state_to_string(State state) {
@@ -176,6 +191,20 @@ void MicroWakeWord::inference_task(void *params) {
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING);
while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) {
if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_PAUSE_MODELS) {
// Safe point: no iterators into wake_word_models_ are held here. Acknowledge the pause and wait for the
// main loop to finish mutating the model lists before resuming.
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::MODELS_PAUSED);
EventBits_t resume_bits = xEventGroupWaitBits(this_mww->event_group_, EventGroupBits::COMMAND_RESUME_MODELS,
pdTRUE, pdTRUE, pdMS_TO_TICKS(MODELS_RESUME_TIMEOUT_MS));
if (!(resume_bits & EventGroupBits::COMMAND_RESUME_MODELS)) {
// Nobody released us, so the main loop abandoned the handshake and did not mutate the lists.
// Rechecking the pause command below is safe, but the wait cost a second of detection, so report it.
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT);
}
continue;
}
if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) {
// Producer asked us to drain; run the consumer-side reset from this thread.
audio_source->clear_buffered_data();
@@ -232,6 +261,130 @@ std::vector<WakeWordModel *> MicroWakeWord::get_wake_words() {
void MicroWakeWord::add_wake_word_model(WakeWordModel *model) { this->wake_word_models_.push_back(model); }
bool MicroWakeWord::try_lock_models_() {
// When the inference task isn't running it holds no iterators into wake_word_models_, so the lists can be
// mutated without a handshake. The main loop is the only caller, so this state cannot change between here
// and the matching unlock_models_() call.
if (!this->inference_task_.is_created() || this->state_ == State::STOPPED) {
return true;
}
// The task is running and iterates wake_word_models_. Ask it to pause at a safe point before we mutate.
// Clear any stale acknowledgement from an abandoned handshake first.
xEventGroupClearBits(this->event_group_, EventGroupBits::MODELS_PAUSED);
xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_PAUSE_MODELS);
EventBits_t bits = xEventGroupWaitBits(this->event_group_, EventGroupBits::MODELS_PAUSED, pdFALSE, pdTRUE,
pdMS_TO_TICKS(MODELS_PAUSE_TIMEOUT_MS));
if (!(bits & EventGroupBits::MODELS_PAUSED)) {
// The task never acknowledged (e.g. it is busy stopping). Withdraw the request and refuse to mutate a
// list it might be iterating.
xEventGroupClearBits(this->event_group_, EventGroupBits::COMMAND_PAUSE_MODELS);
return false;
}
return true;
}
void MicroWakeWord::unlock_models_() {
if (!this->inference_task_.is_created() || this->state_ == State::STOPPED) {
return; // Nothing was paused
}
xEventGroupClearBits(this->event_group_, EventGroupBits::MODELS_PAUSED | EventGroupBits::COMMAND_PAUSE_MODELS);
xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_RESUME_MODELS);
}
bool MicroWakeWord::add_runtime_model(std::unique_ptr<WakeWordModel> model) {
if (!model) {
ESP_LOGE(TAG, "Cannot add null runtime model");
return false;
}
const std::string model_id = model->get_id();
// A model without usable data can never load, so keep it out of the lists entirely. Otherwise it would be
// advertised to Home Assistant as selectable and the inference task would silently disable it again every
// time it was enabled.
if (!model->has_model_data()) {
ESP_LOGE(TAG, "Runtime model '%s' has no valid data", model_id.c_str());
return false;
}
// Reject a duplicate id against every model (compiled or runtime). The inference task only ever reads
// wake_word_models_, so scanning it here (on the main loop) needs no synchronization.
for (auto *existing : this->wake_word_models_) {
if (existing->get_id() == model_id) {
ESP_LOGW(TAG, "Wake word model '%s' already exists", model_id.c_str());
return false;
}
}
if (!this->try_lock_models_()) {
ESP_LOGE(TAG, "Timed out pausing inference task; not adding runtime model '%s'", model_id.c_str());
return false;
}
this->wake_word_models_.push_back(model.get());
this->runtime_models_.push_back(std::move(model));
this->unlock_models_();
ESP_LOGD(TAG, "Added runtime model '%s'", model_id.c_str());
return true;
}
bool MicroWakeWord::remove_runtime_model(const std::string &model_id) {
// Only runtime-downloaded models can be removed; compiled-in models never appear in runtime_models_.
auto runtime_it =
std::find_if(this->runtime_models_.begin(), this->runtime_models_.end(),
[&model_id](const std::unique_ptr<WakeWordModel> &m) { return m->get_id() == model_id; });
if (runtime_it == this->runtime_models_.end()) {
return false;
}
if (!this->try_lock_models_()) {
ESP_LOGE(TAG, "Timed out pausing inference task; not removing runtime model '%s'", model_id.c_str());
return false;
}
WakeWordModel *raw = runtime_it->get();
auto models_it = std::find(this->wake_word_models_.begin(), this->wake_word_models_.end(), raw);
if (models_it != this->wake_word_models_.end()) {
this->wake_word_models_.erase(models_it);
}
// Queued detection events hold a pointer into the model being destroyed, so drop them. The inference task
// is parked, so no new events can be queued concurrently. Losing an undelivered detection from another
// model is acceptable for this rare operation.
xQueueReset(this->detection_queue_);
// Free the interpreter and arenas (safe: the task is parked, not mid-inference), then destroy the model.
// Its ModelData releases the PSRAM model buffer once the last shared_ptr reference drops.
raw->unload_model();
this->runtime_models_.erase(runtime_it);
this->unlock_models_();
ESP_LOGI(TAG, "Removed runtime model '%s'", model_id.c_str());
return true;
}
std::vector<std::string> MicroWakeWord::get_runtime_model_ids() {
std::vector<std::string> ids;
ids.reserve(this->runtime_models_.size());
for (const auto &model : this->runtime_models_) {
ids.push_back(model->get_id());
}
return ids;
}
WakeWordModel *MicroWakeWord::get_model_by_id(const std::string &model_id) {
for (auto *model : this->wake_word_models_) {
if (model->get_id() == model_id) {
return model;
}
}
return nullptr;
}
#ifdef USE_MICRO_WAKE_WORD_VAD
void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size,
size_t tensor_arena_size) {
@@ -270,6 +423,12 @@ void MicroWakeWord::loop() {
"word detection accuracy will temporarily be reduced.");
}
if (event_group_bits & EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT) {
xEventGroupClearBits(this->event_group_, EventGroupBits::WARNING_MODELS_RESUME_TIMEOUT);
ESP_LOGW(TAG, "Inference task paused for %" PRIu32 " ms without being released, so it resumed on its own",
MODELS_RESUME_TIMEOUT_MS);
}
if (event_group_bits & EventGroupBits::TASK_STARTING) {
ESP_LOGD(TAG, "Inference task has started, attempting to allocate memory for buffers");
xEventGroupClearBits(this->event_group_, EventGroupBits::TASK_STARTING);
@@ -66,6 +66,32 @@ class MicroWakeWord final : public Component
void add_wake_word_model(WakeWordModel *model);
/// @brief Adds a runtime-downloaded wake word model. Must be called from the main loop.
/// If the inference task is running it is paused at a safe point before the model lists are mutated,
/// so the task never observes a half-updated vector.
/// Callers should check get_model_by_id() before constructing the model: a WakeWordModel permanently
/// claims a preference backend that is not released when the model is destroyed, so building one only to
/// have it rejected here costs internal RAM that never comes back.
/// @return True if the model was added, false if it has no valid data, on a duplicate id, or if the task
/// could not be paused
bool add_runtime_model(std::unique_ptr<WakeWordModel> model);
/// @brief Removes a runtime-downloaded wake word model and frees its interpreter, arenas, and model buffer.
/// Must be called from the main loop. If the inference task is running it is paused at a safe point first,
/// and any queued detection events are dropped (they hold pointers into the model being destroyed).
/// @return True if the model was removed, false if the id is not a runtime model or the task could not be paused
bool remove_runtime_model(const std::string &model_id);
/// @brief Returns the wake word model with the given id, or nullptr if none matches (compiled or runtime).
/// Must be called from the main loop, as the returned pointer is invalidated by remove_runtime_model().
WakeWordModel *get_model_by_id(const std::string &model_id);
/// @brief Returns the ids of all runtime-downloaded models. Must be called from the main loop.
std::vector<std::string> get_runtime_model_ids();
/// @brief Returns the feature step size (ms) the frontend is configured for. Runtime models must match it.
uint8_t get_features_step_size() const { return this->features_step_size_; }
#ifdef USE_MICRO_WAKE_WORD_VAD
void add_vad_model(const uint8_t *model_start, uint8_t probability_cutoff, size_t sliding_window_size,
size_t tensor_arena_size);
@@ -85,6 +111,7 @@ class MicroWakeWord final : public Component
std::weak_ptr<ring_buffer::RingBuffer> ring_buffer_;
std::vector<WakeWordModel *> wake_word_models_;
std::vector<std::unique_ptr<WakeWordModel>> runtime_models_;
#ifdef USE_MICRO_WAKE_WORD_VAD
std::unique_ptr<VADModel> vad_model_;
@@ -119,6 +146,13 @@ class MicroWakeWord final : public Component
/// @brief Resumes the inference task
void resume_task_();
/// @brief Parks the inference task at a safe point (or verifies it isn't running) so the model lists may be
/// mutated from the main loop. Every successful call must be paired with unlock_models_().
/// @return True if the lists may be mutated, false if the running task never acknowledged the pause request
bool try_lock_models_();
/// @brief Releases the inference task parked by a successful try_lock_models_() call
void unlock_models_();
void set_state_(State state);
/// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples
@@ -0,0 +1,100 @@
#include "model_data.h"
#ifdef USE_ESP32
#include <cstring>
#include "esphome/core/log.h"
#include <tensorflow/lite/core/c/common.h>
#include <tensorflow/lite/micro/micro_interpreter.h>
namespace esphome::micro_wake_word {
static const char *const TAG = "micro_wake_word";
ModelData::~ModelData() { this->deallocate_(); }
bool ModelData::allocate(size_t size) {
// Reject up front: reallocating to zero frees the buffer and returns null, which would leave data_ pointing at
// freed memory. A zero-length model is never usable anyway.
if (size == 0) {
ESP_LOGE(TAG, "Refusing to allocate a zero-length model");
return false;
}
// Already allocated, so reallocate to the new size
if (this->data_) {
uint8_t *new_allocation = this->allocator_.reallocate(this->data_, size);
if (new_allocation == nullptr) {
ESP_LOGE(TAG, "Failed to reallocate %zu bytes", size);
return false;
}
this->data_ = new_allocation;
this->size_ = size;
this->valid_ = false; // Need to revalidate with new data
return true;
}
// Try to allocate in PSRAM first
this->data_ = this->allocator_.allocate(size);
if (this->data_ == nullptr) {
ESP_LOGE(TAG, "Failed to allocate %zu bytes", size);
return false;
}
this->size_ = size;
this->valid_ = false;
return true;
}
void ModelData::deallocate_() {
if (this->data_ != nullptr) {
this->allocator_.deallocate(this->data_, this->size_);
this->data_ = nullptr;
this->size_ = 0;
this->valid_ = false;
}
}
const uint8_t *ModelData::get_model_pointer() const { return this->valid_ ? this->data_ : nullptr; }
uint8_t *ModelData::get_write_pointer() {
this->valid_ = false; // Mark invalid while writing
return this->data_;
}
bool ModelData::validate_and_mark_ready() {
// The magic number lives in bytes 4-7, so we need at least 8 bytes to read it.
if (!this->data_ || this->size_ < 8) {
ESP_LOGE(TAG, "Model data is null or too small");
return false;
}
// Check TFLite magic number "TFL3" in bytes 4-7
if (memcmp(this->data_ + 4, "TFL3", 4) != 0) {
ESP_LOGE(TAG, "Invalid TFLite model magic number");
return false;
}
// Bytes 0-3 hold the offset of the root table. tflite::GetModel only adds that offset to the start of the
// buffer, so check it lands inside the buffer before reading through it.
uint32_t root_offset;
memcpy(&root_offset, this->data_, sizeof(root_offset));
if (root_offset >= this->size_) {
ESP_LOGE(TAG, "TFLite model root offset is out of bounds");
return false;
}
const tflite::Model *model = tflite::GetModel(this->data_);
if (model->version() != TFLITE_SCHEMA_VERSION) {
ESP_LOGE(TAG, "TFLite model version mismatch (expected %d, got %d)", TFLITE_SCHEMA_VERSION, model->version());
return false;
}
this->valid_ = true;
return true;
}
} // namespace esphome::micro_wake_word
#endif // USE_ESP32
@@ -0,0 +1,60 @@
#pragma once
#ifdef USE_ESP32
#include <cstddef>
#include <cstdint>
#include "esphome/core/helpers.h"
namespace esphome::micro_wake_word {
// Owns the buffer holding a runtime-downloaded TFLite model. The buffer prefers PSRAM but falls back to
// internal RAM, so a device without PSRAM can still hold a single model. It is filled over HTTP, checked
// for integrity by the caller (SHA256) and for a usable TFLite header here, then kept alive for the
// lifetime of the WakeWordModel that uses it. Only ever held behind a std::shared_ptr, so copies and
// moves are disabled.
class ModelData {
public:
ModelData() = default;
~ModelData();
// Non-copyable, non-movable
ModelData(const ModelData &) = delete;
ModelData &operator=(const ModelData &) = delete;
ModelData(ModelData &&) = delete;
ModelData &operator=(ModelData &&) = delete;
// Allocate memory for model
bool allocate(size_t size);
// Get stable pointer for TFLite (only valid after validate_and_mark_ready())
const uint8_t *get_model_pointer() const;
// Get writable pointer for downloading (invalidates the model)
uint8_t *get_write_pointer();
// Validate TFLite model and mark as ready for use
bool validate_and_mark_ready();
// Check if model is valid and ready for use
bool is_valid() const { return this->valid_; }
// Get size of model data
size_t size() const { return this->size_; }
// Check if memory is allocated
bool is_allocated() const { return this->data_ != nullptr; }
protected:
// Deallocate memory
void deallocate_();
uint8_t *data_{nullptr};
size_t size_{0};
bool valid_{false};
RAMAllocator<uint8_t> allocator_{RAMAllocator<uint8_t>::NONE};
};
} // namespace esphome::micro_wake_word
#endif // USE_ESP32
@@ -26,6 +26,11 @@ void VADModel::log_model_config() {
}
bool StreamingModel::load_model_() {
if (this->model_start_ == nullptr) {
ESP_LOGE(TAG, "Streaming model has no data to load");
return false;
}
RAMAllocator<uint8_t> arena_allocator;
if (this->var_arena_ == nullptr) {
@@ -188,6 +193,13 @@ void StreamingModel::unload_model() {
}
bool StreamingModel::perform_streaming_inference(const int8_t features[PREPROCESSOR_FEATURE_SIZE]) {
if (this->model_start_ == nullptr) {
// No usable model data, and that cannot change for this object. Skip the model instead of reporting a
// failure, because a false return here stops the inference task for every other model too.
this->enabled_ = false;
return true;
}
if (this->enabled_ && !this->loaded_) {
// Model is enabled but isn't loaded
if (!this->load_model_()) {
@@ -269,6 +281,41 @@ WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start,
}
};
WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr<ModelData> model_data,
uint8_t default_probability_cutoff, size_t sliding_window_average_size,
const std::string &wake_word, std::vector<std::string> trained_languages,
size_t tensor_arena_size) {
this->id_ = id;
this->model_data_ = std::move(model_data);
// Callers are expected to pass a validated buffer, so this is normally the stable model pointer. Tolerate a
// null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is
// never loaded.
this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr;
if (this->model_start_ == nullptr) {
ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str());
}
this->default_probability_cutoff_ = default_probability_cutoff;
this->probability_cutoff_ = default_probability_cutoff;
this->sliding_window_size_ = sliding_window_average_size;
this->recent_streaming_probabilities_.resize(sliding_window_average_size, 0);
this->wake_word_ = wake_word;
this->trained_languages_ = std::move(trained_languages);
this->tensor_arena_size_ = tensor_arena_size;
this->register_streaming_ops_(this->streaming_op_resolver_);
this->current_stride_step_ = 0;
this->internal_only_ = false; // Runtime models are always exposed to Home Assistant
this->pref_ = global_preferences->make_preference<bool>(fnv1_hash(id));
bool enabled;
if (this->pref_.load(&enabled)) {
// Use the enabled state loaded from flash
this->enabled_ = enabled;
} else {
// No saved state: stay disabled. The activation flow calls enable() explicitly after adding.
this->enabled_ = false;
}
};
void WakeWordModel::enable() {
this->enabled_ = true;
if (!this->internal_only_) {
@@ -3,9 +3,11 @@
#ifdef USE_ESP32
#include "preprocessor_settings.h"
#include "model_data.h"
#include "esphome/core/preferences.h"
#include <memory>
#include <tensorflow/lite/core/c/common.h>
#include <tensorflow/lite/micro/micro_interpreter.h>
#include <tensorflow/lite/micro/micro_mutable_op_resolver.h>
@@ -27,6 +29,10 @@ struct DetectionEvent {
class StreamingModel {
public:
// Runtime models are heap owned and destroyed while the device is running, so freeing the arenas cannot
// depend on the owner calling unload_model() first. unload_model() is not virtual and is safe to repeat.
virtual ~StreamingModel() { this->unload_model(); }
virtual void log_model_config() = 0;
virtual DetectionEvent determine_detected() = 0;
@@ -51,6 +57,9 @@ class StreamingModel {
/// @brief Return true if the model is enabled.
bool is_enabled() const { return this->enabled_; }
/// @brief Return true if the model has usable data. A model without it can never be loaded or run.
bool has_model_data() const { return this->model_start_ != nullptr; }
bool get_unprocessed_probability_status() const { return this->unprocessed_probability_status_; }
// Quantized probability cutoffs mapping 0.0 - 1.0 to 0 - 255
@@ -86,7 +95,7 @@ class StreamingModel {
size_t tensor_arena_size_;
std::vector<uint8_t> recent_streaming_probabilities_;
const uint8_t *model_start_;
const uint8_t *model_start_{nullptr};
uint8_t *tensor_arena_{nullptr};
uint8_t *var_arena_{nullptr};
std::unique_ptr<tflite::MicroInterpreter> interpreter_;
@@ -96,7 +105,7 @@ class StreamingModel {
class WakeWordModel final : public StreamingModel {
public:
/// @brief Constructs a wake word model object
/// @brief Constructs a wake word model object with compile-time model data
/// @param id (std::string) identifier for this model
/// @param model_start (const uint8_t *) pointer to the start of the model's TFLite FlatBuffer
/// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said
@@ -110,6 +119,23 @@ class WakeWordModel final : public StreamingModel {
size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size,
bool default_enabled, bool internal_only);
/// @brief Constructs a wake word model object with a runtime-downloaded model
/// @param id (std::string) identifier for this model
/// @param model_data (std::shared_ptr<ModelData>) owning handle to the downloaded model buffer; must be valid
/// @param default_probability_cutoff (uint8_t) probability cutoff for acceping the wake word has been said
/// @param sliding_window_average_size (size_t) the length of the sliding window computing the mean rolling
/// probability
/// @param wake_word (std::string) Friendly name of the wake word
/// @param trained_languages (std::vector<std::string>) Languages the model was trained on
/// @param tensor_arena_size (size_t) Size in bytes for allocating the tensor arena
WakeWordModel(const std::string &id, std::shared_ptr<ModelData> model_data, uint8_t default_probability_cutoff,
size_t sliding_window_average_size, const std::string &wake_word,
std::vector<std::string> trained_languages, size_t tensor_arena_size);
// model_data_ is a member of this class, so it is destroyed before ~StreamingModel() runs. Unload here, while
// the buffer is still alive, so the interpreter is never torn down over freed model data.
~WakeWordModel() override { this->unload_model(); }
void log_model_config() override;
/// @brief Checks for the wake word by comparing the mean probability in the sliding window with the probability
@@ -132,6 +158,10 @@ class WakeWordModel final : public StreamingModel {
bool get_internal_only() { return this->internal_only_; }
protected:
// Kept for runtime-downloaded models so the model buffer stays alive for the model's lifetime.
// Null for compiled-in models (their data lives in flash).
std::shared_ptr<ModelData> model_data_;
std::string id_;
std::string wake_word_;
std::vector<std::string> trained_languages_;
@@ -0,0 +1,21 @@
# Config-only test: micro_wake_word without any compiled-in models. Covers the optional models
# schema, which validates without a model list. Wake word models are added at runtime instead,
# which voice_assistant wires up.
substitutions:
mic_din_pin: GPIO36
packages:
i2s_audio: !include ../../test_build_components/common/i2s_audio/esp32-idf.yaml
microphone:
- platform: i2s_audio
id: echo_microphone
i2s_audio_id: i2s_audio_bus
i2s_din_pin: ${mic_din_pin}
adc_type: external
pdm: true
bits_per_sample: 16bit
micro_wake_word:
microphone: echo_microphone
# models is omitted entirely, so the default empty list applies