/
/
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. Whenever there is no live playback to mix into (idle or parked player), the
9announcement runs as a dedicated stream session instead, leaving the player idle.
10
11Targeting semantics: a player addressed individually announces alone - over its own
12ducked copy of the group's music, while the other rooms play on untouched - whenever
13a group ENTITY exists as the whole-group handle (a syncgroup member, even the one
14leading the underlying session). Only an ad-hoc sync leader, which has no entity
15above it, represents its whole group, exactly like playing media to it does.
16
17A group-entity announcement is forwarded by the player controller to every member
18concurrently; each call arms its own member and they share one audible instant via
19the provider's announce-plan registry, so every room renders the clip in sync.
20Members of a Sendspin GROUP of bridged players each compute their own instant, so a
21group announcement there can be offset by tens of ms across rooms; cross-player
22plan sharing for that case is future work.
23"""
24
25from __future__ import annotations
26
27import asyncio
28import os
29import tempfile
30import time
31from contextlib import suppress
32from pathlib import Path
33from typing import TYPE_CHECKING, cast
34
35from music_assistant_models.enums import ContentType, PlaybackState
36from music_assistant_models.errors import PlayerCommandFailed
37
38from .constants import (
39 AIRPLAY_ANNOUNCE_AT_MARGIN_MS,
40 AIRPLAY_ANNOUNCE_DONE_TIMEOUT_MS,
41 AIRPLAY_ANNOUNCE_DUCK_DB,
42 AIRPLAY_ANNOUNCE_DUCK_TAIL_S,
43 AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS,
44 AIRPLAY_ANNOUNCE_SESSION_DRAIN_S,
45 AIRPLAY_ANNOUNCE_STARTED_TIMEOUT_MS,
46 AIRPLAY_ANNOUNCE_VOLUME_BUMP_DELAY_MS,
47 AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS,
48 AIRPLAY_VOLUME_DB_PER_POINT,
49)
50from .stream_session import AirPlayStreamSession
51
52if TYPE_CHECKING:
53 from collections.abc import AsyncGenerator, Iterable
54
55 from music_assistant_models.media_items import AudioFormat
56 from music_assistant_models.player import PlayerMedia
57
58 from music_assistant.controllers.players.helpers import AnnounceData
59 from music_assistant.controllers.streams.announcements import AnnouncementRender
60
61 from .player import AirPlayPlayer
62 from .provider import AirPlayProvider
63 from .stream import AirPlayStream
64
65# Scheduled announcement-volume changes of one member: the previous level and
66# the timer task ids that apply/restore the announcement volume around the
67# clip (mass.call_later tracks its timers by task id, and only cancel_timer
68# releases a tracked entry that never fired).
69_VolumeSchedule = tuple["AirPlayPlayer", int, list[str]]
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 A live playing session mixes the clip over the music without interrupting
79 it; an idle player plays the announcement as a dedicated stream session and
80 ends idle. A group-entity announcement is forwarded per member by the
81 controller; the members share one audible instant through the provider's
82 announce-plan registry so every room renders the clip in sync.
83
84 :param player: The player the announcement targets.
85 :param announcement: The announcement to play.
86 :param volume_level: Optional volume level for the announcement.
87 """
88 announce_data = cast("AnnounceData | None", announcement.custom_data)
89 if not announce_data or "announcement_url" not in announce_data:
90 raise PlayerCommandFailed(
91 f"Announcement for {player.display_name} carries no announcement data"
92 )
93 renderer = player.mass.streams.announcement_renderer
94 render = renderer.acquire(announce_data)
95 try:
96 await _run_announcement(player, announcement, render, volume_level)
97 finally:
98 await renderer.release(render)
99
100
101async def _run_announcement(
102 player: AirPlayPlayer,
103 announcement: PlayerMedia,
104 render: AnnouncementRender,
105 volume_level: int | None,
106) -> None:
107 """
108 Render the clip, then mix it over live playback or play it as its own session.
109
110 :param player: The player the announcement targets.
111 :param announcement: The announcement to play.
112 :param render: The announcement render to play.
113 :param volume_level: Optional volume level for the announcement.
114 """
115 # The whole clip is rendered up front: the live path hands the binary a
116 # complete file, and the exact duration bounds every wait below.
117 duration = await render.wait_finished()
118 if duration is None:
119 duration = render.duration
120 if duration <= 0:
121 player.logger.warning(
122 "Announcement for %s produced no audio; nothing to play", player.display_name
123 )
124 return
125 if await _announce_over_live_session(player, render, duration, volume_level):
126 return
127 await _announce_with_session(player, announcement, render, duration, volume_level)
128
129
130async def _announce_over_live_session(
131 player: AirPlayPlayer,
132 render: AnnouncementRender,
133 duration: float,
134 volume_level: int | None,
135) -> bool:
136 """
137 Mix the clip over the live playing session.
138
139 :param player: The player the announcement targets.
140 :param render: The (finished) announcement render to play.
141 :param duration: Exact clip duration in seconds.
142 :param volume_level: Optional volume level for the announcement.
143 :return: True when at least one member played the clip; False when there is
144 no live playback to mix into, so the caller runs the dedicated
145 announcement session instead.
146 :raises PlayerCommandFailed: If there was live playback but no member armed
147 the clip (tearing the playing session down for a fallback would trade
148 the user's music for the announcement).
149 """
150 clip_files: dict[str, str] = {}
151 volume_schedules: list[_VolumeSchedule] = []
152 try:
153 # The dispatch decision and the arming run under the player lock - the
154 # same lock play_media holds to mutate the session - while the
155 # multi-second clip waits below run outside it, so provider-internal
156 # paths (DACP feedback, member removal on stream loss) are not blocked
157 # for the clip's duration. The controller's per-player playback lock
158 # serializes this whole announcement against cmd_stop, cmd_resume,
159 # cmd_power, enqueue_next_media, play_media and other announcements,
160 # but NOT against cmd_play/cmd_pause/cmd_seek - those can land inside
161 # the waits, where the binary's own cancel semantics keep them safe: a
162 # pause or flush cancels the clip cleanly (done cancelled=1) and the
163 # announcement ends with a cancelled outcome instead of wedging.
164 async with player._lock:
165 members = _live_members(player)
166 if not members:
167 return False
168 streams: dict[str, AirPlayStream] = {}
169 for member in members:
170 assert member.stream is not None # guaranteed by _live_members
171 streams[member.player_id] = member.stream
172 # one clip file per distinct member stdin format, shared by all
173 # members on that format
174 for stream in streams.values():
175 clip_key = _format_key(stream.pcm_format)
176 if clip_key not in clip_files:
177 clip_files[clip_key] = await _render_clip_file(render, stream.pcm_format)
178 # resolved only now: file rendering above must not eat into the
179 # margin the shared instant carries
180 at_unix_ms = _resolve_announce_instant(player, members, render.key)
181 delivered = await asyncio.gather(
182 *[
183 streams[member.player_id].announce(
184 clip_files[_format_key(streams[member.player_id].pcm_format)],
185 at_unix_ms,
186 _member_duck_db(member, volume_level),
187 )
188 for member in members
189 ],
190 return_exceptions=True,
191 )
192 for member, sent in zip(members, delivered, strict=True):
193 if isinstance(sent, BaseException):
194 player.logger.debug(
195 "Could not deliver the announcement arm to %s: %r",
196 member.display_name,
197 sent,
198 )
199 started_timeout = (
200 max(0.0, at_unix_ms / 1000 - time.time()) + AIRPLAY_ANNOUNCE_STARTED_TIMEOUT_MS / 1000
201 )
202 acks = await asyncio.gather(
203 *[
204 stream.wait_announce_started(started_timeout)
205 if sent is True
206 else _no_announce_ack()
207 for stream, sent in zip(streams.values(), delivered, strict=True)
208 ]
209 )
210 started: dict[str, tuple[int, int]] = {
211 member.player_id: ack
212 for member, ack in zip(members, acks, strict=True)
213 if ack is not None
214 }
215 if not started:
216 # Music keeps playing on every member, so falling back to a
217 # dedicated announcement session would stop the user's playback
218 # over a clip that could not be mixed anyway. Silently ignoring
219 # the unknown arm command is exactly what an outdated cliairplay
220 # build does.
221 raise PlayerCommandFailed(
222 f"No member of {player.display_name} armed the announcement; "
223 "the running cliairplay binary may not support announcements yet "
224 "(version mismatch)"
225 )
226 if volume_level is not None:
227 for member in members:
228 if (ack := started.get(member.player_id)) is None:
229 continue
230 ack_at_unix_ms, ack_duration_ms = ack
231 # The binary-reported duration includes the ducked silence
232 # tail appended to the clip file; the restore must land INSIDE
233 # that cushion, so it is timed on the content length alone.
234 content_seconds = (
235 max(0.0, ack_duration_ms / 1000 - AIRPLAY_ANNOUNCE_DUCK_TAIL_S)
236 if ack_duration_ms
237 else duration
238 )
239 if schedule := _schedule_member_volume(
240 member,
241 volume_level,
242 ack_at_unix_ms or at_unix_ms,
243 content_seconds,
244 ):
245 volume_schedules.append(schedule)
246 padded_duration = duration + AIRPLAY_ANNOUNCE_DUCK_TAIL_S
247 done_results = await asyncio.gather(
248 *[
249 streams[member_id].wait_announce_done(
250 max(0.0, (ack_at or at_unix_ms) / 1000 - time.time())
251 + (ack_duration / 1000 if ack_duration else padded_duration)
252 + AIRPLAY_ANNOUNCE_DONE_TIMEOUT_MS / 1000
253 )
254 for member_id, (ack_at, ack_duration) in started.items()
255 ]
256 )
257 for member_id, done in zip(started, done_results, strict=True):
258 if not done:
259 player.logger.debug(
260 "Announcement on member %s was cut short or its completion went unreported",
261 member_id,
262 )
263 if failed := [m.display_name for m in members if m.player_id not in started]:
264 player.logger.warning(
265 "Announcement was not played on %d member(s) of %s: %s",
266 len(failed),
267 player.display_name,
268 ", ".join(failed),
269 )
270 # announce_done fires when the clip is fully MIXED at the delivery
271 # head - up to a member's span BEFORE it is audible. Returning then
272 # would let the caller restore mutes (and arm a follow-up
273 # announcement) over the audible tail, so hold the return until the
274 # latest audible end across the started members.
275 latest_end_unix_ms = max(
276 (ack_at or at_unix_ms) + (ack_duration or int(padded_duration * 1000))
277 for ack_at, ack_duration in started.values()
278 )
279 await asyncio.sleep(
280 max(0.0, latest_end_unix_ms / 1000 - time.time())
281 + AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS / 1000
282 )
283 return True
284 except BaseException:
285 # The scheduled volume changes belong to an announcement that is no
286 # longer being tracked: cancel them and restore the previous levels
287 # right away (a restore of an unchanged level is a harmless re-send).
288 # This path leaves the music session running, so the restore still
289 # reaches the receiver from its own task - unlike the dedicated
290 # session, which has to be restored before it is torn down.
291 for member, prev_volume, task_ids in volume_schedules:
292 for task_id in task_ids:
293 member.mass.cancel_timer(task_id)
294 player.mass.create_task(member.volume_set(prev_volume))
295 raise
296 finally:
297 for path in clip_files.values():
298 with suppress(OSError):
299 Path(path).unlink()
300
301
302async def _announce_with_session(
303 player: AirPlayPlayer,
304 announcement: PlayerMedia,
305 render: AnnouncementRender,
306 duration: float,
307 volume_level: int | None,
308) -> None:
309 """
310 Play the announcement as a dedicated stream session.
311
312 Any existing (parked) session led by this player is stopped first. The
313 player ends idle - the same end state the generic announcement flow leaves
314 a non-playing player in; the queue keeps its resume position server-side.
315
316 Known limitation: a synced member of a group without live playback is
317 refused here - its stream belongs to the leader's shared (parked) session,
318 which must not be torn down for a single-member announcement, so that
319 announcement simply does not play. Announcing over a parked session
320 without stopping it needs binary-side support (announce while in
321 STANDBY), which is the planned proper fix.
322
323 :param player: The player the announcement targets.
324 :param announcement: The announcement media, owning the session's metadata.
325 :param render: The (finished) announcement render to play.
326 :param duration: Exact clip duration in seconds.
327 :param volume_level: Optional volume level for the announcement.
328 :raises PlayerCommandFailed: If the player is a synced group member or is
329 streamed to by its Sendspin bridge - both own no session this path may
330 replace.
331 """
332 provider = cast("AirPlayProvider", player.provider)
333 if player.synced_to:
334 # The controller's group-forward runs the member calls in a
335 # TaskManager, which does not cancel siblings on a failure, so raising
336 # here cannot take the leader's in-flight announcement down with it.
337 raise PlayerCommandFailed(
338 f"Cannot announce on {player.display_name}: a grouped AirPlay player "
339 "without live playback cannot announce natively"
340 )
341 bridge = provider.bridge_manager.get_bridge(player.player_id)
342 if bridge is not None and bridge.owns_airplay_stream:
343 # The bridge is actively streaming to the device; a dedicated
344 # announcement session would seize it from the Sendspin side with
345 # nothing restoring that playback. Only reachable in a race (the live
346 # mix path handles a playing bridge stream). A bridge that is merely
347 # configured but idle is a bystander: the fallback then behaves
348 # exactly as it does for an unbridged player.
349 raise PlayerCommandFailed(
350 f"Cannot announce on {player.display_name}: the player is being streamed "
351 "to by its Sendspin bridge"
352 )
353 prev_volumes: dict[AirPlayPlayer, int] = {}
354 session: AirPlayStreamSession | None = None
355 try:
356 # Session teardown and setup mirror play_media's cold path, under the
357 # same player lock; the clip wait below runs outside it.
358 async with player._lock:
359 if player.stream and player.stream.running and player.stream.session:
360 player._transitioning = True
361 await player.stream.session.stop()
362 player.stream = None
363 sync_clients = player._get_sync_clients()
364 session_pcm_format = await player._get_session_pcm_format(sync_clients, announcement)
365 if volume_level is not None:
366 for member in sync_clients:
367 prev_volume = member.volume_level
368 if prev_volume is not None and prev_volume != volume_level:
369 prev_volumes[member] = prev_volume
370 await member.volume_set(volume_level)
371 session = AirPlayStreamSession(
372 provider,
373 sync_clients,
374 session_pcm_format,
375 announcement,
376 requested_volume=volume_level,
377 )
378 # The clip is served with its silence tail: the legacy RAOP flow
379 # reports end of stream the moment the last fed sample is audible,
380 # and a volume command is dropped once a stream has ended - so
381 # without the tail the restore below never reaches the speaker.
382 await session.start(_clip_with_tail(render, session_pcm_format))
383 player._transitioning = False
384 # The clip is anchored: wait out the start lead plus the clip, then the
385 # pad that covers the jitter between the anchored and the true audible
386 # end, so the restore below does not land on the announcement's own
387 # tail. Restoring at the end of the drain instead would be a coin flip:
388 # the binary reports its own end of stream on that same margin, and the
389 # server treats that report as the end of the stream.
390 restore_pad = AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS / 1000
391 await asyncio.sleep(max(0.0, session.start_time - time.time()) + duration + restore_pad)
392 await _restore_member_volumes(prev_volumes)
393 # Let the receiver play out what it still has buffered before the stop.
394 # The source ends after the clip, so the session usually ends cleanly on
395 # its own and that stop is cleanup only.
396 await asyncio.sleep(max(0.0, AIRPLAY_ANNOUNCE_SESSION_DRAIN_S - restore_pad))
397 finally:
398 player._transitioning = False
399 try:
400 # Whatever the restore above did not reach (a cancelled
401 # announcement) still goes back while the session is up. A session that
402 # failed to START has already stopped itself, so there the restore
403 # only settles our own state and the device is corrected by the
404 # volume the next stream pushes.
405 await _restore_member_volumes(prev_volumes)
406 finally:
407 if session is not None:
408 await session.stop()
409 # like player.stop(): an idle player must not keep showing media
410 player._attr_current_media = None
411 player.update_state()
412
413
414def _live_members(player: AirPlayPlayer) -> list[AirPlayPlayer]:
415 """
416 Return the members a live announcement targets, or [] without live playback.
417
418 A synced member announced individually is just itself; a session leader
419 covers every member of its session. Only a PLAYING session can mix a clip -
420 a parked (paused) or idle player has no live timeline to mix into.
421 """
422 if player.playback_state != PlaybackState.PLAYING:
423 return []
424 stream = player.stream
425 if stream is None or not stream.running or not stream.connected:
426 return []
427 if player.synced_to:
428 return [player]
429 if stream.session is None:
430 # A Sendspin-bridged player plays without a stream session, but its
431 # stream is a regular AirPlayStream the clip mixes into (self-only).
432 provider = cast("AirPlayProvider", player.provider)
433 bridge = provider.bridge_manager.get_bridge(player.player_id)
434 if bridge is not None and bridge.owns_airplay_stream:
435 return [player]
436 return []
437 if _owning_group_entity(player):
438 # A group ENTITY (e.g. a syncgroup) owns this session, and that entity
439 # is the whole-group announcement handle: this player addressed
440 # individually announces alone, even as the session's sync leader.
441 return [player]
442 # An ad-hoc leader has no entity above it, so it IS the group handle:
443 # announcing to it covers every member of its session.
444 return [
445 member
446 for member in stream.session.sync_clients
447 if member.stream is not None and member.stream.running and member.stream.connected
448 ]
449
450
451def _resolve_announce_instant(
452 player: AirPlayPlayer, members: list[AirPlayPlayer], render_key: str
453) -> int:
454 """
455 Return the audible instant (unix ms) the announcement is armed for.
456
457 When the targets are only a part of a multi-member session (a group-entity
458 announcement is fanned out per member by the controller, each call arming
459 its own member), the instant is shared through the provider's plan
460 registry: the first call computes it from EVERY session member's span, the
461 concurrent sibling calls reuse it, and every room renders the clip in
462 sync. A call that arms its whole target set at once (ad-hoc leader, solo,
463 bridged) needs no plan.
464
465 :param player: The player this call targets.
466 :param members: The members this call arms.
467 :param render_key: Identity of the announcement audio; instants are only
468 ever shared between arms of the SAME announcement.
469 """
470 session = player.stream.session if player.stream else None
471 session_members = session.sync_clients if session else members
472 if len(members) >= len(session_members):
473 return _shared_announce_instant(
474 member.stream for member in members if member.stream is not None
475 )
476 provider = cast("AirPlayProvider", player.provider)
477 plans = provider._announce_plans
478 now_ms = int(time.time() * 1000)
479 # prune settled plans so the registry cannot grow with announcement history
480 for key in [key for key, at_ms in plans.items() if at_ms <= now_ms]:
481 del plans[key]
482 plan_key = (
483 _owning_group_entity(player) or player.synced_to or player.player_id,
484 render_key,
485 )
486 if (at_unix_ms := plans.get(plan_key)) is not None:
487 return at_unix_ms
488 # the instant must clear EVERY session member's span: the sibling calls of
489 # a group fan-out reuse it for their own members
490 at_unix_ms = _shared_announce_instant(
491 member.stream for member in session_members if member.stream is not None
492 )
493 plans[plan_key] = at_unix_ms
494 return at_unix_ms
495
496
497def _shared_announce_instant(streams: Iterable[AirPlayStream]) -> int:
498 """
499 Return the shared audible instant (unix ms) for arming an announcement.
500
501 Every member must mix the clip into audio it has not delivered yet; its
502 span is how far ahead of the audible position that delivery head runs, so
503 the shared instant sits past the largest member span plus a fan-out margin.
504 """
505 max_span_ms = max(_member_span_ms(stream) for stream in streams)
506 return int(time.time() * 1000) + max_span_ms + AIRPLAY_ANNOUNCE_AT_MARGIN_MS
507
508
509def _owning_group_entity(player: AirPlayPlayer) -> str | None:
510 """
511 Return the id of the group ENTITY that owns this player's session, if any.
512
513 ``active_group`` only ever names a real group player (e.g. a syncgroup) -
514 but a protocol player never carries it itself: the model keeps the group
515 state on the device player it renders for, so the ownership is read
516 through the protocol parent when needed.
517 """
518 if player.state.active_group:
519 return player.state.active_group
520 if player.protocol_parent_id and (
521 parent := player.mass.players.get_player(player.protocol_parent_id)
522 ):
523 return parent.state.active_group
524 return None
525
526
527def _member_span_ms(stream: AirPlayStream) -> int:
528 """Return how far a member's delivery head runs ahead of its audible position (ms)."""
529 if stream.warm_lead_ms > 0:
530 return stream.warm_lead_ms
531 if stream.latency_lead_ms > 0:
532 return stream.latency_lead_ms
533 return AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS
534
535
536def _member_duck_db(member: AirPlayPlayer, volume_level: int | None) -> float:
537 """
538 Return the music duck (dB) for one member, compensated for its volume bump.
539
540 The announcement volume is applied as DEVICE volume, which raises the music
541 bed together with the clip. The AirPlay volume scale is linear dB (see
542 AIRPLAY_VOLUME_DB_PER_POINT), so that rise is exactly known and the duck is
543 deepened by the same amount - the music keeps its configured perceived duck
544 depth while the clip plays at the configured announcement loudness. A bump
545 DOWN (a night-mode announcement quieter than the music) symmetrically
546 shallows the duck, and the result never leaves the binary's usable range.
547 """
548 duck_db = float(AIRPLAY_ANNOUNCE_DUCK_DB)
549 if volume_level is None or member.volume_level is None:
550 return duck_db
551 bump_db = (volume_level - member.volume_level) * AIRPLAY_VOLUME_DB_PER_POINT
552 return min(0.0, max(-60.0, duck_db - bump_db))
553
554
555def _schedule_member_volume(
556 member: AirPlayPlayer, volume_level: int, at_unix_ms: int, clip_seconds: float
557) -> _VolumeSchedule | None:
558 """
559 Schedule the announcement volume around one member's audible clip window.
560
561 Both changes are wall-clock timers on the acked instant: the done report
562 arrives when the clip is fully MIXED (at the delivery head), which is ahead
563 of it being heard, so neither change can key off it.
564
565 :param member: The member whose volume is bumped and restored.
566 :param volume_level: The announcement volume level.
567 :param at_unix_ms: The acked audible instant of the clip on this member.
568 :param clip_seconds: The clip duration in seconds.
569 :return: The schedule to track (None when there is nothing to change).
570 """
571 prev_volume = member.volume_level
572 if prev_volume is None or prev_volume == volume_level:
573 return None
574 delay = max(0.0, at_unix_ms / 1000 - time.time())
575 # The bump is biased INTO the clip: a receiver that plays out later than
576 # the reported instant would otherwise get louder while the old music is
577 # still sounding. The duck ramp (and the pre-announce chime) masks the
578 # late bump; short clips cap the bias at their midpoint.
579 bump_delay = delay + min(AIRPLAY_ANNOUNCE_VOLUME_BUMP_DELAY_MS / 1000, clip_seconds / 2)
580 # Deterministic task ids: cancel_timer is the only way to release a tracked
581 # timer that never fires, and reusing the ids also cancels stale timers of
582 # a replaced announcement on the same member.
583 task_ids = [
584 f"airplay_announce_volume_bump_{member.player_id}",
585 f"airplay_announce_volume_restore_{member.player_id}",
586 ]
587 member.mass.call_later(bump_delay, member.volume_set, volume_level, task_id=task_ids[0])
588 member.mass.call_later(
589 delay + clip_seconds + AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS / 1000,
590 member.volume_set,
591 prev_volume,
592 task_id=task_ids[1],
593 )
594 return (member, prev_volume, task_ids)
595
596
597async def _render_clip_file(render: AnnouncementRender, pcm_format: AudioFormat) -> str:
598 """
599 Render the announcement clip into a temp file of raw PCM in the given format.
600
601 A ducked-silence tail is appended: the binary holds the music duck for the
602 whole file, so the music stays ducked briefly past the announcement and the
603 volume restore has a safe window to land in.
604
605 The caller owns the file and removes it once every member is done with it.
606
607 :param render: The (finished) announcement render to read.
608 :param pcm_format: The raw PCM format the file must carry.
609 """
610 clip = bytearray()
611 async for chunk in render.get_stream(pcm_format):
612 clip.extend(chunk)
613 clip.extend(_clip_silence_tail(pcm_format))
614 return await asyncio.to_thread(_write_clip_file, clip)
615
616
617async def _clip_with_tail(
618 render: AnnouncementRender, pcm_format: AudioFormat
619) -> AsyncGenerator[bytes]:
620 """
621 Yield the announcement clip followed by the trailing silence.
622
623 :param render: The (finished) announcement render to read.
624 :param pcm_format: The raw PCM format to yield.
625 """
626 async for chunk in render.get_stream(pcm_format):
627 yield chunk
628 yield _clip_silence_tail(pcm_format)
629
630
631def _clip_silence_tail(pcm_format: AudioFormat) -> bytes:
632 """Return the silence tail appended to an announcement clip, in the given PCM format."""
633 # Wire sizes come from the content type: at 24-bit the stdin carrier is
634 # s32le while bit_depth stays 24, so bit_depth-derived sizes are wrong.
635 bytes_per_sample = {
636 ContentType.PCM_S16LE: 2,
637 ContentType.PCM_S24LE: 3,
638 ContentType.PCM_S32LE: 4,
639 ContentType.PCM_F32LE: 4,
640 }.get(pcm_format.content_type, pcm_format.bit_depth // 8)
641 trail_frames = int(pcm_format.sample_rate * AIRPLAY_ANNOUNCE_DUCK_TAIL_S)
642 return bytes(trail_frames * bytes_per_sample * pcm_format.channels)
643
644
645def _write_clip_file(data: bytes | bytearray) -> str:
646 """Write clip audio to a uniquely named temp file and return its path."""
647 fd, path = tempfile.mkstemp(prefix="ma_airplay_announce_", suffix=".pcm")
648 with os.fdopen(fd, "wb") as clip_file:
649 clip_file.write(data)
650 return path
651
652
653async def _restore_member_volumes(prev_volumes: dict[AirPlayPlayer, int]) -> None:
654 """
655 Put every bumped member back on its pre-announcement volume.
656
657 An AirPlay volume command only reaches the receiver over a running stream,
658 so this has to be called while the announcement session is still up:
659 afterwards it only settles our own state and leaves the speaker sitting at
660 the announcement level.
661
662 An entry is dropped only once its member is restored, so a later call
663 covers exactly what this one did not reach.
664
665 :param prev_volumes: The level each member carried before the announcement.
666 """
667 for member in list(prev_volumes):
668 await member.volume_set(prev_volumes[member])
669 del prev_volumes[member]
670
671
672async def _no_announce_ack() -> tuple[int, int] | None:
673 """Stand in for the started-ack of a member whose arm was never delivered."""
674 return None
675
676
677def _format_key(pcm_format: AudioFormat) -> str:
678 """Return the identity of a raw PCM stdin format for clip-file sharing."""
679 return (
680 f"{pcm_format.content_type.value}_{pcm_format.sample_rate}"
681 f"_{pcm_format.bit_depth}_{pcm_format.channels}"
682 )
683