From: Brad Keifer <15224368+bradkeifer@users.noreply.github.com> Date: Sun, 22 Feb 2026 00:42:49 +0000 (+1100) Subject: Airplay2-configurable-latency (#3210) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=f91e438b46d16d9cc0f9722550c5be636f726765;p=music-assistant-server.git Airplay2-configurable-latency (#3210) --- diff --git a/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 b/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 index b5f487e3..e50b79ca 100755 Binary files a/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 and b/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 differ diff --git a/music_assistant/providers/airplay/bin/cliap2-linux-x86_64 b/music_assistant/providers/airplay/bin/cliap2-linux-x86_64 index 0a6e222d..5882b4ef 100755 Binary files a/music_assistant/providers/airplay/bin/cliap2-linux-x86_64 and b/music_assistant/providers/airplay/bin/cliap2-linux-x86_64 differ diff --git a/music_assistant/providers/airplay/bin/cliap2-macos-arm64 b/music_assistant/providers/airplay/bin/cliap2-macos-arm64 index e2a952dd..8bdc0a7b 100755 Binary files a/music_assistant/providers/airplay/bin/cliap2-macos-arm64 and b/music_assistant/providers/airplay/bin/cliap2-macos-arm64 differ diff --git a/music_assistant/providers/airplay/bin/cliap2-macos-x86_64 b/music_assistant/providers/airplay/bin/cliap2-macos-x86_64 index 56cb9645..a8f54bce 100755 Binary files a/music_assistant/providers/airplay/bin/cliap2-macos-x86_64 and b/music_assistant/providers/airplay/bin/cliap2-macos-x86_64 differ diff --git a/music_assistant/providers/airplay/constants.py b/music_assistant/providers/airplay/constants.py index 8c6e32ba..90dc017b 100644 --- a/music_assistant/providers/airplay/constants.py +++ b/music_assistant/providers/airplay/constants.py @@ -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" diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index 1e2a9b6f..b3551c12 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -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 diff --git a/music_assistant/providers/airplay/protocols/airplay2.py b/music_assistant/providers/airplay/protocols/airplay2.py index ab6896e3..435085ec 100644 --- a/music_assistant/providers/airplay/protocols/airplay2.py +++ b/music_assistant/providers/airplay/protocols/airplay2.py @@ -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 diff --git a/music_assistant/providers/airplay/stream_session.py b/music_assistant/providers/airplay/stream_session.py index 0ac9625d..3ec7c9d1 100644 --- a/music_assistant/providers/airplay/stream_session.py +++ b/music_assistant/providers/airplay/stream_session.py @@ -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