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