ESPHome 2026.11.0-dev
Loading...
Searching...
No Matches
streaming_model.cpp
Go to the documentation of this file.
1#include "streaming_model.h"
2
3#ifdef USE_ESP32
4
6#include "esphome/core/log.h"
7#include <cstring>
8#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM
9#include <esp_memory_utils.h>
10#endif
11
12ESPHOME_LOG_TAG(TAG, "micro_wake_word");
13
15
17 ESP_LOGCONFIG(TAG,
18 " - Wake Word: %s\n"
19 " Probability cutoff: %.2f\n"
20 " Sliding window size: %d",
21 this->wake_word_.c_str(), this->probability_cutoff_ / 255.0f, this->sliding_window_size_);
22}
23
25 ESP_LOGCONFIG(TAG,
26 " - VAD Model\n"
27 " Probability cutoff: %.2f\n"
28 " Sliding window size: %d",
29 this->probability_cutoff_ / 255.0f, this->sliding_window_size_);
30}
31
33 if (this->model_start_ == nullptr) {
34 ESP_LOGE(TAG, "Streaming model has no data to load");
35 return false;
36 }
37
38 RAMAllocator<uint8_t> arena_allocator;
39
40 if (this->var_arena_ == nullptr) {
41 this->var_arena_ = arena_allocator.allocate(STREAMING_MODEL_VARIABLE_ARENA_SIZE);
42 if (this->var_arena_ == nullptr) {
43 ESP_LOGE(TAG, "Could not allocate the streaming model's variable tensor arena.");
44 return false;
45 }
46 this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
47 this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
48 }
49
50 const tflite::Model *model = tflite::GetModel(this->model_start_);
51 if (model->version() != TFLITE_SCHEMA_VERSION) {
52 ESP_LOGE(TAG, "Streaming model's schema is not supported");
53 return false;
54 }
55
56 // Probe for the actual required tensor arena size if not yet determined
57 if (!this->tensor_arena_size_probed_) {
58 size_t probed_size = this->probe_arena_size_();
59 if (probed_size > 0) {
60 ESP_LOGD(TAG, "Probed tensor arena size: %zu bytes", probed_size);
61 this->tensor_arena_size_ = probed_size;
62 } else {
63 ESP_LOGW(TAG, "Arena size probe failed, using manifest size: %zu bytes", this->tensor_arena_size_);
64 }
65 this->tensor_arena_size_probed_ = true;
66 }
67
68 if (this->tensor_arena_ == nullptr) {
69#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM
71 this->tensor_arena_ = preferred_allocator.allocate(this->tensor_arena_size_);
72 if (this->tensor_arena_ != nullptr && !esp_ptr_internal(this->tensor_arena_)) {
73 ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for tensor arena; using default memory",
74 this->tensor_arena_size_);
75 }
76#else
77 this->tensor_arena_ = arena_allocator.allocate(this->tensor_arena_size_);
78#endif
79 if (this->tensor_arena_ == nullptr) {
80 ESP_LOGE(TAG, "Could not allocate the streaming model's tensor arena.");
81 return false;
82 }
83 }
84
85#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM
86 if (!esp_ptr_internal(this->model_start_) && this->model_copy_ == nullptr) {
88 this->model_copy_ = internal_allocator.make_unique_array_for_overwrite(this->model_size_);
89 if (this->model_copy_ == nullptr) {
90 ESP_LOGW(TAG, "Could not allocate %zu bytes of internal RAM for model; using original model data",
91 this->model_size_);
92 } else {
93 std::memcpy(this->model_copy_.get(), this->model_start_, this->model_size_);
94 }
95 }
96#endif
97
98 if (this->interpreter_ == nullptr) {
99 const uint8_t *model_data = this->model_start_;
100#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM
101 if (this->model_copy_ != nullptr)
102 model_data = this->model_copy_.get();
103#endif
104 this->interpreter_ =
105 make_unique<tflite::MicroInterpreter>(tflite::GetModel(model_data), this->streaming_op_resolver_,
106 this->tensor_arena_, this->tensor_arena_size_, this->mrv_);
107 if (this->interpreter_->AllocateTensors() != kTfLiteOk) {
108 ESP_LOGE(TAG, "Failed to allocate tensors for the streaming model");
109 return false;
110 }
111
112 // Verify input tensor matches expected values
113 // Dimension 3 will represent the first layer stride, so skip it may vary
114 TfLiteTensor *input = this->interpreter_->input(0);
115 if ((input->dims->size != 3) || (input->dims->data[0] != 1) ||
116 (input->dims->data[2] != PREPROCESSOR_FEATURE_SIZE)) {
117 ESP_LOGE(TAG, "Streaming model tensor input dimensions has improper dimensions.");
118 return false;
119 }
120
121 if (input->type != kTfLiteInt8) {
122 ESP_LOGE(TAG, "Streaming model tensor input is not int8.");
123 return false;
124 }
125
126 // Verify output tensor matches expected values
127 TfLiteTensor *output = this->interpreter_->output(0);
128 if ((output->dims->size != 2) || (output->dims->data[0] != 1) || (output->dims->data[1] != 1)) {
129 ESP_LOGE(TAG, "Streaming model tensor output dimension is not 1x1.");
130 return false;
131 }
132
133 if (output->type != kTfLiteUInt8) {
134 ESP_LOGE(TAG, "Streaming model tensor output is not uint8.");
135 return false;
136 }
137 }
138
139 this->loaded_ = true;
140 this->reset_probabilities();
141 return true;
142}
143
145 RAMAllocator<uint8_t> arena_allocator;
146
147 // Try with the manifest size first, then escalates to 1.5, then 2x if it fails. Different platforms and different
148 // versions of the esp-nn library require different amounts of memory, so the manifest size may not always be correct,
149 // and probing allows us to find the actual required size for the current build and platform. Aligns test sizes to 16
150 // bytes.
151 size_t attempt_sizes[] = {(this->tensor_arena_size_ + 15) & ~15, (this->tensor_arena_size_ * 3 / 2 + 15) & ~15,
152 (this->tensor_arena_size_ * 2 + 15) & ~15};
153
154 for (size_t attempt_size : attempt_sizes) {
155 uint8_t *probe_arena = arena_allocator.allocate(attempt_size);
156 if (probe_arena == nullptr) {
157 continue;
158 }
159
160 // Verify the model works at all with this arena size
161 auto probe_interpreter = make_unique<tflite::MicroInterpreter>(
162 tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, attempt_size, this->mrv_);
163
164 if (probe_interpreter->AllocateTensors() != kTfLiteOk) {
165 probe_interpreter.reset();
166 arena_allocator.deallocate(probe_arena, attempt_size);
167 this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
168 this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
169 continue;
170 }
171
172 // Try to shrink the arena. Start with arena_used_bytes() + 16 (rounded to 16-byte alignment).
173 // If that works, use it. Otherwise, try midpoints between that and the full size until one succeeds.
174 size_t lower = (probe_interpreter->arena_used_bytes() + 16 + 15) & ~15;
175 probe_interpreter.reset();
176 this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
177 this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
178
179 size_t upper = attempt_size;
180
181 while (lower < upper) {
182 auto test_interpreter = make_unique<tflite::MicroInterpreter>(
183 tflite::GetModel(this->model_start_), this->streaming_op_resolver_, probe_arena, lower, this->mrv_);
184
185 bool ok = test_interpreter->AllocateTensors() == kTfLiteOk;
186
187 test_interpreter.reset();
188 this->ma_ = tflite::MicroAllocator::Create(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
189 this->mrv_ = tflite::MicroResourceVariables::Create(this->ma_, 20);
190
191 if (ok) {
192 // Found a working size smaller than the full arena
193 upper = lower + 16; // Pad by 16 bytes to be safe for future allocations
194 break;
195 }
196
197 // Try the midpoint between current attempt and full size
198 lower = ((lower + upper) / 2 + 15) & ~15;
199 }
200
201 arena_allocator.deallocate(probe_arena, attempt_size);
202 return upper;
203 }
204
205 return 0;
206}
207
209 this->interpreter_.reset();
210#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM
211 this->model_copy_.reset();
212#endif
213
214 RAMAllocator<uint8_t> arena_allocator;
215
216 if (this->tensor_arena_ != nullptr) {
217 arena_allocator.deallocate(this->tensor_arena_, this->tensor_arena_size_);
218 this->tensor_arena_ = nullptr;
219 }
220
221 if (this->var_arena_ != nullptr) {
222 arena_allocator.deallocate(this->var_arena_, STREAMING_MODEL_VARIABLE_ARENA_SIZE);
223 this->var_arena_ = nullptr;
224 }
225
226 this->loaded_ = false;
227}
228
229bool StreamingModel::perform_streaming_inference(const int8_t features[PREPROCESSOR_FEATURE_SIZE]) {
230 if (this->model_start_ == nullptr) {
231 // No usable model data, and that cannot change for this object. Skip the model instead of reporting a
232 // failure, because a false return here stops the inference task for every other model too.
233 this->enabled_ = false;
234 return true;
235 }
236
237 if (this->enabled_ && !this->loaded_) {
238 // Model is enabled but isn't loaded
239 if (!this->load_model_()) {
240 return false;
241 }
242 }
243
244 if (!this->enabled_ && this->loaded_) {
245 // Model is disabled but still loaded
246 this->unload_model();
247 return true;
248 }
249
250 if (this->loaded_) {
251 TfLiteTensor *input = this->interpreter_->input(0);
252
253 uint8_t stride = this->interpreter_->input(0)->dims->data[1];
254 this->current_stride_step_ = this->current_stride_step_ % stride;
255
256 std::memmove(
257 (int8_t *) (tflite::GetTensorData<int8_t>(input)) + PREPROCESSOR_FEATURE_SIZE * this->current_stride_step_,
258 features, PREPROCESSOR_FEATURE_SIZE);
259 ++this->current_stride_step_;
260
261 if (this->current_stride_step_ >= stride) {
262 TfLiteStatus invoke_status = this->interpreter_->Invoke();
263 if (invoke_status != kTfLiteOk) {
264 ESP_LOGW(TAG, "Streaming interpreter invoke failed");
265 return false;
266 }
267
268 TfLiteTensor *output = this->interpreter_->output(0);
269
270 ++this->last_n_index_;
271 if (this->last_n_index_ == this->sliding_window_size_)
272 this->last_n_index_ = 0;
273 this->recent_streaming_probabilities_[this->last_n_index_] = output->data.uint8[0]; // probability;
275 }
277 // Only increment ignore windows if less than the probability cutoff; this forces the model to "cool-off" from a
278 // previous detection and calling ``reset_probabilities`` so it avoids duplicate detections
279 this->ignore_windows_ = std::min(this->ignore_windows_ + 1, 0);
280 }
281 }
282 return true;
283}
284
286 for (auto &prob : this->recent_streaming_probabilities_) {
287 prob = 0;
288 }
289 this->ignore_windows_ = -MIN_SLICES_BEFORE_DETECTION;
290}
291
292WakeWordModel::WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size,
293 uint8_t default_probability_cutoff, size_t sliding_window_average_size,
294 const std::string &wake_word, size_t tensor_arena_size, bool default_enabled,
295 bool internal_only) {
296 this->id_ = id;
297 this->model_start_ = model_start;
298#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM
299 this->model_size_ = model_size;
300#endif
301 this->default_probability_cutoff_ = default_probability_cutoff;
302 this->probability_cutoff_ = default_probability_cutoff;
303 this->sliding_window_size_ = sliding_window_average_size;
304 this->recent_streaming_probabilities_.resize(sliding_window_average_size, 0);
305 this->wake_word_ = wake_word;
306 this->tensor_arena_size_ = tensor_arena_size;
308 this->current_stride_step_ = 0;
309 this->internal_only_ = internal_only;
310
312 bool enabled;
313 if (this->pref_.load(&enabled)) {
314 // Use the enabled state loaded from flash
315 this->enabled_ = enabled;
316 } else {
317 // If no state saved, then use the default
318 this->enabled_ = default_enabled;
319 }
320};
321
322WakeWordModel::WakeWordModel(const std::string &id, std::shared_ptr<ModelData> model_data,
323 uint8_t default_probability_cutoff, size_t sliding_window_average_size,
324 const std::string &wake_word, std::vector<std::string> trained_languages,
325 size_t tensor_arena_size) {
326 this->id_ = id;
327 this->model_data_ = std::move(model_data);
328 // Callers are expected to pass a validated buffer, so this is normally the stable model pointer. Tolerate a
329 // null or unvalidated handle rather than dereferencing it blindly: model_start_ stays null and the model is
330 // never loaded.
331 this->model_start_ = this->model_data_ ? this->model_data_->get_model_pointer() : nullptr;
332#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM
333 this->model_size_ = this->model_data_ ? this->model_data_->size() : 0;
334#endif
335 if (this->model_start_ == nullptr) {
336 ESP_LOGE(TAG, "Model '%s' has no valid data and will not be loaded", id.c_str());
337 }
338 this->default_probability_cutoff_ = default_probability_cutoff;
339 this->probability_cutoff_ = default_probability_cutoff;
340 this->sliding_window_size_ = sliding_window_average_size;
341 this->recent_streaming_probabilities_.resize(sliding_window_average_size, 0);
342 this->wake_word_ = wake_word;
343 this->trained_languages_ = std::move(trained_languages);
344 this->tensor_arena_size_ = tensor_arena_size;
346 this->current_stride_step_ = 0;
347 this->internal_only_ = false; // Runtime models are always exposed to Home Assistant
348
350 bool enabled;
351 if (this->pref_.load(&enabled)) {
352 // Use the enabled state loaded from flash
353 this->enabled_ = enabled;
354 } else {
355 // No saved state: stay disabled. The activation flow calls enable() explicitly after adding.
356 this->enabled_ = false;
357 }
358};
359
361 this->enabled_ = true;
362 if (!this->internal_only_) {
363 this->pref_.save(&this->enabled_);
364 }
365}
366
368 this->enabled_ = false;
369 if (!this->internal_only_) {
370 this->pref_.save(&this->enabled_);
371 }
372}
373
375 DetectionEvent detection_event;
376 detection_event.wake_word = &this->wake_word_;
377 detection_event.max_probability = 0;
378 detection_event.average_probability = 0;
379
380 if ((this->ignore_windows_ < 0) || !this->enabled_) {
381 detection_event.detected = false;
382 return detection_event;
383 }
384
385 uint32_t sum = 0;
386 for (auto &prob : this->recent_streaming_probabilities_) {
387 detection_event.max_probability = std::max(detection_event.max_probability, prob);
388 sum += prob;
389 }
390
391 detection_event.average_probability = sum / this->sliding_window_size_;
392 detection_event.detected = sum > this->probability_cutoff_ * this->sliding_window_size_;
393
395 return detection_event;
396}
397
398VADModel::VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff,
399 size_t sliding_window_size, size_t tensor_arena_size) {
400 this->model_start_ = model_start;
401#ifdef USE_MICRO_WAKE_WORD_INTERNAL_RAM
402 this->model_size_ = model_size;
403#endif
404 this->default_probability_cutoff_ = default_probability_cutoff;
405 this->probability_cutoff_ = default_probability_cutoff;
406 this->sliding_window_size_ = sliding_window_size;
407 this->recent_streaming_probabilities_.resize(sliding_window_size, 0);
408 this->tensor_arena_size_ = tensor_arena_size;
410}
411
413 DetectionEvent detection_event;
414 detection_event.max_probability = 0;
415 detection_event.average_probability = 0;
416
417 if (!this->enabled_) {
418 // We disabled the VAD model for some reason... so we shouldn't block wake words from being detected
419 detection_event.detected = true;
420 return detection_event;
421 }
422
423 uint32_t sum = 0;
424 for (auto &prob : this->recent_streaming_probabilities_) {
425 detection_event.max_probability = std::max(detection_event.max_probability, prob);
426 sum += prob;
427 }
428
429 detection_event.average_probability = sum / this->sliding_window_size_;
430 detection_event.detected = sum > (this->probability_cutoff_ * this->sliding_window_size_);
431
432 return detection_event;
433}
434
435bool StreamingModel::register_streaming_ops_(tflite::MicroMutableOpResolver<20> &op_resolver) {
436 if (op_resolver.AddCallOnce() != kTfLiteOk)
437 return false;
438 if (op_resolver.AddVarHandle() != kTfLiteOk)
439 return false;
440 if (op_resolver.AddReshape() != kTfLiteOk)
441 return false;
442 if (op_resolver.AddReadVariable() != kTfLiteOk)
443 return false;
444 if (op_resolver.AddStridedSlice() != kTfLiteOk)
445 return false;
446 if (op_resolver.AddConcatenation() != kTfLiteOk)
447 return false;
448 if (op_resolver.AddAssignVariable() != kTfLiteOk)
449 return false;
450 if (op_resolver.AddConv2D() != kTfLiteOk)
451 return false;
452 if (op_resolver.AddMul() != kTfLiteOk)
453 return false;
454 if (op_resolver.AddAdd() != kTfLiteOk)
455 return false;
456 if (op_resolver.AddMean() != kTfLiteOk)
457 return false;
458 if (op_resolver.AddFullyConnected() != kTfLiteOk)
459 return false;
460 if (op_resolver.AddLogistic() != kTfLiteOk)
461 return false;
462 if (op_resolver.AddQuantize() != kTfLiteOk)
463 return false;
464 if (op_resolver.AddDepthwiseConv2D() != kTfLiteOk)
465 return false;
466 if (op_resolver.AddAveragePool2D() != kTfLiteOk)
467 return false;
468 if (op_resolver.AddMaxPool2D() != kTfLiteOk)
469 return false;
470 if (op_resolver.AddPad() != kTfLiteOk)
471 return false;
472 if (op_resolver.AddPack() != kTfLiteOk)
473 return false;
474 if (op_resolver.AddSplitV() != kTfLiteOk)
475 return false;
476
477 return true;
478}
479
480} // namespace esphome::micro_wake_word
481
482#endif
An STL allocator that uses SPI or internal RAM.
Definition helpers.h:2298
void deallocate(T *p, size_t n)
Definition helpers.h:2355
T * allocate(size_t n)
Definition helpers.h:2325
RAMUniquePtr< T[]> make_unique_array_for_overwrite(size_t n)
n elements left uninitialized, as std::make_unique_for_overwrite does; empty on exhaustion,...
Definition helpers.h:2371
bool load_model_()
Allocates tensor and variable arenas and sets up the model interpreter.
std::unique_ptr< tflite::MicroInterpreter > interpreter_
tflite::MicroMutableOpResolver< 20 > streaming_op_resolver_
bool register_streaming_ops_(tflite::MicroMutableOpResolver< 20 > &op_resolver)
Returns true if successfully registered the streaming model's TensorFlow operations.
void reset_probabilities()
Sets all recent_streaming_probabilities to 0 and resets the ignore window count.
std::vector< uint8_t > recent_streaming_probabilities_
size_t probe_arena_size_()
Probes the actual required tensor arena size by trial allocation.
tflite::MicroResourceVariables * mrv_
bool perform_streaming_inference(const int8_t features[PREPROCESSOR_FEATURE_SIZE])
void unload_model()
Destroys the TFLite interpreter and frees the tensor and variable arenas' memory.
DetectionEvent determine_detected() override
Checks for voice activity by comparing the max probability in the sliding window with the probability...
VADModel(const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, size_t sliding_window_size, size_t tensor_arena_size)
void enable() override
Enable the model and save to flash. The next performing_streaming_inference call will load it.
DetectionEvent determine_detected() override
Checks for the wake word by comparing the mean probability in the sliding window with the probability...
std::shared_ptr< ModelData > model_data_
WakeWordModel(const std::string &id, const uint8_t *model_start, size_t model_size, uint8_t default_probability_cutoff, size_t sliding_window_average_size, const std::string &wake_word, size_t tensor_arena_size, bool default_enabled, bool internal_only)
Constructs a wake word model object with compile-time model data.
void disable() override
Disable the model and save to flash. The next performing_streaming_inference call will unload it.
std::vector< std::string > trained_languages_
uint16_t id
ESPPreferences * global_preferences
uint32_t fnv1_hash(const char *str)
Calculate a FNV-1 hash of str.
Definition helpers.cpp:169
static void uint32_t
ESPHOME_LOG_TAG(TAG, "micro_wake_word")
ESPPreferenceObject make_preference(size_t, uint32_t, bool)
Definition preferences.h:24