mirror of
https://github.com/esphome/esphome.git
synced 2026-03-23 17:04:43 +08:00
[speaker_source] Add announcement pipeline (#14654)
This commit is contained in:
@@ -20,6 +20,7 @@ DEPENDENCIES = ["media_source", "speaker"]
|
|||||||
|
|
||||||
CODEOWNERS = ["@kahrendt"]
|
CODEOWNERS = ["@kahrendt"]
|
||||||
|
|
||||||
|
CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline"
|
||||||
CONF_MEDIA_PIPELINE = "media_pipeline"
|
CONF_MEDIA_PIPELINE = "media_pipeline"
|
||||||
CONF_ON_MUTE = "on_mute"
|
CONF_ON_MUTE = "on_mute"
|
||||||
CONF_PIPELINE = "pipeline"
|
CONF_PIPELINE = "pipeline"
|
||||||
@@ -42,6 +43,19 @@ PipelineContext = speaker_source_ns.struct("PipelineContext")
|
|||||||
Pipeline = speaker_source_ns.enum("Pipeline")
|
Pipeline = speaker_source_ns.enum("Pipeline")
|
||||||
PIPELINE_ENUM = {
|
PIPELINE_ENUM = {
|
||||||
"media": Pipeline.MEDIA_PIPELINE,
|
"media": Pipeline.MEDIA_PIPELINE,
|
||||||
|
"announcement": Pipeline.ANNOUNCEMENT_PIPELINE,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Maps config key -> (C++ Pipeline enum value, format purpose)
|
||||||
|
_PIPELINE_INFO = {
|
||||||
|
CONF_MEDIA_PIPELINE: (
|
||||||
|
Pipeline.MEDIA_PIPELINE,
|
||||||
|
media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"],
|
||||||
|
),
|
||||||
|
CONF_ANNOUNCEMENT_PIPELINE: (
|
||||||
|
Pipeline.ANNOUNCEMENT_PIPELINE,
|
||||||
|
media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"],
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
SetPlaylistDelayAction = speaker_source_ns.class_(
|
SetPlaylistDelayAction = speaker_source_ns.class_(
|
||||||
@@ -59,7 +73,7 @@ FORMAT_MAPPING = {
|
|||||||
|
|
||||||
# Returns a media_player.MediaPlayerSupportedFormat struct with the configured
|
# Returns a media_player.MediaPlayerSupportedFormat struct with the configured
|
||||||
# format, sample rate, number of channels, purpose, and bytes per sample
|
# format, sample rate, number of channels, purpose, and bytes per sample
|
||||||
def _get_supported_format_struct(pipeline: ConfigType):
|
def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj):
|
||||||
args = [
|
args = [
|
||||||
media_player.MediaPlayerSupportedFormat,
|
media_player.MediaPlayerSupportedFormat,
|
||||||
]
|
]
|
||||||
@@ -68,7 +82,7 @@ def _get_supported_format_struct(pipeline: ConfigType):
|
|||||||
|
|
||||||
args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE]))
|
args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE]))
|
||||||
args.append(("num_channels", pipeline[CONF_NUM_CHANNELS]))
|
args.append(("num_channels", pipeline[CONF_NUM_CHANNELS]))
|
||||||
args.append(("purpose", media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"]))
|
args.append(("purpose", purpose))
|
||||||
|
|
||||||
# Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails
|
# Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails
|
||||||
# if the number of bytes per sample is specified for MP3.
|
# if the number of bytes per sample is specified for MP3.
|
||||||
@@ -115,6 +129,40 @@ PIPELINE_SCHEMA = cv.Schema(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_no_shared_resources(config: ConfigType) -> ConfigType:
|
||||||
|
announcement_config = config.get(CONF_ANNOUNCEMENT_PIPELINE)
|
||||||
|
media_config = config.get(CONF_MEDIA_PIPELINE)
|
||||||
|
|
||||||
|
# Check for duplicates within each pipeline
|
||||||
|
for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE):
|
||||||
|
if pipeline_config := config.get(pipeline_key):
|
||||||
|
source_ids = [s.id for s in pipeline_config[CONF_SOURCES]]
|
||||||
|
if len(source_ids) != len(set(source_ids)):
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"Duplicate media sources in {pipeline_key}. "
|
||||||
|
"Each media source can only appear once per pipeline."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for sources shared between pipelines
|
||||||
|
if announcement_config and media_config:
|
||||||
|
if announcement_config[CONF_SPEAKER] == media_config[CONF_SPEAKER]:
|
||||||
|
raise cv.Invalid(
|
||||||
|
"The announcement and media pipelines cannot use the same speaker. "
|
||||||
|
"Use the `mixer` speaker component to create two source speakers."
|
||||||
|
)
|
||||||
|
|
||||||
|
announcement_source_ids = {s.id for s in announcement_config[CONF_SOURCES]}
|
||||||
|
media_source_ids = {s.id for s in media_config[CONF_SOURCES]}
|
||||||
|
shared = announcement_source_ids & media_source_ids
|
||||||
|
if shared:
|
||||||
|
raise cv.Invalid(
|
||||||
|
f"Media sources cannot be shared between pipelines: {', '.join(shared)}. "
|
||||||
|
"Create separate media source instances for each pipeline."
|
||||||
|
)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _validate_volume_settings(config: ConfigType) -> ConfigType:
|
def _validate_volume_settings(config: ConfigType) -> ConfigType:
|
||||||
# CONF_VOLUME_INITIAL is in the scaled volume domain (0.0-1.0) and doesn't need to be validated
|
# CONF_VOLUME_INITIAL is in the scaled volume domain (0.0-1.0) and doesn't need to be validated
|
||||||
if config[CONF_VOLUME_MIN] > config[CONF_VOLUME_MAX]:
|
if config[CONF_VOLUME_MIN] > config[CONF_VOLUME_MAX]:
|
||||||
@@ -131,7 +179,8 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage,
|
cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage,
|
||||||
cv.Optional(CONF_VOLUME_MAX, default=1.0): 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_VOLUME_MIN, default=0.0): cv.percentage,
|
||||||
cv.Required(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA,
|
cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): PIPELINE_SCHEMA,
|
||||||
|
cv.Optional(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA,
|
||||||
cv.Optional(CONF_ON_MUTE): automation.validate_automation(single=True),
|
cv.Optional(CONF_ON_MUTE): automation.validate_automation(single=True),
|
||||||
cv.Optional(CONF_ON_UNMUTE): 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),
|
cv.Optional(CONF_ON_VOLUME): automation.validate_automation(single=True),
|
||||||
@@ -140,23 +189,37 @@ CONFIG_SCHEMA = cv.All(
|
|||||||
.extend(cv.COMPONENT_SCHEMA)
|
.extend(cv.COMPONENT_SCHEMA)
|
||||||
.extend(media_player.media_player_schema(SpeakerSourceMediaPlayer)),
|
.extend(media_player.media_player_schema(SpeakerSourceMediaPlayer)),
|
||||||
cv.only_on_esp32,
|
cv.only_on_esp32,
|
||||||
|
cv.has_at_least_one_key(CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE),
|
||||||
|
_validate_no_shared_resources,
|
||||||
_validate_volume_settings,
|
_validate_volume_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _final_validate_codecs(config: ConfigType) -> ConfigType:
|
def _final_validate_codecs(config: ConfigType) -> ConfigType:
|
||||||
pipeline = config[CONF_MEDIA_PIPELINE]
|
# "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available.
|
||||||
fmt = pipeline[CONF_FORMAT]
|
# When a specific format is set, only that codec is requested.
|
||||||
if fmt == "NONE":
|
needed_formats: set[str] = set()
|
||||||
|
need_all = False
|
||||||
|
|
||||||
|
for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE):
|
||||||
|
if pipeline := config.get(pipeline_key):
|
||||||
|
fmt = pipeline[CONF_FORMAT]
|
||||||
|
if fmt == "NONE":
|
||||||
|
need_all = True
|
||||||
|
else:
|
||||||
|
needed_formats.add(fmt)
|
||||||
|
|
||||||
|
if need_all:
|
||||||
audio.request_flac_support()
|
audio.request_flac_support()
|
||||||
audio.request_mp3_support()
|
audio.request_mp3_support()
|
||||||
audio.request_opus_support()
|
audio.request_opus_support()
|
||||||
elif fmt == "FLAC":
|
else:
|
||||||
audio.request_flac_support()
|
if "FLAC" in needed_formats:
|
||||||
elif fmt == "MP3":
|
audio.request_flac_support()
|
||||||
audio.request_mp3_support()
|
if "MP3" in needed_formats:
|
||||||
elif fmt == "OPUS":
|
audio.request_mp3_support()
|
||||||
audio.request_opus_support()
|
if "OPUS" in needed_formats:
|
||||||
|
audio.request_opus_support()
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@@ -164,7 +227,8 @@ def _final_validate_codecs(config: ConfigType) -> ConfigType:
|
|||||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||||
cv.Schema(
|
cv.Schema(
|
||||||
{
|
{
|
||||||
cv.Required(CONF_MEDIA_PIPELINE): _validate_pipeline,
|
cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): _validate_pipeline,
|
||||||
|
cv.Optional(CONF_MEDIA_PIPELINE): _validate_pipeline,
|
||||||
},
|
},
|
||||||
extra=cv.ALLOW_EXTRA,
|
extra=cv.ALLOW_EXTRA,
|
||||||
),
|
),
|
||||||
@@ -182,26 +246,25 @@ async def to_code(config: ConfigType) -> None:
|
|||||||
cg.add(var.set_volume_max(config[CONF_VOLUME_MAX]))
|
cg.add(var.set_volume_max(config[CONF_VOLUME_MAX]))
|
||||||
cg.add(var.set_volume_min(config[CONF_VOLUME_MIN]))
|
cg.add(var.set_volume_min(config[CONF_VOLUME_MIN]))
|
||||||
|
|
||||||
pipeline_config = config[CONF_MEDIA_PIPELINE]
|
for pipeline_key, (pipeline_enum, purpose) in _PIPELINE_INFO.items():
|
||||||
pipeline_enum = Pipeline.MEDIA_PIPELINE
|
if pipeline_config := config.get(pipeline_key):
|
||||||
|
for source in pipeline_config[CONF_SOURCES]:
|
||||||
|
src = await cg.get_variable(source)
|
||||||
|
cg.add(var.add_media_source(pipeline_enum, src))
|
||||||
|
|
||||||
for source in pipeline_config[CONF_SOURCES]:
|
cg.add(
|
||||||
src = await cg.get_variable(source)
|
var.set_speaker(
|
||||||
cg.add(var.add_media_source(pipeline_enum, src))
|
pipeline_enum,
|
||||||
|
await cg.get_variable(pipeline_config[CONF_SPEAKER]),
|
||||||
cg.add(
|
)
|
||||||
var.set_speaker(
|
|
||||||
pipeline_enum,
|
|
||||||
await cg.get_variable(pipeline_config[CONF_SPEAKER]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if pipeline_config[CONF_FORMAT] != "NONE":
|
|
||||||
cg.add(
|
|
||||||
var.set_format(
|
|
||||||
pipeline_enum,
|
|
||||||
_get_supported_format_struct(pipeline_config),
|
|
||||||
)
|
)
|
||||||
)
|
if pipeline_config[CONF_FORMAT] != "NONE":
|
||||||
|
cg.add(
|
||||||
|
var.set_format(
|
||||||
|
pipeline_enum,
|
||||||
|
_get_supported_format_struct(pipeline_config, purpose),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if on_mute := config.get(CONF_ON_MUTE):
|
if on_mute := config.get(CONF_ON_MUTE):
|
||||||
await automation.build_automation(
|
await automation.build_automation(
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ void SpeakerSourceMediaPlayer::handle_play_uri_request_(uint8_t pipeline, const
|
|||||||
// Smart source is requesting the player to play a different URI
|
// Smart source is requesting the player to play a different URI
|
||||||
auto call = this->make_call();
|
auto call = this->make_call();
|
||||||
call.set_media_url(uri);
|
call.set_media_url(uri);
|
||||||
|
call.set_announcement(pipeline == ANNOUNCEMENT_PIPELINE);
|
||||||
call.perform();
|
call.perform();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so
|
|||||||
}
|
}
|
||||||
|
|
||||||
// THREAD CONTEXT: Called from main loop (loop)
|
// THREAD CONTEXT: Called from main loop (loop)
|
||||||
media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_state_(
|
media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_source_state_(
|
||||||
media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const {
|
media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const {
|
||||||
if (source != nullptr) {
|
if (source != nullptr) {
|
||||||
switch (source->get_state()) {
|
switch (source->get_state()) {
|
||||||
@@ -218,7 +219,7 @@ media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_stat
|
|||||||
case media_source::MediaSourceState::PAUSED:
|
case media_source::MediaSourceState::PAUSED:
|
||||||
return media_player::MEDIA_PLAYER_STATE_PAUSED;
|
return media_player::MEDIA_PLAYER_STATE_PAUSED;
|
||||||
case media_source::MediaSourceState::ERROR:
|
case media_source::MediaSourceState::ERROR:
|
||||||
ESP_LOGE(TAG, "Source error");
|
ESP_LOGE(TAG, "Media source error");
|
||||||
return media_player::MEDIA_PLAYER_STATE_IDLE;
|
return media_player::MEDIA_PLAYER_STATE_IDLE;
|
||||||
case media_source::MediaSourceState::IDLE:
|
case media_source::MediaSourceState::IDLE:
|
||||||
default:
|
default:
|
||||||
@@ -237,16 +238,47 @@ void SpeakerSourceMediaPlayer::loop() {
|
|||||||
// Process queued control commands
|
// Process queued control commands
|
||||||
this->process_control_queue_();
|
this->process_control_queue_();
|
||||||
|
|
||||||
// Update state based on active sources
|
// Update state based on active sources - announcement pipeline takes priority
|
||||||
media_player::MediaPlayerState old_state = this->state;
|
media_player::MediaPlayerState old_state = this->state;
|
||||||
|
|
||||||
|
PipelineContext &ann_ps = this->pipelines_[ANNOUNCEMENT_PIPELINE];
|
||||||
PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE];
|
PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE];
|
||||||
|
|
||||||
// Check playlist state to detect transitions between items
|
// Check playlist state to detect transitions between items
|
||||||
|
bool announcement_playlist_active = (ann_ps.playlist_index < ann_ps.playlist.size()) ||
|
||||||
|
(ann_ps.repeat_mode != REPEAT_OFF && !ann_ps.playlist.empty());
|
||||||
bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) ||
|
bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) ||
|
||||||
(media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty());
|
(media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty());
|
||||||
|
|
||||||
this->state = this->get_media_pipeline_state_(media_ps.active_source, media_playlist_active, old_state);
|
// Check announcement pipeline first
|
||||||
|
media_source::MediaSource *announcement_source = ann_ps.active_source;
|
||||||
|
if (announcement_source != nullptr) {
|
||||||
|
media_source::MediaSourceState announcement_state = announcement_source->get_state();
|
||||||
|
if (announcement_state != media_source::MediaSourceState::IDLE) {
|
||||||
|
// Announcement is active - announcements take priority and never report PAUSED
|
||||||
|
switch (announcement_state) {
|
||||||
|
case media_source::MediaSourceState::PLAYING:
|
||||||
|
case media_source::MediaSourceState::PAUSED: // Treat paused announcements as announcing
|
||||||
|
this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING;
|
||||||
|
break;
|
||||||
|
case media_source::MediaSourceState::ERROR:
|
||||||
|
ESP_LOGE(TAG, "Announcement source error");
|
||||||
|
// Fall through to media pipeline state
|
||||||
|
this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Announcement source is idle, fall through to media pipeline
|
||||||
|
this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state);
|
||||||
|
}
|
||||||
|
} else if (announcement_playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_ANNOUNCING) {
|
||||||
|
this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING;
|
||||||
|
} else {
|
||||||
|
// No active announcement, check media pipeline
|
||||||
|
this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state);
|
||||||
|
}
|
||||||
|
|
||||||
if (this->state != old_state) {
|
if (this->state != old_state) {
|
||||||
this->publish_state();
|
this->publish_state();
|
||||||
@@ -357,7 +389,7 @@ void SpeakerSourceMediaPlayer::set_playlist_delay_ms(uint8_t pipeline, uint32_t
|
|||||||
// The timeout callback also runs on the main loop.
|
// The timeout callback also runs on the main loop.
|
||||||
void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t delay_ms) {
|
void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t delay_ms) {
|
||||||
if (delay_ms > 0) {
|
if (delay_ms > 0) {
|
||||||
this->set_timeout(PipelineContext::TIMEOUT_IDS[pipeline], delay_ms,
|
this->set_timeout(PIPELINE_TIMEOUT_IDS[pipeline], delay_ms,
|
||||||
[this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); });
|
[this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); });
|
||||||
} else {
|
} else {
|
||||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||||
@@ -366,7 +398,7 @@ void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t de
|
|||||||
|
|
||||||
// THREAD CONTEXT: Called from main loop (loop)
|
// THREAD CONTEXT: Called from main loop (loop)
|
||||||
void SpeakerSourceMediaPlayer::process_control_queue_() {
|
void SpeakerSourceMediaPlayer::process_control_queue_() {
|
||||||
MediaPlayerControlCommand control_command;
|
MediaPlayerControlCommand control_command{};
|
||||||
|
|
||||||
// Use peek to check command without removing it
|
// Use peek to check command without removing it
|
||||||
if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) {
|
if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) {
|
||||||
@@ -383,7 +415,7 @@ void SpeakerSourceMediaPlayer::process_control_queue_() {
|
|||||||
switch (control_command.type) {
|
switch (control_command.type) {
|
||||||
case MediaPlayerControlCommand::PLAY_URI: {
|
case MediaPlayerControlCommand::PLAY_URI: {
|
||||||
// Always use our local playlist to start playback
|
// Always use our local playlist to start playback
|
||||||
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
|
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||||
ps.playlist.clear();
|
ps.playlist.clear();
|
||||||
ps.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist
|
ps.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist
|
||||||
ps.playlist_index = 0; // Reset index
|
ps.playlist_index = 0; // Reset index
|
||||||
@@ -528,7 +560,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC
|
|||||||
|
|
||||||
case media_player::MEDIA_PLAYER_COMMAND_STOP: {
|
case media_player::MEDIA_PLAYER_COMMAND_STOP: {
|
||||||
if (!has_internal_playlist) {
|
if (!has_internal_playlist) {
|
||||||
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
|
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||||
ps.playlist.clear();
|
ps.playlist.clear();
|
||||||
ps.shuffle_indices.clear();
|
ps.shuffle_indices.clear();
|
||||||
ps.playlist_index = 0;
|
ps.playlist_index = 0;
|
||||||
@@ -541,7 +573,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC
|
|||||||
|
|
||||||
case media_player::MEDIA_PLAYER_COMMAND_NEXT: {
|
case media_player::MEDIA_PLAYER_COMMAND_NEXT: {
|
||||||
if (!has_internal_playlist) {
|
if (!has_internal_playlist) {
|
||||||
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
|
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||||
if (ps.playlist_index + 1 < ps.playlist.size()) {
|
if (ps.playlist_index + 1 < ps.playlist.size()) {
|
||||||
ps.playlist_index++;
|
ps.playlist_index++;
|
||||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||||
@@ -557,7 +589,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC
|
|||||||
|
|
||||||
case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: {
|
case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: {
|
||||||
if (!has_internal_playlist) {
|
if (!has_internal_playlist) {
|
||||||
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
|
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||||
if (ps.playlist_index > 0) {
|
if (ps.playlist_index > 0) {
|
||||||
ps.playlist_index--;
|
ps.playlist_index--;
|
||||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||||
@@ -597,7 +629,7 @@ void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerC
|
|||||||
|
|
||||||
case media_player::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: {
|
case media_player::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: {
|
||||||
if (!has_internal_playlist) {
|
if (!has_internal_playlist) {
|
||||||
this->cancel_timeout(PipelineContext::TIMEOUT_IDS[pipeline]);
|
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||||
if (ps.playlist_index < ps.playlist.size()) {
|
if (ps.playlist_index < ps.playlist.size()) {
|
||||||
size_t actual_position = this->get_playlist_position_(pipeline);
|
size_t actual_position = this->get_playlist_position_(pipeline);
|
||||||
ps.playlist[0] = std::move(ps.playlist[actual_position]);
|
ps.playlist[0] = std::move(ps.playlist[actual_position]);
|
||||||
@@ -644,8 +676,24 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaPlayerControlCommand control_command;
|
MediaPlayerControlCommand control_command{};
|
||||||
control_command.pipeline = MEDIA_PIPELINE;
|
|
||||||
|
// Determine which pipeline to use based on announcement flag, falling back if the preferred pipeline
|
||||||
|
// is not configured
|
||||||
|
auto announcement = call.get_announcement();
|
||||||
|
if (announcement.has_value() && announcement.value()) {
|
||||||
|
if (this->pipelines_[ANNOUNCEMENT_PIPELINE].is_configured()) {
|
||||||
|
control_command.pipeline = ANNOUNCEMENT_PIPELINE;
|
||||||
|
} else {
|
||||||
|
control_command.pipeline = MEDIA_PIPELINE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this->pipelines_[MEDIA_PIPELINE].is_configured()) {
|
||||||
|
control_command.pipeline = MEDIA_PIPELINE;
|
||||||
|
} else {
|
||||||
|
control_command.pipeline = ANNOUNCEMENT_PIPELINE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
auto media_url = call.get_media_url();
|
auto media_url = call.get_media_url();
|
||||||
if (media_url.has_value()) {
|
if (media_url.has_value()) {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ namespace esphome::speaker_source {
|
|||||||
//
|
//
|
||||||
// - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(),
|
// - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(),
|
||||||
// handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(),
|
// handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(),
|
||||||
// set_volume_(), set_mute_state_(), control(), get_media_pipeline_state_(),
|
// set_volume_(), set_mute_state_(), control(), get_source_state_(),
|
||||||
// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(),
|
// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(),
|
||||||
// process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_()
|
// process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_()
|
||||||
//
|
//
|
||||||
@@ -55,6 +55,7 @@ namespace esphome::speaker_source {
|
|||||||
|
|
||||||
enum Pipeline : uint8_t {
|
enum Pipeline : uint8_t {
|
||||||
MEDIA_PIPELINE = 0,
|
MEDIA_PIPELINE = 0,
|
||||||
|
ANNOUNCEMENT_PIPELINE = 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
enum RepeatMode : uint8_t {
|
enum RepeatMode : uint8_t {
|
||||||
@@ -85,10 +86,10 @@ struct SourceBinding : public media_source::MediaSourceListener {
|
|||||||
void request_play_uri(const std::string &uri) override;
|
void request_play_uri(const std::string &uri) override;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct PipelineContext {
|
/// @brief Timeout IDs for playlist delay, indexed by Pipeline enum
|
||||||
/// @brief Timeout IDs for playlist delay, indexed by Pipeline enum
|
static constexpr uint32_t PIPELINE_TIMEOUT_IDS[] = {1, 2};
|
||||||
static constexpr const char *const TIMEOUT_IDS[] = {"next_media"};
|
|
||||||
|
|
||||||
|
struct PipelineContext {
|
||||||
speaker::Speaker *speaker{nullptr};
|
speaker::Speaker *speaker{nullptr};
|
||||||
optional<media_player::MediaPlayerSupportedFormat> format;
|
optional<media_player::MediaPlayerSupportedFormat> format;
|
||||||
|
|
||||||
@@ -132,7 +133,7 @@ struct MediaPlayerControlCommand {
|
|||||||
SEND_COMMAND, // Send command to active source
|
SEND_COMMAND, // Send command to active source
|
||||||
};
|
};
|
||||||
Type type;
|
Type type;
|
||||||
uint8_t pipeline;
|
uint8_t pipeline; // MEDIA_PIPELINE or ANNOUNCEMENT_PIPELINE
|
||||||
|
|
||||||
union {
|
union {
|
||||||
std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI and ENQUEUE_URI)
|
std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI and ENQUEUE_URI)
|
||||||
@@ -208,14 +209,13 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla
|
|||||||
/// @brief Saves the current volume and mute state to the flash for restoration.
|
/// @brief Saves the current volume and mute state to the flash for restoration.
|
||||||
void save_volume_restore_state_();
|
void save_volume_restore_state_();
|
||||||
|
|
||||||
/// @brief Determine media player state from the media pipeline's active source
|
/// @brief Determine media player state from a pipeline's active source
|
||||||
/// @param media_source Active source for the media pipeline (may be nullptr)
|
/// @param media_source Active source (may be nullptr)
|
||||||
/// @param playlist_active Whether the media pipeline's playlist is in progress
|
/// @param playlist_active Whether the pipeline's playlist is in progress
|
||||||
/// @param old_state Previous media player state (used for transition smoothing)
|
/// @param old_state Previous media player state (used for transition smoothing)
|
||||||
/// @return The appropriate MediaPlayerState
|
/// @return The appropriate MediaPlayerState
|
||||||
media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source,
|
media_player::MediaPlayerState get_source_state_(media_source::MediaSource *media_source, bool playlist_active,
|
||||||
bool playlist_active,
|
media_player::MediaPlayerState old_state) const;
|
||||||
media_player::MediaPlayerState old_state) const;
|
|
||||||
|
|
||||||
void process_control_queue_();
|
void process_control_queue_();
|
||||||
void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline);
|
void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline);
|
||||||
@@ -235,8 +235,9 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla
|
|||||||
|
|
||||||
QueueHandle_t media_control_command_queue_;
|
QueueHandle_t media_control_command_queue_;
|
||||||
|
|
||||||
// Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules.
|
// Pipeline context for media (index 0) and announcement (index 1) pipelines.
|
||||||
std::array<PipelineContext, 1> pipelines_;
|
// See THREADING MODEL at top of namespace for access rules.
|
||||||
|
std::array<PipelineContext, 2> pipelines_;
|
||||||
|
|
||||||
// Used to save volume/mute state for restoration on reboot
|
// Used to save volume/mute state for restoration on reboot
|
||||||
ESPPreferenceObject pref_;
|
ESPPreferenceObject pref_;
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ speaker:
|
|||||||
i2s_dout_pin: ${i2s_dout_pin}
|
i2s_dout_pin: ${i2s_dout_pin}
|
||||||
sample_rate: 48000
|
sample_rate: 48000
|
||||||
num_channels: 2
|
num_channels: 2
|
||||||
|
- platform: mixer
|
||||||
|
output_speaker: speaker_id
|
||||||
|
source_speakers:
|
||||||
|
- id: announcement_mixer_speaker_id
|
||||||
|
- id: media_mixer_speaker_id
|
||||||
|
|
||||||
audio_file:
|
audio_file:
|
||||||
- id: test_audio
|
- id: test_audio
|
||||||
@@ -19,7 +24,9 @@ audio_file:
|
|||||||
|
|
||||||
media_source:
|
media_source:
|
||||||
- platform: audio_file
|
- platform: audio_file
|
||||||
id: audio_file_source
|
id: announcement_audio_file_source
|
||||||
|
- platform: audio_file
|
||||||
|
id: media_audio_file_source
|
||||||
|
|
||||||
media_player:
|
media_player:
|
||||||
- platform: speaker_source
|
- platform: speaker_source
|
||||||
@@ -29,12 +36,18 @@ media_player:
|
|||||||
volume_initial: 0.75
|
volume_initial: 0.75
|
||||||
volume_max: 0.95
|
volume_max: 0.95
|
||||||
volume_min: 0.0
|
volume_min: 0.0
|
||||||
media_pipeline:
|
announcement_pipeline:
|
||||||
speaker: speaker_id
|
speaker: announcement_mixer_speaker_id
|
||||||
format: FLAC
|
format: FLAC
|
||||||
num_channels: 1
|
num_channels: 1
|
||||||
sources:
|
sources:
|
||||||
- audio_file_source
|
- announcement_audio_file_source
|
||||||
|
media_pipeline:
|
||||||
|
speaker: media_mixer_speaker_id
|
||||||
|
format: FLAC
|
||||||
|
num_channels: 1
|
||||||
|
sources:
|
||||||
|
- media_audio_file_source
|
||||||
on_mute:
|
on_mute:
|
||||||
- media_player.shuffle:
|
- media_player.shuffle:
|
||||||
id: media_player_id
|
id: media_player_id
|
||||||
|
|||||||
Reference in New Issue
Block a user