ESPHome 2026.10.2
Loading...
Searching...
No Matches
speaker_media_player.cpp
Go to the documentation of this file.
2
3#ifdef USE_ESP32
4
5#include "esphome/core/log.h"
6
8#ifdef USE_OTA
10#endif
11
12namespace esphome::speaker {
13
14// Framework:
15// - Media player that can handle two streams: one for media and one for announcements
16// - Each stream has an individual speaker component for output
17// - Each stream is handled by an ``AudioPipeline`` object with two parts/tasks
18// - ``AudioReader`` handles reading from an HTTP source or from a PROGMEM flash set at compile time
19// - ``AudioDecoder`` handles decoding the audio file. All formats are limited to two channels and 16 bits per
20// sample.
21// Each format is enabled independently at compile time:
22// - FLAC
23// - MP3 (based on the libhelix decoder)
24// - Ogg Opus
25// - WAV
26// - Each task runs until it is done processing the file or it receives a stop command
27// - Inter-task communication uses a FreeRTOS Event Group
28// - The ``AudioPipeline`` sets up a ring buffer between the reader and decoder tasks. The decoder task outputs audio
29// directly to a speaker component.
30// - The pipelines internal state needs to be processed by regularly calling ``process_state``.
31// - Generic media player commands are received by the ``control`` function. The commands are added to the
32// ``media_control_command_queue_`` to be processed in the component's loop
33// - Local file play back is initiatied with ``play_file`` and adds it to the ``media_control_command_queue_``
34// - Starting a stream intializes the appropriate pipeline or stops it if it is already running
35// - Volume and mute commands are achieved by the ``mute``, ``unmute``, ``set_volume`` functions.
36// - Volume commands are ignored if the media control queue is full to avoid crashing with rapid volume
37// increases/decreases.
38// - These functions all send the appropriate information to the speakers to implement.
39// - Pausing is implemented in the decoder task and is also sent directly to the media speaker component to decrease
40// latency.
41// - The components main loop performs housekeeping:
42// - It reads the media control queue and processes it directly
43// - It determines the overall state of the media player by considering the state of each pipeline
44// - announcement playback takes highest priority
45// - Handles playlists and repeating by starting the appropriate file when a previous file is finished
46// - Logging only happens in the main loop task to reduce task stack memory usage.
47
48static const uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20;
49
50static const UBaseType_t MEDIA_PIPELINE_TASK_PRIORITY = 1;
51static const UBaseType_t ANNOUNCEMENT_PIPELINE_TASK_PRIORITY = 1;
52
53static const char *const TAG = "speaker_media_player";
54
56#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF
58#else
60#endif
61
62 this->media_control_command_queue_ = xQueueCreate(MEDIA_CONTROLS_QUEUE_LENGTH, sizeof(MediaCallCommand));
63
65
66 VolumeRestoreState volume_restore_state;
67 if (this->pref_.load(&volume_restore_state)) {
68 this->set_volume_(volume_restore_state.volume);
69 this->set_mute_state_(volume_restore_state.is_muted);
70 } else {
71 this->set_volume_(this->volume_initial_);
72 this->set_mute_state_(false);
73 }
74
75#ifdef USE_OTA_STATE_LISTENER
77#endif
78
80 make_unique<AudioPipeline>(this->announcement_speaker_, this->buffer_size_, this->task_stack_in_psram_, "ann",
81 ANNOUNCEMENT_PIPELINE_TASK_PRIORITY);
82
83 if (this->announcement_pipeline_ == nullptr) {
84 ESP_LOGE(TAG, "Failed to create announcement pipeline");
85 this->mark_failed();
86 }
87
88 if (!this->single_pipeline_()) {
89 this->media_pipeline_ = make_unique<AudioPipeline>(this->media_speaker_, this->buffer_size_,
90 this->task_stack_in_psram_, "med", MEDIA_PIPELINE_TASK_PRIORITY);
91
92 if (this->media_pipeline_ == nullptr) {
93 ESP_LOGE(TAG, "Failed to create media pipeline");
94 this->mark_failed();
95 }
96 }
97
98 ESP_LOGI(TAG, "Set up speaker media player");
99}
100
102 switch (pipeline_type) {
104 this->announcement_playlist_delay_ms_ = delay_ms;
105 break;
107 this->media_playlist_delay_ms_ = delay_ms;
108 break;
109 }
110}
111
113 this->media_pipeline_->stop();
114 this->unpause_media_remaining_ = 3;
115 this->set_interval("unpause_med", 50, [this]() {
117 this->cancel_interval("unpause_med");
118 this->media_pipeline_->set_pause_state(false);
119 this->is_paused_ = false;
120 } else if (--this->unpause_media_remaining_ == 0) {
121 this->cancel_interval("unpause_med");
122 }
123 });
124}
125
127 if (!this->is_ready()) {
128 return;
129 }
130
131 MediaCallCommand media_command;
132
133 if (xQueueReceive(this->media_control_command_queue_, &media_command, 0) == pdTRUE) {
134 bool enqueue = media_command.enqueue.has_value() && media_command.enqueue.value();
135
136 if (media_command.url.has_value() || media_command.file.has_value()) {
137#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF
141 }
142#endif
143 PlaylistItem playlist_item;
144 if (media_command.url.has_value()) {
145 playlist_item.url = *media_command.url.value();
146 delete media_command.url.value();
147 }
148 if (media_command.file.has_value()) {
149 playlist_item.file = media_command.file;
150 }
151
152 if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) {
153 if (!enqueue) {
154 // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause
155 this->cancel_timeout("next_ann");
156 this->announcement_playlist_.clear();
157 this->announcement_item_failed_ = false;
158 if (media_command.file.has_value()) {
159 this->announcement_pipeline_->start_file(playlist_item.file.value());
160 } else if (media_command.url.has_value()) {
161 this->announcement_pipeline_->start_url(playlist_item.url.value());
162 }
163 this->announcement_pipeline_->set_pause_state(false);
164 }
165 this->announcement_playlist_.push_back(playlist_item);
166 } else {
167 if (!enqueue) {
168 // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause
169 this->cancel_timeout("next_media");
170 this->media_playlist_.clear();
171 this->media_item_failed_ = false;
172 if (this->is_paused_) {
173 // If paused, stop the media pipeline and unpause it after confirming its stopped. This avoids playing a
174 // short segment of the paused file before starting the new one.
176 } else {
177 // Not paused, just directly start the file
178 if (media_command.file.has_value()) {
179 this->media_pipeline_->start_file(playlist_item.file.value());
180 } else if (media_command.url.has_value()) {
181 this->media_pipeline_->start_url(playlist_item.url.value());
182 }
183 this->media_pipeline_->set_pause_state(false);
184 this->is_paused_ = false;
185 }
186 }
187 this->media_playlist_.push_back(playlist_item);
188 }
189
190 return; // Don't process the new file play command further
191 }
192
193 if (media_command.volume.has_value()) {
194 this->set_volume_(media_command.volume.value());
195 this->publish_state();
196 }
197
198 if (media_command.command.has_value()) {
199 switch (media_command.command.value()) {
201#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF
205 }
206#endif
207 if ((this->media_pipeline_ != nullptr) && (this->is_paused_)) {
208 this->media_pipeline_->set_pause_state(false);
209 }
210 this->is_paused_ = false;
211 break;
213 if ((this->media_pipeline_ != nullptr) && (!this->is_paused_)) {
214 this->media_pipeline_->set_pause_state(true);
215 }
216 this->is_paused_ = true;
217 break;
218#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF
222 this->publish_state();
223 }
224 break;
226 this->is_turn_off_ = true;
227 [[fallthrough]];
228#endif
230 // Pipelines do not stop immediately after calling the stop command, so confirm its stopped before unpausing.
231 // This avoids an audible short segment playing after receiving the stop command in a paused state.
232#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF
233 if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value()) ||
234 (this->is_turn_off_ && this->announcement_pipeline_state_ != AudioPipelineState::STOPPED)) {
235#else
236 if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) {
237#endif
238 if (this->announcement_pipeline_ != nullptr) {
239 this->cancel_timeout("next_ann");
240 this->announcement_playlist_.clear();
241 this->announcement_item_failed_ = false;
242 this->announcement_pipeline_->stop();
244 this->set_interval("unpause_ann", 50, [this]() {
246 this->cancel_interval("unpause_ann");
247 this->announcement_pipeline_->set_pause_state(false);
248 } else if (--this->unpause_announcement_remaining_ == 0) {
249 this->cancel_interval("unpause_ann");
250 }
251 });
252 }
253 } else {
254 if (this->media_pipeline_ != nullptr) {
255 this->cancel_timeout("next_media");
256 this->media_playlist_.clear();
257 this->media_item_failed_ = false;
259 }
260 }
261
262 break;
264 if (this->media_pipeline_ != nullptr) {
265 if (this->is_paused_) {
266 this->media_pipeline_->set_pause_state(false);
267 this->is_paused_ = false;
268 } else {
269 this->media_pipeline_->set_pause_state(true);
270 this->is_paused_ = true;
271 }
272 }
273 break;
275 this->set_mute_state_(true);
276
277 this->publish_state();
278 break;
279 }
281 this->set_mute_state_(false);
282 this->publish_state();
283 break;
285 this->set_volume_(std::min(1.0f, this->volume + this->volume_increment_));
286 this->publish_state();
287 break;
289 this->set_volume_(std::max(0.0f, this->volume - this->volume_increment_));
290 this->publish_state();
291 break;
293 if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) {
294 this->announcement_repeat_one_ = true;
295 } else {
296 this->media_repeat_one_ = true;
297 }
298 break;
300 if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) {
301 this->announcement_repeat_one_ = false;
302 } else {
303 this->media_repeat_one_ = false;
304 }
305 break;
307 if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) {
308 if (this->announcement_playlist_.empty()) {
309 this->announcement_playlist_.resize(1);
310 }
311 } else {
312 if (this->media_playlist_.empty()) {
313 this->media_playlist_.resize(1);
314 }
315 }
316 break;
317 default:
318 break;
319 }
320 }
321 }
322}
323
324#ifdef USE_OTA_STATE_LISTENER
325void SpeakerMediaPlayer::on_ota_global_state(ota::OTAState state, float progress, uint8_t error,
326 ota::OTAComponent *comp) {
327 if (state == ota::OTA_STARTED) {
328 if (this->media_pipeline_ != nullptr) {
329 this->media_pipeline_->suspend_tasks();
330 }
331 if (this->announcement_pipeline_ != nullptr) {
332 this->announcement_pipeline_->suspend_tasks();
333 }
334 } else if (state == ota::OTA_ERROR) {
335 if (this->media_pipeline_ != nullptr) {
336 this->media_pipeline_->resume_tasks();
337 }
338 if (this->announcement_pipeline_ != nullptr) {
339 this->announcement_pipeline_->resume_tasks();
340 }
341 }
342}
343#endif
344
346 this->watch_media_commands_();
347
348 // Determine state of the media player
349 media_player::MediaPlayerState old_state = this->state;
350
351 AudioPipelineState old_media_pipeline_state = this->media_pipeline_state_;
352 if (this->media_pipeline_ != nullptr) {
353 this->media_pipeline_state_ = this->media_pipeline_->process_state();
354 }
355
357 ESP_LOGE(TAG, "The media pipeline's file reader encountered an error.");
358 this->media_item_failed_ = true;
360 ESP_LOGE(TAG, "The media pipeline's audio decoder encountered an error.");
361 this->media_item_failed_ = true;
362 }
363
364 AudioPipelineState old_announcement_pipeline_state = this->announcement_pipeline_state_;
365 if (this->announcement_pipeline_ != nullptr) {
366 this->announcement_pipeline_state_ = this->announcement_pipeline_->process_state();
367 }
368
370 ESP_LOGE(TAG, "The announcement pipeline's file reader encountered an error.");
371 this->announcement_item_failed_ = true;
373 ESP_LOGE(TAG, "The announcement pipeline's audio decoder encountered an error.");
374 this->announcement_item_failed_ = true;
375 }
376
379 } else {
380 if (!this->announcement_playlist_.empty()) {
381 uint32_t timeout_ms = 0;
382 if (this->announcement_item_failed_) {
383 // Drop the item that failed, even with repeat enabled; otherwise it is restarted as soon as the pipeline
384 // stops, which after an error is usually on the next loop
385 this->announcement_item_failed_ = false;
386 this->announcement_playlist_.pop_front();
387 } else if (old_announcement_pipeline_state == AudioPipelineState::PLAYING) {
388 // Finished the current announcement file
389 if (!this->announcement_repeat_one_) {
390 // Pop item off the playlist if repeat is disabled
391 this->announcement_playlist_.pop_front();
392 }
393 // Only delay starting playback if moving on the next playlist item or repeating the current item
394 timeout_ms = this->announcement_playlist_delay_ms_;
395 }
396
397 if (!this->announcement_playlist_.empty()) {
398 // Start the next announcement file
399 PlaylistItem playlist_item = this->announcement_playlist_.front();
400 if (playlist_item.url.has_value()) {
401 this->announcement_pipeline_->start_url(playlist_item.url.value());
402 } else if (playlist_item.file.has_value()) {
403 this->announcement_pipeline_->start_file(playlist_item.file.value());
404 }
405
406 if (timeout_ms > 0) {
407 // Pause pipeline internally to facilitate the delay between items
408 this->announcement_pipeline_->set_pause_state(true);
409 // Internally unpause the pipeline after the delay between playlist items. Announcements do not follow the
410 // media player's pause state.
411 this->set_timeout("next_ann", timeout_ms, [this]() { this->announcement_pipeline_->set_pause_state(false); });
412 }
413 }
414 } else {
415 // Nothing left to retry
416 this->announcement_item_failed_ = false;
417 if (this->is_paused_) {
418#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF
419 if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) {
421 }
422#else
424#endif
428 if (!media_playlist_.empty()) {
429 uint32_t timeout_ms = 0;
430 if (this->media_item_failed_) {
431 // Drop the item that failed, even with repeat enabled; otherwise it is restarted as soon as the pipeline
432 // stops. The flag also covers an error that happened while an announcement was playing.
433 this->media_item_failed_ = false;
434 this->media_playlist_.pop_front();
435 } else if (old_media_pipeline_state == AudioPipelineState::PLAYING) {
436 // Finished the current media file
437 if (!this->media_repeat_one_) {
438 // Pop item off the playlist if repeat is disabled
439 this->media_playlist_.pop_front();
440 }
441 // Only delay starting playback if moving on the next playlist item or repeating the current item
442 timeout_ms = this->media_playlist_delay_ms_;
443 }
444 if (!this->media_playlist_.empty()) {
445 PlaylistItem playlist_item = this->media_playlist_.front();
446 if (playlist_item.url.has_value()) {
447 this->media_pipeline_->start_url(playlist_item.url.value());
448 } else if (playlist_item.file.has_value()) {
449 this->media_pipeline_->start_file(playlist_item.file.value());
450 }
451
452 if (timeout_ms > 0) {
453 // Pause pipeline internally to facilitate the delay between items
454 this->media_pipeline_->set_pause_state(true);
455 // Internally unpause the pipeline after the delay between playlist items, if the media player state is
456 // not paused.
457 this->set_timeout("next_media", timeout_ms,
458 [this]() { this->media_pipeline_->set_pause_state(this->is_paused_); });
459 }
460 }
461 } else {
462 // Nothing left to retry
463 this->media_item_failed_ = false;
464#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF
465 if (this->state != media_player::MEDIA_PLAYER_STATE_OFF) {
467 }
468#else
470#endif
471 }
472 }
473 }
474 }
475
476 if (this->state != old_state) {
477 this->publish_state();
478 ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state));
479 }
480#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF
481 if (this->is_turn_off_ && (this->state == media_player::MEDIA_PLAYER_STATE_PAUSED ||
483 this->is_turn_off_ = false;
484 if (this->state == media_player::MEDIA_PLAYER_STATE_PAUSED) {
486 this->publish_state();
487 ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state));
488 }
490 this->publish_state();
491 ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state));
492 }
493#endif
494}
495
496void SpeakerMediaPlayer::play_file(const audio::AudioFile *media_file, bool announcement, bool enqueue) {
497 if (!this->is_ready()) {
498 // Ignore any commands sent before the media player is setup
499 return;
500 }
501
502 MediaCallCommand media_command;
503
504 media_command.file = media_file;
505 if (this->single_pipeline_() || announcement) {
506 media_command.announce = true;
507 } else {
508 media_command.announce = false;
509 }
510 media_command.enqueue = enqueue;
511 xQueueSend(this->media_control_command_queue_, &media_command, portMAX_DELAY);
512}
513
514void SpeakerMediaPlayer::control(const media_player::MediaPlayerCall &call) {
515 if (!this->is_ready()) {
516 // Ignore any commands sent before the media player is setup
517 return;
518 }
519
520 MediaCallCommand media_command;
521
522 auto ann = call.get_announcement();
523 if (this->single_pipeline_() || (ann.has_value() && *ann)) {
524 media_command.announce = true;
525 } else {
526 media_command.announce = false;
527 }
528
529 const auto &media_url = call.get_media_url();
530 if (media_url.has_value()) {
531 media_command.url =
532 new std::string(*media_url); // Must be manually deleted after receiving media_command from a queue
533
534 auto cmd = call.get_command();
535 if (cmd.has_value()) {
537 media_command.enqueue = true;
538 }
539 }
540
541 xQueueSend(this->media_control_command_queue_, &media_command, portMAX_DELAY);
542 return;
543 }
544
545 auto vol = call.get_volume();
546 if (vol.has_value()) {
547 media_command.volume = vol;
548 // Wait 0 ticks for queue to be free, volume sets aren't that important!
549 xQueueSend(this->media_control_command_queue_, &media_command, 0);
550 return;
551 }
552
553 auto cmd = call.get_command();
554 if (cmd.has_value()) {
555 media_command.command = cmd;
556 TickType_t ticks_to_wait = portMAX_DELAY;
559 ticks_to_wait = 0; // Wait 0 ticks for queue to be free, volume sets aren't that important!
560 }
561 xQueueSend(this->media_control_command_queue_, &media_command, ticks_to_wait);
562 return;
563 }
564}
565
566media_player::MediaPlayerTraits SpeakerMediaPlayer::get_traits() {
567 auto traits = media_player::MediaPlayerTraits();
568 if (!this->single_pipeline_()) {
569 traits.set_supports_pause(true);
570 }
571#ifdef USE_SPEAKER_MEDIA_PLAYER_ON_OFF
572 traits.set_supports_turn_off_on(true);
573#endif
574
575 if (this->announcement_format_.has_value()) {
576 traits.get_supported_formats().push_back(this->announcement_format_.value());
577 }
578 if (this->media_format_.has_value()) {
579 traits.get_supported_formats().push_back(this->media_format_.value());
580 } else if (this->single_pipeline_() && this->announcement_format_.has_value()) {
581 // Only one pipeline is defined, so use the announcement format (if configured) for the default purpose
582 media_player::MediaPlayerSupportedFormat media_format = this->announcement_format_.value();
584 traits.get_supported_formats().push_back(media_format);
585 }
586
587 return traits;
588};
589
591 VolumeRestoreState volume_restore_state;
592 volume_restore_state.volume = this->volume;
593 volume_restore_state.is_muted = this->is_muted_;
594 this->pref_.save(&volume_restore_state);
595}
596
597void SpeakerMediaPlayer::set_mute_state_(bool mute_state) {
598 if (this->media_speaker_ != nullptr) {
599 this->media_speaker_->set_mute_state(mute_state);
600 }
601 if (this->announcement_speaker_ != nullptr) {
602 this->announcement_speaker_->set_mute_state(mute_state);
603 }
604
605 bool old_mute_state = this->is_muted_;
606 this->is_muted_ = mute_state;
607
609
610 if (old_mute_state != mute_state) {
611 if (mute_state) {
612 this->defer([this]() { this->mute_trigger_.trigger(); });
613 } else {
614 this->defer([this]() { this->unmute_trigger_.trigger(); });
615 }
616 }
617}
618
619void SpeakerMediaPlayer::set_volume_(float volume, bool publish) {
620 // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so
621 // the speaker silences it, otherwise volume_min would make it audible.
622 float bounded_volume = (volume < SILENT_VOLUME_THRESHOLD)
623 ? 0.0f
624 : remap<float, float>(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_);
625
626 if (this->media_speaker_ != nullptr) {
627 this->media_speaker_->set_volume(bounded_volume);
628 }
629
630 if (this->announcement_speaker_ != nullptr) {
631 this->announcement_speaker_->set_volume(bounded_volume);
632 }
633
634 if (publish) {
635 this->volume = volume;
637 }
638
639 this->defer([this, volume]() { this->volume_trigger_.trigger(volume); });
640}
641
642} // namespace esphome::speaker
643
644#endif
void mark_failed()
Mark this component as failed.
bool cancel_interval(const char *name)
Cancel an interval function.
Definition component.cpp:92
bool cancel_timeout(const char *name)
Cancel a timeout function.
bool is_ready() const
void set_timeout(const char *name, uint32_t timeout, std::function< void()> &&f)
Set a timeout function with a const char* name.
Definition component.cpp:96
void defer(const char *name, std::function< void()> &&f)
Defer a callback to the next loop() call with a const char* name.
void set_interval(const char *name, uint32_t interval, std::function< void()> &&f)
Set an interval function with a const char* name.
Definition component.cpp:88
ESPPreferenceObject make_entity_preference(uint32_t version=0)
Create a preference object for storing this entity's state/settings.
void trigger(const Ts &...x) ESPHOME_ALWAYS_INLINE
Inform the parent automation that the event has triggered.
Definition automation.h:528
void add_global_state_listener(OTAGlobalStateListener *listener)
virtual void set_volume(float volume)
Definition speaker.h:74
virtual void set_mute_state(bool mute_state)
Definition speaker.h:85
optional< media_player::MediaPlayerSupportedFormat > announcement_format_
void save_volume_restore_state_()
Saves the current volume and mute state to the flash for restoration.
std::deque< PlaylistItem > announcement_playlist_
void set_volume_(float volume, bool publish=true)
Updates this->volume and saves volume/mute state to flash for restortation if publish is true.
void play_file(const audio::AudioFile *media_file, bool announcement, bool enqueue)
void set_mute_state_(bool mute_state)
Sets the mute state.
std::deque< PlaylistItem > media_playlist_
std::unique_ptr< AudioPipeline > media_pipeline_
void stop_and_unpause_media_()
Stops the media pipeline and polls until stopped to unpause it, avoiding an audible glitch.
optional< media_player::MediaPlayerSupportedFormat > media_format_
void control(const media_player::MediaPlayerCall &call) override
void set_playlist_delay_ms(AudioPipelineType pipeline_type, uint32_t delay_ms)
void on_ota_global_state(ota::OTAState state, float progress, uint8_t error, ota::OTAComponent *comp) override
std::unique_ptr< AudioPipeline > announcement_pipeline_
media_player::MediaPlayerTraits get_traits() override
bool single_pipeline_()
Returns true if the media player has only the announcement pipeline defined, false if both the announ...
FLAG_HAS_TRANSITION float
const char * media_player_state_to_string(MediaPlayerState state)
OTAGlobalCallback * get_global_ota_callback()
const char *const TAG
Definition spi.cpp:7
T remap(U value, U min, U max, T min_out, T max_out)
Remap value from the range (min, max) to (min_out, max_out).
Definition helpers.h:884
static void uint32_t