From: Brad Keifer <15224368+bradkeifer@users.noreply.github.com> Date: Fri, 28 Nov 2025 22:49:43 +0000 (+1100) Subject: Airplay2 improvements (#2702) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=dec537fc18a9da8e36178d4ca182156a74880afb;p=music-assistant-server.git Airplay2 improvements (#2702) --- diff --git a/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 b/music_assistant/providers/airplay/bin/cliap2-linux-aarch64 index 5ae73492..8ca802f6 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 fc161ec1..65ae8f40 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 c1f8e4de..7742fb03 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 ad27e054..3efe1324 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 1487e221..08562028 100644 --- a/music_assistant/providers/airplay/constants.py +++ b/music_assistant/providers/airplay/constants.py @@ -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" diff --git a/music_assistant/providers/airplay/helpers.py b/music_assistant/providers/airplay/helpers.py index 2ae3d576..68ec1da2 100644 --- a/music_assistant/providers/airplay/helpers.py +++ b/music_assistant/providers/airplay/helpers.py @@ -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 diff --git a/music_assistant/providers/airplay/player.py b/music_assistant/providers/airplay/player.py index a4a5feed..410965eb 100644 --- a/music_assistant/providers/airplay/player.py +++ b/music_assistant/providers/airplay/player.py @@ -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 diff --git a/music_assistant/providers/airplay/protocols/airplay2.py b/music_assistant/providers/airplay/protocols/airplay2.py index fe3f5df2..678ce2d8 100644 --- a/music_assistant/providers/airplay/protocols/airplay2.py +++ b/music_assistant/providers/airplay/protocols/airplay2.py @@ -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() diff --git a/music_assistant/providers/airplay/protocols/raop.py b/music_assistant/providers/airplay/protocols/raop.py index 45227df3..0d8bec5c 100644 --- a/music_assistant/providers/airplay/protocols/raop.py +++ b/music_assistant/providers/airplay/protocols/raop.py @@ -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, diff --git a/music_assistant/providers/airplay/stream_session.py b/music_assistant/providers/airplay/stream_session.py index c7e72dba..aa56f2cc 100644 --- a/music_assistant/providers/airplay/stream_session.py +++ b/music_assistant/providers/airplay/stream_session.py @@ -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