Add 24 and 32-bit audio support for Sendspin (#2977)
authorMaxim Raznatovski <nda.mr43@gmail.com>
Tue, 20 Jan 2026 18:01:06 +0000 (19:01 +0100)
committerGitHub <noreply@github.com>
Tue, 20 Jan 2026 18:01:06 +0000 (19:01 +0100)
* feat: add 32-bit audio and mono output support for sendspin

* refactor: remove mono output conversion for per-player streams

The mono rendering with aiosendspin upmixing added complexity
without clear benefit. Reverting to direct output format usage.

music_assistant/providers/sendspin/player.py
music_assistant/providers/sendspin/timed_client_stream.py

index 69687b1906e5832f6a9ae6a787bc69d2fa3af7a7..6e4625f76eed8dc0af5ef6dcc127b3b9d8a81286 100644 (file)
@@ -148,7 +148,6 @@ class MusicAssistantMediaStream(MediaStream):
             return None
 
         # Get per-player DSP filter parameters
-        # Convert from internal format to output format
         filter_params = get_player_filter_params(
             mass, player_id, self.internal_format, self.output_format
         )
@@ -396,10 +395,11 @@ class SendspinPlayer(Player):
     async def _run_playback(self, media: PlayerMedia) -> None:
         """Run the actual playback in a background task."""
         try:
+            # Use 32-bit for the main channel: aiosendspin converts per player as needed
             pcm_format = AudioFormat(
-                content_type=ContentType.PCM_S16LE,
+                content_type=ContentType.PCM_S32LE,
                 sample_rate=48000,
-                bit_depth=16,
+                bit_depth=32,
                 channels=2,
             )
             flow_pcm_format = AudioFormat(
@@ -426,7 +426,6 @@ class SendspinPlayer(Player):
             )
 
             # Setup the main channel subscription
-            # aiosendspin only really supports 16-bit for now TODO: upgrade later to 32-bit
             main_channel_gen, main_position = await self.timed_client_stream.get_stream(
                 output_format=pcm_format,
                 filter_params=None,  # TODO: this should probably still include the safety limiter
index 738c0d82da127d5395286c20a8990ff3cd2f391c..bb0b96200ce2e000ec882ea8123f77d21ca4bfc4 100644 (file)
@@ -218,7 +218,12 @@ class TimedClientStream:
         """
         audio_gen, position = await self.subscribe_raw()
 
+        # Calculate frame size for alignment
+        # Frame size = channels * bytes_per_sample
+        bytes_per_frame = output_format.channels * (output_format.bit_depth // 8)
+
         async def _stream_with_ffmpeg() -> AsyncGenerator[bytes, None]:
+            buffer = b""
             try:
                 async for chunk in get_ffmpeg_stream(
                     audio_input=audio_gen,
@@ -226,7 +231,17 @@ class TimedClientStream:
                     output_format=output_format,
                     filter_params=filter_params,
                 ):
-                    yield chunk
+                    buffer += chunk
+                    # Yield only complete frames
+                    aligned_size = (len(buffer) // bytes_per_frame) * bytes_per_frame
+                    if aligned_size > 0:
+                        yield buffer[:aligned_size]
+                        buffer = buffer[aligned_size:]
+                # Yield any remaining complete frames at end of stream
+                if buffer:
+                    aligned_size = (len(buffer) // bytes_per_frame) * bytes_per_frame
+                    if aligned_size > 0:
+                        yield buffer[:aligned_size]
             finally:
                 # Ensure audio_gen cleanup runs immediately
                 with suppress(Exception):