Revamped Crossfade support (#2087)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Mon, 31 Mar 2025 20:05:20 +0000 (22:05 +0200)
committerGitHub <noreply@github.com>
Mon, 31 Mar 2025 20:05:20 +0000 (22:05 +0200)
* Completely refactor crossfade support

Handle crossfade entirely in the streams controller, even if a player natively supports crossfading.

Support crossfading without flow mode if a player supports gapless.

Optionally support crossfade between different sample rate (only if player supports that).

Do not crossfade tracks of same album.

Unify the crossfade settings.

All players can now set the crossfade duration

Allow crossfade duration up to 15s

* Chore: Ensure sonos queue gets refreshed when items update

21 files changed:
music_assistant/constants.py
music_assistant/controllers/player_queues.py
music_assistant/controllers/streams.py
music_assistant/helpers/audio.py
music_assistant/helpers/ffmpeg.py
music_assistant/helpers/process.py
music_assistant/models/player_provider.py
music_assistant/providers/_template_player_provider/__init__.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/bluesound/__init__.py
music_assistant/providers/builtin_player/__init__.py
music_assistant/providers/chromecast/__init__.py
music_assistant/providers/dlna/__init__.py
music_assistant/providers/fully_kiosk/__init__.py
music_assistant/providers/player_group/__init__.py
music_assistant/providers/snapcast/__init__.py
music_assistant/providers/sonos/const.py
music_assistant/providers/sonos/player.py
music_assistant/providers/sonos/provider.py
music_assistant/providers/sonos_s1/__init__.py
music_assistant/providers/squeezelite/__init__.py

index 725b138724778e3f219f90f7048f013e4ba8df6c..51d27e6eafea5f8b4ae1e6a36b004f5b5af1b363 100644 (file)
@@ -297,7 +297,7 @@ CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED = ConfigEntry(
 CONF_ENTRY_CROSSFADE_DURATION = ConfigEntry(
     key=CONF_CROSSFADE_DURATION,
     type=ConfigEntryType.INTEGER,
-    range=(1, 10),
+    range=(1, 15),
     default_value=8,
     label="Crossfade duration",
     description="Duration in seconds of the crossfade between tracks (if enabled)",
@@ -305,10 +305,6 @@ CONF_ENTRY_CROSSFADE_DURATION = ConfigEntry(
     category="advanced",
 )
 
-CONF_ENTRY_CROSSFADE_DURATION_HIDDEN = ConfigEntry.from_dict(
-    {**CONF_ENTRY_CROSSFADE_DURATION.to_dict(), "hidden": True}
-)
-
 CONF_ENTRY_HIDE_PLAYER_IN_UI = ConfigEntry(
     key=CONF_HIDE_PLAYER_IN_UI,
     type=ConfigEntryType.STRING,
index 26c01f396419a402f7b7e554f5491b91dc6ced47..95f303164f376c077b39e938277d2c9c5a3c306b 100644 (file)
@@ -19,6 +19,7 @@ import time
 from types import NoneType
 from typing import TYPE_CHECKING, Any, TypedDict, cast
 
+import shortuuid
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
 from music_assistant_models.enums import (
     ConfigEntryType,
@@ -780,6 +781,8 @@ class PlayerQueuesController(CoreController):
         queue.flow_mode_stream_log = []
         queue.flow_mode = await self.mass.config.get_player_config_value(queue_id, CONF_FLOW_MODE)
         queue.current_item = queue_item
+        # always update session id when we start a new playback session
+        queue.session_id = shortuuid.random(length=8)
 
         # handle resume point of audiobook(chapter) or podcast(episode)
         if not seek_position and (
@@ -1092,7 +1095,7 @@ class PlayerQueuesController(CoreController):
         self.logger.debug("PlayerQueue %s loaded item %s in buffer", queue.display_name, item_id)
         self.signal_update(queue_id)
         # enqueue next track on the player
-        self._enqueue_next_item(queue_id, self._get_next_item(queue_id, item_id))
+        self._enqueue_next_item(queue_id, self.get_next_item(queue_id, item_id))
         # preload next streamdetails
         self._preload_next_item(queue_id, queue.index_in_buffer)
 
@@ -1136,7 +1139,8 @@ class PlayerQueuesController(CoreController):
         if (
             insert_at_index == (index_in_buffer + 1)
             and queue.state != PlayerState.IDLE
-            and (next_item := self._get_next_item(queue_id, index_in_buffer))
+            and (next_item := self.get_next_item(queue_id, index_in_buffer))
+            and queue.next_item_id_enqueued != next_item.queue_item_id
         ):
             self._enqueue_next_item(queue_id, next_item)
 
@@ -1144,6 +1148,10 @@ class PlayerQueuesController(CoreController):
         """Update the existing queue items, mostly caused by reordering."""
         self._queue_items[queue_id] = queue_items
         self._queues[queue_id].items = len(self._queue_items[queue_id])
+        # to track if the queue items changed we set a timestamp
+        # this is a simple way to detect changes in the list of items
+        # without having to compare the entire list
+        self._queues[queue_id].items_last_updated = time.time()
         self.signal_update(queue_id, True)
 
     # Helper methods
@@ -1197,12 +1205,23 @@ class PlayerQueuesController(CoreController):
         self, queue_item: QueueItem, flow_mode: bool
     ) -> PlayerMedia:
         """Parse PlayerMedia from QueueItem."""
+        queue = self._queues[queue_item.queue_id]
+        if queue_item.streamdetails:
+            # prefer netto duration
+            # when seeking, the player only receives the remaining duration
+            duration = queue_item.streamdetails.duration or queue_item.duration
+            if duration and queue_item.streamdetails.seek_position:
+                duration = duration - queue_item.streamdetails.seek_position
+        else:
+            duration = queue_item.duration
         media = PlayerMedia(
-            uri=await self.mass.streams.resolve_stream_url(queue_item, flow_mode=flow_mode),
+            uri=await self.mass.streams.resolve_stream_url(
+                queue.session_id, queue_item, flow_mode=flow_mode
+            ),
             media_type=MediaType.FLOW_STREAM if flow_mode else queue_item.media_type,
             title="Music Assistant" if flow_mode else queue_item.name,
             image_url=MASS_LOGO_ONLINE,
-            duration=queue_item.duration,
+            duration=duration,
             queue_id=queue_item.queue_id,
             queue_item_id=queue_item.queue_item_id,
         )
@@ -1412,7 +1431,7 @@ class PlayerQueuesController(CoreController):
         # all other: just the next index
         return cur_index + 1
 
-    def _get_next_item(self, queue_id: str, cur_index: int | str | None = None) -> QueueItem | None:
+    def get_next_item(self, queue_id: str, cur_index: int | str | None = None) -> QueueItem | None:
         """Return next QueueItem for given queue."""
         if isinstance(cur_index, str):
             cur_index = self.index_by_id(queue_id, cur_index)
@@ -1457,6 +1476,7 @@ class PlayerQueuesController(CoreController):
                 player_id=queue_id,
                 media=await self.player_media_from_queue_item(next_item, False),
             )
+            queue.next_item_id_enqueued = next_item.queue_item_id
             self.logger.debug(
                 "Enqueued next track %s on queue %s",
                 next_item.name,
@@ -1466,7 +1486,7 @@ class PlayerQueuesController(CoreController):
         # Enqueue the next item immediately once the player started
         # buffering/playing an item (with a small debounce delay).
         task_id = f"enqueue_next_item_{queue_id}"
-        self.mass.call_later(1, _enqueue_next_item_on_player, next_item, task_id=task_id)
+        self.mass.call_later(0.5, _enqueue_next_item_on_player, next_item, task_id=task_id)
 
     def _preload_next_item(self, queue_id: str, item_id_in_buffer: str) -> None:
         """
@@ -1500,7 +1520,7 @@ class PlayerQueuesController(CoreController):
         if current_item.media_type == MediaType.RADIO or not current_item.duration:
             # radio items or no duration, nothing to do
             return
-        if not (next_item := self._get_next_item(queue_id, item_id_in_buffer)):
+        if not (next_item := self.get_next_item(queue_id, item_id_in_buffer)):
             return  # nothing to do
         if next_item.available and next_item.streamdetails:
             # streamdetails already loaded, nothing to do
@@ -1509,7 +1529,8 @@ class PlayerQueuesController(CoreController):
         # preload the streamdetails for the next item 60 seconds before the current item ends
         # this should be enough time to load the stream details and start buffering
         # NOTE: we use the duration of the current item, not the next item
-        delay = max(0, current_item.duration - 60)
+        netto_duration = current_item.duration - current_item.streamdetails.seek_position
+        delay = max(0, netto_duration - 60)
         task_id = f"preload_next_item_{queue_id}"
         self.mass.call_later(delay, _preload_streamdetails, task_id=task_id)
 
@@ -1678,7 +1699,7 @@ class PlayerQueuesController(CoreController):
             # get current/next item based on current index
             queue.current_index = current_index
             queue.current_item = current_item = self.get_item(queue_id, current_index)
-            queue.next_item = self._get_next_item(queue_id, current_index) if current_item else None
+            queue.next_item = self.get_next_item(queue_id, current_index) if current_item else None
 
             # correct elapsed time when seeking
             if (
index 2c79c640c761a70b704208a997168407a131c608..261c8046dfdfea169059bc076c46abbdd34e6eb3 100644 (file)
@@ -13,6 +13,8 @@ import os
 import shutil
 import urllib.parse
 from collections.abc import AsyncGenerator
+from contextlib import suppress
+from dataclasses import dataclass, field
 from typing import TYPE_CHECKING
 
 from aiofiles.os import wrap
@@ -22,6 +24,7 @@ from music_assistant_models.enums import (
     ConfigEntryType,
     ContentType,
     MediaType,
+    PlayerFeature,
     StreamType,
     VolumeNormalizationMode,
 )
@@ -63,7 +66,7 @@ from music_assistant.helpers.audio import (
 )
 from music_assistant.helpers.audio import LOGGER as AUDIO_LOGGER
 from music_assistant.helpers.ffmpeg import LOGGER as FFMPEG_LOGGER
-from music_assistant.helpers.ffmpeg import check_ffmpeg_version, get_ffmpeg_stream
+from music_assistant.helpers.ffmpeg import FFMpeg, check_ffmpeg_version, get_ffmpeg_stream
 from music_assistant.helpers.util import (
     get_folder_size,
     get_free_space,
@@ -98,6 +101,16 @@ def parse_pcm_info(content_type: str) -> tuple[int, int, int]:
     return (sample_rate, sample_size, channels)
 
 
+@dataclass
+class CrossfadeData:
+    """Data class to hold crossfade data."""
+
+    fadeout_part: bytes = b""
+    pcm_format: AudioFormat = field(default_factory=AudioFormat)
+    queue_item_id: str | None = None
+    session_id: str | None = None
+
+
 class StreamsController(CoreController):
     """Webserver Controller to stream audio to players."""
 
@@ -124,6 +137,7 @@ class StreamsController(CoreController):
         # prefer /tmp/.audio as audio cache dir
         self._audio_cache_dir = os.path.join("/tmp/.audio")  # noqa: S108
         self.allow_cache_default = "auto"
+        self._crossfade_data: dict[str, CrossfadeData] = {}
 
     @property
     def base_url(self) -> str:
@@ -272,12 +286,12 @@ class StreamsController(CoreController):
             static_routes=[
                 (
                     "*",
-                    "/flow/{queue_id}/{queue_item_id}.{fmt}",
+                    "/flow/{session_id}/{queue_id}/{queue_item_id}.{fmt}",
                     self.serve_queue_flow_stream,
                 ),
                 (
                     "*",
-                    "/single/{queue_id}/{queue_item_id}.{fmt}",
+                    "/single/{session_id}/{queue_id}/{queue_item_id}.{fmt}",
                     self.serve_queue_item_stream,
                 ),
                 (
@@ -304,6 +318,7 @@ class StreamsController(CoreController):
 
     async def resolve_stream_url(
         self,
+        session_id: str,
         queue_item: QueueItem,
         flow_mode: bool = False,
         player_id: str | None = None,
@@ -323,7 +338,7 @@ class StreamsController(CoreController):
         if output_codec.is_pcm() and ";" not in fmt:
             fmt += f";codec=pcm;rate={44100};bitrate={16};channels={2}"
         base_path = "flow" if flow_mode else "single"
-        return f"{self._server.base_url}/{base_path}/{queue_item.queue_id}/{queue_item.queue_item_id}.{fmt}"  # noqa: E501
+        return f"{self._server.base_url}/{base_path}/{session_id}/{queue_item.queue_id}/{queue_item.queue_item_id}.{fmt}"  # noqa: E501
 
     async def get_plugin_source_url(
         self,
@@ -347,6 +362,9 @@ class StreamsController(CoreController):
         queue = self.mass.player_queues.get(queue_id)
         if not queue:
             raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
+        session_id = request.match_info["session_id"]
+        if session_id != queue.session_id:
+            raise web.HTTPNotFound(reason=f"Unknown (or invalid) session: {session_id}")
         queue_player = self.mass.players.get(queue_id)
         queue_item_id = request.match_info["queue_item_id"]
         queue_item = self.mass.player_queues.get_item(queue_id, queue_item_id)
@@ -364,19 +382,13 @@ class StreamsController(CoreController):
                 queue_item.available = False
                 raise web.HTTPNotFound(reason=f"No streamdetails for Queue item: {queue_item_id}")
 
-        # pick pcm format based on the streamdetails and player capabilities
-        pcm_format = AudioFormat(
-            content_type=DEFAULT_PCM_FORMAT.content_type,
-            sample_rate=queue_item.streamdetails.audio_format.sample_rate,
-            bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
-            channels=2,
-        )
-        # work out output format/details
+        # pick output format based on the streamdetails and player capabilities
         output_format = await self.get_output_format(
             output_format_str=request.match_info["fmt"],
             player=queue_player,
-            content_sample_rate=pcm_format.sample_rate,
-            content_bit_depth=pcm_format.bit_depth,
+            content_sample_rate=queue_item.streamdetails.audio_format.sample_rate,
+            # always use f32 internally for extra headroom for filters etc
+            content_bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
         )
 
         # prepare request, add some DLNA/UPNP compatible headers
@@ -410,23 +422,31 @@ class StreamsController(CoreController):
         if request.method != "GET":
             return resp
 
-        # all checks passed, start streaming!
-        self.logger.debug(
-            "Start serving audio stream for QueueItem %s (%s) to %s",
-            queue_item.name,
-            queue_item.uri,
-            queue.display_name,
+        # work out pcm format based on output format
+        pcm_format = AudioFormat(
+            content_type=DEFAULT_PCM_FORMAT.content_type,
+            sample_rate=output_format.sample_rate,
+            # always use f32 internally for extra headroom for filters etc
+            bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
+            channels=2,
         )
 
-        chunk_num = 0
-
         # inform the queue that the track is now loaded in the buffer
         # so for example the next track can be enqueued
         self.mass.player_queues.track_loaded_in_buffer(queue_id, queue_item_id)
+
+        # work out crossfade details
+        self._crossfade_data.setdefault(queue_id, CrossfadeData())
+        crossfade_data = self._crossfade_data[queue_id]
+        enable_crossfade = self._get_crossfade_config(queue_item, flow_mode=False)
+
         async for chunk in get_ffmpeg_stream(
             audio_input=self.get_queue_item_stream(
                 queue_item=queue_item,
                 pcm_format=pcm_format,
+                enable_crossfade=enable_crossfade,
+                crossfade_data=crossfade_data,
+                session_id=session_id,
             ),
             input_format=pcm_format,
             output_format=output_format,
@@ -437,7 +457,6 @@ class StreamsController(CoreController):
         ):
             try:
                 await resp.write(chunk)
-                chunk_num += 1
             except (BrokenPipeError, ConnectionResetError, ConnectionError):
                 break
         if queue_item.streamdetails.stream_error:
@@ -454,6 +473,36 @@ class StreamsController(CoreController):
                     await resp.write(chunk)
                 except (BrokenPipeError, ConnectionResetError, ConnectionError):
                     break
+            return resp
+        # lookup next item in queue to determine additional actions
+        next_item = self.mass.player_queues.get_next_item(queue_id, queue_item_id)
+        if not next_item:
+            # end of queue reached: make sure we yield the last_fadeout_part
+            if crossfade_data and crossfade_data.fadeout_part:
+                await resp.write(crossfade_data.fadeout_part)
+                crossfade_data.fadeout_part = b""
+        if (
+            crossfade_data.fadeout_part
+            and next_item
+            and next_item.streamdetails
+            and next_item.streamdetails.audio_format.sample_rate
+            != crossfade_data.pcm_format.sample_rate
+            and PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE not in queue_player.supported_features
+        ):
+            # next track's sample rate differs from current track
+            # most players do not properly support gapless playback between different sample rates
+            # so let's just output the fadeout data
+            crossfade_data.session_id = ""
+            self.logger.debug("Skipping crossfade: sample rate mismatch")
+            async with FFMpeg(
+                audio_input="-",
+                input_format=crossfade_data.pcm_format,
+                output_format=output_format,
+            ) as ffmpeg:
+                res = await ffmpeg.communicate(crossfade_data.fadeout_part)
+                with suppress(BrokenPipeError, ConnectionResetError, ConnectionError):
+                    await resp.write(res[0])
+
         return resp
 
     async def serve_queue_flow_stream(self, request: web.Request) -> web.Response:
@@ -463,6 +512,9 @@ class StreamsController(CoreController):
         queue = self.mass.player_queues.get(queue_id)
         if not queue:
             raise web.HTTPNotFound(reason=f"Unknown Queue: {queue_id}")
+        session_id = request.match_info["session_id"]
+        if session_id != queue.session_id:
+            raise web.HTTPNotFound(reason=f"Unknown (or invalid) session: {session_id}")
         if not (queue_player := self.mass.players.get(queue_id)):
             raise web.HTTPNotFound(reason=f"Unknown Player: {queue_id}")
         start_queue_item_id = request.match_info["queue_item_id"]
@@ -740,157 +792,69 @@ class StreamsController(CoreController):
         """Get a flow stream of all tracks in the queue as raw PCM audio."""
         # ruff: noqa: PLR0915
         assert pcm_format.content_type.is_pcm()
-        queue_track = None
-        last_fadeout_part = b""
+        queue_item: QueueItem | None = None
+        crossfade_data = CrossfadeData(b"", pcm_format)
         queue.flow_mode = True
-        use_crossfade = await self.mass.config.get_player_config_value(
-            queue.queue_id, CONF_CROSSFADE
-        )
+
         if not start_queue_item:
             # this can happen in some (edge case) race conditions
             return
-        if start_queue_item.media_type != MediaType.TRACK:
-            use_crossfade = False
+
         pcm_sample_size = int(
             pcm_format.sample_rate * (pcm_format.bit_depth / 8) * pcm_format.channels
         )
         self.logger.info(
-            "Start Queue Flow stream for Queue %s - crossfade: %s",
+            "Start Queue Flow stream for Queue %s",
             queue.display_name,
-            use_crossfade,
         )
-        total_bytes_sent = 0
-
         while True:
             # get (next) queue item to stream
-            if queue_track is None:
-                queue_track = start_queue_item
+            if queue_item is None:
+                queue_item = start_queue_item
             else:
                 try:
-                    queue_track = await self.mass.player_queues.preload_next_queue_item(
-                        queue.queue_id, queue_track.queue_item_id
+                    queue_item = await self.mass.player_queues.preload_next_queue_item(
+                        queue.queue_id, queue_item.queue_item_id
                     )
                 except QueueEmpty:
                     break
 
-            if queue_track.streamdetails is None:
+            if queue_item.streamdetails is None:
                 raise RuntimeError(
                     "No Streamdetails known for queue item %s",
-                    queue_track.queue_item_id,
+                    queue_item.queue_item_id,
                 )
 
-            self.logger.debug(
-                "Start Streaming queue track: %s (%s) for queue %s",
-                queue_track.streamdetails.uri,
-                queue_track.name,
-                queue.display_name,
-            )
-            self.mass.player_queues.track_loaded_in_buffer(
-                queue.queue_id, queue_track.queue_item_id
-            )
+            self.mass.player_queues.track_loaded_in_buffer(queue.queue_id, queue_item.queue_item_id)
             # append to play log so the queue controller can work out which track is playing
-            play_log_entry = PlayLogEntry(queue_track.queue_item_id)
+            play_log_entry = PlayLogEntry(queue_item.queue_item_id)
             queue.flow_mode_stream_log.append(play_log_entry)
 
-            # set some basic vars
-            pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2)
-            crossfade_duration = self.mass.config.get_raw_player_config_value(
-                queue.queue_id, CONF_CROSSFADE_DURATION, 10
-            )
-            crossfade_size = int(pcm_sample_size * crossfade_duration)
-            bytes_written = 0
-            buffer = b""
+            # work out crossfade details
+            enable_crossfade = self._get_crossfade_config(queue_item, flow_mode=True)
+
             # handle incoming audio chunks
             async for chunk in self.get_queue_item_stream(
-                queue_track,
+                queue_item,
                 pcm_format=pcm_format,
+                enable_crossfade=enable_crossfade,
+                crossfade_data=crossfade_data,
             ):
-                # buffer size needs to be big enough to include the crossfade part
-                req_buffer_size = pcm_sample_size if not use_crossfade else crossfade_size
-
-                # ALWAYS APPEND CHUNK TO BUFFER
-                buffer += chunk
-                del chunk
-                if len(buffer) < req_buffer_size:
-                    # buffer is not full enough, move on
-                    continue
-
-                ####  HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
-                if last_fadeout_part:
-                    # perform crossfade
-                    fadein_part = buffer[:crossfade_size]
-                    remaining_bytes = buffer[crossfade_size:]
-                    crossfade_part = await crossfade_pcm_parts(
-                        fadein_part,
-                        last_fadeout_part,
-                        pcm_format=pcm_format,
-                    )
-                    # send crossfade_part (as one big chunk)
-                    bytes_written += len(crossfade_part)
-                    yield crossfade_part
-
-                    # also write the leftover bytes from the crossfade action
-                    if remaining_bytes:
-                        yield remaining_bytes
-                        bytes_written += len(remaining_bytes)
-                        del remaining_bytes
-                    # clear vars
-                    last_fadeout_part = b""
-                    buffer = b""
-
-                #### OTHER: enough data in buffer, feed to output
-                while len(buffer) > req_buffer_size:
-                    yield buffer[:pcm_sample_size]
-                    bytes_written += pcm_sample_size
-                    buffer = buffer[pcm_sample_size:]
+                yield chunk
 
             #### HANDLE END OF TRACK
-            if last_fadeout_part:
-                # edge case: we did not get enough data to make the crossfade
-                yield last_fadeout_part
-                bytes_written += len(last_fadeout_part)
-                last_fadeout_part = b""
-            if use_crossfade:
-                # if crossfade is enabled, save fadeout part to pickup for next track
-                last_fadeout_part = buffer[-crossfade_size:]
-                remaining_bytes = buffer[:-crossfade_size]
-                if remaining_bytes:
-                    yield remaining_bytes
-                    bytes_written += len(remaining_bytes)
-                del remaining_bytes
-            elif buffer:
-                # no crossfade enabled, just yield the buffer last part
-                bytes_written += len(buffer)
-                yield buffer
-            # make sure the buffer gets cleaned up
-            del buffer
-
-            # update duration details based on the actual pcm data we sent
-            # this also accounts for crossfade and silence stripping
-            seconds_streamed = bytes_written / pcm_sample_size
-            queue_track.streamdetails.seconds_streamed = seconds_streamed
-            queue_track.streamdetails.duration = (
-                queue_track.streamdetails.seek_position + seconds_streamed
-            )
-            play_log_entry.seconds_streamed = seconds_streamed
-            play_log_entry.duration = queue_track.streamdetails.duration
-            total_bytes_sent += bytes_written
-            self.logger.debug(
-                "Finished Streaming queue track: %s (%s) on queue %s",
-                queue_track.streamdetails.uri,
-                queue_track.name,
-                queue.display_name,
-            )
+            play_log_entry.seconds_streamed = queue_item.streamdetails.seconds_streamed
+            play_log_entry.duration = queue_item.streamdetails.duration
+
         #### HANDLE END OF QUEUE FLOW STREAM
         # end of queue flow: make sure we yield the last_fadeout_part
-        if last_fadeout_part:
-            yield last_fadeout_part
+        if crossfade_data and crossfade_data.fadeout_part:
+            yield crossfade_data.fadeout_part
             # correct seconds streamed/duration
-            last_part_seconds = len(last_fadeout_part) / pcm_sample_size
-            queue_track.streamdetails.seconds_streamed += last_part_seconds
-            queue_track.streamdetails.duration += last_part_seconds
-            del last_fadeout_part
-        total_bytes_sent += bytes_written
+            last_part_seconds = len(crossfade_data.fadeout_part) / pcm_sample_size
+            queue_item.streamdetails.seconds_streamed += last_part_seconds
+            queue_item.streamdetails.duration += last_part_seconds
+            del crossfade_data
         self.logger.info("Finished Queue Flow stream for Queue %s", queue.display_name)
 
     async def get_announcement_stream(
@@ -967,12 +931,27 @@ class StreamsController(CoreController):
         self,
         queue_item: QueueItem,
         pcm_format: AudioFormat,
+        enable_crossfade: bool = False,
+        crossfade_data: CrossfadeData | None = None,
+        session_id: str | None = None,
     ) -> AsyncGenerator[bytes, None]:
         """Get the audio stream for a single queue item as raw PCM audio."""
         # collect all arguments for ffmpeg
         streamdetails = queue_item.streamdetails
         assert streamdetails
         filter_params = []
+        crossfade_duration = self.mass.config.get_raw_player_config_value(
+            queue_item.queue_id, CONF_CROSSFADE_DURATION, 10
+        )
+
+        queue = self.mass.player_queues.get(queue_item.queue_id)
+        self.logger.debug(
+            "Start Streaming queue track: %s (%s) for queue %s - crossfade: %s",
+            queue_item.streamdetails.uri,
+            queue_item.name,
+            queue.display_name,
+            f"{crossfade_duration}s" if enable_crossfade else "disabled",
+        )
 
         # handle volume normalization
         gain_correct: float | None = None
@@ -1009,7 +988,23 @@ class StreamsController(CoreController):
             # if the stream does not provide a look ahead buffer
             pad_silence_seconds = 4
 
+        if crossfade_data.session_id != session_id:
+            # invalidate expired crossfade data
+            crossfade_data.fadeout_part = b""
+
         first_chunk_received = False
+        buffer = b""
+        bytes_written = 0
+        pcm_sample_size = int(pcm_format.sample_rate * (pcm_format.bit_depth / 8) * 2)
+        # buffer size needs to be big enough to include the crossfade part
+
+        crossfade_size = int(pcm_sample_size * crossfade_duration)
+        req_buffer_size = pcm_sample_size
+        if enable_crossfade or (crossfade_data and crossfade_data.fadeout_part):
+            # crossfade is enabled, so we need to make sure we have enough data in the buffer
+            # to perform the crossfade
+            req_buffer_size += crossfade_size
+
         async for chunk in get_media_stream(
             self.mass,
             streamdetails=streamdetails,
@@ -1023,8 +1018,84 @@ class StreamsController(CoreController):
                 async for silence in get_silence(pad_silence_seconds, pcm_format):
                     yield silence
                     del silence
-            yield chunk
+
+            # ALWAYS APPEND CHUNK TO BUFFER
+            buffer += chunk
             del chunk
+            if len(buffer) < req_buffer_size:
+                # buffer is not full enough, move on
+                continue
+
+            ####  HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
+            if crossfade_data and crossfade_data.fadeout_part:
+                # perform crossfade
+                fade_in_part = buffer[:crossfade_size]
+                remaining_bytes = buffer[crossfade_size:]
+                crossfade_part = await crossfade_pcm_parts(
+                    fade_in_part=fade_in_part,
+                    fade_out_part=crossfade_data.fadeout_part,
+                    pcm_format=pcm_format,
+                    fade_out_pcm_format=crossfade_data.pcm_format,
+                )
+                # send crossfade_part (as one big chunk)
+                bytes_written += len(crossfade_part)
+                yield crossfade_part
+
+                # also write the leftover bytes from the crossfade action
+                if remaining_bytes:
+                    yield remaining_bytes
+                    bytes_written += len(remaining_bytes)
+                    del remaining_bytes
+                # clear vars
+                crossfade_data.fadeout_part = b""
+                buffer = b""
+                del fade_in_part
+
+            #### OTHER: enough data in buffer, feed to output
+            while len(buffer) > req_buffer_size:
+                yield buffer[:pcm_sample_size]
+                bytes_written += pcm_sample_size
+                buffer = buffer[pcm_sample_size:]
+
+        #### HANDLE END OF TRACK
+        if crossfade_data and crossfade_data.fadeout_part:
+            # edge case: we did not get enough data to make the crossfade
+            if crossfade_data.pcm_format == pcm_format:
+                yield crossfade_data.fadeout_part
+                bytes_written += len(crossfade_data.fadeout_part)
+            crossfade_data.fadeout_part = b""
+        if enable_crossfade:
+            # if crossfade is enabled, save fadeout part to pickup for next track
+            crossfade_data.fadeout_part = buffer[-crossfade_size:]
+            crossfade_data.pcm_format = pcm_format
+            crossfade_data.session_id = session_id
+            crossfade_data.queue_item_id = queue_item.queue_item_id
+            remaining_bytes = buffer[:-crossfade_size]
+            if remaining_bytes:
+                yield remaining_bytes
+                bytes_written += len(remaining_bytes)
+            del remaining_bytes
+        elif buffer:
+            # no crossfade enabled, just yield the buffer last part
+            bytes_written += len(buffer)
+            yield buffer
+        # make sure the buffer gets cleaned up
+        del buffer
+
+        # update duration details based on the actual pcm data we sent
+        # this also accounts for crossfade and silence stripping
+        seconds_streamed = bytes_written / pcm_sample_size
+        streamdetails.seconds_streamed = seconds_streamed
+        streamdetails.duration = streamdetails.seek_position + seconds_streamed
+        queue_item.duration = streamdetails.duration
+        if queue_item.media_type:
+            queue_item.media_item.duration = streamdetails.duration
+        self.logger.debug(
+            "Finished Streaming queue track: %s (%s) on queue %s",
+            queue_item.streamdetails.uri,
+            queue_item.name,
+            queue.display_name,
+        )
 
     def _log_request(self, request: web.Request) -> None:
         """Log request."""
@@ -1129,3 +1200,56 @@ class StreamsController(CoreController):
             await asyncio.to_thread(_clean_old_files, foldersize)
         # reschedule self
         self.mass.call_later(3600, self._clean_audio_cache)
+
+    def _get_crossfade_config(self, queue_item: QueueItem, flow_mode: bool = False) -> bool:
+        """Get the crossfade config for a queue item."""
+        if not (queue_player := self.mass.players.get(queue_item.queue_id)):
+            return False  # just a guard
+        use_crossfade = self.mass.config.get_raw_player_config_value(
+            queue_item.queue_id, CONF_CROSSFADE, False
+        )
+        if not use_crossfade:
+            return False
+        if not flow_mode and PlayerFeature.GAPLESS_PLAYBACK not in queue_player.supported_features:
+            # crossfade is not supported on this player due to missing gapless playback
+            self.logger.debug("Skipping crossfade: gapless playback not supported on player")
+            return False
+        if queue_item.media_type != MediaType.TRACK:
+            return False
+        # check if the next item is part of the same album
+        next_item = self.mass.player_queues.get_next_item(
+            queue_item.queue_id, queue_item.queue_item_id
+        )
+        if not next_item:
+            return False
+        if (
+            queue_item.media_item
+            and queue_item.media_item.album
+            and next_item.media_item
+            and next_item.media_item.album
+            and queue_item.media_item.album == next_item.media_item.album
+        ):
+            # in general, crossfade is not desired for tracks of the same (gapless) album
+            # because we have no accurate way to determine if the album is gapless or not,
+            # for now we just never crossfade between tracks of the same album
+            self.logger.debug("Skipping crossfade: next item is part of the same album")
+            return False
+        # check if next item is a track
+        if next_item.media_type != MediaType.TRACK:
+            self.logger.debug("Skipping crossfade: next item is not a track")
+            return False
+        # check if next item sample rate matches
+        if (
+            not flow_mode
+            and next_item.streamdetails
+            and (
+                queue_item.streamdetails.audio_format.sample_rate
+                != next_item.streamdetails.audio_format.sample_rate
+            )
+            and (queue_player := self.mass.players.get(queue_item.queue_id))
+            and PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE not in queue_player.supported_features
+        ):
+            self.logger.debug("Skipping crossfade: sample rate mismatch")
+            return 0
+        # all checks passed, crossfade is enabled/allowed
+        return True
index 4df5a00ab2db1e6754768366db982a6088d946a8..e630cf2350ba66b623689ceb91d49544c75ca608 100644 (file)
@@ -284,14 +284,22 @@ async def crossfade_pcm_parts(
     fade_in_part: bytes,
     fade_out_part: bytes,
     pcm_format: AudioFormat,
+    fade_out_pcm_format: AudioFormat | None = None,
 ) -> bytes:
     """Crossfade two chunks of pcm/raw audio using ffmpeg."""
-    sample_size = pcm_format.pcm_sample_size
+    if fade_out_pcm_format is None:
+        fade_out_pcm_format = pcm_format
+
     # calculate the fade_length from the smallest chunk
-    fade_length = min(len(fade_in_part), len(fade_out_part)) / sample_size
+    fade_length = min(
+        len(fade_in_part) / pcm_format.pcm_sample_size,
+        len(fade_out_part) / fade_out_pcm_format.pcm_sample_size,
+    )
+    # write the fade_out_part to a temporary file
     fadeout_filename = f"/tmp/{shortuuid.random(20)}.pcm"  # noqa: S108
     async with aiofiles.open(fadeout_filename, "wb") as outfile:
         await outfile.write(fade_out_part)
+
     args = [
         # generic args
         "ffmpeg",
@@ -300,30 +308,42 @@ async def crossfade_pcm_parts(
         "quiet",
         # fadeout part (as file)
         "-acodec",
-        pcm_format.content_type.name.lower(),
-        "-f",
-        pcm_format.content_type.value,
+        fade_out_pcm_format.content_type.name.lower(),
         "-ac",
-        str(pcm_format.channels),
+        str(fade_out_pcm_format.channels),
         "-ar",
-        str(pcm_format.sample_rate),
+        str(fade_out_pcm_format.sample_rate),
+        "-channel_layout",
+        "mono" if fade_out_pcm_format.channels == 1 else "stereo",
+        "-f",
+        fade_out_pcm_format.content_type.value,
         "-i",
         fadeout_filename,
         # fade_in part (stdin)
         "-acodec",
         pcm_format.content_type.name.lower(),
-        "-f",
-        pcm_format.content_type.value,
         "-ac",
         str(pcm_format.channels),
+        "-channel_layout",
+        "mono" if pcm_format.channels == 1 else "stereo",
         "-ar",
         str(pcm_format.sample_rate),
+        "-f",
+        pcm_format.content_type.value,
         "-i",
         "-",
         # filter args
         "-filter_complex",
         f"[0][1]acrossfade=d={fade_length}",
         # output args
+        "-acodec",
+        pcm_format.content_type.name.lower(),
+        "-ac",
+        str(pcm_format.channels),
+        "-channel_layout",
+        "mono" if pcm_format.channels == 1 else "stereo",
+        "-ar",
+        str(pcm_format.sample_rate),
         "-f",
         pcm_format.content_type.value,
         "-",
@@ -346,6 +366,16 @@ async def crossfade_pcm_parts(
         len(fade_in_part),
         len(fade_out_part),
     )
+    if fade_out_pcm_format.sample_rate != pcm_format.sample_rate:
+        # Edge case: the sample rates are different,
+        # we need to resample the fade_out part to the same sample rate as the fade_in part
+        async with FFMpeg(
+            audio_input="-",
+            input_format=fade_out_pcm_format,
+            output_format=pcm_format,
+        ) as ffmpeg:
+            res = await ffmpeg.communicate(fade_out_part)
+            return res[0] + fade_in_part
     return fade_out_part + fade_in_part
 
 
index 352b51a549e8ecd755715172722ae4c85721d244..fa9c2b8ae89ef5a91012ac432c19379a97870639 100644 (file)
@@ -94,6 +94,20 @@ class FFMpeg(AsyncProcess):
         if isinstance(self.audio_input, AsyncGenerator):
             self._stdin_task = asyncio.create_task(self._feed_stdin())
 
+    async def communicate(
+        self,
+        input: bytes | None = None,  # noqa: A002
+        timeout: float | None = None,
+    ) -> tuple[bytes, bytes]:
+        """Override communicate to avoid blocking."""
+        if self._stdin_task and not self._stdin_task.done():
+            self._stdin_task.cancel()
+            with suppress(asyncio.CancelledError):
+                await self._stdin_task
+        if self._logger_task and not self._logger_task.done():
+            self._logger_task.cancel()
+        return await super().communicate(input, timeout)
+
     async def close(self, send_signal: bool = True) -> None:
         """Close/terminate the process and wait for exit."""
         if self.closed:
index eadae4cee0d185aac7e940f2eb2d7b6531c433f7..b18f0a6b2f17a879beebf7124347298355919ca5 100644 (file)
@@ -189,6 +189,27 @@ class AsyncProcess:
                 continue
             yield line
 
+    async def communicate(
+        self,
+        input: bytes | None = None,  # noqa: A002
+        timeout: float | None = None,
+    ) -> tuple[bytes, bytes]:
+        """Communicate with the process and return stdout and stderr."""
+        if self.closed:
+            raise RuntimeError("communicate called while process already done")
+        # abort existing readers on stderr/stdout first before we send communicate
+        waiter: asyncio.Future
+        if self.proc.stdout and (waiter := self.proc.stdout._waiter):
+            self.proc.stdout._waiter = None
+            if waiter and not waiter.done():
+                waiter.set_exception(asyncio.CancelledError())
+        if self.proc.stderr and (waiter := self.proc.stderr._waiter):
+            self.proc.stderr._waiter = None
+            if waiter and not waiter.done():
+                waiter.set_exception(asyncio.CancelledError())
+        stdout, stderr = await asyncio.wait_for(self.proc.communicate(input), timeout)
+        return (stdout, stderr)
+
     async def close(self, send_signal: bool = False) -> None:
         """Close/terminate the process and wait for exit."""
         self._close_called = True
index b28460d89e17ae291a48cb341e796afe60eebe34..0e1234d72b6b9b605d7875852fc5b21c8f2b8d96 100644 (file)
@@ -22,6 +22,7 @@ from music_assistant.constants import (
     CONF_ENTRY_ANNOUNCE_VOLUME_MIN,
     CONF_ENTRY_ANNOUNCE_VOLUME_STRATEGY,
     CONF_ENTRY_AUTO_PLAY,
+    CONF_ENTRY_CROSSFADE,
     CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
     CONF_ENTRY_EXPOSE_PLAYER_TO_HA,
@@ -69,7 +70,7 @@ class PlayerProvider(Provider):
             # config entries that are valid for all/most players
             CONF_ENTRY_PLAYER_ICON,
             CONF_ENTRY_FLOW_MODE,
-            CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
+            CONF_ENTRY_CROSSFADE,
             CONF_ENTRY_CROSSFADE_DURATION,
             CONF_ENTRY_VOLUME_NORMALIZATION,
             CONF_ENTRY_OUTPUT_LIMITER,
@@ -79,6 +80,9 @@ class PlayerProvider(Provider):
         if not (player := self.mass.players.get(player_id)):
             return base_entries
 
+        if PlayerFeature.GAPLESS_PLAYBACK not in player.supported_features:
+            base_entries += (CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,)
+
         if player.type == PlayerType.GROUP:
             # return group player specific entries
             return (
index 73f33e65f0c8cdc48951d13d7cf3eeb3e213b9cc..d6b8cc849b2cc9e3d63798bba2b28377e4fd5626 100644 (file)
@@ -318,7 +318,7 @@ class MyDemoPlayerprovider(PlayerProvider):
         # the queue controller will simply call this play_media method for
         # each item in the queue to play them one by one.
 
-        # In order to support true gapless and/or crossfade, we offer the option of
+        # In order to support true gapless and/or enqueuing, we offer the option of
         # 'flow_mode' playback. In that case the queue controller will stitch together
         # all songs in the playback queue into a single stream and send that to the player.
         # In that case the URI (and metadata) received here is that of the 'flow mode' stream.
index a9072a288ad16a1dbf3d8c3d5178566be111940e..080e57c28bc0c648fd9d48adaa557fcb782f532a 100644 (file)
@@ -25,8 +25,6 @@ from zeroconf import ServiceStateChange
 from zeroconf.asyncio import AsyncServiceInfo
 
 from music_assistant.constants import (
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_DEPRECATED_EQ_BASS,
     CONF_ENTRY_DEPRECATED_EQ_MID,
     CONF_ENTRY_DEPRECATED_EQ_TREBLE,
@@ -65,8 +63,6 @@ CONF_IGNORE_VOLUME = "ignore_volume"
 
 PLAYER_CONFIG_ENTRIES = (
     CONF_ENTRY_FLOW_MODE_ENFORCED,
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_DEPRECATED_EQ_BASS,
     CONF_ENTRY_DEPRECATED_EQ_MID,
     CONF_ENTRY_DEPRECATED_EQ_TREBLE,
index ebaa8d42d001e38d4dad4f72a9026b05991042fa..44873a9c67919daf6f1b00e25e9cca05745c58d1 100644 (file)
@@ -14,7 +14,6 @@ from pyblu import Status, SyncStatus
 from zeroconf import ServiceStateChange
 
 from music_assistant.constants import (
-    CONF_ENTRY_CROSSFADE,
     CONF_ENTRY_ENABLE_ICY_METADATA,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_HTTP_PROFILE_FORCED_2,
@@ -310,11 +309,10 @@ class BluesoundPlayerProvider(PlayerProvider):
         base_entries = await super().get_player_config_entries(self.player_id)
         if not self.bluos_players.get(player_id):
             # TODO fix player entries
-            return (*base_entries, CONF_ENTRY_CROSSFADE)
+            return (*base_entries,)
         return (
             *base_entries,
             CONF_ENTRY_HTTP_PROFILE_FORCED_2,
-            CONF_ENTRY_CROSSFADE,
             CONF_ENTRY_OUTPUT_CODEC,
             CONF_ENTRY_FLOW_MODE_ENFORCED,
             CONF_ENTRY_ENABLE_ICY_METADATA,
index ee836c8d48ba5b621fcf235e2d0532c72fd7af69..e1d2c38bd5470b6903d1ea33b8b51719db7b48c8 100644 (file)
@@ -41,8 +41,6 @@ from music_assistant_models.media_items import AudioFormat
 from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
 
 from music_assistant.constants import (
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_HTTP_PROFILE,
     CONF_ENTRY_HTTP_PROFILE_HIDDEN,
@@ -141,8 +139,6 @@ class BuiltinPlayerProvider(PlayerProvider):
         return (
             *await super().get_player_config_entries(player_id),
             CONF_ENTRY_FLOW_MODE_ENFORCED,
-            CONF_ENTRY_CROSSFADE,
-            CONF_ENTRY_CROSSFADE_DURATION,
             CONF_ENTRY_HTTP_PROFILE,
             # Hide power/volume/mute control options since they are guaranteed to work
             ConfigEntry(
index ae8a48b11380bac42e328fb36028ae1fcb97713f..938bf1eb33bdeaeb3067b6b9f1a0c0fb29cf39b1 100644 (file)
@@ -21,8 +21,6 @@ from pychromecast.discovery import CastBrowser, SimpleCastListener
 from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED
 
 from music_assistant.constants import (
-    CONF_ENTRY_CROSSFADE_DURATION,
-    CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
     CONF_ENTRY_HTTP_PROFILE,
     CONF_ENTRY_MANUAL_DISCOVERY_IPS,
     CONF_ENTRY_OUTPUT_CODEC,
@@ -48,8 +46,6 @@ if TYPE_CHECKING:
 
 
 CAST_PLAYER_CONFIG_ENTRIES = (
-    CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
-    CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_OUTPUT_CODEC,
     CONF_ENTRY_HTTP_PROFILE,
 )
index c1d623b9341e3f5b27454738492af87cd035158b..c004c19e57c4b6493a8c538ca3e3435f95cd975d 100644 (file)
@@ -28,8 +28,6 @@ from music_assistant_models.errors import PlayerUnavailableError
 from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
 
 from music_assistant.constants import (
-    CONF_ENTRY_CROSSFADE_DURATION,
-    CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
     CONF_ENTRY_ENABLE_ICY_METADATA,
     CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED,
     CONF_ENTRY_HTTP_PROFILE,
@@ -57,8 +55,6 @@ if TYPE_CHECKING:
 
 
 PLAYER_CONFIG_ENTRIES = (
-    CONF_ENTRY_CROSSFADE_FLOW_MODE_REQUIRED,
-    CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_OUTPUT_CODEC,
     CONF_ENTRY_HTTP_PROFILE,
     CONF_ENTRY_ENABLE_ICY_METADATA,
@@ -598,6 +594,7 @@ class DLNAPlayerProvider(PlayerProvider):
             # so we simply assume it does and if it doesn't
             # you'll find out at playback time and we log a warning
             PlayerFeature.ENQUEUE,
+            PlayerFeature.GAPLESS_PLAYBACK,
         }
         if dlna_player.device.has_volume_level:
             supported_features.add(PlayerFeature.VOLUME_SET)
index f97fe44ea418ba372384aa47aae96aabbd902b07..19ce41cc5ed1af3be70e190e796b3bff653d72ba 100644 (file)
@@ -14,8 +14,6 @@ from music_assistant_models.errors import PlayerUnavailableError, SetupFailedErr
 from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
 
 from music_assistant.constants import (
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_HTTP_PROFILE,
     CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
@@ -157,8 +155,6 @@ class FullyKioskProvider(PlayerProvider):
         return (
             *base_entries,
             CONF_ENTRY_FLOW_MODE_ENFORCED,
-            CONF_ENTRY_CROSSFADE,
-            CONF_ENTRY_CROSSFADE_DURATION,
             CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
             CONF_ENTRY_HTTP_PROFILE,
         )
index 3fe7d221e13142da27b5bb46ae36bc6c97c895e0..d6c21e5d249d452049cd1836ef97a81b2334b338 100644 (file)
@@ -45,8 +45,6 @@ from music_assistant.constants import (
     CONF_CROSSFADE,
     CONF_CROSSFADE_DURATION,
     CONF_ENABLE_ICY_METADATA,
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_FLOW_MODE,
     CONF_GROUP_MEMBERS,
@@ -249,8 +247,6 @@ class PlayerGroupProvider(PlayerProvider):
                 *base_entries,
                 group_members,
                 CONFIG_ENTRY_UGP_NOTE,
-                CONF_ENTRY_CROSSFADE,
-                CONF_ENTRY_CROSSFADE_DURATION,
                 CONF_ENTRY_SAMPLE_RATES_UGP,
                 CONF_ENTRY_FLOW_MODE_ENFORCED,
             )
index 3c8a6784f743ce49f92f5bef097b04ba9a14efe6..16caf2f7cda726e1289128f428d328e02e1341b0 100644 (file)
@@ -33,8 +33,6 @@ from zeroconf import NonUniqueNameException
 from zeroconf.asyncio import AsyncServiceInfo
 
 from music_assistant.constants import (
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
     DEFAULT_PCM_FORMAT,
@@ -439,8 +437,6 @@ class SnapCastProvider(PlayerProvider):
         return (
             *base_entries,
             CONF_ENTRY_FLOW_MODE_ENFORCED,
-            CONF_ENTRY_CROSSFADE,
-            CONF_ENTRY_CROSSFADE_DURATION,
             CONF_ENTRY_SAMPLE_RATES_SNAPCAST,
             CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
         )
index f5892264b592b8caa9376a91b21c467d8d223bd2..cd8afb3b64efc8d9b1c3b5ca16a556b700928a02 100644 (file)
@@ -20,6 +20,8 @@ PLAYER_FEATURES_BASE = {
     PlayerFeature.NEXT_PREVIOUS,
     PlayerFeature.SEEK,
     PlayerFeature.SELECT_SOURCE,
+    PlayerFeature.GAPLESS_PLAYBACK,
+    PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE,
 }
 
 SOURCE_LINE_IN = "line_in"
index d9808cf9d9b35f67b989f68573e31085a65206dd..0c03fd177e5b52dccb4ef4a2c0151eeb663ee94e 100644 (file)
@@ -14,7 +14,6 @@ import time
 from collections.abc import Callable
 from typing import TYPE_CHECKING
 
-import shortuuid
 from aiohttp.client_exceptions import ClientConnectorError
 from aiosonos.api.models import ContainerType, MusicService, SonosCapability
 from aiosonos.client import SonosLocalApiClient
@@ -77,7 +76,6 @@ class SonosPlayer:
         # We can do some smart stuff if we link them together where possible.
         # The player we can just guess from the sonos player id (mac address).
         self.airplay_player_id = f"ap{self.player_id[7:-5].lower()}"
-        self.queue_version: str = shortuuid.random(8)
         self._on_cleanup_callbacks: list[Callable[[], None]] = []
 
     @property
@@ -513,8 +511,9 @@ class SonosPlayer:
             # sync crossfade and repeat modes
             await self.sync_play_modes(event.object_id)
         elif event.event == EventType.QUEUE_ITEMS_UPDATED:
-            # update the queue version to force a refresh
-            self.queue_version = shortuuid.random(8)
+            # refresh cloud queue
+            if session_id := self.client.player.group.active_session_id:
+                await self.client.api.playback_session.refresh_cloud_queue(session_id)
 
     async def sync_play_modes(self, queue_id: str) -> None:
         """Sync the play modes between MA and Sonos."""
index 0b770677506e94049c57047e6aa9fb4acf2c90c7..ef00014954ed060b5c815f92d8919ec6afb08314 100644 (file)
@@ -11,7 +11,6 @@ import asyncio
 import time
 from typing import TYPE_CHECKING, Any
 
-import shortuuid
 from aiohttp import web
 from aiohttp.client_exceptions import ClientError
 from aiosonos.api.models import SonosCapability
@@ -23,9 +22,6 @@ from music_assistant_models.player import DeviceInfo, PlayerMedia
 from zeroconf import ServiceStateChange
 
 from music_assistant.constants import (
-    CONF_CROSSFADE,
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION_HIDDEN,
     CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
     CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
     CONF_ENTRY_MANUAL_DISCOVERY_IPS,
@@ -149,8 +145,6 @@ class SonosPlayerProvider(PlayerProvider):
         """Return Config Entries for the given player."""
         base_entries = (
             *await super().get_player_config_entries(player_id),
-            CONF_ENTRY_CROSSFADE,
-            CONF_ENTRY_CROSSFADE_DURATION_HIDDEN,
             CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
             CONF_ENTRY_OUTPUT_CODEC,
             CONF_ENTRY_HTTP_PROFILE_DEFAULT_2,
@@ -301,7 +295,6 @@ class SonosPlayerProvider(PlayerProvider):
     ) -> None:
         """Handle PLAY MEDIA on given player."""
         sonos_player = self.sonos_players[player_id]
-        sonos_player.queue_version = shortuuid.random(8)
         mass_player = self.mass.players.get(player_id)
         if sonos_player.client.player.is_passive:
             # this should be already handled by the player manager, but just in case...
@@ -344,11 +337,12 @@ class SonosPlayerProvider(PlayerProvider):
             # Regular Queue item playback
             # create a sonos cloud queue and load it
             cloud_queue_url = f"{self.mass.streams.base_url}/sonos_queue/v2.3/"
+            mass_queue = self.mass.player_queues.get(media.queue_id)
             await sonos_player.client.player.group.play_cloud_queue(
                 cloud_queue_url,
                 http_authorization=media.queue_id,
                 item_id=media.queue_item_id,
-                queue_version=sonos_player.queue_version,
+                queue_version=str(int(mass_queue.items_last_updated)),
             )
             self.mass.call_later(5, sonos_player.sync_play_modes, media.queue_id)
             return
@@ -481,10 +475,11 @@ class SonosPlayerProvider(PlayerProvider):
         self.logger.log(VERBOSE_LOG_LEVEL, "Cloud Queue Version request: %s", request.query)
         sonos_playback_id = request.headers["X-Sonos-Playback-Id"]
         sonos_player_id = sonos_playback_id.split(":")[0]
-        if not (sonos_player := self.sonos_players.get(sonos_player_id)):
+        if not (self.sonos_players.get(sonos_player_id)):
             return web.Response(status=501)
+        mass_queue = self.mass.player_queues.get_active_queue(sonos_player_id)
         context_version = request.query.get("contextVersion") or "1"
-        queue_version = sonos_player.queue_version
+        queue_version = str(int(mass_queue.items_last_updated))
         result = {"contextVersion": context_version, "queueVersion": queue_version}
         return web.json_response(result)
 
@@ -499,11 +494,11 @@ class SonosPlayerProvider(PlayerProvider):
         sonos_player_id = sonos_playback_id.split(":")[0]
         if not (mass_queue := self.mass.player_queues.get_active_queue(sonos_player_id)):
             return web.Response(status=501)
-        if not (sonos_player := self.sonos_players.get(sonos_player_id)):
+        if not (self.sonos_players.get(sonos_player_id)):
             return web.Response(status=501)
         result = {
             "contextVersion": "1",
-            "queueVersion": sonos_player.queue_version,
+            "queueVersion": str(int(mass_queue.items_last_updated)),
             "container": {
                 "type": "playlist",
                 "name": "Music Assistant",
@@ -529,7 +524,7 @@ class SonosPlayerProvider(PlayerProvider):
                 "canSeek": False,
                 "canRepeat": True,
                 "canRepeatOne": True,
-                "canCrossfade": True,
+                "canCrossfade": False,  # crossfading is handled by our streams controller
                 "canShuffle": True,
             },
         }
@@ -562,7 +557,9 @@ class SonosPlayerProvider(PlayerProvider):
 
     async def _parse_sonos_queue_item(self, queue_item: QueueItem) -> dict[str, Any]:
         """Parse a Sonos queue item to a PlayerMedia object."""
-        stream_url = await self.mass.streams.resolve_stream_url(queue_item)
+        queue = self.mass.player_queues.get(queue_item.queue_id)
+        assert queue  # for type checking
+        stream_url = await self.mass.streams.resolve_stream_url(queue.session_id, queue_item)
         if streamdetails := queue_item.streamdetails:
             duration = streamdetails.duration or queue_item.duration
             if duration and streamdetails.seek_position:
@@ -574,7 +571,7 @@ class SonosPlayerProvider(PlayerProvider):
             "id": queue_item.queue_item_id,
             "deleted": not queue_item.available,
             "policies": {
-                "canCrossfade": True,
+                "canCrossfade": False,  # crossfading is handled by our streams controller
                 "canSkip": True,
                 "canSkipBack": True,
                 "canSkipToItem": True,
@@ -586,7 +583,7 @@ class SonosPlayerProvider(PlayerProvider):
             },
             "track": {
                 "type": "track",
-                "mediaUrl": await self.mass.streams.resolve_stream_url(queue_item),
+                "mediaUrl": stream_url,
                 "contentType": f"audio/{stream_url.split('.')[-1]}",
                 "service": {
                     "name": "Music Assistant",
@@ -681,9 +678,7 @@ class SonosPlayerProvider(PlayerProvider):
                     f"Failed to send command to Sonos player: {resp.status} {resp.reason}"
                 )
 
-        # set crossfade mode if needed
-        crossfade = bool(
-            await self.mass.config.get_player_config_value(sonos_player.player_id, CONF_CROSSFADE)
-        )
-        if sonos_player.client.player.group.play_modes.crossfade != crossfade:
-            await sonos_player.client.player.group.set_play_modes(crossfade=True)
+        # disable crossfade mode if needed
+        # crossfading is handled by our streams controller
+        if sonos_player.client.player.group.play_modes.crossfade:
+            await sonos_player.client.player.group.set_play_modes(crossfade=False)
index 787064e6687dd8f1622e69a5e89b46ef16fcfa63..28218ae0cf771c38b8373821f47d0a77b6a0bef4 100644 (file)
@@ -31,9 +31,6 @@ from soco import config as soco_config
 from soco.discovery import discover, scan_network
 
 from music_assistant.constants import (
-    CONF_CROSSFADE,
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION_HIDDEN,
     CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
     CONF_ENTRY_HTTP_PROFILE_DEFAULT_1,
     CONF_ENTRY_MANUAL_DISCOVERY_IPS,
@@ -59,6 +56,8 @@ PLAYER_FEATURES = {
     PlayerFeature.VOLUME_MUTE,
     PlayerFeature.PAUSE,
     PlayerFeature.ENQUEUE,
+    PlayerFeature.GAPLESS_PLAYBACK,
+    PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE,
 }
 
 CONF_NETWORK_SCAN = "network_scan"
@@ -182,8 +181,6 @@ class SonosPlayerProvider(PlayerProvider):
         base_entries = await super().get_player_config_entries(player_id)
         return (
             *base_entries,
-            CONF_ENTRY_CROSSFADE,
-            CONF_ENTRY_CROSSFADE_DURATION_HIDDEN,
             CONF_ENTRY_SAMPLE_RATES,
             CONF_ENTRY_OUTPUT_CODEC,
             CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
@@ -303,14 +300,15 @@ class SonosPlayerProvider(PlayerProvider):
         """Handle enqueuing of the next queue item on the player."""
         sonos_player = self.sonosplayers[player_id]
         didl_metadata = create_didl_metadata(media)
-        # set crossfade according to player setting
-        crossfade = bool(await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE))
-        if sonos_player.crossfade != crossfade:
+
+        # disable crossfade mode if needed
+        # crossfading is handled by our streams controller
+        if sonos_player.crossfade:
 
             def set_crossfade() -> None:
                 try:
-                    sonos_player.soco.cross_fade = crossfade
-                    sonos_player.crossfade = crossfade
+                    sonos_player.soco.cross_fade = False
+                    sonos_player.crossfade = False
                 except Exception as err:
                     self.logger.warning(
                         "Unable to set crossfade for player %s: %s", sonos_player.zone_name, err
index 746785edd2988b309324618acadd4c0b46065999..c315929d3d1e17034f572a97c661c6525de388e3 100644 (file)
@@ -14,7 +14,6 @@ from typing import TYPE_CHECKING
 from aiohttp import web
 from aioslimproto.client import PlayerState as SlimPlayerState
 from aioslimproto.client import SlimClient
-from aioslimproto.client import TransitionType as SlimTransition
 from aioslimproto.models import EventType as SlimEventType
 from aioslimproto.models import Preset as SlimPreset
 from aioslimproto.models import VisualisationType as SlimVisualisationType
@@ -40,10 +39,6 @@ from music_assistant_models.media_items import AudioFormat
 from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
 
 from music_assistant.constants import (
-    CONF_CROSSFADE,
-    CONF_CROSSFADE_DURATION,
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_DEPRECATED_EQ_BASS,
     CONF_ENTRY_DEPRECATED_EQ_MID,
     CONF_ENTRY_DEPRECATED_EQ_TREBLE,
@@ -311,11 +306,9 @@ class SlimprotoProvider(PlayerProvider):
             base_entries
             + preset_entries
             + (
-                CONF_ENTRY_CROSSFADE,
                 CONF_ENTRY_DEPRECATED_EQ_BASS,
                 CONF_ENTRY_DEPRECATED_EQ_MID,
                 CONF_ENTRY_DEPRECATED_EQ_TREBLE,
-                CONF_ENTRY_CROSSFADE_DURATION,
                 CONF_ENTRY_OUTPUT_CODEC,
                 CONF_ENTRY_SYNC_ADJUST,
                 CONF_ENTRY_DISPLAY,
@@ -460,14 +453,6 @@ class SlimprotoProvider(PlayerProvider):
         auto_play: bool = False,
     ) -> None:
         """Handle playback of an url on slimproto player(s)."""
-        player_id = slimplayer.player_id
-        if crossfade := await self.mass.config.get_player_config_value(player_id, CONF_CROSSFADE):
-            transition_duration = await self.mass.config.get_player_config_value(
-                player_id, CONF_CROSSFADE_DURATION
-            )
-        else:
-            transition_duration = 0
-
         metadata = {
             "item_id": media.uri,
             "title": media.title,
@@ -487,8 +472,6 @@ class SlimprotoProvider(PlayerProvider):
             metadata=metadata,
             enqueue=enqueue,
             send_flush=send_flush,
-            transition=SlimTransition.CROSSFADE if crossfade else SlimTransition.NONE,
-            transition_duration=transition_duration,
             # if autoplay=False playback will not start automatically
             # instead 'buffer ready' will be called when the buffer is full
             # to coordinate a start of multiple synced players
@@ -507,8 +490,6 @@ class SlimprotoProvider(PlayerProvider):
                     metadata=metadata,
                     enqueue=True,
                     send_flush=False,
-                    transition=SlimTransition.CROSSFADE if crossfade else SlimTransition.NONE,
-                    transition_duration=transition_duration,
                     autostart=True,
                 ),
             )
@@ -657,6 +638,7 @@ class SlimprotoProvider(PlayerProvider):
                     PlayerFeature.PAUSE,
                     PlayerFeature.VOLUME_MUTE,
                     PlayerFeature.ENQUEUE,
+                    PlayerFeature.GAPLESS_PLAYBACK,
                 },
                 can_group_with={self.instance_id},
             )