Fix several edge cases for streaming (with crossfade enabled) (#2547)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sat, 25 Oct 2025 15:20:10 +0000 (17:20 +0200)
committerGitHub <noreply@github.com>
Sat, 25 Oct 2025 15:20:10 +0000 (17:20 +0200)
music_assistant/constants.py
music_assistant/controllers/streams.py
music_assistant/helpers/audio.py
music_assistant/providers/airplay/constants.py
music_assistant/providers/builtin_player/player.py
music_assistant/providers/snapcast/player.py
music_assistant/providers/squeezelite/player.py
music_assistant/providers/universal_group/constants.py

index 9cfaa5618a6ca80f15a0d148d49e8dcdfd35337c..3ead2c86e0d60b9bf63e18e7e35bde074bce6355 100644 (file)
@@ -630,6 +630,18 @@ CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED = ConfigEntry.from_dict(
     }
 )
 
+CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES = ConfigEntry(
+    key="crossfade_different_sample_rates",
+    type=ConfigEntryType.BOOLEAN,
+    label="Allow crossfade between tracks with different sample rates",
+    description="Enable this option to allow crossfading between tracks that have different "
+    "sample rates (e.g. 44.1kHz to 48kHz). \n\n "
+    "Only enable this option if your player actually support this, otherwise you may "
+    "experience audio glitches during crossfades.",
+    default_value=False,
+    category="advanced",
+)
+
 CONF_ENTRY_WARN_PREVIEW = ConfigEntry(
     key="preview_note",
     type=ConfigEntryType.ALERT,
@@ -929,13 +941,13 @@ ICY_HEADERS = {
     "icy-logo": MASS_LOGO_ONLINE,
 }
 
-DEFAULT_PCM_FORMAT = AudioFormat(
+INTERNAL_PCM_FORMAT = AudioFormat(
     # always prefer float32 as internal pcm format to create headroom
     # for filters such as dsp and volume normalization
     content_type=ContentType.PCM_F32LE,
-    sample_rate=48000,
-    bit_depth=32,
-    channels=2,
+    bit_depth=32,  # related to float32
+    sample_rate=48000,  # static for flow stream, dynamic for anything else
+    channels=2,  # static for flow stream, dynamic for anything else
 )
 
 # extra data / extra attributes keys
index fda520399cf87e19da6cf01d44c88a9308d15bec..8982606f8a592d9ab71cfe754ed3d3f5ca4444d8 100644 (file)
@@ -38,6 +38,7 @@ from music_assistant.constants import (
     CONF_BIND_PORT,
     CONF_CROSSFADE_DURATION,
     CONF_ENTRY_ENABLE_ICY_METADATA,
+    CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES,
     CONF_HTTP_PROFILE,
     CONF_OUTPUT_CHANNELS,
     CONF_OUTPUT_CODEC,
@@ -48,18 +49,18 @@ from music_assistant.constants import (
     CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS,
     CONF_VOLUME_NORMALIZATION_RADIO,
     CONF_VOLUME_NORMALIZATION_TRACKS,
-    DEFAULT_PCM_FORMAT,
     DEFAULT_STREAM_HEADERS,
     ICY_HEADERS,
+    INTERNAL_PCM_FORMAT,
     SILENCE_FILE,
     VERBOSE_LOG_LEVEL,
 )
 from music_assistant.controllers.players.player_controller import AnnounceData
 from music_assistant.helpers.audio import LOGGER as AUDIO_LOGGER
 from music_assistant.helpers.audio import (
+    get_buffered_media_stream,
     get_chunksize,
     get_media_stream,
-    get_media_stream_with_buffer,
     get_player_filter_params,
     get_silence,
     get_stream_details,
@@ -92,6 +93,7 @@ if TYPE_CHECKING:
 isfile = wrap(os.path.isfile)
 
 CONF_ALLOW_BUFFER: Final[str] = "allow_buffering"
+CONF_ALLOW_CROSSFADE_SAME_ALBUM: Final[str] = "allow_crossfade_same_album"
 
 
 def parse_pcm_info(content_type: str) -> tuple[int, int, int]:
@@ -231,6 +233,15 @@ class StreamsController(CoreController):
                 label="Fixed/fallback gain adjustment for tracks",
                 category="audio",
             ),
+            ConfigEntry(
+                key=CONF_ALLOW_CROSSFADE_SAME_ALBUM,
+                type=ConfigEntryType.BOOLEAN,
+                default_value=False,
+                label="Allow crossfade between tracks from the same album",
+                description="Enabling this option allows for crossfading between tracks "
+                "that are part of the same album.",
+                category="audio",
+            ),
             ConfigEntry(
                 key=CONF_BIND_IP,
                 type=ConfigEntryType.STRING,
@@ -380,7 +391,7 @@ class StreamsController(CoreController):
             player=queue_player,
             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,
+            content_bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
         )
 
         # prepare request, add some DLNA/UPNP compatible headers
@@ -435,19 +446,18 @@ class StreamsController(CoreController):
             )
             smart_fades_mode = SmartFadesMode.DISABLED
 
-        # work out pcm format based on output format
+        # work out pcm format based on streamdetails
         pcm_format = AudioFormat(
-            sample_rate=output_format.sample_rate,
+            sample_rate=queue_item.streamdetails.audio_format.sample_rate,
             # always use f32 internally for extra headroom for filters etc
-            content_type=ContentType.PCM_F32LE,
-            bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
-            channels=2,
+            content_type=INTERNAL_PCM_FORMAT.content_type,
+            bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
+            channels=queue_item.streamdetails.audio_format.channels,
         )
         if smart_fades_mode != SmartFadesMode.DISABLED:
             # crossfade is enabled, use special crossfaded single item stream
             # where the crossfade of the next track is present in the stream of
-            # a single track. This only works if the player supports gapless playback.
-
+            # a single track. This only works if the player supports gapless playback!
             audio_input = self.get_queue_item_stream_with_smartfade(
                 queue_item=queue_item,
                 pcm_format=pcm_format,
@@ -865,10 +875,21 @@ class StreamsController(CoreController):
             # append to play log so the queue controller can work out which track is playing
             play_log_entry = PlayLogEntry(queue_track.queue_item_id)
             queue.flow_mode_stream_log.append(play_log_entry)
-            if smart_fades_mode == SmartFadesMode.SMART_FADES:
-                crossfade_size = int(pcm_format.pcm_sample_size * SMART_CROSSFADE_DURATION)
-            else:
-                crossfade_size = int(pcm_format.pcm_sample_size * standard_crossfade_duration + 4)
+
+            # calculate crossfade buffer size
+            crossfade_buffer_duration = (
+                SMART_CROSSFADE_DURATION
+                if smart_fades_mode == SmartFadesMode.SMART_FADES
+                else standard_crossfade_duration
+            )
+            crossfade_buffer_duration = min(
+                crossfade_buffer_duration,
+                int(queue_track.streamdetails.duration / 2)
+                if queue_track.streamdetails.duration
+                else crossfade_buffer_duration,
+            )
+            crossfade_buffer_size = int(pcm_format.pcm_sample_size * crossfade_buffer_duration)
+
             bytes_written = 0
             buffer = b""
             # handle incoming audio chunks
@@ -881,7 +902,7 @@ class StreamsController(CoreController):
                 req_buffer_size = (
                     pcm_sample_size
                     if smart_fades_mode == SmartFadesMode.DISABLED
-                    else crossfade_size
+                    else crossfade_buffer_size
                 )
 
                 # ALWAYS APPEND CHUNK TO BUFFER
@@ -894,8 +915,8 @@ class StreamsController(CoreController):
                 ####  HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
                 if last_fadeout_part and last_streamdetails:
                     # perform crossfade
-                    fadein_part = buffer[:crossfade_size]
-                    remaining_bytes = buffer[crossfade_size:]
+                    fadein_part = buffer[:crossfade_buffer_size]
+                    remaining_bytes = buffer[crossfade_buffer_size:]
                     # Use the mixer to handle all crossfade logic
                     crossfade_part = await self._smart_fades_mixer.mix(
                         fade_in_part=fadein_part,
@@ -944,10 +965,10 @@ class StreamsController(CoreController):
                 queue_track, smart_fades_mode=smart_fades_mode, flow_mode=True
             ):
                 # if crossfade is enabled, save fadeout part to pickup for next track
-                last_fadeout_part = buffer[-crossfade_size:]
+                last_fadeout_part = buffer[-crossfade_buffer_size:]
                 last_streamdetails = queue_track.streamdetails
                 last_play_log_entry = play_log_entry
-                remaining_bytes = buffer[:-crossfade_size]
+                remaining_bytes = buffer[:-crossfade_buffer_size]
                 if remaining_bytes:
                     yield remaining_bytes
                     bytes_written += len(remaining_bytes)
@@ -1127,7 +1148,7 @@ class StreamsController(CoreController):
             streamdetails.volume_normalization_mode,
         )
         if allow_buffer:
-            media_stream_gen = get_media_stream_with_buffer(
+            media_stream_gen = get_buffered_media_stream(
                 self.mass,
                 streamdetails=streamdetails,
                 pcm_format=pcm_format,
@@ -1191,7 +1212,7 @@ class StreamsController(CoreController):
             seconds_streamed = bytes_received / pcm_format.pcm_sample_size
             streamdetails.seconds_streamed = seconds_streamed
             self.logger.debug(
-                "stream %s for %s in %.2f seconds - seconds streamed: %s",
+                "stream %s for %s in %.2f seconds - seconds streamed: %.2f",
                 "aborted" if aborted else "finished",
                 streamdetails.uri,
                 asyncio.get_event_loop().time() - stream_started_at,
@@ -1217,7 +1238,7 @@ class StreamsController(CoreController):
         smart_fades_mode: SmartFadesMode = SmartFadesMode.SMART_FADES,
         standard_crossfade_duration: int = 10,
     ) -> AsyncGenerator[bytes, None]:
-        """Get the audio stream for a single queue item with crossfade to the next item."""
+        """Get the audio stream for a single queue item with (smart) crossfade to the next item."""
         queue = self.mass.player_queues.get(queue_item.queue_id)
         if not queue:
             raise RuntimeError(f"Queue {queue_item.queue_id} not found")
@@ -1226,28 +1247,42 @@ class StreamsController(CoreController):
         assert streamdetails
         crossfade_data = self._crossfade_data.get(queue.queue_id)
 
+        if crossfade_data and crossfade_data.session_id != session_id:
+            # invalidate expired crossfade data
+            crossfade_data = None
+
         self.logger.debug(
-            "Start Streaming queue track: %s (%s) for queue %s - crossfade: %s",
+            "Start Streaming queue track: %s (%s) for queue %s "
+            "- crossfade mode: %s "
+            "- crossfading from previous track: %s ",
             queue_item.streamdetails.uri if queue_item.streamdetails else "Unknown URI",
             queue_item.name,
             queue.display_name,
             smart_fades_mode,
+            "true" if crossfade_data else "false",
         )
 
-        if crossfade_data and crossfade_data.session_id != session_id:
-            # invalidate expired crossfade data
-            crossfade_data = None
-
         buffer = b""
         bytes_written = 0
-        if smart_fades_mode == SmartFadesMode.SMART_FADES:
-            crossfade_size = int(pcm_format.pcm_sample_size * SMART_CROSSFADE_DURATION)
-        else:
-            crossfade_size = int(pcm_format.pcm_sample_size * standard_crossfade_duration + 4)
+        # calculate crossfade buffer size
+        crossfade_buffer_duration = (
+            SMART_CROSSFADE_DURATION
+            if smart_fades_mode == SmartFadesMode.SMART_FADES
+            else standard_crossfade_duration
+        )
+        crossfade_buffer_duration = min(
+            crossfade_buffer_duration,
+            int(streamdetails.duration / 2)
+            if streamdetails.duration
+            else crossfade_buffer_duration,
+        )
+        crossfade_buffer_size = int(pcm_format.pcm_sample_size * crossfade_buffer_duration)
         fade_out_data: bytes | None = None
 
         if crossfade_data:
-            discard_seconds = int(crossfade_data.fade_in_size / pcm_format.pcm_sample_size) - 1
+            discard_seconds = (
+                int(crossfade_data.fade_in_size / crossfade_data.pcm_format.pcm_sample_size) - 1
+            )
             discard_bytes = int(discard_seconds * pcm_format.pcm_sample_size)
             discard_leftover = int(crossfade_data.fade_in_size - discard_bytes)
         else:
@@ -1257,40 +1292,53 @@ class StreamsController(CoreController):
         async for chunk in self.get_queue_item_stream(
             queue_item, pcm_format, seek_position=discard_seconds
         ):
+            if discard_leftover:
+                # discard leftover bytes from crossfade data
+                chunk = chunk[discard_leftover:]  # noqa: PLW2901
+                discard_leftover = 0
             # ALWAYS APPEND CHUNK TO BUFFER
             buffer += chunk
             del chunk
-            if len(buffer) < crossfade_size:
+            if len(buffer) < crossfade_buffer_size:
                 # buffer is not full enough, move on
                 continue
 
             ####  HANDLE CROSSFADE DATA FROM PREVIOUS TRACK
             if crossfade_data:
-                # discard the fade_in_part from the crossfade data (minus what we already seeked)
-                buffer = buffer[discard_leftover:]
                 # send the (second half of the) crossfade data
                 if crossfade_data.pcm_format != pcm_format:
-                    # pcm format mismatch, we need to resample the crossfade data
-                    async for _crossfade_chunk in resample_pcm_audio(
-                        crossfade_data.data, crossfade_data.pcm_format, pcm_format
-                    ):
-                        yield _crossfade_chunk
-                        bytes_written += len(_crossfade_chunk)
-                        del _crossfade_chunk
-                else:
-                    yield crossfade_data.data
-                    bytes_written += len(crossfade_data.data)
+                    # edge case: pcm format mismatch, we need to resample
+                    crossfade_data.data = await resample_pcm_audio(
+                        crossfade_data.data,
+                        crossfade_data.pcm_format,
+                        pcm_format,
+                    )
+                yield crossfade_data.data
+                bytes_written += len(crossfade_data.data)
                 # clear vars
                 crossfade_data = None
 
             #### OTHER: enough data in buffer, feed to output
-            while len(buffer) > crossfade_size:
+            while len(buffer) > crossfade_buffer_size:
                 yield buffer[: pcm_format.pcm_sample_size]
                 bytes_written += pcm_format.pcm_sample_size
                 buffer = buffer[pcm_format.pcm_sample_size :]
 
         #### HANDLE END OF TRACK
 
+        if crossfade_data:
+            # edge case: we did not get enough data to send the crossfade data
+            # send the (second half of the) crossfade data
+            if crossfade_data.pcm_format != pcm_format:
+                # (yet another) edge case: pcm format mismatch, we need to resample
+                crossfade_data.data = await resample_pcm_audio(
+                    crossfade_data.data,
+                    crossfade_data.pcm_format,
+                    pcm_format,
+                )
+            yield crossfade_data.data
+            bytes_written += len(crossfade_data.data)
+            crossfade_data = None
         if not self._crossfade_allowed(
             queue_item, smart_fades_mode=smart_fades_mode, flow_mode=False
         ):
@@ -1298,55 +1346,72 @@ class StreamsController(CoreController):
             bytes_written += len(buffer)
             yield buffer
         else:
-            # if crossfade is enabled, save fadeout part to pickup for next track
-            fade_out_data = buffer[-crossfade_size:]
-            remaining_bytes = buffer[:-crossfade_size]
-            if remaining_bytes:
-                yield remaining_bytes
-                bytes_written += len(remaining_bytes)
-            del remaining_bytes
+            # if crossfade is enabled, save fadeout part in buffer to pickup for next track
+            fade_out_data = buffer
             buffer = b""
             # get next track for crossfade
             try:
                 next_queue_item = await self.mass.player_queues.load_next_queue_item(
                     queue.queue_id, queue_item.queue_item_id
                 )
-                async for chunk in self.get_queue_item_stream(next_queue_item, pcm_format):
-                    # ALWAYS APPEND CHUNK TO BUFFER
+                next_queue_item_pcm_format = AudioFormat(
+                    content_type=INTERNAL_PCM_FORMAT.content_type,
+                    bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
+                    sample_rate=next_queue_item.streamdetails.audio_format.sample_rate,
+                    channels=next_queue_item.streamdetails.audio_format.channels,
+                )
+                async for chunk in self.get_queue_item_stream(
+                    next_queue_item, next_queue_item_pcm_format
+                ):
+                    # append to buffer until we reach crossfade size
+                    # we only need the first X seconds of the NEXT track so we can
+                    # perform the crossfade.
+                    # the crossfaded audio of the previous and next track will be
+                    # sent in two equal parts: first half now, second half
+                    # when the next track starts. We use CrossfadeData to store
+                    # the second half to be picked up by the next track's stream generator.
+                    # Note that we more or less expect the user to have enabled the in-memory
+                    # buffer so we can keep the next track's audio data in memory.
                     buffer += chunk
                     del chunk
-                    if len(buffer) < crossfade_size:
-                        # buffer is not full enough, move on
-                        continue
-                    ####  HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
-                    crossfade_data = await self._smart_fades_mixer.mix(
-                        fade_in_part=buffer,
-                        fade_out_part=fade_out_data,
-                        fade_in_streamdetails=next_queue_item.streamdetails,
-                        fade_out_streamdetails=queue_item.streamdetails,
-                        pcm_format=pcm_format,
-                        standard_crossfade_duration=standard_crossfade_duration,
-                        mode=smart_fades_mode,
-                    )
-                    # send half of the crossfade_part (= approx the fadeout part)
-                    crossfade_first, crossfade_second = (
-                        crossfade_data[: len(crossfade_data) // 2 + len(crossfade_data) % 2],
-                        crossfade_data[len(crossfade_data) // 2 + len(crossfade_data) % 2 :],
-                    )
-                    bytes_written += len(crossfade_first)
-                    yield crossfade_first
-                    del crossfade_first
-                    # store the other half for the next track
-                    self._crossfade_data[queue_item.queue_id] = CrossfadeData(
-                        data=crossfade_second,
-                        fade_in_size=len(buffer),
-                        pcm_format=pcm_format,
-                        queue_item_id=next_queue_item.queue_item_id,
-                        session_id=session_id,
+                    if len(buffer) >= crossfade_buffer_size:
+                        break
+                ####  HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
+                if next_queue_item_pcm_format != pcm_format:
+                    # edge case: pcm format mismatch, we need to resample the next track's
+                    # beginning part before crossfading
+                    buffer = await resample_pcm_audio(
+                        buffer,
+                        next_queue_item_pcm_format,
+                        pcm_format,
                     )
-                    # clear vars and break out of loop
-                    del crossfade_data
-                    break
+                crossfade_bytes = await self._smart_fades_mixer.mix(
+                    fade_in_part=buffer,
+                    fade_out_part=fade_out_data,
+                    fade_in_streamdetails=next_queue_item.streamdetails,
+                    fade_out_streamdetails=queue_item.streamdetails,
+                    pcm_format=pcm_format,
+                    standard_crossfade_duration=standard_crossfade_duration,
+                    mode=smart_fades_mode,
+                )
+                # send half of the crossfade_part (= approx the fadeout part)
+                crossfade_first, crossfade_second = (
+                    crossfade_bytes[: len(crossfade_bytes) // 2 + len(crossfade_bytes) % 2],
+                    crossfade_bytes[len(crossfade_bytes) // 2 + len(crossfade_bytes) % 2 :],
+                )
+                del crossfade_bytes
+                bytes_written += len(crossfade_first)
+                yield crossfade_first
+                del crossfade_first
+                # store the other half for the next track
+                self._crossfade_data[queue_item.queue_id] = CrossfadeData(
+                    data=crossfade_second,
+                    fade_in_size=len(buffer),
+                    pcm_format=pcm_format,
+                    queue_item_id=next_queue_item.queue_item_id,
+                    session_id=session_id,
+                )
+
             except QueueEmpty:
                 # end of queue reached or crossfade failed - no crossfade possible
                 yield fade_out_data
@@ -1433,15 +1498,15 @@ class StreamsController(CoreController):
             player.player_id, CONF_SAMPLE_RATES, unpack_splitted_values=True
         )
         supported_sample_rates: tuple[int] = tuple(int(x[0]) for x in supported_rates_conf)
-        output_sample_rate = DEFAULT_PCM_FORMAT.sample_rate
+        output_sample_rate = INTERNAL_PCM_FORMAT.sample_rate
         for sample_rate in (192000, 96000, 48000, 44100):
             if sample_rate in supported_sample_rates:
                 output_sample_rate = sample_rate
                 break
         return AudioFormat(
-            content_type=DEFAULT_PCM_FORMAT.content_type,
+            content_type=INTERNAL_PCM_FORMAT.content_type,
             sample_rate=output_sample_rate,
-            bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
+            bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
             channels=2,
         )
 
@@ -1461,6 +1526,7 @@ class StreamsController(CoreController):
             queue_item.queue_id, queue_item.queue_item_id
         )
         if not next_item:
+            # there is no next item!
             return False
         # check if next item is a track
         if next_item.media_type != MediaType.TRACK:
@@ -1474,6 +1540,9 @@ class StreamsController(CoreController):
             and next_item.media_item
             and next_item.media_item.album
             and queue_item.media_item.album == next_item.media_item.album
+            and not self.mass.config.get_raw_core_config_value(
+                self.domain, CONF_ALLOW_CROSSFADE_SAME_ALBUM, False
+            )
         ):
             # 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,
@@ -1490,9 +1559,16 @@ class StreamsController(CoreController):
                 != 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
+            and not (
+                PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE in queue_player.supported_features
+                or self.mass.config.get_raw_player_config_value(
+                    queue_player.player_id,
+                    CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES.key,
+                    CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES.default_value,
+                )
+            )
         ):
             self.logger.debug("Skipping crossfade: sample rate mismatch")
-            return 0
+            return False
         # all checks passed, crossfade is enabled/allowed
         return True
index 3f8d3929ee35049a9ba76f5b197b327a1c6a8328..fede30dd73301c7d2a79866328cfccfa5f36811f 100644 (file)
@@ -52,7 +52,7 @@ from music_assistant.helpers.util import clean_stream_title, remove_file
 from .audio_buffer import AudioBuffer
 from .datetime import utc
 from .dsp import filter_to_ffmpeg_params
-from .ffmpeg import FFMpeg, get_ffmpeg_stream
+from .ffmpeg import FFMpeg, get_ffmpeg_args, get_ffmpeg_stream
 from .playlists import IsHLSPlaylist, PlaylistItem, fetch_playlist, parse_m3u
 from .process import AsyncProcess, communicate
 from .util import detect_charset
@@ -421,7 +421,7 @@ async def get_stream_details(
     return streamdetails
 
 
-async def get_media_stream_with_buffer(
+async def get_buffered_media_stream(
     mass: MusicAssistant,
     streamdetails: StreamDetails,
     pcm_format: AudioFormat,
@@ -436,8 +436,8 @@ async def get_media_stream_with_buffer(
         seek_position,
     )
 
-    # checksum based on pcm_format and filter_params
-    checksum = f"{pcm_format}-{filter_params}"
+    # checksum based on filter_params
+    checksum = f"{filter_params}"
 
     async def fill_buffer_task() -> None:
         """Background task to fill the audio buffer."""
@@ -528,6 +528,25 @@ async def get_media_stream_with_buffer(
         task = mass.loop.create_task(fill_buffer_task())
         audio_buffer.attach_fill_task(task)
 
+    # special case: pcm format mismatch, resample on the fly
+    # this may happen in some special situations such as crossfading
+    # and its a bit of a waste to throw away the existing buffer
+    if audio_buffer.pcm_format != pcm_format:
+        LOGGER.info(
+            "buffered_media_stream: pcm format mismatch, resampling on the fly for %s - "
+            "buffer format: %s - requested format: %s",
+            streamdetails.uri,
+            audio_buffer.pcm_format,
+            pcm_format,
+        )
+        async for chunk in get_ffmpeg_stream(
+            audio_input=audio_buffer.iter(seek_position=seek_position),
+            input_format=audio_buffer.pcm_format,
+            output_format=pcm_format,
+        ):
+            yield chunk
+        return
+
     # yield data from the buffer
     chunk_count = 0
     try:
@@ -631,7 +650,7 @@ async def get_media_stream(
                 first_chunk_received = True
                 streamdetails.audio_format.codec_type = ffmpeg_proc.input_format.codec_type
                 logger.debug(
-                    "First chunk received after %s seconds (codec detected: %s)",
+                    "First chunk received after %.2f seconds (codec detected: %s)",
                     mass.loop.time() - stream_start,
                     ffmpeg_proc.input_format.codec_type,
                 )
@@ -1209,23 +1228,19 @@ async def get_silence(
 
 
 async def resample_pcm_audio(
-    input_audio: bytes | AsyncGenerator[bytes, None],
+    input_audio: bytes,
     input_format: AudioFormat,
     output_format: AudioFormat,
-) -> AsyncGenerator[bytes, None]:
+) -> bytes:
     """Resample (a chunk of) PCM audio from input_format to output_format using ffmpeg."""
-    LOGGER.debug(f"Resampling audio from {input_format} to {output_format}")
-
-    async def _yielder() -> AsyncGenerator[bytes, None]:
-        yield input_audio  # type: ignore[misc]
-
-    async for chunk in get_ffmpeg_stream(
-        audio_input=_yielder() if isinstance(input_audio, bytes) else input_audio,
-        input_format=input_format,
-        output_format=output_format,
-        raise_ffmpeg_exception=True,
-    ):
-        yield chunk
+    if input_format == output_format:
+        return input_audio
+    LOGGER.log(VERBOSE_LOG_LEVEL, f"Resampling audio from {input_format} to {output_format}")
+    ffmpeg_args = get_ffmpeg_args(
+        input_format=input_format, output_format=output_format, filter_params=[]
+    )
+    _, stdout, _ = await communicate(ffmpeg_args, input_audio)
+    return stdout
 
 
 def get_chunksize(
index edca96a1b3c69b5df2db99919a2979a098cd163a..e02a5982d33ffb3b566a261204ef67eefe387f45 100644 (file)
@@ -7,7 +7,7 @@ from typing import Final
 from music_assistant_models.enums import ContentType
 from music_assistant_models.media_items import AudioFormat
 
-from music_assistant.constants import DEFAULT_PCM_FORMAT
+from music_assistant.constants import INTERNAL_PCM_FORMAT
 
 DOMAIN = "airplay"
 
@@ -27,9 +27,9 @@ BACKOFF_TIME_UPPER_LIMIT: Final[int] = 300  # Five minutes
 FALLBACK_VOLUME: Final[int] = 20
 
 AIRPLAY_FLOW_PCM_FORMAT = AudioFormat(
-    content_type=DEFAULT_PCM_FORMAT.content_type,
+    content_type=INTERNAL_PCM_FORMAT.content_type,
     sample_rate=44100,
-    bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
+    bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
 )
 AIRPLAY_PCM_FORMAT = AudioFormat(
     content_type=ContentType.from_bit_depth(16), sample_rate=44100, bit_depth=16
index 92548e7e487416e4daced8d7a8c251f71df10a2e..25160c1ec9ad64607d6f53a4302631a306098b69 100644 (file)
@@ -27,8 +27,8 @@ from music_assistant.constants import (
     CONF_MUTE_CONTROL,
     CONF_POWER_CONTROL,
     CONF_VOLUME_CONTROL,
-    DEFAULT_PCM_FORMAT,
     DEFAULT_STREAM_HEADERS,
+    INTERNAL_PCM_FORMAT,
     create_sample_rates_config_entry,
 )
 from music_assistant.helpers.audio import get_player_filter_params
@@ -274,9 +274,9 @@ class BuiltinPlayer(Player):
 
         pcm_format = AudioFormat(
             sample_rate=stream_format.sample_rate,
-            content_type=DEFAULT_PCM_FORMAT.content_type,
-            bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
-            channels=DEFAULT_PCM_FORMAT.channels,
+            content_type=INTERNAL_PCM_FORMAT.content_type,
+            bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
+            channels=INTERNAL_PCM_FORMAT.channels,
         )
         async for chunk in get_ffmpeg_stream(
             audio_input=self.mass.streams.get_queue_flow_stream(
index a216603c5ce03dbfc5756e8e2504bfe5068c7eda..eab60597f6a9197e05a7d48c0edd9ff62adb943d 100644 (file)
@@ -19,7 +19,7 @@ from music_assistant.constants import (
     ATTR_ANNOUNCEMENT_IN_PROGRESS,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
-    DEFAULT_PCM_FORMAT,
+    INTERNAL_PCM_FORMAT,
 )
 from music_assistant.helpers.audio import get_player_filter_params
 from music_assistant.helpers.compare import create_safe_string
@@ -216,7 +216,7 @@ class SnapCastPlayer(Player):
             audio_source = self.mass.streams.get_queue_flow_stream(
                 queue=queue,
                 start_queue_item=start_queue_item,
-                pcm_format=DEFAULT_PCM_FORMAT,
+                pcm_format=INTERNAL_PCM_FORMAT,
             )
         else:
             # assume url or some other direct path
index 965d2af63041fa5f11e6c1df6797fd8036dda627..16a4617e9c12c15c7f9e45823415a4c3a5d2ab8c 100644 (file)
@@ -33,8 +33,9 @@ from music_assistant.constants import (
     CONF_ENTRY_DEPRECATED_EQ_TREBLE,
     CONF_ENTRY_HTTP_PROFILE_FORCED_2,
     CONF_ENTRY_OUTPUT_CODEC,
+    CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES,
     CONF_ENTRY_SYNC_ADJUST,
-    DEFAULT_PCM_FORMAT,
+    INTERNAL_PCM_FORMAT,
     VERBOSE_LOG_LEVEL,
     create_sample_rates_config_entry,
 )
@@ -92,6 +93,7 @@ class SqueezelitePlayer(Player):
             PlayerFeature.VOLUME_MUTE,
             PlayerFeature.ENQUEUE,
             PlayerFeature.GAPLESS_PLAYBACK,
+            PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE,
         }
         self._attr_can_group_with = {provider.lookup_key}
         self.multi_client_stream: MultiClientStream | None = None
@@ -162,6 +164,7 @@ class SqueezelitePlayer(Player):
             create_sample_rates_config_entry(
                 max_sample_rate=max_sample_rate, max_bit_depth=24, safe_max_bit_depth=24
             ),
+            CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES,
         ]
 
     async def power(self, powered: bool) -> None:
@@ -229,9 +232,9 @@ class SqueezelitePlayer(Player):
 
         # this is a syncgroup, we need to handle this with a multi client stream
         master_audio_format = AudioFormat(
-            content_type=DEFAULT_PCM_FORMAT.content_type,
-            sample_rate=DEFAULT_PCM_FORMAT.sample_rate,
-            bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
+            content_type=INTERNAL_PCM_FORMAT.content_type,
+            sample_rate=INTERNAL_PCM_FORMAT.sample_rate,
+            bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
         )
         if media.media_type == MediaType.ANNOUNCEMENT:
             # special case: stream announcement
index 27d1a795a8209b04971b36a7873c79521068947c..46f4b705a94a9979fa53e178ba1e8f1a335adf54 100644 (file)
@@ -8,7 +8,7 @@ from music_assistant_models.config_entries import ConfigEntry
 from music_assistant_models.enums import ConfigEntryType
 from music_assistant_models.media_items import AudioFormat
 
-from music_assistant.constants import DEFAULT_PCM_FORMAT, create_sample_rates_config_entry
+from music_assistant.constants import INTERNAL_PCM_FORMAT, create_sample_rates_config_entry
 
 UGP_PREFIX: Final[str] = "ugp_"
 
@@ -29,7 +29,7 @@ CONFIG_ENTRY_UGP_NOTE = ConfigEntry(
 
 
 UGP_FORMAT = AudioFormat(
-    content_type=DEFAULT_PCM_FORMAT.content_type,
-    sample_rate=DEFAULT_PCM_FORMAT.sample_rate,
-    bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
+    content_type=INTERNAL_PCM_FORMAT.content_type,
+    sample_rate=INTERNAL_PCM_FORMAT.sample_rate,
+    bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
 )