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