Airplay2-configurable-latency (#3210)
authorBrad Keifer <15224368+bradkeifer@users.noreply.github.com>
Sun, 22 Feb 2026 00:42:49 +0000 (11:42 +1100)
committerGitHub <noreply@github.com>
Sun, 22 Feb 2026 00:42:49 +0000 (01:42 +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/player.py
music_assistant/providers/airplay/protocols/airplay2.py
music_assistant/providers/airplay/stream_session.py

index b5f487e3094d59ba494e6009f17ff88ea71c3329..e50b79cac37af47ba9aff1f5c5b8e96100b0a599 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 0a6e222d23beb027001abf8b93bab1c9f51c793f..5882b4ef85aef983438e405e915f86da48475a73 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 e2a952dd9d4a20db3e47dd403f68fa5f598f3e94..8bdc0a7bd26426dc802a8dd09dabcf7ea0d388cd 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 56cb964581c1f12ee7f44dad1e62e804aa1aae41..a8f54bcea2b6482850a8dc06cd0e38893185d00d 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 8c6e32ba9168bd4e148b93fa5b5e956e599296d7..90dc017bd8695fa81919cef30a9137c45a58d304 100644 (file)
@@ -36,15 +36,17 @@ RAOP_DISCOVERY_TYPE: Final[str] = "_raop._tcp.local."
 DACP_DISCOVERY_TYPE: Final[str] = "_dacp._tcp.local."
 
 AIRPLAY_OUTPUT_BUFFER_DURATION_MS: Final[int] = (
-    2000  # Read ahead buffer for cliraop. Output buffer duration for cliap2.
+    2000  # Read ahead buffer for cliraop. Default output buffer duration for cliap2.
 )
+AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS: Final[int] = 250  # Minimum output buffer duration permitted.
 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
+AIRPLAY2_CONNECT_TIME_MS: Final[int] = 3300  # Time in ms to allow AirPlay2 device to connect
 RAOP_CONNECT_TIME_MS: Final[int] = 1000  # Time in ms to allow RAOP device to connect
 
 # Per-protocol credential storage keys
 CONF_RAOP_CREDENTIALS: Final[str] = "raop_credentials"
 CONF_AIRPLAY_CREDENTIALS: Final[str] = "airplay_credentials"
+CONF_AIRPLAY_LATENCY: Final[str] = "airplay_latency"
 
 # Legacy credential key (for migration)
 CONF_AP_CREDENTIALS: Final[str] = "ap_credentials"
index 1e2a9b6fca1616f06f412038d7a74f943e542c00..b3551c12a725a3c7cbf02d7fe7d559fcd6091355 100644 (file)
@@ -22,6 +22,8 @@ from music_assistant.models.player import DeviceInfo, Player, PlayerMedia
 from .constants import (
     AIRPLAY_DISCOVERY_TYPE,
     AIRPLAY_FLOW_PCM_FORMAT,
+    AIRPLAY_OUTPUT_BUFFER_DURATION_MS,
+    AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS,
     BASE_PLAYER_FEATURES,
     BROKEN_AIRPLAY_WARN,
     CACHE_CATEGORY_PREV_VOLUME,
@@ -29,6 +31,7 @@ from .constants import (
     CONF_ACTION_RESET_PAIRING,
     CONF_ACTION_START_PAIRING,
     CONF_AIRPLAY_CREDENTIALS,
+    CONF_AIRPLAY_LATENCY,
     CONF_AIRPLAY_PROTOCOL,
     CONF_ALAC_ENCODE,
     CONF_ENCRYPTION,
@@ -143,6 +146,14 @@ class AirPlayPlayer(Player):
             features.add(PlayerFeature.PAUSE)
         return features
 
+    @property
+    def output_buffer_duration_ms(self) -> int:
+        """Get the configured output buffer duration in milliseconds."""
+        return cast(
+            "int",
+            self.config.get_value(CONF_AIRPLAY_LATENCY, AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS),
+        )
+
     async def get_config_entries(
         self,
         action: str | None = None,
@@ -215,6 +226,9 @@ class AirPlayPlayer(Player):
                 required=False,
                 label="Device password",
                 description="Some devices require a password to connect/play.",
+                depends_on=CONF_AIRPLAY_PROTOCOL,
+                depends_on_value=StreamingProtocol.RAOP.value,
+                hidden=self.protocol != StreamingProtocol.RAOP,
                 category="protocol_generic",
                 advanced=True,
             ),
@@ -223,22 +237,21 @@ class AirPlayPlayer(Player):
                 supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
             ),
             ConfigEntry(
-                key=CONF_IGNORE_VOLUME,
-                type=ConfigEntryType.BOOLEAN,
-                default_value=False,
-                label="Ignore volume reports sent by the device itself",
+                key=CONF_AIRPLAY_LATENCY,
+                type=ConfigEntryType.INTEGER,
+                default_value=AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS,
+                range=(AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS, AIRPLAY_OUTPUT_BUFFER_DURATION_MS),
+                label="Milliseconds of data to buffer",
                 description=(
-                    "The AirPlay protocol allows devices to report their own volume "
-                    "level. \n"
-                    "For some devices this is not reliable and can cause unexpected "
-                    "volume changes. \n"
-                    "Enable this option to ignore these reports."
+                    "The number of milliseconds of data to buffer\n"
+                    "NOTE: This adds to the latency experienced for commencement "
+                    "of playback. \n"
+                    "Try increasing value if playback is unreliable."
                 ),
                 category="protocol_generic",
-                # TODO: remove depends_on when DACP support is added for AirPlay2
                 depends_on=CONF_AIRPLAY_PROTOCOL,
-                depends_on_value=StreamingProtocol.RAOP.value,
-                hidden=self.protocol != StreamingProtocol.RAOP,
+                depends_on_value=StreamingProtocol.AIRPLAY2.value,
+                hidden=self.protocol != StreamingProtocol.AIRPLAY2,
                 advanced=True,
             ),
         ]
@@ -615,7 +628,7 @@ class AirPlayPlayer(Player):
 
             # add new child to the existing stream (RAOP or AirPlay2) session (if any)
             self._attr_group_members.append(player_id)
-            if stream_session:
+            if stream_session and child_player_to_add is not None:
                 await stream_session.add_client(child_player_to_add)
 
         # Ensure group leader includes itself in group_members when it has members
index ab6896e32fe10a385af78aa00b3b97be568e8696..435085ec7566ecb2f6e9a82a0f061d13f678992d 100644 (file)
@@ -12,7 +12,9 @@ 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,
+    AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS,
     CONF_AIRPLAY_CREDENTIALS,
+    CONF_AIRPLAY_LATENCY,
 )
 from music_assistant.providers.airplay.helpers import get_cli_binary
 
@@ -55,19 +57,22 @@ class AirPlay2Stream(AirPlayProtocol):
 
     async def start(self, start_ntp: int) -> None:
         """Start cliap2 process."""
-        cli_binary = await get_cli_binary(self.player.protocol)
         assert self.player.airplay_discovery_info is not None
-
+        cli_binary = await get_cli_binary(self.player.protocol)
         player_id = self.player.player_id
-        sync_adjust = self.mass.config.get_raw_player_config_value(player_id, CONF_SYNC_ADJUST, 0)
+        sync_adjust = self.player.config.get_value(CONF_SYNC_ADJUST)
         assert isinstance(sync_adjust, int)
+        latency = self.player.config.get_value(
+            CONF_AIRPLAY_LATENCY, AIRPLAY_OUTPUT_BUFFER_MIN_DURATION_MS
+        )
+        assert isinstance(latency, int)
 
         txt_kv: str = ""
         for key, value in self.player.airplay_discovery_info.decoded_properties.items():
             txt_kv += f'"{key}={value}" '
 
         # cliap2 is the binary that handles the actual streaming to the player
-        # this binary leverages from the AirPlay2 support in owntones
+        # this binary leverages from the AirPlay2 support in OwnTone
         # https://github.com/music-assistant/cliairplay
 
         # Get AirPlay credentials if available (for Apple devices that require pairing)
@@ -98,12 +103,12 @@ class AirPlay2Stream(AirPlayProtocol):
             str(self._cli_loglevel),
             "--dacp_id",
             prov.dacp_id,
-            "--active_remote",
-            self.active_remote_id,
             "--pipe",
             "-",  # Use stdin for audio input
             "--command_pipe",
             self.commands_pipe.path,
+            "--latency",
+            str(latency),
         ]
 
         # Add credentials for authenticated AirPlay devices (Apple TV, HomePod, etc.)
@@ -150,10 +155,16 @@ class AirPlay2Stream(AirPlayProtocol):
                 )
             if "put delay detected" in line:
                 if "resetting all outputs" in line:
-                    logger.error("High packet loss detected, restarting playback...")
+                    logger.error(
+                        "Repeated output buffer low level detected, restarting playback..."
+                    )
+                    logger.info(
+                        "Recommended to increase 'Milliseconds of data to buffer' in player "
+                        "advanced settings"
+                    )
                     self.mass.create_task(self.mass.players.cmd_resume(self.player.player_id))
                 else:
-                    logger.warning("Packet loss detected!")
+                    logger.warning("Output buffer low level detected!")
             if "end of stream reached" in line:
                 logger.debug("End of stream reached")
                 break
index 0ac9625dc4edb2d32ca53e0cbc443a15a3841dcd..3ec7c9d1dacde705f1917ca47a2db2bbb5e50a50 100644 (file)
@@ -73,7 +73,14 @@ class AirPlayStreamSession:
         has_airplay2_client = any(
             p.protocol == StreamingProtocol.AIRPLAY2 for p in self.sync_clients
         )
-        wait_start = AIRPLAY2_CONNECT_TIME_MS if has_airplay2_client else RAOP_CONNECT_TIME_MS
+        max_output_buffer_ms: int = 0
+        if has_airplay2_client:
+            max_output_buffer_ms = max(p.output_buffer_duration_ms for p in self.sync_clients)
+        wait_start = (
+            AIRPLAY2_CONNECT_TIME_MS + max_output_buffer_ms
+            if has_airplay2_client
+            else RAOP_CONNECT_TIME_MS
+        )
         wait_start_seconds = wait_start / 1000
         self.wait_start = wait_start_seconds
         self.start_time = cur_time + wait_start_seconds