/
/
/
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 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 for member, prev_volume, task_ids in volume_schedules:
289 for task_id in task_ids:
290 member.mass.cancel_timer(task_id)
291 player.mass.create_task(member.volume_set(prev_volume))
292 raise
293 finally:
294 for path in clip_files.values():
295 with suppress(OSError):
296 Path(path).unlink()
297
298
299async def _announce_with_session(
300 player: AirPlayPlayer,
301 announcement: PlayerMedia,
302 render: AnnouncementRender,
303 duration: float,
304 volume_level: int | None,
305) -> None:
306 """
307 Play the announcement as a dedicated stream session.
308
309 Any existing (parked) session led by this player is stopped first. The
310 player ends idle - the same end state the generic announcement flow leaves
311 a non-playing player in; the queue keeps its resume position server-side.
312
313 Known limitation: a synced member of a group without live playback is
314 refused here - its stream belongs to the leader's shared (parked) session,
315 which must not be torn down for a single-member announcement, so that
316 announcement simply does not play. Announcing over a parked session
317 without stopping it needs binary-side support (announce while in
318 STANDBY), which is the planned proper fix.
319
320 :param player: The player the announcement targets.
321 :param announcement: The announcement media, owning the session's metadata.
322 :param render: The (finished) announcement render to play.
323 :param duration: Exact clip duration in seconds.
324 :param volume_level: Optional volume level for the announcement.
325 :raises PlayerCommandFailed: If the player is a synced group member or is
326 streamed to by its Sendspin bridge - both own no session this path may
327 replace.
328 """
329 provider = cast("AirPlayProvider", player.provider)
330 if player.synced_to:
331 # The controller's group-forward runs the member calls in a
332 # TaskManager, which does not cancel siblings on a failure, so raising
333 # here cannot take the leader's in-flight announcement down with it.
334 raise PlayerCommandFailed(
335 f"Cannot announce on {player.display_name}: a grouped AirPlay player "
336 "without live playback cannot announce natively"
337 )
338 bridge = provider.bridge_manager.get_bridge(player.player_id)
339 if bridge is not None and bridge.owns_airplay_stream:
340 # The bridge is actively streaming to the device; a dedicated
341 # announcement session would seize it from the Sendspin side with
342 # nothing restoring that playback. Only reachable in a race (the live
343 # mix path handles a playing bridge stream). A bridge that is merely
344 # configured but idle is a bystander: the fallback then behaves
345 # exactly as it does for an unbridged player.
346 raise PlayerCommandFailed(
347 f"Cannot announce on {player.display_name}: the player is being streamed "
348 "to by its Sendspin bridge"
349 )
350 prev_volumes: dict[AirPlayPlayer, int] = {}
351 session: AirPlayStreamSession | None = None
352 try:
353 # Session teardown and setup mirror play_media's cold path, under the
354 # same player lock; the clip wait below runs outside it.
355 async with player._lock:
356 if player.stream and player.stream.running and player.stream.session:
357 player._transitioning = True
358 await player.stream.session.stop()
359 player.stream = None
360 sync_clients = player._get_sync_clients()
361 session_pcm_format = await player._get_session_pcm_format(sync_clients, announcement)
362 if volume_level is not None:
363 for member in sync_clients:
364 prev_volume = member.volume_level
365 if prev_volume is not None and prev_volume != volume_level:
366 prev_volumes[member] = prev_volume
367 await member.volume_set(volume_level)
368 session = AirPlayStreamSession(provider, sync_clients, session_pcm_format, announcement)
369 await session.start(render.get_stream(session_pcm_format))
370 player._transitioning = False
371 # The clip is anchored: wait out the start lead plus the clip and the
372 # receiver drain. The source ends after the clip, so the session
373 # usually ends cleanly on its own and the stop below is cleanup only.
374 await asyncio.sleep(
375 max(0.0, session.start_time - time.time()) + duration + AIRPLAY_ANNOUNCE_SESSION_DRAIN_S
376 )
377 finally:
378 player._transitioning = False
379 if session is not None:
380 await session.stop()
381 # like player.stop(): an idle player must not keep showing media
382 player._attr_current_media = None
383 player.update_state()
384 for member, prev_volume in prev_volumes.items():
385 await member.volume_set(prev_volume)
386
387
388def _live_members(player: AirPlayPlayer) -> list[AirPlayPlayer]:
389 """
390 Return the members a live announcement targets, or [] without live playback.
391
392 A synced member announced individually is just itself; a session leader
393 covers every member of its session. Only a PLAYING session can mix a clip -
394 a parked (paused) or idle player has no live timeline to mix into.
395 """
396 if player.playback_state != PlaybackState.PLAYING:
397 return []
398 stream = player.stream
399 if stream is None or not stream.running or not stream.connected:
400 return []
401 if player.synced_to:
402 return [player]
403 if stream.session is None:
404 # A Sendspin-bridged player plays without a stream session, but its
405 # stream is a regular AirPlayStream the clip mixes into (self-only).
406 provider = cast("AirPlayProvider", player.provider)
407 bridge = provider.bridge_manager.get_bridge(player.player_id)
408 if bridge is not None and bridge.owns_airplay_stream:
409 return [player]
410 return []
411 if _owning_group_entity(player):
412 # A group ENTITY (e.g. a syncgroup) owns this session, and that entity
413 # is the whole-group announcement handle: this player addressed
414 # individually announces alone, even as the session's sync leader.
415 return [player]
416 # An ad-hoc leader has no entity above it, so it IS the group handle:
417 # announcing to it covers every member of its session.
418 return [
419 member
420 for member in stream.session.sync_clients
421 if member.stream is not None and member.stream.running and member.stream.connected
422 ]
423
424
425def _resolve_announce_instant(
426 player: AirPlayPlayer, members: list[AirPlayPlayer], render_key: str
427) -> int:
428 """
429 Return the audible instant (unix ms) the announcement is armed for.
430
431 When the targets are only a part of a multi-member session (a group-entity
432 announcement is fanned out per member by the controller, each call arming
433 its own member), the instant is shared through the provider's plan
434 registry: the first call computes it from EVERY session member's span, the
435 concurrent sibling calls reuse it, and every room renders the clip in
436 sync. A call that arms its whole target set at once (ad-hoc leader, solo,
437 bridged) needs no plan.
438
439 :param player: The player this call targets.
440 :param members: The members this call arms.
441 :param render_key: Identity of the announcement audio; instants are only
442 ever shared between arms of the SAME announcement.
443 """
444 session = player.stream.session if player.stream else None
445 session_members = session.sync_clients if session else members
446 if len(members) >= len(session_members):
447 return _shared_announce_instant(
448 member.stream for member in members if member.stream is not None
449 )
450 provider = cast("AirPlayProvider", player.provider)
451 plans = provider._announce_plans
452 now_ms = int(time.time() * 1000)
453 # prune settled plans so the registry cannot grow with announcement history
454 for key in [key for key, at_ms in plans.items() if at_ms <= now_ms]:
455 del plans[key]
456 plan_key = (
457 _owning_group_entity(player) or player.synced_to or player.player_id,
458 render_key,
459 )
460 if (at_unix_ms := plans.get(plan_key)) is not None:
461 return at_unix_ms
462 # the instant must clear EVERY session member's span: the sibling calls of
463 # a group fan-out reuse it for their own members
464 at_unix_ms = _shared_announce_instant(
465 member.stream for member in session_members if member.stream is not None
466 )
467 plans[plan_key] = at_unix_ms
468 return at_unix_ms
469
470
471def _shared_announce_instant(streams: Iterable[AirPlayStream]) -> int:
472 """
473 Return the shared audible instant (unix ms) for arming an announcement.
474
475 Every member must mix the clip into audio it has not delivered yet; its
476 span is how far ahead of the audible position that delivery head runs, so
477 the shared instant sits past the largest member span plus a fan-out margin.
478 """
479 max_span_ms = max(_member_span_ms(stream) for stream in streams)
480 return int(time.time() * 1000) + max_span_ms + AIRPLAY_ANNOUNCE_AT_MARGIN_MS
481
482
483def _owning_group_entity(player: AirPlayPlayer) -> str | None:
484 """
485 Return the id of the group ENTITY that owns this player's session, if any.
486
487 ``active_group`` only ever names a real group player (e.g. a syncgroup) -
488 but a protocol player never carries it itself: the model keeps the group
489 state on the device player it renders for, so the ownership is read
490 through the protocol parent when needed.
491 """
492 if player.state.active_group:
493 return player.state.active_group
494 if player.protocol_parent_id and (
495 parent := player.mass.players.get_player(player.protocol_parent_id)
496 ):
497 return parent.state.active_group
498 return None
499
500
501def _member_span_ms(stream: AirPlayStream) -> int:
502 """Return how far a member's delivery head runs ahead of its audible position (ms)."""
503 if stream.warm_lead_ms > 0:
504 return stream.warm_lead_ms
505 if stream.latency_lead_ms > 0:
506 return stream.latency_lead_ms
507 return AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS
508
509
510def _member_duck_db(member: AirPlayPlayer, volume_level: int | None) -> float:
511 """
512 Return the music duck (dB) for one member, compensated for its volume bump.
513
514 The announcement volume is applied as DEVICE volume, which raises the music
515 bed together with the clip. The AirPlay volume scale is linear dB (see
516 AIRPLAY_VOLUME_DB_PER_POINT), so that rise is exactly known and the duck is
517 deepened by the same amount - the music keeps its configured perceived duck
518 depth while the clip plays at the configured announcement loudness. A bump
519 DOWN (a night-mode announcement quieter than the music) symmetrically
520 shallows the duck, and the result never leaves the binary's usable range.
521 """
522 duck_db = float(AIRPLAY_ANNOUNCE_DUCK_DB)
523 if volume_level is None or member.volume_level is None:
524 return duck_db
525 bump_db = (volume_level - member.volume_level) * AIRPLAY_VOLUME_DB_PER_POINT
526 return min(0.0, max(-60.0, duck_db - bump_db))
527
528
529def _schedule_member_volume(
530 member: AirPlayPlayer, volume_level: int, at_unix_ms: int, clip_seconds: float
531) -> _VolumeSchedule | None:
532 """
533 Schedule the announcement volume around one member's audible clip window.
534
535 Both changes are wall-clock timers on the acked instant: the done report
536 arrives when the clip is fully MIXED (at the delivery head), which is ahead
537 of it being heard, so neither change can key off it.
538
539 :param member: The member whose volume is bumped and restored.
540 :param volume_level: The announcement volume level.
541 :param at_unix_ms: The acked audible instant of the clip on this member.
542 :param clip_seconds: The clip duration in seconds.
543 :return: The schedule to track (None when there is nothing to change).
544 """
545 prev_volume = member.volume_level
546 if prev_volume is None or prev_volume == volume_level:
547 return None
548 delay = max(0.0, at_unix_ms / 1000 - time.time())
549 # The bump is biased INTO the clip: a receiver that plays out later than
550 # the reported instant would otherwise get louder while the old music is
551 # still sounding. The duck ramp (and the pre-announce chime) masks the
552 # late bump; short clips cap the bias at their midpoint.
553 bump_delay = delay + min(AIRPLAY_ANNOUNCE_VOLUME_BUMP_DELAY_MS / 1000, clip_seconds / 2)
554 # Deterministic task ids: cancel_timer is the only way to release a tracked
555 # timer that never fires, and reusing the ids also cancels stale timers of
556 # a replaced announcement on the same member.
557 task_ids = [
558 f"airplay_announce_volume_bump_{member.player_id}",
559 f"airplay_announce_volume_restore_{member.player_id}",
560 ]
561 member.mass.call_later(bump_delay, member.volume_set, volume_level, task_id=task_ids[0])
562 member.mass.call_later(
563 delay + clip_seconds + AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS / 1000,
564 member.volume_set,
565 prev_volume,
566 task_id=task_ids[1],
567 )
568 return (member, prev_volume, task_ids)
569
570
571async def _render_clip_file(render: AnnouncementRender, pcm_format: AudioFormat) -> str:
572 """
573 Render the announcement clip into a temp file of raw PCM in the given format.
574
575 A ducked-silence tail is appended: the binary holds the music duck for the
576 whole file, so the music stays ducked briefly past the announcement and the
577 volume restore has a safe window to land in.
578
579 The caller owns the file and removes it once every member is done with it.
580
581 :param render: The (finished) announcement render to read.
582 :param pcm_format: The raw PCM format the file must carry.
583 """
584 clip = bytearray()
585 async for chunk in render.get_stream(pcm_format):
586 clip.extend(chunk)
587 # Wire sizes come from the content type: at 24-bit the stdin carrier is
588 # s32le while bit_depth stays 24, so bit_depth-derived sizes are wrong.
589 bytes_per_sample = {
590 ContentType.PCM_S16LE: 2,
591 ContentType.PCM_S24LE: 3,
592 ContentType.PCM_S32LE: 4,
593 ContentType.PCM_F32LE: 4,
594 }.get(pcm_format.content_type, pcm_format.bit_depth // 8)
595 trail_frames = int(pcm_format.sample_rate * AIRPLAY_ANNOUNCE_DUCK_TAIL_S)
596 clip.extend(bytes(trail_frames * bytes_per_sample * pcm_format.channels))
597 return await asyncio.to_thread(_write_clip_file, clip)
598
599
600def _write_clip_file(data: bytes | bytearray) -> str:
601 """Write clip audio to a uniquely named temp file and return its path."""
602 fd, path = tempfile.mkstemp(prefix="ma_airplay_announce_", suffix=".pcm")
603 with os.fdopen(fd, "wb") as clip_file:
604 clip_file.write(data)
605 return path
606
607
608async def _no_announce_ack() -> tuple[int, int] | None:
609 """Stand in for the started-ack of a member whose arm was never delivered."""
610 return None
611
612
613def _format_key(pcm_format: AudioFormat) -> str:
614 """Return the identity of a raw PCM stdin format for clip-file sharing."""
615 return (
616 f"{pcm_format.content_type.value}_{pcm_format.sample_rate}"
617 f"_{pcm_format.bit_depth}_{pcm_format.channels}"
618 )
619