From ee278eaaa7ed7317513fc0b8415ab44ec62f4fd1 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 26 Mar 2024 20:04:53 +0100 Subject: [PATCH] Some enhancements to the Announcement feature (#1184) --- music_assistant/client/players.py | 4 +- .../common/models/config_entries.py | 56 +++++++++++++ music_assistant/constants.py | 6 ++ music_assistant/server/controllers/players.py | 83 ++++++++++++++++--- music_assistant/server/controllers/streams.py | 1 + music_assistant/server/helpers/audio.py | 2 +- .../server/models/player_provider.py | 14 +++- .../server/providers/snapcast/__init__.py | 4 +- .../server/providers/sonos/__init__.py | 8 +- 9 files changed, 159 insertions(+), 19 deletions(-) diff --git a/music_assistant/client/players.py b/music_assistant/client/players.py index 2e920324..f4d4e709 100644 --- a/music_assistant/client/players.py +++ b/music_assistant/client/players.py @@ -125,7 +125,8 @@ class Players: self, player_id: str, url: str, - use_pre_announce: bool = False, + use_pre_announce: bool | None = None, + volume_level: int | None = None, ) -> None: """Handle playback of an announcement (url) on given player.""" await self.client.send_command( @@ -133,6 +134,7 @@ class Players: player_id=player_id, url=url, use_pre_announce=use_pre_announce, + volume_level=volume_level, ) # PlayerGroup related endpoints/commands diff --git a/music_assistant/common/models/config_entries.py b/music_assistant/common/models/config_entries.py index 48e3faec..0234fbbf 100644 --- a/music_assistant/common/models/config_entries.py +++ b/music_assistant/common/models/config_entries.py @@ -12,6 +12,10 @@ from mashumaro import DataClassDictMixin from music_assistant.common.models.enums import ProviderType from music_assistant.constants import ( + CONF_ANNOUNCE_VOLUME, + CONF_ANNOUNCE_VOLUME_MAX, + CONF_ANNOUNCE_VOLUME_MIN, + CONF_ANNOUNCE_VOLUME_STRATEGY, CONF_AUTO_PLAY, CONF_CROSSFADE, CONF_CROSSFADE_DURATION, @@ -24,6 +28,7 @@ from music_assistant.constants import ( CONF_LOG_LEVEL, CONF_OUTPUT_CHANNELS, CONF_SYNC_ADJUST, + CONF_TTS_PRE_ANNOUNCE, CONF_VOLUME_NORMALIZATION, CONF_VOLUME_NORMALIZATION_TARGET, SECURE_STRING_SUBSTITUTE, @@ -434,3 +439,54 @@ CONF_ENTRY_SYNC_ADJUST = ConfigEntry( "you can shift the audio a bit.", advanced=True, ) + + +CONF_ENTRY_TTS_PRE_ANNOUNCE = ConfigEntry( + key=CONF_TTS_PRE_ANNOUNCE, + type=ConfigEntryType.BOOLEAN, + default_value=True, + label="Pre-announce TTS announcements", + description="When a TTS (text-to-speech) message is sent to the Announce feature, " + "prepend the announcement with a short pre-announcement sound (bell whistle).", +) + + +CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY = ConfigEntry( + key=CONF_ANNOUNCE_VOLUME_STRATEGY, + type=ConfigEntryType.STRING, + options=[ + ConfigValueOption("Absolute volume", "absolute"), + ConfigValueOption("Relative volume increase", "relative"), + ConfigValueOption("Percentual volume increase", "percentual"), + ConfigValueOption("Do not adjust volume", "none"), + ], + default_value="percentual", + label="Volume strategy for Announcements", + description="When an announcement is being broadcast to this player, " + "how should the volume be adjusted temporary (use in combination with the volume value below).", +) + +CONF_ENTRY_ANNOUNCE_VOLUME = ConfigEntry( + key=CONF_ANNOUNCE_VOLUME, + type=ConfigEntryType.INTEGER, + default_value=85, + label="Volume for Announcements", + description="The percentual, relative or absolute volume level, " + "used with the strategy for announcements.", +) + +CONF_ENTRY_ANNOUNCE_VOLUME_MIN = ConfigEntry( + key=CONF_ANNOUNCE_VOLUME_MIN, + type=ConfigEntryType.INTEGER, + default_value=15, + label="Minimum Volume level for Announcements", + description="The volume (adjustment) of announcements should no go below this level.", +) + +CONF_ENTRY_ANNOUNCE_VOLUME_MAX = ConfigEntry( + key=CONF_ANNOUNCE_VOLUME_MAX, + type=ConfigEntryType.INTEGER, + default_value=75, + label="Maximum Volume level for Announcements", + description="The volume (adjustment) of announcements should no go above this level.", +) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 4ec33f5e..383952b5 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -55,6 +55,12 @@ CONF_GROUP_MEMBERS: Final[str] = "group_members" CONF_HIDE_PLAYER: Final[str] = "hide_player" CONF_ENFORCE_MP3: Final[str] = "enforce_mp3" CONF_SYNC_ADJUST: Final[str] = "sync_adjust" +CONF_TTS_PRE_ANNOUNCE: Final[str] = "tts_pre_announce" +CONF_ANNOUNCE_VOLUME_STRATEGY: Final[str] = "announce_volume_strategy" +CONF_ANNOUNCE_VOLUME: Final[str] = "announce_volume" +CONF_ANNOUNCE_VOLUME_MIN: Final[str] = "announce_volume_min" +CONF_ANNOUNCE_VOLUME_MAX: Final[str] = "announce_volume_max" + # config default values DEFAULT_HOST: Final[str] = "0.0.0.0" diff --git a/music_assistant/server/controllers/players.py b/music_assistant/server/controllers/players.py index b57087ba..c44f2cbc 100644 --- a/music_assistant/server/controllers/players.py +++ b/music_assistant/server/controllers/players.py @@ -10,6 +10,13 @@ from typing import TYPE_CHECKING, Any, Concatenate, ParamSpec, TypeVar, cast import shortuuid from music_assistant.common.helpers.util import get_changed_values +from music_assistant.common.models.config_entries import ( + CONF_ENTRY_ANNOUNCE_VOLUME, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, + CONF_ENTRY_TTS_PRE_ANNOUNCE, +) from music_assistant.common.models.enums import ( ContentType, EventType, @@ -600,7 +607,8 @@ class PlayerController(CoreController): self, player_id: str, url: str, - use_pre_announce: bool = False, + use_pre_announce: bool | None = None, + volume_level: int | None = None, ) -> None: """Handle playback of an announcement (url) on given player.""" player = self.get(player_id, True) @@ -615,6 +623,49 @@ class PlayerController(CoreController): use_pre_announce, url, ) + # work out preferences for announcements + if use_pre_announce is None and "tts" in url: + use_pre_announce = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_TTS_PRE_ANNOUNCE.key, + CONF_ENTRY_TTS_PRE_ANNOUNCE.default_value, + ) + volume_strategy = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value, + ) + volume_strategy = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.key, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY.default_value, + ) + volume_strategy_volume = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_ANNOUNCE_VOLUME.key, + CONF_ENTRY_ANNOUNCE_VOLUME.default_value, + ) + if volume_level is None and volume_strategy == "absolute": + volume_level = volume_strategy_volume + elif volume_level is None and volume_strategy == "relative": + volume_level = player.volume_level + volume_strategy_volume + elif volume_level is None and volume_strategy == "percentual": + percentual = (player.volume_level / 100) * volume_strategy_volume + volume_level = player.volume_level + percentual + if volume_level is not None: + announce_volume_min = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN.key, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN.default_value, + ) + announce_volume_max = self.mass.config.get_raw_player_config_value( + player_id, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX.key, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX.default_value, + ) + volume_level = max(announce_volume_min, volume_level) + volume_level = min(announce_volume_max, volume_level) + # check for native announce support if PlayerFeature.PLAY_ANNOUNCEMENT in player.supported_features: if prov := self.mass.get_provider(player.provider): @@ -624,10 +675,10 @@ class PlayerController(CoreController): announcement_url = self.mass.streams.get_announcement_url( player.player_id, url, use_pre_announce=use_pre_announce ) - await prov.play_announcement(player_id, announcement_url) + await prov.play_announcement(player_id, announcement_url, volume_level) return # use fallback/default implementation - await self._play_announcement(player, url, use_pre_announce) + await self._play_announcement(player, url, use_pre_announce, volume_level) finally: player.announcement_in_progress = False @@ -1081,7 +1132,13 @@ class PlayerController(CoreController): # if a child player turned ON while the group player is on, we need to resync/resume self.mass.create_task(self._sync_syncgroup(group_player.player_id)) - async def _play_announcement(self, player: Player, url: str, use_pre_announce: bool) -> None: + async def _play_announcement( + self, + player: Player, + url: str, + use_pre_announce: bool, + announcement_volume: int | None = None, + ) -> None: """Handle (default/fallback) implementation of the play announcement feature. This default implementation will; @@ -1139,16 +1196,17 @@ class PlayerController(CoreController): await self.cmd_stop(player.player_id) # wait for the player to stop with suppress(TimeoutError): - await self.wait_for_state(player, PlayerState.IDLE, 5) - # increase volume a bit - temp_volume = max(int(min(75, prev_volume) * 1.5), 15) + await self.wait_for_state(player, PlayerState.IDLE, 10) + # adjust volume if needed + temp_volume = announcement_volume or player.volume_level if temp_volume > prev_volume: self.logger.debug( "Announcement to player %s - setting temporary volume (%s)...", player.display_name, - temp_volume, + announcement_volume, ) - await self.cmd_volume_set(player.player_id, temp_volume) + await self.cmd_volume_set(player.player_id, announcement_volume) + await asyncio.sleep(0.5) # play the announcement self.logger.debug( "Announcement to player %s - playing the announcement on the player...", @@ -1157,20 +1215,23 @@ class PlayerController(CoreController): await self.play_media(player_id=player.player_id, queue_item=queue_item) # wait for the player to play with suppress(TimeoutError): - await self.wait_for_state(player, PlayerState.PLAYING, 5) + await self.wait_for_state(player, PlayerState.PLAYING, 10) self.logger.debug( "Announcement to player %s - waiting on the player to stop playing...", player.display_name, ) # wait for the player to stop playing with suppress(TimeoutError): - await self.wait_for_state(player, PlayerState.IDLE, 30) + await self.wait_for_state( + player, PlayerState.IDLE, (queue_item.streamdetails.duration or 30) + 3 + ) self.logger.debug( "Announcement to player %s - restore previous state...", player.display_name ) # restore volume if temp_volume != prev_volume: await self.cmd_volume_set(player.player_id, prev_volume) + await asyncio.sleep(0.5) player.current_item_id = prev_item_id # either power off the player or resume playing if not prev_power: diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index 79e19a8f..b6b74fdd 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -949,6 +949,7 @@ class StreamsController(CoreController): output_format=output_format, extra_args=extra_args, filter_params=filter_params, + loglevel="info", ): yield chunk diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index 584a3267..f76a54e2 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -878,7 +878,7 @@ def get_ffmpeg_args( if input_format.sample_rate != output_format.sample_rate and libsoxr_support: filter_params.append("aresample=resampler=soxr") - if filter_params: + if filter_params and "-filter_complex" not in extra_args: extra_args += ["-af", ",".join(filter_params)] return generic_args + input_args + extra_args + output_args diff --git a/music_assistant/server/models/player_provider.py b/music_assistant/server/models/player_provider.py index 22aad036..b869c042 100644 --- a/music_assistant/server/models/player_provider.py +++ b/music_assistant/server/models/player_provider.py @@ -6,8 +6,13 @@ from abc import abstractmethod from typing import TYPE_CHECKING from music_assistant.common.models.config_entries import ( + CONF_ENTRY_ANNOUNCE_VOLUME, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, CONF_ENTRY_AUTO_PLAY, CONF_ENTRY_HIDE_PLAYER, + CONF_ENTRY_TTS_PRE_ANNOUNCE, CONF_ENTRY_VOLUME_NORMALIZATION, CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, ConfigEntry, @@ -40,6 +45,11 @@ class PlayerProvider(Provider): CONF_ENTRY_AUTO_PLAY, CONF_ENTRY_VOLUME_NORMALIZATION_TARGET, CONF_ENTRY_HIDE_PLAYER, + CONF_ENTRY_TTS_PRE_ANNOUNCE, + CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY, + CONF_ENTRY_ANNOUNCE_VOLUME, + CONF_ENTRY_ANNOUNCE_VOLUME_MIN, + CONF_ENTRY_ANNOUNCE_VOLUME_MAX, ) if player_id.startswith(SYNCGROUP_PREFIX): # add default entries for syncgroups @@ -133,7 +143,9 @@ class PlayerProvider(Provider): This will NOT be called if the player is using flow mode to playback the queue. """ - async def play_announcement(self, player_id: str, announcement_url: str) -> None: + async def play_announcement( + self, player_id: str, announcement_url: str, volume_level: int | None = None + ) -> None: """Handle (provider native) playback of an announcement on given player.""" # will only be called for players with PLAY_ANNOUNCEMENT feature set. raise NotImplementedError diff --git a/music_assistant/server/providers/snapcast/__init__.py b/music_assistant/server/providers/snapcast/__init__.py index 0065bbd0..ac4daae1 100644 --- a/music_assistant/server/providers/snapcast/__init__.py +++ b/music_assistant/server/providers/snapcast/__init__.py @@ -348,11 +348,12 @@ class SnapCastProvider(PlayerProvider): self.mass.players.update(player_id) def stream_callback(_stream) -> None: - player.state = PlayerState(stream.status) + player.state = PlayerState(_stream.status) if player.state == PlayerState.PLAYING: player.current_item_id = f"{queue_item.queue_id}.{queue_item.queue_item_id}" player.elapsed_time = 0 player.elapsed_time_last_updated = time.time() + self.mass.players.update(player_id) self._set_childs_state(player_id, player.state) stream.set_callback(stream_callback) @@ -379,6 +380,7 @@ class SnapCastProvider(PlayerProvider): # we need to wait a bit for the stream status to become idle # to ensure that all snapclients have consumed the audio await self.mass.players.wait_for_state(player, PlayerState.IDLE) + await asyncio.sleep(5) finally: self.logger.debug("Finished streaming to %s", stream_path) # there is no way to unsub the callback to we do this nasty diff --git a/music_assistant/server/providers/sonos/__init__.py b/music_assistant/server/providers/sonos/__init__.py index 0150da87..69177341 100644 --- a/music_assistant/server/providers/sonos/__init__.py +++ b/music_assistant/server/providers/sonos/__init__.py @@ -396,11 +396,11 @@ class SonosPlayerProvider(PlayerProvider): await self._enqueue_item(sonos_player, url=url, queue_item=queue_item) - async def play_announcement(self, player_id: str, announcement_url: str) -> None: + async def play_announcement( + self, player_id: str, announcement_url: str, volume_level: int | None = None + ) -> None: """Handle (provider native) playback of an announcement on given player.""" sonos_player = self.sonosplayers[player_id] - mass_player = self.mass.players.get(player_id) - temp_volume = max(int(min(75, mass_player.volume_level) * 1.5), 15) self.logger.debug( "Playing announcement %s using websocket audioclip on %s", announcement_url, @@ -409,7 +409,7 @@ class SonosPlayerProvider(PlayerProvider): try: response, _ = await sonos_player.websocket.play_clip( announcement_url, - volume=temp_volume, + volume=volume_level, ) except SonosWebsocketError as exc: raise PlayerCommandFailed(f"Error when calling Sonos websocket: {exc}") from exc -- 2.34.1