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"
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"
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
CONF_IGNORE_VOLUME,
CONF_PAIRING_PIN,
CONF_PASSWORD,
- CONF_READ_AHEAD_BUFFER,
FALLBACK_VOLUME,
RAOP_DISCOVERY_TYPE,
StreamingProtocol,
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
from __future__ import annotations
+import asyncio
import logging
from music_assistant_models.enums import PlaybackState
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
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():
txt_kv,
"--ntpstart",
str(start_ntp),
- "--latency",
- str(read_ahead),
"--volume",
str(self.player.volume_level),
"--loglevel",
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.")
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()
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
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
"-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,
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
# 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
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