Add more error handling and logging to (different sample rate) crossfade actions
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 6 Nov 2025 13:06:40 +0000 (14:06 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 6 Nov 2025 13:06:40 +0000 (14:06 +0100)
music_assistant/controllers/streams.py
music_assistant/helpers/audio.py

index 9b16bdd9a240746ad7a6ce385b16f6b97fef914f..41c0aad0c6b085224d2bc0d7eb6443f1c30b88c6 100644 (file)
@@ -1485,17 +1485,32 @@ class StreamsController(CoreController):
                 # send the (second half of the) crossfade data
                 if crossfade_data.pcm_format != pcm_format:
                     # edge case: pcm format mismatch, we need to resample
+                    self.logger.debug(
+                        "Resampling crossfade data from %s to %s for queue %s",
+                        crossfade_data.pcm_format.sample_rate,
+                        pcm_format.sample_rate,
+                        queue.display_name,
+                    )
                     resampled_data = await resample_pcm_audio(
                         crossfade_data.data,
                         crossfade_data.pcm_format,
                         pcm_format,
                     )
-                    for _chunk in divide_chunks(resampled_data, pcm_format.pcm_sample_size):
-                        yield _chunk
+                    if resampled_data:
+                        for _chunk in divide_chunks(resampled_data, pcm_format.pcm_sample_size):
+                            yield _chunk
+                        bytes_written += len(resampled_data)
+                    else:
+                        # Resampling failed, error already logged in resample_pcm_audio
+                        # Skip crossfade data entirely - stream continues without it
+                        self.logger.warning(
+                            "Skipping crossfade data for queue %s due to resampling failure",
+                            queue.display_name,
+                        )
                 else:
                     for _chunk in divide_chunks(crossfade_data.data, pcm_format.pcm_sample_size):
                         yield _chunk
-                bytes_written += len(crossfade_data.data)
+                    bytes_written += len(crossfade_data.data)
                 # clear vars
                 crossfade_data = None
 
@@ -1512,15 +1527,32 @@ class StreamsController(CoreController):
             # 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(
+                self.logger.debug(
+                    "Resampling remaining crossfade data from %s to %s for queue %s",
+                    crossfade_data.pcm_format.sample_rate,
+                    pcm_format.sample_rate,
+                    queue.display_name,
+                )
+                resampled_crossfade_data = await resample_pcm_audio(
                     crossfade_data.data,
                     crossfade_data.pcm_format,
                     pcm_format,
                 )
-            for _chunk in divide_chunks(crossfade_data.data, pcm_format.pcm_sample_size):
-                yield _chunk
-            bytes_written += len(crossfade_data.data)
-            crossfade_data = None
+                if resampled_crossfade_data:
+                    crossfade_data.data = resampled_crossfade_data
+                else:
+                    # Resampling failed, error already logged in resample_pcm_audio
+                    # Skip the crossfade data entirely
+                    self.logger.warning(
+                        "Skipping remaining crossfade data for queue %s due to resampling failure",
+                        queue.display_name,
+                    )
+                    crossfade_data = None
+            if crossfade_data:
+                for _chunk in divide_chunks(crossfade_data.data, pcm_format.pcm_sample_size):
+                    yield _chunk
+                bytes_written += len(crossfade_data.data)
+                crossfade_data = None
         next_queue_item: QueueItem | None = None
         if not self._crossfade_allowed(
             queue_item, smart_fades_mode=smart_fades_mode, flow_mode=False
@@ -1569,38 +1601,77 @@ class StreamsController(CoreController):
                     if len(buffer) >= crossfade_buffer_size:
                         break
                 ####  HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
+                # Store original buffer size before any resampling for fade_in_size calculation
+                # This size is in the next track's original format which is what we need
+                original_buffer_size = len(buffer)
                 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(
+                    self.logger.debug(
+                        "Resampling next track from %s to %s for queue %s",
+                        next_queue_item_pcm_format.sample_rate,
+                        pcm_format.sample_rate,
+                        queue.display_name,
+                    )
+                    resampled_buffer = await resample_pcm_audio(
                         buffer,
                         next_queue_item_pcm_format,
                         pcm_format,
                     )
-                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)
-                split_point = (len(crossfade_bytes) + 1) // 2
-                crossfade_first = crossfade_bytes[:split_point]
-                crossfade_second = crossfade_bytes[split_point:]
-                del crossfade_bytes
-                bytes_written += len(crossfade_first)
-                for _chunk in divide_chunks(crossfade_first, pcm_format.pcm_sample_size):
-                    yield _chunk
-                # 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,
-                )
+                    if resampled_buffer:
+                        buffer = resampled_buffer
+                    else:
+                        # Resampling failed, error already logged in resample_pcm_audio
+                        # Cannot crossfade safely - yield fade_out_data and raise error
+                        self.logger.error(
+                            "Failed to resample next track for crossfade in queue %s - "
+                            "skipping crossfade",
+                            queue.display_name,
+                        )
+                        yield fade_out_data
+                        bytes_written += len(fade_out_data)
+                        raise AudioError("Failed to resample next track for crossfade")
+                try:
+                    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)
+                    split_point = (len(crossfade_bytes) + 1) // 2
+                    crossfade_first = crossfade_bytes[:split_point]
+                    crossfade_second = crossfade_bytes[split_point:]
+                    del crossfade_bytes
+                    bytes_written += len(crossfade_first)
+                    for _chunk in divide_chunks(crossfade_first, pcm_format.pcm_sample_size):
+                        yield _chunk
+                    # store the other half for the next track
+                    # IMPORTANT: Use original buffer size (in next track's format) for fade_in_size
+                    # because the next track will stream in its native format and needs to know
+                    # how many bytes to discard in that format.
+                    # However, crossfade_second data is in current track's format (pcm_format)
+                    # because it was created from the resampled buffer used for mixing.
+                    self._crossfade_data[queue_item.queue_id] = CrossfadeData(
+                        data=crossfade_second,
+                        fade_in_size=original_buffer_size,
+                        pcm_format=pcm_format,
+                        queue_item_id=next_queue_item.queue_item_id,
+                    )
+                except Exception as err:
+                    self.logger.error(
+                        "Failed to create crossfade for queue %s: %s - "
+                        "falling back to no crossfade",
+                        queue.display_name,
+                        err,
+                    )
+                    # Fallback: just yield the fade_out_data without crossfade
+                    yield fade_out_data
+                    bytes_written += len(fade_out_data)
+                    next_queue_item = None
             except (QueueEmpty, AudioError):
                 # end of queue reached, next item  skipped or crossfade failed
                 # no crossfade possible, just yield the fade_out_data
index 4661b5ec8d72b829ddb89c5e5259fe6eca9d65dd..cb212520d43623d8593684abe6b52cfe66050774 100644 (file)
@@ -1219,15 +1219,46 @@ async def resample_pcm_audio(
     input_format: AudioFormat,
     output_format: AudioFormat,
 ) -> bytes:
-    """Resample (a chunk of) PCM audio from input_format to output_format using ffmpeg."""
+    """
+    Resample (a chunk of) PCM audio from input_format to output_format using ffmpeg.
+
+    :param input_audio: Raw PCM audio data to resample.
+    :param input_format: AudioFormat of the input audio.
+    :param output_format: Desired AudioFormat for the output audio.
+
+    :return: Resampled audio data, frame-aligned. Returns empty bytes if resampling fails.
+    """
     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
+    try:
+        ffmpeg_args = get_ffmpeg_args(
+            input_format=input_format, output_format=output_format, filter_params=[]
+        )
+        _, stdout, stderr = await communicate(ffmpeg_args, input_audio)
+        if not stdout:
+            LOGGER.error(
+                "Resampling failed: no output from ffmpeg. Input: %s, Output: %s, stderr: %s",
+                input_format,
+                output_format,
+                stderr.decode() if stderr else "(no stderr)",
+            )
+            return b""
+        # Ensure frame alignment after resampling
+        # Import inline to avoid circular dependency at module level
+        from music_assistant.helpers.smart_fades import (  # noqa: PLC0415
+            align_audio_to_frame_boundary,
+        )
+
+        return align_audio_to_frame_boundary(stdout, output_format)
+    except Exception as err:
+        LOGGER.exception(
+            "Failed to resample audio from %s to %s: %s",
+            input_format,
+            output_format,
+            err,
+        )
+        return b""
 
 
 def get_chunksize(