mirror of
https://github.com/esphome/esphome.git
synced 2026-10-21 21:36:14 +00:00
[speaker][speaker_source] Add a set volume for announcements that start while nothing is playing (#21002)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user