[speaker][speaker_source] Add a set volume for announcements that start while nothing is playing (#21002)

This commit is contained in:
Remco van Essen
2026-10-20 14:48:11 -10:00
committed by GitHub
parent cef13c8697
commit eb32f80d53
14 changed files with 443 additions and 19 deletions
+1
View File
@@ -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"
@@ -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
@@ -0,0 +1,76 @@
#pragma once
#include <cmath>
#include <utility>
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<typename F> void set_volume(float volume, F &&apply) {
this->volume_ = volume;
if (this->active_) {
std::forward<F>(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<typename F> 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<F>(apply)(this->volume_);
}
/// Switches back to the media player volume if the announcement volume is in use.
template<typename F> void end(float media_volume, F &&apply) {
if (!this->active_) {
return;
}
this->active_ = false;
this->log_end_(media_volume);
std::forward<F>(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<typename F>
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<typename F> void playlist_finished(float media_volume, F &&apply) {
this->last_item_announcement_ = false;
this->end(media_volume, std::forward<F>(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
@@ -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_),
)
@@ -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
@@ -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<media_player::MediaPlayerCommand> command;
optional<float> volume;
optional<bool> announce;
// The caller asked for an announcement; `announce` is also forced on in single-pipeline mode
bool is_announcement{false};
optional<std::string *> url; // Must be manually deleted after receiving this struct from a queue
optional<const audio::AudioFile *> file;
optional<bool> enqueue;
@@ -35,6 +38,7 @@ struct MediaCallCommand {
struct PlaylistItem {
optional<std::string> url;
optional<const audio::AudioFile *> 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_;
@@ -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_),
)
@@ -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<float, float>(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<float, float>(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];
@@ -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};
};
@@ -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: {}})
@@ -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;
@@ -0,0 +1,146 @@
#include <gtest/gtest.h>
#include <vector>
#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<float> 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<float>{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<float>{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<float>{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<float>{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<float>{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<float>{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<float>{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<float>{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<float>{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<float>{0.6f, 0.6f, 0.2f}));
}
} // namespace esphome::speaker::testing
@@ -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;