From 3040bd2abe223b5d7205a03fc1d0db924fc1d504 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 31 Mar 2025 22:05:20 +0200 Subject: [PATCH] Revamped Crossfade support (#2087) * 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 --- music_assistant/constants.py | 6 +- music_assistant/controllers/player_queues.py | 39 +- music_assistant/controllers/streams.py | 406 ++++++++++++------ music_assistant/helpers/audio.py | 48 ++- music_assistant/helpers/ffmpeg.py | 14 + music_assistant/helpers/process.py | 21 + music_assistant/models/player_provider.py | 6 +- .../_template_player_provider/__init__.py | 2 +- music_assistant/providers/airplay/provider.py | 4 - .../providers/bluesound/__init__.py | 4 +- .../providers/builtin_player/__init__.py | 4 - .../providers/chromecast/__init__.py | 4 - music_assistant/providers/dlna/__init__.py | 5 +- .../providers/fully_kiosk/__init__.py | 4 - .../providers/player_group/__init__.py | 4 - .../providers/snapcast/__init__.py | 4 - music_assistant/providers/sonos/const.py | 2 + music_assistant/providers/sonos/player.py | 7 +- music_assistant/providers/sonos/provider.py | 39 +- .../providers/sonos_s1/__init__.py | 18 +- .../providers/squeezelite/__init__.py | 20 +- 21 files changed, 409 insertions(+), 252 deletions(-) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 725b1387..51d27e6e 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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, diff --git a/music_assistant/controllers/player_queues.py b/music_assistant/controllers/player_queues.py index 26c01f39..95f30316 100644 --- a/music_assistant/controllers/player_queues.py +++ b/music_assistant/controllers/player_queues.py @@ -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 ( diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index 2c79c640..261c8046 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -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 diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index 4df5a00a..e630cf23 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -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 diff --git a/music_assistant/helpers/ffmpeg.py b/music_assistant/helpers/ffmpeg.py index 352b51a5..fa9c2b8a 100644 --- a/music_assistant/helpers/ffmpeg.py +++ b/music_assistant/helpers/ffmpeg.py @@ -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: diff --git a/music_assistant/helpers/process.py b/music_assistant/helpers/process.py index eadae4ce..b18f0a6b 100644 --- a/music_assistant/helpers/process.py +++ b/music_assistant/helpers/process.py @@ -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 diff --git a/music_assistant/models/player_provider.py b/music_assistant/models/player_provider.py index b28460d8..0e1234d7 100644 --- a/music_assistant/models/player_provider.py +++ b/music_assistant/models/player_provider.py @@ -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 ( diff --git a/music_assistant/providers/_template_player_provider/__init__.py b/music_assistant/providers/_template_player_provider/__init__.py index 73f33e65..d6b8cc84 100644 --- a/music_assistant/providers/_template_player_provider/__init__.py +++ b/music_assistant/providers/_template_player_provider/__init__.py @@ -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. diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index a9072a28..080e57c2 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -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, diff --git a/music_assistant/providers/bluesound/__init__.py b/music_assistant/providers/bluesound/__init__.py index ebaa8d42..44873a9c 100644 --- a/music_assistant/providers/bluesound/__init__.py +++ b/music_assistant/providers/bluesound/__init__.py @@ -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, diff --git a/music_assistant/providers/builtin_player/__init__.py b/music_assistant/providers/builtin_player/__init__.py index ee836c8d..e1d2c38b 100644 --- a/music_assistant/providers/builtin_player/__init__.py +++ b/music_assistant/providers/builtin_player/__init__.py @@ -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( diff --git a/music_assistant/providers/chromecast/__init__.py b/music_assistant/providers/chromecast/__init__.py index ae8a48b1..938bf1eb 100644 --- a/music_assistant/providers/chromecast/__init__.py +++ b/music_assistant/providers/chromecast/__init__.py @@ -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, ) diff --git a/music_assistant/providers/dlna/__init__.py b/music_assistant/providers/dlna/__init__.py index c1d623b9..c004c19e 100644 --- a/music_assistant/providers/dlna/__init__.py +++ b/music_assistant/providers/dlna/__init__.py @@ -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) diff --git a/music_assistant/providers/fully_kiosk/__init__.py b/music_assistant/providers/fully_kiosk/__init__.py index f97fe44e..19ce41cc 100644 --- a/music_assistant/providers/fully_kiosk/__init__.py +++ b/music_assistant/providers/fully_kiosk/__init__.py @@ -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, ) diff --git a/music_assistant/providers/player_group/__init__.py b/music_assistant/providers/player_group/__init__.py index 3fe7d221..d6c21e5d 100644 --- a/music_assistant/providers/player_group/__init__.py +++ b/music_assistant/providers/player_group/__init__.py @@ -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, ) diff --git a/music_assistant/providers/snapcast/__init__.py b/music_assistant/providers/snapcast/__init__.py index 3c8a6784..16caf2f7 100644 --- a/music_assistant/providers/snapcast/__init__.py +++ b/music_assistant/providers/snapcast/__init__.py @@ -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, ) diff --git a/music_assistant/providers/sonos/const.py b/music_assistant/providers/sonos/const.py index f5892264..cd8afb3b 100644 --- a/music_assistant/providers/sonos/const.py +++ b/music_assistant/providers/sonos/const.py @@ -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" diff --git a/music_assistant/providers/sonos/player.py b/music_assistant/providers/sonos/player.py index d9808cf9..0c03fd17 100644 --- a/music_assistant/providers/sonos/player.py +++ b/music_assistant/providers/sonos/player.py @@ -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.""" diff --git a/music_assistant/providers/sonos/provider.py b/music_assistant/providers/sonos/provider.py index 0b770677..ef000149 100644 --- a/music_assistant/providers/sonos/provider.py +++ b/music_assistant/providers/sonos/provider.py @@ -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) diff --git a/music_assistant/providers/sonos_s1/__init__.py b/music_assistant/providers/sonos_s1/__init__.py index 787064e6..28218ae0 100644 --- a/music_assistant/providers/sonos_s1/__init__.py +++ b/music_assistant/providers/sonos_s1/__init__.py @@ -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 diff --git a/music_assistant/providers/squeezelite/__init__.py b/music_assistant/providers/squeezelite/__init__.py index 746785ed..c315929d 100644 --- a/music_assistant/providers/squeezelite/__init__.py +++ b/music_assistant/providers/squeezelite/__init__.py @@ -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}, ) -- 2.34.1