From eb32f80d53c018218aa7962e832f5711727ab2d2 Mon Sep 17 00:00:00 2001 From: Remco van Essen Date: Wed, 21 Oct 2026 02:48:11 +0200 Subject: [PATCH] [speaker][speaker_source] Add a set volume for announcements that start while nothing is playing (#21002) --- esphome/components/const/__init__.py | 1 + .../speaker/announcement_volume.cpp | 13 ++ .../components/speaker/announcement_volume.h | 76 +++++++++ .../speaker/media_player/__init__.py | 17 ++ .../media_player/speaker_media_player.cpp | 41 ++++- .../media_player/speaker_media_player.h | 17 ++ .../components/speaker_source/media_player.py | 29 ++++ .../speaker_source_media_player.cpp | 60 +++++-- .../speaker_source_media_player.h | 20 ++- .../speaker_source/__init__.py | 0 .../speaker_source/test_media_player.py | 31 ++++ .../speaker/common-media_player.yaml | 7 + .../speaker/test_announcement_volume.cpp | 146 ++++++++++++++++++ tests/components/speaker_source/common.yaml | 4 + 14 files changed, 443 insertions(+), 19 deletions(-) create mode 100644 esphome/components/speaker/announcement_volume.cpp create mode 100644 esphome/components/speaker/announcement_volume.h create mode 100644 tests/component_tests/speaker_source/__init__.py create mode 100644 tests/component_tests/speaker_source/test_media_player.py create mode 100644 tests/components/speaker/test_announcement_volume.cpp diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 93730da285a..b5f2a563d08 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -11,6 +11,7 @@ CONF_ACCELEROMETER_RANGE = "accelerometer_range" CONF_ADC_AVERAGING = "adc_averaging" CONF_ADC_TIME = "adc_time" CONF_ALLOWED_IPS = "allowed_ips" +CONF_ANNOUNCEMENT_VOLUME = "announcement_volume" CONF_AQI = "aqi" CONF_AUTOMATIC_BASELINE_CALIBRATION = "automatic_baseline_calibration" CONF_B_CONSTANT = "b_constant" diff --git a/esphome/components/speaker/announcement_volume.cpp b/esphome/components/speaker/announcement_volume.cpp new file mode 100644 index 00000000000..76ec74c7c9f --- /dev/null +++ b/esphome/components/speaker/announcement_volume.cpp @@ -0,0 +1,13 @@ +#include "announcement_volume.h" + +#include "esphome/core/log.h" + +namespace esphome::speaker { + +ESPHOME_LOG_TAG(TAG, "announcement_volume"); + +void AnnouncementVolume::log_begin_() const { ESP_LOGD(TAG, "Using announcement volume %.2f", this->volume_); } + +void AnnouncementVolume::log_end_(float media_volume) const { ESP_LOGD(TAG, "Restoring volume %.2f", media_volume); } + +} // namespace esphome::speaker diff --git a/esphome/components/speaker/announcement_volume.h b/esphome/components/speaker/announcement_volume.h new file mode 100644 index 00000000000..c441e164e52 --- /dev/null +++ b/esphome/components/speaker/announcement_volume.h @@ -0,0 +1,76 @@ +#pragma once + +#include +#include + +namespace esphome::speaker { + +/// Tracks a separate volume for announcements that start while nothing else is playing. +/// The methods that change the volume take `apply`, called with the volume the speakers must switch to. +class AnnouncementVolume { + public: + /// Stores a new announcement volume and applies it right away if it is in use. + template void set_volume(float volume, F &&apply) { + this->volume_ = volume; + if (this->active_) { + std::forward(apply)(volume); + } + } + float get_volume() const { return this->volume_; } + bool is_configured() const { return !std::isnan(this->volume_); } + bool is_active() const { return this->active_; } + + /// Called when an announcement starts. Switches to the announcement volume unless other audio is playing. + /// @param other_audio_playing Media, or an announcement that does not use the announcement volume, is playing + template void begin(bool other_audio_playing, F &&apply) { + if (this->active_ || other_audio_playing || !this->is_configured()) { + return; + } + this->active_ = true; + this->log_begin_(); + std::forward(apply)(this->volume_); + } + + /// Switches back to the media player volume if the announcement volume is in use. + template void end(float media_volume, F &&apply) { + if (!this->active_) { + return; + } + this->active_ = false; + this->log_end_(media_volume); + std::forward(apply)(media_volume); + } + + /// The media player volume was set and already sent to the speakers; it applies until the next announcement. + void cancel() { this->active_ = false; } + + /// Called when an item starts on a playlist that holds announcements, and may hold media too. + /// Media switches back to the media volume. An announcement that follows media, or starts the playlist, switches to + /// the announcement volume unless other audio is playing. One that follows another announcement keeps the volume + /// that one used, including a volume change made while it played. + template + void item_started(bool is_announcement, bool other_audio_playing, float media_volume, F &&apply) { + if (!is_announcement) { + this->end(media_volume, apply); + } else if (!this->last_item_announcement_) { + this->begin(other_audio_playing, apply); + } + this->last_item_announcement_ = is_announcement; + } + + /// Called when the playlist that holds announcements has finished. Switches back to the media volume. + template void playlist_finished(float media_volume, F &&apply) { + this->last_item_announcement_ = false; + this->end(media_volume, std::forward(apply)); + } + + protected: + void log_begin_() const; + void log_end_(float media_volume) const; + + float volume_{NAN}; + bool active_{false}; + bool last_item_announcement_{false}; +}; + +} // namespace esphome::speaker diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index 83f406e9e65..abbb26445bd 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -12,6 +12,7 @@ from esphome.components import ( speaker, ) from esphome.components.const import ( + CONF_ANNOUNCEMENT_VOLUME, CONF_VOLUME_INCREMENT, CONF_VOLUME_INITIAL, CONF_VOLUME_MAX, @@ -28,6 +29,7 @@ from esphome.const import ( CONF_SAMPLE_RATE, CONF_SPEAKER, CONF_TASK_STACK_IN_PSRAM, + CONF_VOLUME, ) AUTO_LOAD = ["audio"] @@ -140,6 +142,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage, cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage, cv.Optional(CONF_VOLUME_MIN, default=0.0): cv.percentage, + cv.Optional(CONF_ANNOUNCEMENT_VOLUME): cv.percentage, cv.Optional(CONF_ON_MUTE): automation.validate_automation(single=True), cv.Optional(CONF_ON_UNMUTE): automation.validate_automation(single=True), cv.Optional(CONF_ON_VOLUME): automation.validate_automation(single=True), @@ -182,6 +185,7 @@ async def to_code(config): cg.add(var.set_volume_initial(config[CONF_VOLUME_INITIAL])) cg.add(var.set_volume_max(config[CONF_VOLUME_MAX])) cg.add(var.set_volume_min(config[CONF_VOLUME_MIN])) + cg.add_optional(var.set_announcement_volume, config.get(CONF_ANNOUNCEMENT_VOLUME)) announcement_pipeline_config = config[CONF_ANNOUNCEMENT_PIPELINE] await cg.add_variable( @@ -258,3 +262,16 @@ automation.register_apply_action( ), ), ) + + +automation.register_apply_action( + "media_player.speaker.set_announcement_volume", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(SpeakerMediaPlayer), + cv.Required(CONF_VOLUME): cv.templatable(cv.percentage), + }, + key=CONF_VOLUME, + ), + automation.ApplyField(CONF_VOLUME, "set_announcement_volume", cg.float_), +) diff --git a/esphome/components/speaker/media_player/speaker_media_player.cpp b/esphome/components/speaker/media_player/speaker_media_player.cpp index 8ab4128e927..281c0f558f8 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.cpp +++ b/esphome/components/speaker/media_player/speaker_media_player.cpp @@ -153,9 +153,15 @@ void SpeakerMediaPlayer::watch_media_commands_() { if (media_command.file.has_value()) { playlist_item.file = media_command.file; } + playlist_item.is_announcement = media_command.is_announcement; if (this->single_pipeline_() || (media_command.announce.has_value() && media_command.announce.value())) { if (!enqueue) { + // Enqueued items switch the volume in loop() when they start + this->announcement_volume_.item_started(media_command.is_announcement, + this->media_pipeline_state_ == AudioPipelineState::PLAYING || + this->announcement_pipeline_state_ != AudioPipelineState::STOPPED, + this->volume, this->speaker_volume_applier_()); // Ensure the loaded next item doesn't start playing, clear the queue, start the file, and unpause this->cancel_timeout(NEXT_ANNOUNCEMENT_TIMEOUT_ID); this->announcement_playlist_.clear(); @@ -402,6 +408,9 @@ void SpeakerMediaPlayer::loop() { if (!this->announcement_playlist_.empty()) { // Start the next announcement file PlaylistItem playlist_item = this->announcement_playlist_.front(); + this->announcement_volume_.item_started(playlist_item.is_announcement, + this->media_pipeline_state_ == AudioPipelineState::PLAYING, + this->volume, this->speaker_volume_applier_()); if (playlist_item.url.has_value()) { this->announcement_pipeline_->start_url(playlist_item.url.value()); } else if (playlist_item.file.has_value()) { @@ -479,6 +488,12 @@ void SpeakerMediaPlayer::loop() { } } + if (this->announcement_pipeline_state_ == AudioPipelineState::STOPPED && this->announcement_playlist_.empty()) { + this->announcement_volume_.playlist_finished(this->volume, this->speaker_volume_applier_()); + } else if (this->media_pipeline_state_ == AudioPipelineState::PLAYING) { + this->announcement_volume_.end(this->volume, this->speaker_volume_applier_()); + } + if (this->state != old_state) { this->publish_state(); ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); @@ -508,6 +523,7 @@ void SpeakerMediaPlayer::play_file(const audio::AudioFile *media_file, bool anno MediaCallCommand media_command; media_command.file = media_file; + media_command.is_announcement = announcement; if (this->single_pipeline_() || announcement) { media_command.announce = true; } else { @@ -526,7 +542,8 @@ void SpeakerMediaPlayer::control(const media_player::MediaPlayerCall &call) { MediaCallCommand media_command; auto ann = call.get_announcement(); - if (this->single_pipeline_() || (ann.has_value() && *ann)) { + media_command.is_announcement = ann.has_value() && *ann; + if (this->single_pipeline_() || media_command.is_announcement) { media_command.announce = true; } else { media_command.announce = false; @@ -623,6 +640,19 @@ void SpeakerMediaPlayer::set_mute_state_(bool mute_state) { } void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { + // A volume change made while an announcement uses the announcement volume applies right away + this->announcement_volume_.cancel(); + this->apply_speaker_volume_(volume); + + if (publish) { + this->volume = volume; + this->save_volume_restore_state_(); + } + + this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); +} + +void SpeakerMediaPlayer::apply_speaker_volume_(float volume) { // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so // the speaker silences it, otherwise volume_min would make it audible. float bounded_volume = (volume < SILENT_VOLUME_THRESHOLD) @@ -636,13 +666,10 @@ void SpeakerMediaPlayer::set_volume_(float volume, bool publish) { if (this->announcement_speaker_ != nullptr) { this->announcement_speaker_->set_volume(bounded_volume); } +} - if (publish) { - this->volume = volume; - this->save_volume_restore_state_(); - } - - this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); +void SpeakerMediaPlayer::set_announcement_volume(float announcement_volume) { + this->announcement_volume_.set_volume(announcement_volume, this->speaker_volume_applier_()); } } // namespace esphome::speaker diff --git a/esphome/components/speaker/media_player/speaker_media_player.h b/esphome/components/speaker/media_player/speaker_media_player.h index e98196785c8..bc14e21faed 100644 --- a/esphome/components/speaker/media_player/speaker_media_player.h +++ b/esphome/components/speaker/media_player/speaker_media_player.h @@ -6,6 +6,7 @@ #include "esphome/components/audio/audio.h" #include "esphome/components/media_player/media_player.h" +#include "esphome/components/speaker/announcement_volume.h" #include "esphome/components/speaker/speaker.h" #include "esphome/core/automation.h" @@ -27,6 +28,8 @@ struct MediaCallCommand { optional command; optional volume; optional announce; + // The caller asked for an announcement; `announce` is also forced on in single-pipeline mode + bool is_announcement{false}; optional url; // Must be manually deleted after receiving this struct from a queue optional file; optional enqueue; @@ -35,6 +38,7 @@ struct MediaCallCommand { struct PlaylistItem { optional url; optional file; + bool is_announcement{false}; }; struct VolumeRestoreState { @@ -74,6 +78,9 @@ class SpeakerMediaPlayer final : public Component, void set_volume_max(float volume_max) { this->volume_max_ = volume_max; } void set_volume_min(float volume_min) { this->volume_min_ = volume_min; } + /// @brief Sets the volume for announcements that start while nothing else is playing. + void set_announcement_volume(float announcement_volume); + void set_announcement_speaker(Speaker *announcement_speaker) { this->announcement_speaker_ = announcement_speaker; } void set_announcement_format(const media_player::MediaPlayerSupportedFormat &announcement_format) { this->announcement_format_ = announcement_format; @@ -99,6 +106,14 @@ class SpeakerMediaPlayer final : public Component, /// @brief Updates this->volume and saves volume/mute state to flash for restortation if publish is true. void set_volume_(float volume, bool publish = true); + /// @brief Remaps a volume to the configured limits and sends it to the speakers. + void apply_speaker_volume_(float volume); + + /// @brief Returns a callable that sends a volume to the speakers, for announcement_volume_ to apply its changes. + auto speaker_volume_applier_() { + return [this](float volume) { this->apply_speaker_volume_(volume); }; + } + /// @brief Sets the mute state. Restores previous volume if unmuting. Always saves volume/mute state to flash for /// restoration. /// @param mute_state If true, audio will be muted. If false, audio will be unmuted @@ -162,6 +177,8 @@ class SpeakerMediaPlayer final : public Component, float volume_max_; float volume_min_; + AnnouncementVolume announcement_volume_; + // Used to save volume/mute state for restoration on reboot ESPPreferenceObject pref_; diff --git a/esphome/components/speaker_source/media_player.py b/esphome/components/speaker_source/media_player.py index d69ee1881b8..589814df6c1 100644 --- a/esphome/components/speaker_source/media_player.py +++ b/esphome/components/speaker_source/media_player.py @@ -2,6 +2,7 @@ from esphome import automation import esphome.codegen as cg from esphome.components import audio, media_player, media_source, speaker from esphome.components.const import ( + CONF_ANNOUNCEMENT_VOLUME, CONF_SOURCES, CONF_VOLUME_INCREMENT, CONF_VOLUME_INITIAL, @@ -16,6 +17,7 @@ from esphome.const import ( CONF_NUM_CHANNELS, CONF_SAMPLE_RATE, CONF_SPEAKER, + CONF_VOLUME, ) from esphome.types import ConfigType @@ -123,6 +125,17 @@ def _validate_volume_settings(config: ConfigType) -> ConfigType: return config +def _validate_announcement_volume(config: ConfigType) -> ConfigType: + # With a single pipeline, announcements and media share one playlist, so there is no idle announcement to tell apart + if CONF_ANNOUNCEMENT_VOLUME in config and not ( + CONF_ANNOUNCEMENT_PIPELINE in config and CONF_MEDIA_PIPELINE in config + ): + raise cv.Invalid( + f"{CONF_ANNOUNCEMENT_VOLUME} requires both {CONF_ANNOUNCEMENT_PIPELINE} and {CONF_MEDIA_PIPELINE}" + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -130,6 +143,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage, cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage, cv.Optional(CONF_VOLUME_MIN, default=0.0): cv.percentage, + cv.Optional(CONF_ANNOUNCEMENT_VOLUME): cv.percentage, cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): PIPELINE_SCHEMA, cv.Optional(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA, cv.Optional(CONF_ON_MUTE): automation.validate_automation(single=True), @@ -143,6 +157,7 @@ CONFIG_SCHEMA = cv.All( cv.has_at_least_one_key(CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE), _validate_no_shared_resources, _validate_volume_settings, + _validate_announcement_volume, ) @@ -174,6 +189,7 @@ async def to_code(config: ConfigType) -> None: cg.add(var.set_volume_initial(config[CONF_VOLUME_INITIAL])) cg.add(var.set_volume_max(config[CONF_VOLUME_MAX])) cg.add(var.set_volume_min(config[CONF_VOLUME_MIN])) + cg.add_optional(var.set_announcement_volume, config.get(CONF_ANNOUNCEMENT_VOLUME)) for pipeline_key, (pipeline_enum, purpose) in _PIPELINE_INFO.items(): if pipeline_config := config.get(pipeline_key): @@ -234,3 +250,16 @@ automation.register_apply_action( ((CONF_PIPELINE, cg.uint8), (CONF_DELAY, cg.uint32)), ), ) + + +automation.register_apply_action( + "speaker_source.set_announcement_volume", + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(SpeakerSourceMediaPlayer), + cv.Required(CONF_VOLUME): cv.templatable(cv.percentage), + }, + key=CONF_VOLUME, + ), + automation.ApplyField(CONF_VOLUME, "set_announcement_volume", cg.float_), +) diff --git a/esphome/components/speaker_source/speaker_source_media_player.cpp b/esphome/components/speaker_source/speaker_source_media_player.cpp index 657c26af80a..7f4d1799217 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.cpp +++ b/esphome/components/speaker_source/speaker_source_media_player.cpp @@ -56,6 +56,9 @@ void SpeakerSourceMediaPlayer::dump_config() { " Volume Min: %.2f\n" " Volume Max: %.2f", this->volume_increment_, this->volume_min_, this->volume_max_); + if (this->announcement_volume_.is_configured()) { + ESP_LOGCONFIG(TAG, " Announcement Volume: %.2f", this->announcement_volume_.get_volume()); + } } void SpeakerSourceMediaPlayer::setup() { @@ -289,12 +292,29 @@ void SpeakerSourceMediaPlayer::loop() { this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state); } + // After the source goes idle the speaker still plays its buffered audio, so also wait until it has stopped + if (this->is_media_playing_() || + (ann_ps.is_configured() && ann_ps.active_source == nullptr && ann_ps.pending_source == nullptr && + !announcement_playlist_active && ann_ps.speaker->is_stopped())) { + this->announcement_volume_.end(this->volume, this->speaker_volume_applier_()); + } + if (this->state != old_state) { this->publish_state(); ESP_LOGD(TAG, "State changed to %s", media_player::media_player_state_to_string(this->state)); } } +bool SpeakerSourceMediaPlayer::is_media_playing_() const { + const PipelineContext &ps = this->pipelines_[MEDIA_PIPELINE]; + media_source::MediaSource *source = ps.active_source; + if (source != nullptr) { + return source->get_state() == media_source::MediaSourceState::PLAYING; + } + // After the source goes idle the speaker still plays its buffered audio + return ps.is_configured() && !ps.speaker->is_stopped(); +} + media_source::MediaSource *SpeakerSourceMediaPlayer::find_source_for_uri_(const std::string &uri, uint8_t pipeline) { PipelineContext &ps = this->pipelines_[pipeline]; media_source::MediaSource *first_match = nullptr; @@ -361,6 +381,13 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin return false; // Speaker not ready yet, retry later } + // Without a media pipeline, media also plays on the announcement pipeline and must keep the media volume + if (pipeline == ANNOUNCEMENT_PIPELINE && this->pipelines_[MEDIA_PIPELINE].is_configured()) { + this->announcement_volume_.begin( + this->state == media_player::MEDIA_PLAYER_STATE_ANNOUNCING || this->is_media_playing_(), + this->speaker_volume_applier_()); + } + // Set pending source so handle_media_state_changed_ can recognize it when the source transitions to PLAYING ps.pending_source = target_source; @@ -818,17 +845,9 @@ void SpeakerSourceMediaPlayer::set_mute_state_(bool mute_state, bool publish) { } void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { - // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so - // the speaker silences it, otherwise volume_min would make it audible. - float bounded_volume = (volume < speaker::SILENT_VOLUME_THRESHOLD) - ? 0.0f - : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); - - for (auto &ps : this->pipelines_) { - if (ps.is_configured()) { - ps.speaker->set_volume(bounded_volume); - } - } + // A volume change made while an announcement uses the announcement volume applies right away + this->announcement_volume_.cancel(); + this->apply_speaker_volume_(volume); if (publish) { this->volume = volume; @@ -848,6 +867,25 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) { this->defer([this, volume]() { this->volume_trigger_.trigger(volume); }); } +void SpeakerSourceMediaPlayer::apply_speaker_volume_(float volume) { + // Remap the volume to fit within the configured limits. An effectively zero volume is passed through as zero so + // the speaker silences it, otherwise volume_min would make it audible. + float bounded_volume = (volume < speaker::SILENT_VOLUME_THRESHOLD) + ? 0.0f + : remap(volume, 0.0f, 1.0f, this->volume_min_, this->volume_max_); + + for (auto &ps : this->pipelines_) { + if (ps.is_configured()) { + ps.speaker->set_volume(bounded_volume); + } + } +} + +// THREAD CONTEXT: Called from main loop via automation commands (direct) +void SpeakerSourceMediaPlayer::set_announcement_volume(float announcement_volume) { + this->announcement_volume_.set_volume(announcement_volume, this->speaker_volume_applier_()); +} + size_t SpeakerSourceMediaPlayer::get_playlist_position_(uint8_t pipeline) const { const PipelineContext &ps = this->pipelines_[pipeline]; diff --git a/esphome/components/speaker_source/speaker_source_media_player.h b/esphome/components/speaker_source/speaker_source_media_player.h index ab1f8edfed7..61c7d6bdef9 100644 --- a/esphome/components/speaker_source/speaker_source_media_player.h +++ b/esphome/components/speaker_source/speaker_source_media_player.h @@ -7,6 +7,7 @@ #include "esphome/components/audio/audio.h" #include "esphome/components/media_source/media_source.h" #include "esphome/components/media_player/media_player.h" +#include "esphome/components/speaker/announcement_volume.h" #include "esphome/components/speaker/speaker.h" #include "esphome/core/automation.h" @@ -28,7 +29,8 @@ namespace esphome::speaker_source { // // - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(), // handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(), -// set_volume_(), set_mute_state_(), control(), get_source_state_(), +// set_volume_(), apply_speaker_volume_(), set_announcement_volume(), is_media_playing_(), set_mute_state_(), +// control(), get_source_state_(), // find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(), // process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_() // @@ -168,6 +170,9 @@ class SpeakerSourceMediaPlayer final : public Component, public media_player::Me void set_volume_max(float volume_max) { this->volume_max_ = volume_max; } void set_volume_min(float volume_min) { this->volume_min_ = volume_min; } + /// @brief Sets the volume for announcements that start while nothing else is playing. + void set_announcement_volume(float announcement_volume); + /// @brief Adds a media source to a pipeline and registers this player as its listener void add_media_source(uint8_t pipeline, media_source::MediaSource *media_source); @@ -201,6 +206,17 @@ class SpeakerSourceMediaPlayer final : public Component, public media_player::Me /// @brief Updates this->volume and saves volume/mute state to flash for restoration if publish is true. void set_volume_(float volume, bool publish = true); + /// @brief Remaps a volume to the configured limits and sends it to the speakers. + void apply_speaker_volume_(float volume); + + /// @brief Returns a callable that sends a volume to the speakers, for announcement_volume_ to apply its changes. + auto speaker_volume_applier_() { + return [this](float volume) { this->apply_speaker_volume_(volume); }; + } + + /// @brief Whether media is audible: the active source plays, or the speaker plays what an idle source left. + bool is_media_playing_() const; + /// @brief Sets the mute state. /// @param mute_state If true, audio will be muted. If false, audio will be unmuted /// @param publish If true, saves volume/mute state to flash for restoration @@ -255,6 +271,8 @@ class SpeakerSourceMediaPlayer final : public Component, public media_player::Me float volume_max_; float volume_min_; + speaker::AnnouncementVolume announcement_volume_; + bool is_muted_{false}; }; diff --git a/tests/component_tests/speaker_source/__init__.py b/tests/component_tests/speaker_source/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/speaker_source/test_media_player.py b/tests/component_tests/speaker_source/test_media_player.py new file mode 100644 index 00000000000..0db5b3875e3 --- /dev/null +++ b/tests/component_tests/speaker_source/test_media_player.py @@ -0,0 +1,31 @@ +"""Tests for the speaker_source media player announcement volume validation.""" + +import pytest + +from esphome import config_validation as cv +from esphome.components.const import CONF_ANNOUNCEMENT_VOLUME +from esphome.components.speaker_source.media_player import ( + CONF_ANNOUNCEMENT_PIPELINE, + CONF_MEDIA_PIPELINE, + _validate_announcement_volume, +) + + +def test_announcement_volume_with_both_pipelines() -> None: + config = { + CONF_ANNOUNCEMENT_VOLUME: 0.6, + CONF_ANNOUNCEMENT_PIPELINE: {}, + CONF_MEDIA_PIPELINE: {}, + } + assert _validate_announcement_volume(config) is config + + +def test_no_announcement_volume_with_one_pipeline() -> None: + config = {CONF_ANNOUNCEMENT_PIPELINE: {}} + assert _validate_announcement_volume(config) is config + + +@pytest.mark.parametrize("pipeline", [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE]) +def test_announcement_volume_needs_both_pipelines(pipeline: str) -> None: + with pytest.raises(cv.Invalid, match="requires both"): + _validate_announcement_volume({CONF_ANNOUNCEMENT_VOLUME: 0.6, pipeline: {}}) diff --git a/tests/components/speaker/common-media_player.yaml b/tests/components/speaker/common-media_player.yaml index 7aef904a920..1ac67142068 100644 --- a/tests/components/speaker/common-media_player.yaml +++ b/tests/components/speaker/common-media_player.yaml @@ -16,6 +16,7 @@ media_player: volume_increment: 0.02 volume_max: 0.95 volume_min: 0.0 + announcement_volume: 60% task_stack_in_psram: true files: - id: speaker_test_audio @@ -35,3 +36,9 @@ script: media_file: speaker_test_audio announcement: !lambda return id(speaker_my_number).state > 50; enqueue: !lambda return id(speaker_my_number).state < 50; + - media_player.speaker.set_announcement_volume: + id: speaker_media_player_id + volume: 70% + - media_player.speaker.set_announcement_volume: + id: speaker_media_player_id + volume: !lambda return id(speaker_my_number).state / 100.0f; diff --git a/tests/components/speaker/test_announcement_volume.cpp b/tests/components/speaker/test_announcement_volume.cpp new file mode 100644 index 00000000000..0a361131091 --- /dev/null +++ b/tests/components/speaker/test_announcement_volume.cpp @@ -0,0 +1,146 @@ +#include + +#include + +#include "esphome/components/speaker/announcement_volume.h" + +namespace esphome::speaker::testing { + +// Records every volume the speakers are asked to switch to +class AnnouncementVolumeTest : public ::testing::Test { + protected: + auto apply_() { + return [this](float volume) { this->applied_.push_back(volume); }; + } + + AnnouncementVolume volume_; + std::vector applied_; +}; + +TEST_F(AnnouncementVolumeTest, NotConfiguredNeverBegins) { + EXPECT_FALSE(this->volume_.is_configured()); + this->volume_.begin(false, this->apply_()); + EXPECT_FALSE(this->volume_.is_active()); + this->volume_.end(0.2f, this->apply_()); + EXPECT_TRUE(this->applied_.empty()); +} + +TEST_F(AnnouncementVolumeTest, AnnouncementFromIdleSwitchesAndRestores) { + this->volume_.set_volume(0.6f, this->apply_()); + EXPECT_TRUE(this->applied_.empty()); + EXPECT_TRUE(this->volume_.is_configured()); + EXPECT_FLOAT_EQ(this->volume_.get_volume(), 0.6f); + + this->volume_.begin(false, this->apply_()); + EXPECT_TRUE(this->volume_.is_active()); + this->volume_.end(0.2f, this->apply_()); + EXPECT_FALSE(this->volume_.is_active()); + // Already restored, so a second end has nothing to apply + this->volume_.end(0.2f, this->apply_()); + EXPECT_EQ(this->applied_, (std::vector{0.6f, 0.2f})); +} + +TEST_F(AnnouncementVolumeTest, AnnouncementOverOtherAudioKeepsMediaVolume) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.begin(true, this->apply_()); + EXPECT_FALSE(this->volume_.is_active()); + this->volume_.end(0.2f, this->apply_()); + EXPECT_TRUE(this->applied_.empty()); +} + +TEST_F(AnnouncementVolumeTest, SecondAnnouncementKeepsAnnouncementVolume) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.begin(false, this->apply_()); + // The running announcement is the other audio; the volume is already applied, so nothing to send again + this->volume_.begin(true, this->apply_()); + EXPECT_TRUE(this->volume_.is_active()); + EXPECT_EQ(this->applied_, (std::vector{0.6f})); +} + +TEST_F(AnnouncementVolumeTest, SetWhileActiveAppliesRightAway) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.begin(false, this->apply_()); + this->volume_.set_volume(0.3f, this->apply_()); + EXPECT_FLOAT_EQ(this->volume_.get_volume(), 0.3f); + EXPECT_EQ(this->applied_, (std::vector{0.6f, 0.3f})); +} + +TEST_F(AnnouncementVolumeTest, MediaVolumeChangeCancelsUntilNextAnnouncement) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.begin(false, this->apply_()); + this->volume_.cancel(); + EXPECT_FALSE(this->volume_.is_active()); + // The new media volume was already sent, so the end of the announcement has nothing to restore + this->volume_.end(0.2f, this->apply_()); + // A new announcement changes the volume while the old one still plays, which counts as other audio + this->volume_.begin(true, this->apply_()); + this->volume_.begin(false, this->apply_()); + EXPECT_EQ(this->applied_, (std::vector{0.6f, 0.6f})); +} + +TEST_F(AnnouncementVolumeTest, SetWhileInactiveWaitsForNextAnnouncement) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.set_volume(0.4f, this->apply_()); + EXPECT_TRUE(this->applied_.empty()); + this->volume_.begin(false, this->apply_()); + EXPECT_EQ(this->applied_, (std::vector{0.4f})); +} + +TEST_F(AnnouncementVolumeTest, EndRestoresTheGivenMediaVolume) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.begin(false, this->apply_()); + // The media volume may differ from the one before the announcement, e.g. after a restore from flash + this->volume_.end(0.35f, this->apply_()); + EXPECT_EQ(this->applied_, (std::vector{0.6f, 0.35f})); +} + +TEST_F(AnnouncementVolumeTest, AnnouncementQueuedBehindMediaSwitchesWhenItStarts) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.item_started(false, false, 0.2f, this->apply_()); + EXPECT_TRUE(this->applied_.empty()); + // The media before it has finished, so nothing else plays + this->volume_.item_started(true, false, 0.2f, this->apply_()); + EXPECT_TRUE(this->volume_.is_active()); + EXPECT_EQ(this->applied_, (std::vector{0.6f})); +} + +TEST_F(AnnouncementVolumeTest, MediaQueuedBehindAnnouncementRestoresWhenItStarts) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.item_started(true, false, 0.2f, this->apply_()); + this->volume_.item_started(false, false, 0.2f, this->apply_()); + EXPECT_FALSE(this->volume_.is_active()); + EXPECT_EQ(this->applied_, (std::vector{0.6f, 0.2f})); +} + +TEST_F(AnnouncementVolumeTest, QueuedAnnouncementKeepsVolumeChangeOfThePreviousOne) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.item_started(true, false, 0.2f, this->apply_()); + this->volume_.cancel(); + this->volume_.item_started(true, false, 0.3f, this->apply_()); + EXPECT_FALSE(this->volume_.is_active()); + EXPECT_EQ(this->applied_, (std::vector{0.6f})); +} + +TEST_F(AnnouncementVolumeTest, QueuedAnnouncementOverMediaKeepsMediaVolume) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.item_started(true, true, 0.2f, this->apply_()); + // Still over media, or the media stopped while the first one played; both keep the media volume + this->volume_.item_started(true, false, 0.2f, this->apply_()); + EXPECT_FALSE(this->volume_.is_active()); + EXPECT_TRUE(this->applied_.empty()); +} + +TEST_F(AnnouncementVolumeTest, PlaylistFinishedRestoresAndStartsFresh) { + this->volume_.set_volume(0.6f, this->apply_()); + this->volume_.item_started(true, false, 0.2f, this->apply_()); + this->volume_.cancel(); + this->volume_.playlist_finished(0.2f, this->apply_()); + // A new playlist uses the announcement volume again, even after a volume change in the previous one + this->volume_.item_started(true, false, 0.2f, this->apply_()); + EXPECT_TRUE(this->volume_.is_active()); + this->volume_.playlist_finished(0.2f, this->apply_()); + EXPECT_FALSE(this->volume_.is_active()); + EXPECT_EQ(this->applied_, (std::vector{0.6f, 0.6f, 0.2f})); +} + +} // namespace esphome::speaker::testing diff --git a/tests/components/speaker_source/common.yaml b/tests/components/speaker_source/common.yaml index 655e4241f18..f45e3fc232c 100644 --- a/tests/components/speaker_source/common.yaml +++ b/tests/components/speaker_source/common.yaml @@ -32,6 +32,7 @@ media_player: volume_initial: 0.75 volume_max: 0.95 volume_min: 0.0 + announcement_volume: 60% announcement_pipeline: speaker: announcement_mixer_speaker_id format: FLAC @@ -55,3 +56,6 @@ media_player: id: media_player_id pipeline: media delay: 500ms + - speaker_source.set_announcement_volume: + id: media_player_id + volume: !lambda return x;