Airplay2 improvements (#2702)
authorBrad Keifer <15224368+bradkeifer@users.noreply.github.com>
Fri, 28 Nov 2025 22:49:43 +0000 (09:49 +1100)
committerGitHub <noreply@github.com>
Fri, 28 Nov 2025 22:49:43 +0000 (23:49 +0100)
music_assistant/providers/airplay/bin/cliap2-linux-aarch64
music_assistant/providers/airplay/bin/cliap2-linux-x86_64
music_assistant/providers/airplay/bin/cliap2-macos-arm64
music_assistant/providers/airplay/bin/cliap2-macos-x86_64
music_assistant/providers/airplay/constants.py
music_assistant/providers/airplay/helpers.py
music_assistant/providers/airplay/player.py
music_assistant/providers/airplay/protocols/airplay2.py
music_assistant/providers/airplay/protocols/raop.py
music_assistant/providers/airplay/stream_session.py

index 5ae73492ee97f2ea10a7ba022ed8470da3dda93a..8ca802f63bc891f329207f35ed06da576c83143b 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 and b/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 differ
index fc161ec1a18c94a287ded6e969ea5dc4f9edb35f..65ae8f403a2b449bead34cbce16bdad804c14ec0 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliap2-linux-x86_64 and b/music_assistant/providers/airplay/bin/cliap2-linux-x86_64 differ
index c1f8e4deb5bb30ab069c00ed70e1a7a83b4392b4..7742fb03992f559a1a27af52d235172b2784db57 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliap2-macos-arm64 and b/music_assistant/providers/airplay/bin/cliap2-macos-arm64 differ
index ad27e054e14bbd712c44adcc222f53f0523b542f..3efe132419ddc71af1bccb71f780b6e40d762540 100755 (executable)
Binary files a/music_assistant/providers/airplay/bin/cliap2-macos-x86_64 and b/music_assistant/providers/airplay/bin/cliap2-macos-x86_64 differ
index 1487e221f51d5cd66dcf14aa3ecf2074fee2f5f5..08562028b50e351dfe9dfd6f3db423050594e4e7 100644 (file)
@@ -26,7 +26,6 @@ CONF_ENCRYPTION: Final[str] = "encryption"
 CONF_ALAC_ENCODE: Final[str] = "alac_encode"
 CONF_VOLUME_START: Final[str] = "volume_start"
 CONF_PASSWORD: Final[str] = "password"
-CONF_READ_AHEAD_BUFFER: Final[str] = "read_ahead_buffer"
 CONF_IGNORE_VOLUME: Final[str] = "ignore_volume"
 CONF_CREDENTIALS: Final[str] = "credentials"
 CONF_AIRPLAY_PROTOCOL: Final[str] = "airplay_protocol"
@@ -35,7 +34,17 @@ AIRPLAY_DISCOVERY_TYPE: Final[str] = "_airplay._tcp.local."
 RAOP_DISCOVERY_TYPE: Final[str] = "_raop._tcp.local."
 DACP_DISCOVERY_TYPE: Final[str] = "_dacp._tcp.local."
 
+AIRPLAY_PRELOAD_SECONDS: Final[int] = (
+    5  # Number of seconds (in PCM) to preload before throttling back
+)
+AIRPLAY_PROCESS_SPAWN_TIME_MS: Final[int] = (
+    200  # Time in ms to allow AirPlay CLI processes to spawn and initialise
+)
+AIRPLAY_OUTPUT_BUFFER_DURATION_MS: Final[int] = (
+    2000  # Read ahead buffer for cliraop. Output buffer duration for cliap2.
+)
 AIRPLAY2_MIN_LOG_LEVEL: Final[int] = 3  # Min loglevel to ensure stderr output contains what we need
+AIRPLAY2_CONNECT_TIME_MS: Final[int] = 2500  # Time in ms to allow AirPlay2 device to connect
 CONF_AP_CREDENTIALS: Final[str] = "ap_credentials"
 CONF_MRP_CREDENTIALS: Final[str] = "mrp_credentials"
 CONF_ACTION_START_PAIRING: Final[str] = "start_ap_pairing"
index 2ae3d576b58b4bd9eb8e0d0ee0a43f4db4341562..68ec1da2edcfbbdb3030f2711f123f9474697d79 100644 (file)
@@ -156,14 +156,11 @@ async def get_cli_binary(protocol: StreamingProtocol) -> str:
                     cli_path,
                     "--testrun",
                 ]
+                passing_output = "cliap2 check"
 
             returncode, output = await check_output(*args)
             _LOGGER.debug("%s returned %d with output: %s", cli_path, int(returncode), str(output))
-            if (
-                protocol == StreamingProtocol.RAOP
-                and returncode == 0
-                and output.strip().decode() == passing_output
-            ) or (protocol == StreamingProtocol.AIRPLAY2 and returncode == 0):
+            if returncode == 0 and output.strip().decode() == passing_output:
                 return cli_path
         except OSError:
             pass
index a4a5feed78085731f50ec8508d6907405a407247..410965eb2f6a8f7ef8abcdea50ff05c34bc7e63e 100644 (file)
@@ -34,7 +34,6 @@ from .constants import (
     CONF_IGNORE_VOLUME,
     CONF_PAIRING_PIN,
     CONF_PASSWORD,
-    CONF_READ_AHEAD_BUFFER,
     FALLBACK_VOLUME,
     RAOP_DISCOVERY_TYPE,
     StreamingProtocol,
@@ -212,21 +211,6 @@ class AirPlayPlayer(Player):
                 description="Some devices require a password to connect/play.",
                 category="airplay",
             ),
-            ConfigEntry(
-                key=CONF_READ_AHEAD_BUFFER,
-                type=ConfigEntryType.INTEGER,
-                default_value=1000,
-                required=False,
-                label="Audio buffer (ms)",
-                description="Amount of buffer (in milliseconds), "
-                "the player should keep to absorb network throughput jitter. "
-                "Lower values reduce latency but may cause dropouts. "
-                "Recommended: 1000ms for stable playback.",
-                category="airplay",
-                range=(500, 2000),
-                depends_on=CONF_AIRPLAY_PROTOCOL,
-                depends_on_value=StreamingProtocol.RAOP.value,
-            ),
             # airplay has fixed sample rate/bit depth so make this config entry static and hidden
             create_sample_rates_config_entry(
                 supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
index fe3f5df2907a74f3bc827af4e498ef13273810c3..678ce2d8f9e81275a374058bd4424fd0ceaea1f7 100644 (file)
@@ -2,6 +2,7 @@
 
 from __future__ import annotations
 
+import asyncio
 import logging
 
 from music_assistant_models.enums import PlaybackState
@@ -11,7 +12,6 @@ from music_assistant.constants import CONF_SYNC_ADJUST, VERBOSE_LOG_LEVEL
 from music_assistant.helpers.process import AsyncProcess
 from music_assistant.providers.airplay.constants import (
     AIRPLAY2_MIN_LOG_LEVEL,
-    CONF_READ_AHEAD_BUFFER,
 )
 from music_assistant.providers.airplay.helpers import get_cli_binary
 
@@ -59,9 +59,6 @@ class AirPlay2Stream(AirPlayProtocol):
         player_id = self.player.player_id
         sync_adjust = self.mass.config.get_raw_player_config_value(player_id, CONF_SYNC_ADJUST, 0)
         assert isinstance(sync_adjust, int)
-        read_ahead = await self.mass.config.get_player_config_value(
-            player_id, CONF_READ_AHEAD_BUFFER, return_type=int
-        )
 
         txt_kv: str = ""
         for key, value in self.player.airplay_discovery_info.decoded_properties.items():
@@ -88,8 +85,6 @@ class AirPlay2Stream(AirPlayProtocol):
             txt_kv,
             "--ntpstart",
             str(start_ntp),
-            "--latency",
-            str(read_ahead),
             "--volume",
             str(self.player.volume_level),
             "--loglevel",
@@ -112,7 +107,7 @@ class AirPlay2Stream(AirPlayProtocol):
         if self.prov.logger.level > logging.INFO:
             num_lines *= 10
         for _ in range(num_lines):
-            line = (await self._cli_proc.read_stderr()).decode("utf-8", errors="ignore")
+            line = (await self._cli_proc.read_stderr()).decode("utf-8", errors="ignore").strip()
             self.player.logger.debug(line)
             if f"airplay: Adding AirPlay device '{self.player.display_name}'" in line:
                 self.player.logger.info("AirPlay device connected. Starting playback.")
@@ -161,6 +156,7 @@ class AirPlay2Stream(AirPlayProtocol):
                 logger.log(VERBOSE_LOG_LEVEL, line)
             else:  # for now, log unknown lines as error
                 logger.error(line)
+            await asyncio.sleep(0)  # Yield to event loop
 
         # ensure we're cleaned up afterwards (this also logs the returncode)
         await self.stop()
index 45227df3d1d89580bf8f5b8df0098d51e33fc904..0d8bec5c953d4796e50547fee65dbc8ea6e995a8 100644 (file)
@@ -12,11 +12,11 @@ from music_assistant_models.errors import PlayerCommandFailed
 from music_assistant.constants import VERBOSE_LOG_LEVEL
 from music_assistant.helpers.process import AsyncProcess
 from music_assistant.providers.airplay.constants import (
+    AIRPLAY_OUTPUT_BUFFER_DURATION_MS,
     CONF_ALAC_ENCODE,
     CONF_AP_CREDENTIALS,
     CONF_ENCRYPTION,
     CONF_PASSWORD,
-    CONF_READ_AHEAD_BUFFER,
 )
 from music_assistant.providers.airplay.helpers import get_cli_binary
 
@@ -63,9 +63,6 @@ class RaopStream(AirPlayProtocol):
             extra_args += ["-debug", "5"]
         elif self.prov.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
             extra_args += ["-debug", "10"]
-        read_ahead = await self.mass.config.get_player_config_value(
-            player_id, CONF_READ_AHEAD_BUFFER, return_type=int
-        )
 
         # cliraop is the binary that handles the actual raop streaming to the player
         # this is a slightly modified version of philippe44's libraop
@@ -80,7 +77,7 @@ class RaopStream(AirPlayProtocol):
             "-port",
             str(self.player.raop_discovery_info.port),
             "-latency",
-            str(read_ahead),
+            str(AIRPLAY_OUTPUT_BUFFER_DURATION_MS),
             "-volume",
             str(self.player.volume_level),
             *extra_args,
index c7e72dba465f7f335d52f95574d51c873b4f4306..aa56f2ccfb9e5326dd9de561acee3209db94f812 100644 (file)
@@ -16,7 +16,15 @@ from music_assistant.helpers.ffmpeg import FFMpeg
 from music_assistant.helpers.util import TaskManager
 from music_assistant.providers.airplay.helpers import ntp_to_unix_time, unix_time_to_ntp
 
-from .constants import CONF_ENABLE_LATE_JOIN, ENABLE_LATE_JOIN_DEFAULT, StreamingProtocol
+from .constants import (
+    AIRPLAY2_CONNECT_TIME_MS,
+    AIRPLAY_OUTPUT_BUFFER_DURATION_MS,
+    AIRPLAY_PRELOAD_SECONDS,
+    AIRPLAY_PROCESS_SPAWN_TIME_MS,
+    CONF_ENABLE_LATE_JOIN,
+    ENABLE_LATE_JOIN_DEFAULT,
+    StreamingProtocol,
+)
 from .protocols.airplay2 import AirPlay2Stream
 from .protocols.raop import RaopStream
 
@@ -88,7 +96,18 @@ class AirPlayStreamSession:
         # Start all clients
         # Get current NTP timestamp and calculate wait time
         cur_time = time.time()
-        wait_start = 1500 + (250 * len(self.sync_clients))  # in milliseconds
+        # AirPlay2 clients need around 2500ms to establish connection and start playback
+        # The also have a fixed 2000ms output buffer. We will not be able to respect the
+        # ntpstart time unless we cater for all these time delays.
+        # RAOP clients need less due to less RTSP exchanges and different packet buffer
+        # handling
+        # Plus we need to cater for process spawn and initialisation time
+        wait_start = (
+            AIRPLAY2_CONNECT_TIME_MS
+            + AIRPLAY_OUTPUT_BUFFER_DURATION_MS
+            + AIRPLAY_PROCESS_SPAWN_TIME_MS
+            + (250 * len(self.sync_clients))
+        )  # in milliseconds
         wait_start_seconds = wait_start / 1000
         self.wait_start = wait_start_seconds  # in seconds
         self.start_time = cur_time + wait_start_seconds
@@ -414,7 +433,13 @@ class AirPlayStreamSession:
             output_format=airplay_player.stream.pcm_format,
             filter_params=filter_params,
             audio_output=airplay_player.stream.audio_pipe.path,
-            extra_input_args=["-y", "-re"],
+            extra_input_args=[
+                "-y",
+                "-readrate",
+                "1",
+                "-readrate_initial_burst",
+                f"{AIRPLAY_PRELOAD_SECONDS}",
+            ],
         )
         await ffmpeg.start()
         self._player_ffmpeg[airplay_player.player_id] = ffmpeg