/
/
/
1"""
2Native AirPlay announcement orchestration.
3
4The cliairplay binary mixes a raw-PCM clip over the outgoing music with the music
5ducked underneath - no flush, no re-anchor, the group timeline stays untouched. This
6module renders the shared announcement clip once per member stdin format, arms every
7member of the live session at one shared audible instant and tracks the per-member
8outcome. Native announcements are only offered while there is live playback to mix
9into; without it the player controller plays the announcement its own way.
10
11The clip is wrapped in ducked silence: the binary holds the music duck for the whole
12file, so the lead-in is a window in which the music is already quiet and nothing is
13being said yet. That is where the announcement volume is raised, and the trailing
14silence is where it is put back - neither change is ever heard on the music itself.
15
16Targeting semantics: a player addressed individually announces alone - over its own
17ducked copy of the group's music, while the other rooms play on untouched - whenever
18a group ENTITY exists as the whole-group handle (a syncgroup member, even the one
19leading the underlying session). Only an ad-hoc sync leader, which has no entity
20above it, represents its whole group, exactly like playing media to it does.
21
22A group-entity announcement is forwarded by the player controller to every member
23concurrently; each call arms its own member and they share one audible instant via
24the provider's announce-plan registry, so every room renders the clip in sync.
25Members of a Sendspin GROUP of bridged players each compute their own instant, so a
26group announcement there can be offset by tens of ms across rooms; cross-player
27plan sharing for that case is future work.
28"""
29
30from __future__ import annotations
31
32import asyncio
33import os
34import tempfile
35import time
36from contextlib import suppress
37from pathlib import Path
38from typing import TYPE_CHECKING, cast
39
40from music_assistant_models.enums import ContentType
41from music_assistant_models.errors import PlayerCommandFailed
42
43from .constants import (
44 AIRPLAY_ANNOUNCE_AT_MARGIN_MS,
45 AIRPLAY_ANNOUNCE_DONE_TIMEOUT_MS,
46 AIRPLAY_ANNOUNCE_DUCK_DB,
47 AIRPLAY_ANNOUNCE_DUCK_LEAD_S,
48 AIRPLAY_ANNOUNCE_DUCK_TAIL_S,
49 AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS,
50 AIRPLAY_ANNOUNCE_STARTED_TIMEOUT_MS,
51 AIRPLAY_ANNOUNCE_VOLUME_BUMP_DELAY_MS,
52 AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS,
53 AIRPLAY_VOLUME_DB_PER_POINT,
54 AIRPLAY_VOLUME_ECHO_GRACE_S,
55)
56
57if TYPE_CHECKING:
58 from collections.abc import Iterable
59
60 from music_assistant_models.media_items import AudioFormat
61 from music_assistant_models.player import PlayerMedia
62
63 from music_assistant.controllers.players.helpers import AnnounceData
64 from music_assistant.controllers.streams.announcements import AnnouncementRender
65 from music_assistant.models.player import Player
66
67 from .player import AirPlayPlayer
68 from .provider import AirPlayProvider
69 from .stream import AirPlayStream
70
71
72async def play_announcement(
73 player: AirPlayPlayer, announcement: PlayerMedia, volume_level: int | None
74) -> None:
75 """
76 Play an announcement on the player (and, for an ad-hoc leader, its members).
77
78 The clip is mixed over the live playing session without interrupting it. A
79 group-entity announcement is forwarded per member by the controller; the
80 members share one audible instant through the provider's announce-plan
81 registry so every room renders the clip in sync.
82
83 :param player: The player the announcement targets.
84 :param announcement: The announcement to play.
85 :param volume_level: Optional volume level for the announcement.
86 """
87 announce_data = cast("AnnounceData | None", announcement.custom_data)
88 if not announce_data or "announcement_url" not in announce_data:
89 raise PlayerCommandFailed(
90 f"Announcement for {player.display_name} carries no announcement data"
91 )
92 renderer = player.mass.streams.announcement_renderer
93 render = renderer.acquire(announce_data)
94 try:
95 await _run_announcement(player, render, volume_level)
96 finally:
97 await renderer.release(render)
98
99
100async def _run_announcement(
101 player: AirPlayPlayer,
102 render: AnnouncementRender,
103 volume_level: int | None,
104) -> None:
105 """
106 Render the clip and mix it over the live playing session.
107
108 :param player: The player the announcement targets.
109 :param render: The announcement render to play.
110 :param volume_level: Optional volume level for the announcement.
111 """
112 # The whole clip is rendered up front: the binary is handed a complete file,
113 # and the exact duration bounds every wait below.
114 duration = await render.wait_finished()
115 if duration is None:
116 duration = render.duration
117 if duration <= 0:
118 player.logger.warning(
119 "Announcement for %s produced no audio; nothing to play", player.display_name
120 )
121 return
122 await _announce_over_live_session(player, render, duration, volume_level)
123
124
125async def _announce_over_live_session(
126 player: AirPlayPlayer,
127 render: AnnouncementRender,
128 duration: float,
129 volume_level: int | None,
130) -> None:
131 """
132 Mix the clip over the live playing session.
133
134 :param player: The player the announcement targets.
135 :param render: The (finished) announcement render to play.
136 :param duration: Exact clip duration in seconds.
137 :param volume_level: Optional volume level for the announcement.
138 :raises PlayerCommandFailed: If the player stopped playing before the clip
139 could be armed, or if no member armed it.
140 """
141 clip_files: dict[str, str] = {}
142 bumped: dict[str, int] = {}
143 try:
144 # The dispatch decision and the arming run under the player lock - the
145 # same lock play_media holds to mutate the session - while the
146 # multi-second clip waits below run outside it, so provider-internal
147 # paths (DACP feedback, member removal on stream loss) are not blocked
148 # for the clip's duration. The controller's per-player playback lock
149 # serializes this whole announcement against cmd_stop, cmd_resume,
150 # cmd_power, enqueue_next_media, play_media and other announcements,
151 # but NOT against cmd_play/cmd_pause/cmd_seek - those can land inside
152 # the waits, where the binary's own cancel semantics keep them safe: a
153 # pause or flush cancels the clip cleanly (done cancelled=1) and the
154 # announcement ends with a cancelled outcome instead of wedging.
155 async with player._lock:
156 members = _live_members(player)
157 if not members:
158 # the feature is only advertised while there is live audio to mix into,
159 # so by now that playback ended or moved to a stream we do not own
160 raise PlayerCommandFailed(
161 f"Cannot announce on {player.display_name}: "
162 "there is no live playback to mix the announcement into"
163 )
164 streams: dict[str, AirPlayStream] = {}
165 for member in members:
166 assert member.stream is not None # guaranteed by _live_members
167 streams[member.player_id] = member.stream
168 # one clip file per distinct member stdin format, shared by all
169 # members on that format
170 for stream in streams.values():
171 clip_key = _format_key(stream.pcm_format)
172 if clip_key not in clip_files:
173 clip_files[clip_key] = await _render_clip_file(render, stream.pcm_format)
174 # resolved only now: file rendering above must not eat into the
175 # margin the shared instant carries
176 at_unix_ms = _resolve_announce_instant(player, members, render.key)
177 delivered = await asyncio.gather(
178 *[
179 streams[member.player_id].announce(
180 clip_files[_format_key(streams[member.player_id].pcm_format)],
181 at_unix_ms,
182 _member_duck_db(member, volume_level),
183 )
184 for member in members
185 ],
186 return_exceptions=True,
187 )
188 for member, sent in zip(members, delivered, strict=True):
189 if isinstance(sent, BaseException):
190 player.logger.debug(
191 "Could not deliver the announcement arm to %s: %r",
192 member.display_name,
193 sent,
194 )
195 started_timeout = (
196 max(0.0, at_unix_ms / 1000 - time.time()) + AIRPLAY_ANNOUNCE_STARTED_TIMEOUT_MS / 1000
197 )
198 acks = await asyncio.gather(
199 *[
200 stream.wait_announce_started(started_timeout)
201 if sent is True
202 else _no_announce_ack()
203 for stream, sent in zip(streams.values(), delivered, strict=True)
204 ]
205 )
206 started: dict[str, tuple[int, int]] = {
207 member.player_id: ack
208 for member, ack in zip(members, acks, strict=True)
209 if ack is not None
210 }
211 if not started:
212 # Music keeps playing on every member, so failing here leaves the
213 # user's playback untouched. Silently ignoring the unknown arm
214 # command is exactly what an outdated cliairplay build does.
215 raise PlayerCommandFailed(
216 f"No member of {player.display_name} armed the announcement; "
217 "the running cliairplay binary may not support announcements yet "
218 "(version mismatch)"
219 )
220 armed = [member for member in members if member.player_id in started]
221 if failed := [m.display_name for m in members if m.player_id not in started]:
222 player.logger.warning(
223 "Announcement was not played on %d member(s) of %s: %s",
224 len(failed),
225 player.display_name,
226 ", ".join(failed),
227 )
228 # Every instant below is derived from the acked start of the clip FILE,
229 # which opens with the ducked lead-in: the music is already ducked there
230 # while the announcement itself has not started yet.
231 file_seconds = AIRPLAY_ANNOUNCE_DUCK_LEAD_S + duration + AIRPLAY_ANNOUNCE_DUCK_TAIL_S
232 earliest_at_unix_ms = min(ack_at or at_unix_ms for ack_at, _ in started.values())
233 content_end_unix_ms = (
234 max(ack_at or at_unix_ms for ack_at, _ in started.values())
235 + (AIRPLAY_ANNOUNCE_DUCK_LEAD_S + duration) * 1000
236 )
237 latest_end_unix_ms = max(
238 (ack_at or at_unix_ms) + (ack_duration or int(file_seconds * 1000))
239 for ack_at, ack_duration in started.values()
240 )
241 # the receiver echoes every level it is given, and those echoes must not be
242 # read as the user reaching for the volume mid-announcement
243 for member in armed:
244 member.suppress_volume_reports(
245 max(0.0, latest_end_unix_ms / 1000 - time.time()) + AIRPLAY_VOLUME_ECHO_GRACE_S
246 )
247 # the done reports arrive while the volume timeline below runs its course
248 done_task = asyncio.gather(
249 *[
250 streams[member_id].wait_announce_done(
251 max(0.0, (ack_at or at_unix_ms) / 1000 - time.time())
252 + (ack_duration / 1000 if ack_duration else file_seconds)
253 + AIRPLAY_ANNOUNCE_DONE_TIMEOUT_MS / 1000
254 )
255 for member_id, (ack_at, ack_duration) in started.items()
256 ]
257 )
258 try:
259 if volume_level is not None:
260 await _volume_around_clip(
261 player,
262 armed,
263 volume_level,
264 earliest_at_unix_ms,
265 content_end_unix_ms,
266 bumped,
267 )
268 done_results = await done_task
269 except BaseException:
270 done_task.cancel()
271 raise
272 for member_id, done in zip(started, done_results, strict=True):
273 if not done:
274 player.logger.debug(
275 "Announcement on member %s was cut short or its completion went unreported",
276 member_id,
277 )
278 # announce_done fires when the clip is fully MIXED at the delivery
279 # head - up to a member's span BEFORE it is audible. Returning then
280 # would let the caller restore mutes (and arm a follow-up
281 # announcement) over the audible tail, so hold the return until the
282 # latest audible end across the started members.
283 await _hold_until(latest_end_unix_ms + AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS)
284 finally:
285 if bumped:
286 # A failed or cancelled announcement leaves the music playing, so whatever
287 # the timeline above did not put back still has to be restored - from its
288 # own task, since an await here is cancelled along with this one.
289 player.mass.create_task(_restore_announcement_volume(player, bumped))
290 for path in clip_files.values():
291 with suppress(OSError):
292 Path(path).unlink()
293
294
295def _live_members(player: AirPlayPlayer) -> list[AirPlayPlayer]:
296 """
297 Return the members a live announcement targets, or [] without live playback.
298
299 A synced member announced individually is just itself; a session leader
300 covers every member of its session. Only a PLAYING session can mix a clip -
301 a parked (paused) or idle player has no live timeline to mix into.
302 """
303 if not player.has_live_audio:
304 return []
305 stream = player.stream
306 assert stream is not None # guaranteed by has_live_audio
307 if player.synced_to:
308 return [player]
309 if stream.session is None:
310 # A Sendspin-bridged player plays without a stream session, but its
311 # stream is a regular AirPlayStream the clip mixes into (self-only).
312 provider = cast("AirPlayProvider", player.provider)
313 bridge = provider.bridge_manager.get_bridge(player.player_id)
314 if bridge is not None and bridge.owns_airplay_stream:
315 return [player]
316 return []
317 if _owning_group_entity(player):
318 # A group ENTITY (e.g. a syncgroup) owns this session, and that entity
319 # is the whole-group announcement handle: this player addressed
320 # individually announces alone, even as the session's sync leader.
321 return [player]
322 # An ad-hoc leader has no entity above it, so it IS the group handle:
323 # announcing to it covers every member of its session.
324 return [
325 member
326 for member in stream.session.sync_clients
327 if member.stream is not None and member.stream.running and member.stream.connected
328 ]
329
330
331def _resolve_announce_instant(
332 player: AirPlayPlayer, members: list[AirPlayPlayer], render_key: str
333) -> int:
334 """
335 Return the audible instant (unix ms) the announcement is armed for.
336
337 When the targets are only a part of a multi-member session (a group-entity
338 announcement is fanned out per member by the controller, each call arming
339 its own member), the instant is shared through the provider's plan
340 registry: the first call computes it from EVERY session member's span, the
341 concurrent sibling calls reuse it, and every room renders the clip in
342 sync. A call that arms its whole target set at once (ad-hoc leader, solo,
343 bridged) needs no plan.
344
345 :param player: The player this call targets.
346 :param members: The members this call arms.
347 :param render_key: Identity of the announcement audio; instants are only
348 ever shared between arms of the SAME announcement.
349 """
350 session = player.stream.session if player.stream else None
351 session_members = session.sync_clients if session else members
352 if len(members) >= len(session_members):
353 return _shared_announce_instant(
354 member.stream for member in members if member.stream is not None
355 )
356 provider = cast("AirPlayProvider", player.provider)
357 plans = provider._announce_plans
358 now_ms = int(time.time() * 1000)
359 # prune settled plans so the registry cannot grow with announcement history
360 for key in [key for key, at_ms in plans.items() if at_ms <= now_ms]:
361 del plans[key]
362 plan_key = (
363 _owning_group_entity(player) or player.synced_to or player.player_id,
364 render_key,
365 )
366 if (at_unix_ms := plans.get(plan_key)) is not None:
367 return at_unix_ms
368 # the instant must clear EVERY session member's span: the sibling calls of
369 # a group fan-out reuse it for their own members
370 at_unix_ms = _shared_announce_instant(
371 member.stream for member in session_members if member.stream is not None
372 )
373 plans[plan_key] = at_unix_ms
374 return at_unix_ms
375
376
377def _shared_announce_instant(streams: Iterable[AirPlayStream]) -> int:
378 """
379 Return the shared audible instant (unix ms) for arming an announcement.
380
381 Every member must mix the clip into audio it has not delivered yet; its
382 span is how far ahead of the audible position that delivery head runs, so
383 the shared instant sits past the largest member span plus a fan-out margin.
384 """
385 max_span_ms = max(_member_span_ms(stream) for stream in streams)
386 return int(time.time() * 1000) + max_span_ms + AIRPLAY_ANNOUNCE_AT_MARGIN_MS
387
388
389def _owning_group_entity(player: AirPlayPlayer) -> str | None:
390 """
391 Return the id of the group ENTITY that owns this player's session, if any.
392
393 ``active_group`` only ever names a real group player (e.g. a syncgroup) -
394 but a protocol player never carries it itself: the model keeps the group
395 state on the device player it renders for, so the ownership is read
396 through the protocol parent when needed.
397 """
398 if player.state.active_group:
399 return player.state.active_group
400 if player.protocol_parent_id and (
401 parent := player.mass.players.get_player(player.protocol_parent_id)
402 ):
403 return parent.state.active_group
404 return None
405
406
407def _member_span_ms(stream: AirPlayStream) -> int:
408 """Return how far a member's delivery head runs ahead of its audible position (ms)."""
409 if stream.warm_lead_ms > 0:
410 return stream.warm_lead_ms
411 if stream.latency_lead_ms > 0:
412 return stream.latency_lead_ms
413 return AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS
414
415
416def _volume_target(member: AirPlayPlayer) -> Player:
417 """
418 Return the player whose volume control owns this member's output.
419
420 An AirPlay volume writes the receiver's own level, so it may only be set when
421 nothing else owns it; on a device that is also reachable through a native
422 provider the announcement volume belongs on that parent instead.
423 """
424 if (parent_id := member.protocol_parent_id) and (
425 parent := member.mass.players.get_player(parent_id)
426 ):
427 return parent
428 return member
429
430
431def _member_duck_db(member: AirPlayPlayer, volume_level: int | None) -> float:
432 """
433 Return the music duck (dB) for one member, compensated for its volume bump.
434
435 The announcement volume raises the music bed together with the clip, so the duck
436 is deepened by that same rise and the music keeps its configured perceived duck
437 depth while the clip plays at the configured announcement loudness. A bump DOWN
438 (a night-mode announcement quieter than the music) symmetrically shallows the
439 duck, and the result never leaves the binary's usable range.
440
441 The rise is read off the AirPlay volume scale, which is linear dB (see
442 AIRPLAY_VOLUME_DB_PER_POINT). A level that lands on another control (the native
443 volume of the device this output renders for) follows that control's own taper,
444 so there the compensation is an approximation - still far closer than leaving the
445 bed to ride up with the clip.
446 """
447 duck_db = float(AIRPLAY_ANNOUNCE_DUCK_DB)
448 if volume_level is None:
449 return duck_db
450 target = _volume_target(member)
451 if (prev_volume := target.state.volume_level) is None:
452 return duck_db
453 # the levels are logical, and the volume limits configured on the target decide
454 # what they land on: the device levels are what the rise is actually made of
455 scale = member.mass.players.scale_volume_to_device
456 bump_db = (
457 scale(target.player_id, volume_level) - scale(target.player_id, prev_volume)
458 ) * AIRPLAY_VOLUME_DB_PER_POINT
459 return min(0.0, max(-60.0, duck_db - bump_db))
460
461
462async def _volume_around_clip(
463 player: AirPlayPlayer,
464 armed: list[AirPlayPlayer],
465 volume_level: int,
466 lead_in_unix_ms: float,
467 content_end_unix_ms: float,
468 bumped: dict[str, int],
469) -> None:
470 """
471 Move the volume to the announcement level and back, inside the ducked silence.
472
473 Both changes are timed on the acked instant of the clip file: the done report
474 arrives when the clip is fully MIXED (at the delivery head), which is ahead of
475 it being heard, so neither change can key off it.
476
477 :param player: The player the announcement targets.
478 :param armed: The members playing the clip.
479 :param volume_level: The announcement volume level.
480 :param lead_in_unix_ms: Start of the earliest member's ducked lead-in.
481 :param content_end_unix_ms: End of the latest member's announcement audio.
482 :param bumped: Mapping that tracks which players still need restoring.
483 """
484 await _hold_until(lead_in_unix_ms + AIRPLAY_ANNOUNCE_VOLUME_BUMP_DELAY_MS)
485 await _apply_announcement_volume(armed, volume_level, bumped)
486 await _hold_until(content_end_unix_ms + AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS)
487 await _restore_announcement_volume(player, bumped)
488
489
490async def _apply_announcement_volume(
491 members: list[AirPlayPlayer], volume_level: int, bumped: dict[str, int]
492) -> None:
493 """
494 Put every member on the announcement volume.
495
496 :param members: The members playing the clip.
497 :param volume_level: The announcement volume level.
498 :param bumped: Mapping that is filled in-place with the previous level per player
499 id, so the caller restores exactly what was changed even if this call fails.
500 """
501 targets: list[Player] = []
502 for member in members:
503 target = _volume_target(member)
504 prev_volume = target.state.volume_level
505 if prev_volume is None or prev_volume == volume_level or target.player_id in bumped:
506 continue
507 bumped[target.player_id] = prev_volume
508 targets.append(target)
509 # the command travels through the controller so it lands on the control that owns
510 # the output, on that control's own scale
511 results = await asyncio.gather(
512 *[target.mass.players.cmd_volume_set(target.player_id, volume_level) for target in targets],
513 return_exceptions=True,
514 )
515 for target, result in zip(targets, results, strict=True):
516 if isinstance(result, BaseException):
517 target.logger.warning(
518 "Could not set the announcement volume on %s: %r", target.display_name, result
519 )
520
521
522async def _restore_announcement_volume(player: AirPlayPlayer, bumped: dict[str, int]) -> None:
523 """
524 Put every bumped player back on the level it had before the announcement.
525
526 An entry is dropped only once its player is restored, so a later call covers
527 exactly what this one did not reach.
528
529 :param player: The player the announcement targets.
530 :param bumped: The level each player carried before the announcement.
531 """
532 for player_id in list(bumped):
533 try:
534 await player.mass.players.cmd_volume_set(player_id, bumped[player_id])
535 except Exception as err:
536 player.logger.warning(
537 "Could not restore the volume of %s after the announcement: %r", player_id, err
538 )
539 continue
540 del bumped[player_id]
541
542
543async def _hold_until(unix_ms: float) -> None:
544 """Wait for the given wall-clock instant (unix ms) to arrive."""
545 await asyncio.sleep(max(0.0, unix_ms / 1000 - time.time()))
546
547
548async def _render_clip_file(render: AnnouncementRender, pcm_format: AudioFormat) -> str:
549 """
550 Render the announcement clip into a temp file of raw PCM in the given format.
551
552 The clip is wrapped in ducked silence: the binary holds the music duck for the
553 whole file, so the music is already ducked before the announcement starts and
554 stays ducked briefly past it - the window in which the announcement volume is
555 raised and put back.
556
557 The caller owns the file and removes it once every member is done with it.
558
559 :param render: The (finished) announcement render to read.
560 :param pcm_format: The raw PCM format the file must carry.
561 """
562 clip = bytearray(_clip_silence(pcm_format, AIRPLAY_ANNOUNCE_DUCK_LEAD_S))
563 async for chunk in render.get_stream(pcm_format):
564 clip.extend(chunk)
565 clip.extend(_clip_silence(pcm_format, AIRPLAY_ANNOUNCE_DUCK_TAIL_S))
566 return await asyncio.to_thread(_write_clip_file, clip)
567
568
569def _clip_silence(pcm_format: AudioFormat, seconds: float) -> bytes:
570 """Return the silence an announcement clip is wrapped in, in the given PCM format."""
571 # Wire sizes come from the content type: at 24-bit the stdin carrier is
572 # s32le while bit_depth stays 24, so bit_depth-derived sizes are wrong.
573 bytes_per_sample = {
574 ContentType.PCM_S16LE: 2,
575 ContentType.PCM_S24LE: 3,
576 ContentType.PCM_S32LE: 4,
577 ContentType.PCM_F32LE: 4,
578 }.get(pcm_format.content_type, pcm_format.bit_depth // 8)
579 frames = int(pcm_format.sample_rate * seconds)
580 return bytes(frames * bytes_per_sample * pcm_format.channels)
581
582
583def _write_clip_file(data: bytes | bytearray) -> str:
584 """Write clip audio to a uniquely named temp file and return its path."""
585 fd, path = tempfile.mkstemp(prefix="ma_airplay_announce_", suffix=".pcm")
586 with os.fdopen(fd, "wb") as clip_file:
587 clip_file.write(data)
588 return path
589
590
591async def _no_announce_ack() -> tuple[int, int] | None:
592 """Stand in for the started-ack of a member whose arm was never delivered."""
593 return None
594
595
596def _format_key(pcm_format: AudioFormat) -> str:
597 """Return the identity of a raw PCM stdin format for clip-file sharing."""
598 return (
599 f"{pcm_format.content_type.value}_{pcm_format.sample_rate}"
600 f"_{pcm_format.bit_depth}_{pcm_format.channels}"
601 )
602