feat: attach output format to the player (#1920)
authorMaxim Raznatovski <nda.mr43@gmail.com>
Thu, 30 Jan 2025 12:35:53 +0000 (13:35 +0100)
committerGitHub <noreply@github.com>
Thu, 30 Jan 2025 12:35:53 +0000 (13:35 +0100)
* feat: add output_format parameter to `get_player_filter_params`

* feat: store each players output format once known

* feat: attach output_format to `DSPDetaily`

* fix: improve `DSPDetails` change detection to listen for output format changes

* fix: remove underscore from `_output_format`

* Bump models to 1.1.19

* Chore(deps): Bump actions/setup-python from 5.3.0 to 5.4.0 (#1919)

Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.3.0 to 5.4.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5.3.0...v5.4.0)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* fix: explain why we catch RuntimeErrors

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
music_assistant/controllers/player_queues.py
music_assistant/controllers/streams.py
music_assistant/helpers/audio.py
music_assistant/providers/airplay/raop.py
music_assistant/providers/player_group/__init__.py
music_assistant/providers/slimproto/__init__.py
music_assistant/providers/snapcast/__init__.py

index a10c8c96b38620e0a24c6843ab095d0058930874..04c46e2487272843d4e280c6daaae10619146fc5 100644 (file)
@@ -108,7 +108,7 @@ class CompareState(TypedDict):
     elapsed_time: int
     stream_title: str | None
     content_type: str | None
-    group_childs_count: int
+    output_formats: list[str] | None
 
 
 class PlayerQueuesController(CoreController):
@@ -966,10 +966,19 @@ class PlayerQueuesController(CoreController):
                 next_item_id=None,
                 elapsed_time=0,
                 stream_title=None,
-                group_childs_count=0,
+                output_formats=None,
             ),
         )
 
+        # This is enough to detect any changes in the DSPDetails
+        # (so child count changed, or any output format changed)
+        output_formats = []
+        if player.output_format:
+            output_formats.append(player.output_format.output_format_str)
+        for child_id in player.group_childs:
+            if (child := self.mass.players.get(child_id)) and child.output_format:
+                output_formats.append(child.output_format.output_format_str)
+
         # basic throttle: do not send state changed events if queue did not actually change
         new_state = CompareState(
             queue_id=queue_id,
@@ -983,7 +992,7 @@ class PlayerQueuesController(CoreController):
             content_type=queue.current_item.streamdetails.audio_format.output_format_str
             if queue.current_item and queue.current_item.streamdetails
             else None,
-            group_childs_count=len(player.group_childs),
+            output_formats=output_formats,
         )
         changed_keys = get_changed_keys(prev_state, new_state)
         # return early if nothing changed
@@ -1005,8 +1014,8 @@ class PlayerQueuesController(CoreController):
         else:
             self._prev_states.pop(queue_id, None)
 
-        if "group_childs_count" in changed_keys:
-            # refresh DSP details since a player has been added/removed from the group
+        if "output_formats" in changed_keys:
+            # refresh DSP details since they may have changed
             dsp = get_stream_dsp_details(self.mass, queue_id)
             if queue.current_item and queue.current_item.streamdetails:
                 queue.current_item.streamdetails.dsp = dsp
index 190f64953d72c37f0dd4a44e2656d30451dd716e..c5f5d11b6e23cfc0d79321042af340b5bd4ad6f9 100644 (file)
@@ -345,7 +345,9 @@ class StreamsController(CoreController):
             ),
             input_format=pcm_format,
             output_format=output_format,
-            filter_params=get_player_filter_params(self.mass, queue_player.player_id, pcm_format),
+            filter_params=get_player_filter_params(
+                self.mass, queue_player.player_id, pcm_format, output_format
+            ),
         ):
             try:
                 await resp.write(chunk)
@@ -440,7 +442,7 @@ class StreamsController(CoreController):
             input_format=flow_pcm_format,
             output_format=output_format,
             filter_params=get_player_filter_params(
-                self.mass, queue_player.player_id, flow_pcm_format
+                self.mass, queue_player.player_id, flow_pcm_format, output_format
             ),
             chunk_size=icy_meta_interval if enable_icy else None,
         ):
index 55c931e59f04ba058a9e40447f22bc6ae23c9003..662cafa0aa2fb084459045544fd85b7042dd4df4 100644 (file)
@@ -210,6 +210,7 @@ def get_player_dsp_details(
         filters=dsp_config.filters,
         output_gain=dsp_config.output_gain,
         output_limiter=dsp_config.output_limiter,
+        output_format=player.output_format,
     )
 
 
@@ -219,15 +220,30 @@ def get_stream_dsp_details(
 ) -> dict[str, DSPDetails]:
     """Return DSP details of all players playing this queue, keyed by player_id."""
     player = mass.players.get(queue_id)
-    dsp = {}
+    dsp: dict[str, DSPDetails] = {}
     group_preventing_dsp = is_grouping_preventing_dsp(player)
+    output_format = None
 
-    # We skip the PlayerGroups as they don't provide an audio output
-    # by themselves, but only sync other players.
-    if not player.provider.startswith("player_group"):
+    if player.provider.startswith("player_group"):
+        if group_preventing_dsp:
+            try:
+                # We need a bit of a hack here since only the leader knows the correct output format
+                provider = mass.get_provider(player.provider)
+                if provider:
+                    output_format = provider._get_sync_leader(player).output_format
+            except RuntimeError:
+                # _get_sync_leader will raise a RuntimeError if this group has no players
+                # just ignore this and continue without output_format
+                LOGGER.warning("Unable to get the sync group leader for %s", queue_id)
+    else:
+        # We only add real players (so skip the PlayerGroups as they only sync containing players)
         details = get_player_dsp_details(mass, player)
         details.is_leader = True
         dsp[player.player_id] = details
+        if group_preventing_dsp:
+            # The leader is responsible for sending the (combined) audio stream, so get
+            # the output format from the leader.
+            output_format = player.output_format
 
     if player and player.group_childs:
         # grouped playback, get DSP details for each player in the group
@@ -236,6 +252,11 @@ def get_stream_dsp_details(
                 dsp[child_id] = get_player_dsp_details(
                     mass, child_player, group_preventing_dsp=group_preventing_dsp
                 )
+                if group_preventing_dsp:
+                    # Use the correct format from the group leader, since
+                    # this player is part of a group that does not support
+                    # multi device DSP processing.
+                    dsp[child_id].output_format = output_format
     return dsp
 
 
@@ -944,6 +965,7 @@ def get_player_filter_params(
     mass: MusicAssistant,
     player_id: str,
     input_format: AudioFormat,
+    output_format: AudioFormat,
 ) -> list[str]:
     """Get player specific filter parameters for ffmpeg (if any)."""
     filter_params = []
@@ -969,6 +991,11 @@ def get_player_filter_params(
                 # This should normally never happen, but if it does, we disable DSP.
                 dsp.enabled = False
 
+        # We here implicitly know what output format is used for the player
+        # in the audio processing steps. We save this information to
+        # later be able to show this to the user in the UI.
+        player.output_format = output_format
+
     if dsp.enabled:
         # Apply input gain
         if dsp.input_gain != 0:
index 585fdf40facabffaa7a20a3341567024a360e39c..844566da57de05d0c07ce4d7a6f57fc41c9f9b02 100644 (file)
@@ -235,7 +235,9 @@ class RaopStream:
             audio_input="-",
             input_format=self.session.input_format,
             output_format=AIRPLAY_PCM_FORMAT,
-            filter_params=get_player_filter_params(self.mass, player_id, self.session.input_format),
+            filter_params=get_player_filter_params(
+                self.mass, player_id, self.session.input_format, AIRPLAY_PCM_FORMAT
+            ),
             audio_output=write,
         )
         await self._ffmpeg_proc.start()
index ca0921c00933273a9a8c476084b3247ee929e974..d2bafb61b28c188cc5c7f26da7de8856081fb6a2 100644 (file)
@@ -905,7 +905,7 @@ class PlayerGroupProvider(PlayerProvider):
         filter_params = None
         if child_player_id:
             filter_params = get_player_filter_params(
-                self.mass, child_player_id, stream.input_format
+                self.mass, child_player_id, stream.input_format, output_format
             )
 
         async for chunk in stream.get_stream(
index 9dbbe51afa6d314c423367d02a47f52a8993d7e0..17c28bc67f34be5f18e07fb96f309bbaa2aa1e39 100644 (file)
@@ -962,10 +962,12 @@ class SlimprotoProvider(PlayerProvider):
             "Start serving multi-client flow audio stream to %s",
             child_player.display_name,
         )
-
+        output_format = AudioFormat(content_type=ContentType.try_parse(fmt))
         async for chunk in stream.get_stream(
-            output_format=AudioFormat(content_type=ContentType.try_parse(fmt)),
-            filter_params=get_player_filter_params(self.mass, child_player_id, stream.audio_format)
+            output_format=output_format,
+            filter_params=get_player_filter_params(
+                self.mass, child_player_id, stream.audio_format, output_format
+            )
             if child_player_id
             else None,
         ):
index 53d8a04e3e0439cc34ed8415a07de4263806ae74..93a8f5449b12e89ceffd94fb0fe8434c717cecc9 100644 (file)
@@ -542,7 +542,9 @@ class SnapCastProvider(PlayerProvider):
                     audio_input=audio_source,
                     input_format=input_format,
                     output_format=DEFAULT_SNAPCAST_FORMAT,
-                    filter_params=get_player_filter_params(self.mass, player_id, input_format),
+                    filter_params=get_player_filter_params(
+                        self.mass, player_id, input_format, DEFAULT_SNAPCAST_FORMAT
+                    ),
                     audio_output=stream_path,
                 ) as ffmpeg_proc:
                     player.state = PlayerState.PLAYING