/
/
/
1"""Unit tests for the native AirPlay announcement orchestration."""
2
3import asyncio
4import logging
5import time
6from collections.abc import AsyncGenerator, Coroutine, Iterator
7from contextlib import contextmanager
8from pathlib import Path
9from typing import Any
10from unittest.mock import AsyncMock, MagicMock, call, patch
11
12import pytest
13from music_assistant_models.enums import ContentType
14from music_assistant_models.errors import PlayerCommandFailed
15from music_assistant_models.media_items import AudioFormat
16
17from music_assistant.providers.airplay import announce
18from music_assistant.providers.airplay.constants import (
19 AIRPLAY_ANNOUNCE_AT_MARGIN_MS,
20 AIRPLAY_ANNOUNCE_DUCK_DB,
21 AIRPLAY_ANNOUNCE_DUCK_LEAD_S,
22 AIRPLAY_ANNOUNCE_DUCK_TAIL_S,
23 AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS,
24 AIRPLAY_ANNOUNCE_VOLUME_BUMP_DELAY_MS,
25 AIRPLAY_PCM_FORMAT,
26 AIRPLAY_VOLUME_ECHO_GRACE_S,
27)
28
29ANNOUNCE_DATA = {
30 "announcement_url": "http://ma.local/tts.mp3",
31 "pre_announce": True,
32 "pre_announce_url": "http://ma.local/chime.mp3",
33 "announce_player_id": None,
34}
35HIRES_PCM_FORMAT = AudioFormat(content_type=ContentType.PCM_S32LE, sample_rate=48000, bit_depth=24)
36
37
38def _make_render(duration: float = 1.5) -> MagicMock:
39 """Build a mock announcement render that finished with the given duration."""
40 render = MagicMock()
41 render.duration = duration
42 render.wait_finished = AsyncMock(return_value=duration)
43
44 async def get_stream(_output_format: Any) -> AsyncGenerator[bytes]:
45 # non-silent, so a clip is distinguishable from the silence around it
46 yield b"\xff" * 64
47
48 render.get_stream = get_stream
49 return render
50
51
52def _make_stream(
53 pcm_format: AudioFormat = AIRPLAY_PCM_FORMAT,
54 ack: tuple[int, int] | None = (1, 1),
55) -> MagicMock:
56 """
57 Build a mock member stream whose announce arm resolves with the given ack.
58
59 The default ack reports a long-past audible instant, so every wait the
60 announcement holds for resolves without spending wall-clock time.
61 """
62 stream = MagicMock()
63 stream.running = True
64 stream.connected = True
65 stream.pcm_format = pcm_format
66 stream.warm_lead_ms = 0
67 stream.latency_lead_ms = 0
68 stream.announce = AsyncMock(return_value=True)
69 stream.wait_announce_started = AsyncMock(return_value=ack)
70 stream.wait_announce_done = AsyncMock(return_value=True)
71 return stream
72
73
74def _make_player(player_id: str, stream: MagicMock | None = None) -> MagicMock:
75 """Build a mock AirPlay player for announcement orchestration tests."""
76 player = MagicMock()
77 player.player_id = player_id
78 player.display_name = player_id
79 player.synced_to = None
80 player.state.active_group = None
81 player.state.volume_level = 30
82 player.protocol_parent_id = None
83 player.has_live_audio = stream is not None
84 player.stream = stream
85 player._lock = asyncio.Lock()
86 player.logger = logging.getLogger("test.airplay.announce")
87 player.mass.create_task = MagicMock(
88 side_effect=lambda coro, *_args, **_kwargs: asyncio.get_running_loop().create_task(coro)
89 )
90 player.mass.players.cmd_volume_set = AsyncMock()
91 # what the controller's scaling returns for the default 0/100 volume limits
92 player.mass.players.scale_volume_to_device = MagicMock(
93 side_effect=lambda _player_id, level: level
94 )
95 player.provider._announce_plans = {}
96 player.provider.bridge_manager.get_bridge = MagicMock(return_value=None)
97 renderer = player.mass.streams.announcement_renderer
98 renderer.acquire = MagicMock(return_value=_make_render())
99 renderer.release = AsyncMock()
100 return player
101
102
103def _make_playing_group(*streams: MagicMock) -> list[MagicMock]:
104 """Build a playing session of one member per given stream; first one leads."""
105 members = [_make_player(f"member_{i}", stream=stream) for i, stream in enumerate(streams)]
106 session = MagicMock()
107 session.sync_clients = members
108 for member in members:
109 member.stream.session = session
110 return members
111
112
113def _make_announcement() -> MagicMock:
114 """Build the announcement PlayerMedia handed down by the player controller."""
115 return MagicMock(custom_data=dict(ANNOUNCE_DATA))
116
117
118def _record_spawned_tasks(player: MagicMock) -> list[asyncio.Task[None]]:
119 """
120 Make the player's task spawner record what it starts, and return that record.
121
122 :param player: The player the announcement targets; the restore its teardown
123 falls back on runs from a task of its own.
124 """
125 tasks: list[asyncio.Task[None]] = []
126
127 def create_task(
128 coro: Coroutine[Any, Any, None], *_args: Any, **_kwargs: Any
129 ) -> asyncio.Task[None]:
130 task = asyncio.get_running_loop().create_task(coro)
131 tasks.append(task)
132 return task
133
134 player.mass.create_task = MagicMock(side_effect=create_task)
135 return tasks
136
137
138@contextmanager
139def _timeline(player: MagicMock) -> Iterator[list[tuple[str, float]]]:
140 """
141 Record the announcement's volume timeline without spending its waits.
142
143 Yields the ordered log of the instants (unix ms) the announcement holds for and
144 the volume levels it sets, so a test can assert the ORDER of both volume changes
145 against the clip's own instants without reading the wall clock.
146
147 :param player: The player the announcement targets; its controller is the one
148 every volume command travels through.
149 """
150 events: list[tuple[str, float]] = []
151
152 async def hold_until(unix_ms: float) -> None:
153 events.append(("hold", unix_ms))
154
155 player.mass.players.cmd_volume_set = AsyncMock(
156 side_effect=lambda _player_id, level: events.append(("volume", level))
157 )
158 with patch.object(announce, "_hold_until", hold_until):
159 yield events
160
161
162def test_member_span_prefers_the_warm_lead() -> None:
163 """A splice-timeline member's span is its warm lead, not the device lead."""
164 stream = _make_stream()
165 stream.warm_lead_ms = 600
166 stream.latency_lead_ms = 1900
167
168 assert announce._member_span_ms(stream) == 600
169
170
171def test_member_span_uses_the_device_lead_without_a_warm_lead() -> None:
172 """Without a warm lead, the reported device lead bounds the delivery head."""
173 stream = _make_stream()
174 stream.latency_lead_ms = 1900
175
176 assert announce._member_span_ms(stream) == 1900
177
178
179def test_member_span_falls_back_when_nothing_was_reported() -> None:
180 """Both leads at 0 mean unreported: assume the binary's default playback lead."""
181 assert announce._member_span_ms(_make_stream()) == AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS
182
183
184def test_shared_instant_covers_the_slowest_member() -> None:
185 """The shared instant sits past the LARGEST member span plus the fan-out margin."""
186 fast = _make_stream()
187 fast.warm_lead_ms = 600
188 slow = _make_stream()
189 slow.latency_lead_ms = 1900
190 unreported = _make_stream()
191
192 before_ms = int(time.time() * 1000)
193 at_unix_ms = announce._shared_announce_instant([fast, slow, unreported])
194 after_ms = time.time() * 1000
195
196 # the unreported member's fallback span (2000) is the largest of the three
197 expected_lead = AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS + AIRPLAY_ANNOUNCE_AT_MARGIN_MS
198 assert before_ms + expected_lead <= at_unix_ms <= after_ms + expected_lead
199
200
201@pytest.mark.asyncio
202async def test_live_announcement_arms_every_member_at_one_shared_instant() -> None:
203 """A playing session arms every member with the same instant, duck and clip file."""
204 streams = [_make_stream(), _make_stream()]
205 members = _make_playing_group(*streams)
206 leader = members[0]
207
208 await announce.play_announcement(leader, _make_announcement(), None)
209
210 arms = [stream.announce.call_args for stream in streams]
211 for arm in arms:
212 assert arm is not None
213 # one shared instant and the default duck for every member
214 assert len({arm.args[1] for arm in arms}) == 1
215 assert {arm.args[2] for arm in arms} == {AIRPLAY_ANNOUNCE_DUCK_DB}
216 # both members share one stdin format, so they share one clip file
217 assert len({arm.args[0] for arm in arms}) == 1
218 assert arms[0].args[0].endswith(".pcm")
219 for stream in streams:
220 stream.wait_announce_done.assert_awaited_once()
221 leader.mass.streams.announcement_renderer.release.assert_awaited_once()
222
223
224@pytest.mark.asyncio
225async def test_live_announcement_renders_one_clip_per_distinct_format() -> None:
226 """Members on different stdin formats each get a clip in exactly their format."""
227 streams = [_make_stream(), _make_stream(pcm_format=HIRES_PCM_FORMAT)]
228 members = _make_playing_group(*streams)
229
230 await announce.play_announcement(members[0], _make_announcement(), None)
231
232 clip_paths = {stream.announce.call_args.args[0] for stream in streams}
233 assert len(clip_paths) == 2
234
235
236@pytest.mark.asyncio
237async def test_clip_file_wraps_the_audio_in_ducked_silence() -> None:
238 """The clip file is lead-in silence, then the announcement audio, then tail silence."""
239 clip_path = await announce._render_clip_file(_make_render(), HIRES_PCM_FORMAT)
240 try:
241 clip = Path(clip_path).read_bytes()
242 finally:
243 Path(clip_path).unlink()
244
245 # the silence is sized on the content type: this format carries 24 bit over an
246 # s32le wire, so a bit_depth-derived size would come out a quarter short
247 frame_bytes = 4 * HIRES_PCM_FORMAT.channels
248 lead_bytes = int(HIRES_PCM_FORMAT.sample_rate * AIRPLAY_ANNOUNCE_DUCK_LEAD_S) * frame_bytes
249 tail_bytes = int(HIRES_PCM_FORMAT.sample_rate * AIRPLAY_ANNOUNCE_DUCK_TAIL_S) * frame_bytes
250 assert clip == bytes(lead_bytes) + b"\xff" * 64 + bytes(tail_bytes)
251
252
253@pytest.mark.asyncio
254async def test_player_without_live_audio_is_refused() -> None:
255 """Without audio to mix into the announcement is refused, releasing the render."""
256 player = _make_player("solo")
257
258 with pytest.raises(PlayerCommandFailed, match="no live playback"):
259 await announce.play_announcement(player, _make_announcement(), 40)
260
261 player.mass.streams.announcement_renderer.release.assert_awaited_once()
262
263
264@pytest.mark.asyncio
265async def test_no_member_arming_fails_without_killing_the_music() -> None:
266 """
267 Live members that never arm the clip fail the announcement, not the music.
268
269 An outdated cliairplay silently ignores the arm command: nothing is said, but
270 the session every member is playing from is left exactly as it was.
271 """
272 streams = [_make_stream(ack=None), _make_stream(ack=None)]
273 members = _make_playing_group(*streams)
274 live_session = streams[0].session
275 live_session.stop = AsyncMock()
276
277 with pytest.raises(PlayerCommandFailed, match="may not support announcements"):
278 await announce.play_announcement(members[0], _make_announcement(), None)
279
280 for stream in streams:
281 stream.announce.assert_awaited_once()
282 stream.wait_announce_done.assert_not_awaited()
283 live_session.stop.assert_not_awaited()
284 members[0].mass.streams.announcement_renderer.release.assert_awaited_once()
285
286
287@pytest.mark.asyncio
288async def test_partial_success_warns_about_the_members_that_stayed_silent(
289 caplog: pytest.LogCaptureFixture,
290) -> None:
291 """One member playing the clip is a success; the members that did not are named."""
292 streams = [_make_stream(), _make_stream(ack=None)]
293 members = _make_playing_group(*streams)
294
295 with caplog.at_level(logging.WARNING):
296 await announce.play_announcement(members[0], _make_announcement(), None)
297
298 assert "member_1" in caplog.text
299 streams[0].wait_announce_done.assert_awaited_once()
300 streams[1].wait_announce_done.assert_not_awaited()
301
302
303@pytest.mark.asyncio
304async def test_volume_moves_inside_the_ducked_silence() -> None:
305 """
306 The volume is raised in the ducked lead-in and restored in the ducked tail.
307
308 Both changes are timed on the acked start of the clip FILE: the raise lands
309 after that start but before the announcement audio at the end of the lead-in,
310 and the restore after that audio but before the file ends - so neither is ever
311 heard on the music, which is ducked for the whole file.
312 """
313 duration = 1.5
314 file_ms = int((AIRPLAY_ANNOUNCE_DUCK_LEAD_S + duration + AIRPLAY_ANNOUNCE_DUCK_TAIL_S) * 1000)
315 ack_at_unix_ms = 1_700_000_000_000
316 (member,) = _make_playing_group(_make_stream(ack=(ack_at_unix_ms, file_ms)))
317
318 with _timeline(member) as events:
319 await announce.play_announcement(member, _make_announcement(), 55)
320
321 assert [name for name, _ in events] == ["hold", "volume", "hold", "volume", "hold"]
322 assert events[1][1] == 55
323 assert events[3][1] == 30 # the level the volume target carried before
324 raised_at, restored_at = events[0][1], events[2][1]
325 assert raised_at == ack_at_unix_ms + AIRPLAY_ANNOUNCE_VOLUME_BUMP_DELAY_MS
326 # inside the lead-in: the music is already ducked, nothing is being said yet
327 assert ack_at_unix_ms < raised_at < ack_at_unix_ms + AIRPLAY_ANNOUNCE_DUCK_LEAD_S * 1000
328 # inside the tail: the announcement is over, the clip file is not
329 assert (
330 ack_at_unix_ms + (AIRPLAY_ANNOUNCE_DUCK_LEAD_S + duration) * 1000
331 <= restored_at
332 < ack_at_unix_ms + file_ms
333 )
334
335
336@pytest.mark.asyncio
337async def test_short_clip_is_fully_covered_by_the_announcement_volume() -> None:
338 """
339 A clip shorter than the ducked lead-in still plays at the announcement volume.
340
341 The raise is timed on the lead-in, never on the clip's own length, so a short
342 announcement is at the announcement level from its first word (the regression
343 heard as a short announcement not playing at all).
344 """
345 duration = 0.4
346 file_ms = int((AIRPLAY_ANNOUNCE_DUCK_LEAD_S + duration + AIRPLAY_ANNOUNCE_DUCK_TAIL_S) * 1000)
347 ack_at_unix_ms = 1_700_000_000_000
348 (member,) = _make_playing_group(_make_stream(ack=(ack_at_unix_ms, file_ms)))
349 member.mass.streams.announcement_renderer.acquire = MagicMock(
350 return_value=_make_render(duration)
351 )
352
353 with _timeline(member) as events:
354 await announce.play_announcement(member, _make_announcement(), 55)
355
356 assert [name for name, _ in events] == ["hold", "volume", "hold", "volume", "hold"]
357 raised_at, restored_at = events[0][1], events[2][1]
358 # the raise still lands before a word is said, the restore only after the last
359 assert raised_at < ack_at_unix_ms + AIRPLAY_ANNOUNCE_DUCK_LEAD_S * 1000
360 assert restored_at >= ack_at_unix_ms + (AIRPLAY_ANNOUNCE_DUCK_LEAD_S + duration) * 1000
361
362
363@pytest.mark.asyncio
364async def test_announcement_volume_lands_on_the_protocol_parent() -> None:
365 """
366 The announcement volume is set on the control that owns the member's output.
367
368 An AirPlay child of a native player must not write the receiver's own level:
369 the command travels through the controller to the parent, on the parent's scale.
370 """
371 (member,) = _make_playing_group(_make_stream())
372 parent = _make_player("parent")
373 parent.state.volume_level = 20
374 parent.mass = member.mass
375 member.protocol_parent_id = "parent"
376 member.mass.players.get_player = MagicMock(return_value=parent)
377
378 with _timeline(member):
379 await announce.play_announcement(member, _make_announcement(), 55)
380
381 # the parent's own level is bumped and restored; the child is never addressed
382 assert member.mass.players.cmd_volume_set.await_args_list == [
383 call("parent", 55),
384 call("parent", 20),
385 ]
386
387
388@pytest.mark.asyncio
389async def test_armed_members_ignore_their_own_volume_echoes() -> None:
390 """
391 Every armed member ignores the device's volume reports until its clip is over.
392
393 The receiver echoes each level it is handed back over DACP; an echo read as the
394 user reaching for the volume would be written straight back to the device.
395 """
396 # taken WITH the wall clock the ack is built from, so the window below is
397 # bracketed exactly however slow the run itself is
398 now_ms = int(time.time() * 1000)
399 file_ms = 3000
400 (member,) = _make_playing_group(_make_stream(ack=(now_ms + 300, file_ms)))
401
402 with _timeline(member):
403 await announce.play_announcement(member, _make_announcement(), 55)
404 elapsed = time.time() - now_ms / 1000
405
406 audible_end_s = (300 + file_ms) / 1000
407 window = member.suppress_volume_reports.call_args.args[0]
408 assert audible_end_s + AIRPLAY_VOLUME_ECHO_GRACE_S - elapsed <= window
409 assert window <= audible_end_s + AIRPLAY_VOLUME_ECHO_GRACE_S
410
411
412def test_member_duck_compensates_the_volume_bump() -> None:
413 """
414 The duck deepens by exactly the device-volume bump so the music never rises.
415
416 38 -> 61 volume points is +6.9 dB on the AirPlay dB scale; the -18 dB duck
417 becomes -24.9 dB so the music's perceived level stays at the configured
418 duck depth (the regression heard as "the music was not ducked"). A bump
419 down shallows it symmetrically, and without a bump the base duck applies.
420 """
421 member = _make_player("m")
422 member.state.volume_level = 38
423 assert announce._member_duck_db(member, 61) == pytest.approx(-24.9)
424 assert announce._member_duck_db(member, 18) == pytest.approx(-12.0)
425 assert announce._member_duck_db(member, None) == pytest.approx(-18.0)
426 assert announce._member_duck_db(member, 38) == pytest.approx(-18.0)
427 # extreme bumps clamp to the binary's usable range (never boost the music)
428 member.state.volume_level = 0
429 assert announce._member_duck_db(member, 100) == pytest.approx(-48.0)
430 assert announce._member_duck_db(member, 0) == pytest.approx(-18.0)
431 member.state.volume_level = 100
432 assert announce._member_duck_db(member, 0) == pytest.approx(0.0)
433
434
435def test_member_duck_compensates_the_volume_target_bump() -> None:
436 """The compensation follows the level of the control the bump is applied to."""
437 member = _make_player("child")
438 member.state.volume_level = 100
439 parent = _make_player("parent")
440 parent.state.volume_level = 38
441 member.protocol_parent_id = "parent"
442 member.mass.players.get_player = MagicMock(return_value=parent)
443
444 assert announce._member_duck_db(member, 61) == pytest.approx(-24.9)
445
446
447def test_member_duck_compensates_the_bump_the_device_actually_gets() -> None:
448 """
449 The compensation is made of the DEVICE levels, not the logical ones.
450
451 A volume limit configured on the target scales every logical level down before it
452 reaches the device, so the music rises by less than the logical delta says and a
453 duck deepened by that delta would bury it.
454 """
455 member = _make_player("m")
456 member.state.volume_level = 30
457 # max_volume 60: logical 30 lands on device 18, logical 55 on device 33
458 member.mass.players.scale_volume_to_device = MagicMock(
459 side_effect=lambda _player_id, level: (level * 60) // 100
460 )
461
462 # +15 device points is +4.5 dB, where the logical +25 would read as +7.5 dB
463 assert announce._member_duck_db(member, 55) == pytest.approx(-22.5)
464
465
466def test_volume_target_is_the_protocol_parent() -> None:
467 """The volume of a member with a protocol parent is owned by that parent."""
468 member = _make_player("child")
469 parent = _make_player("parent")
470 member.protocol_parent_id = "parent"
471 member.mass.players.get_player = MagicMock(return_value=parent)
472
473 assert announce._volume_target(member) is parent
474 # a member without one owns its own volume
475 member.protocol_parent_id = None
476 assert announce._volume_target(member) is member
477
478
479@pytest.mark.asyncio
480async def test_no_volume_level_leaves_the_volume_alone() -> None:
481 """Without an announcement volume no level is touched on any member."""
482 members = _make_playing_group(_make_stream())
483
484 await announce.play_announcement(members[0], _make_announcement(), None)
485
486 members[0].mass.players.cmd_volume_set.assert_not_awaited()
487
488
489@pytest.mark.asyncio
490async def test_cancelled_announcement_hands_the_bumped_volume_back() -> None:
491 """
492 An announcement cancelled after the raise still puts the previous level back.
493
494 The music plays on, so the speaker must not be left sitting at the announcement
495 level. The restore runs from a task of its own, since an await in the cancelled
496 call would be cancelled right along with it.
497 """
498 (member,) = _make_playing_group(_make_stream())
499 spawned = _record_spawned_tasks(member)
500 raised = asyncio.Event()
501 parked = asyncio.Event()
502 member.mass.players.cmd_volume_set = AsyncMock(side_effect=lambda *_args: raised.set())
503
504 async def hold_until(_unix_ms: float) -> None:
505 if not raised.is_set():
506 return
507 # the hold in the ducked tail, where the cancel lands
508 parked.set()
509 await asyncio.Event().wait()
510
511 with patch.object(announce, "_hold_until", hold_until):
512 announcing = asyncio.create_task(
513 announce.play_announcement(member, _make_announcement(), 55)
514 )
515 await parked.wait()
516 announcing.cancel()
517 with pytest.raises(asyncio.CancelledError):
518 await announcing
519 await asyncio.gather(*spawned)
520
521 assert member.mass.players.cmd_volume_set.await_args_list == [
522 call("member_0", 55),
523 call("member_0", 30),
524 ]
525
526
527@pytest.mark.asyncio
528async def test_completed_announcement_leaves_nothing_to_restore() -> None:
529 """
530 An announcement that ran its course needs no restore behind it.
531
532 Its own timeline put the level back inline, so the teardown's safety net finds
533 nothing left to hand over and never spawns a second restore.
534 """
535 (member,) = _make_playing_group(_make_stream())
536 spawned = _record_spawned_tasks(member)
537
538 with _timeline(member):
539 await announce.play_announcement(member, _make_announcement(), 55)
540
541 assert spawned == []
542 assert member.mass.players.cmd_volume_set.await_args_list == [
543 call("member_0", 55),
544 call("member_0", 30),
545 ]
546
547
548@pytest.mark.asyncio
549async def test_failed_restore_keeps_its_level_for_a_retry(
550 caplog: pytest.LogCaptureFixture,
551) -> None:
552 """
553 A level that could not be restored stays behind for a later call, and is logged.
554
555 Dropping it would leave the speaker at the announcement level with nothing left
556 that knows what to put back.
557 """
558 player = _make_player("solo")
559 bumped = {"solo": 30, "other": 40}
560 player.mass.players.cmd_volume_set = AsyncMock(
561 side_effect=[RuntimeError("device unreachable"), None]
562 )
563
564 with caplog.at_level(logging.WARNING):
565 await announce._restore_announcement_volume(player, bumped)
566
567 assert bumped == {"solo": 30}
568 assert "Could not restore the volume of solo" in caplog.text
569
570 player.mass.players.cmd_volume_set = AsyncMock()
571 await announce._restore_announcement_volume(player, bumped)
572
573 assert bumped == {}
574 player.mass.players.cmd_volume_set.assert_awaited_once_with("solo", 30)
575
576
577@pytest.mark.asyncio
578async def test_group_entity_fanout_arms_each_member_at_one_shared_instant() -> None:
579 """
580 A group-entity fan-out arms each member once, at one shared instant.
581
582 The controller forwards a group-entity announcement per member; each call
583 arms only its OWN member, and all of them share one audible instant through
584 the provider's plan registry, so every room renders in sync.
585 """
586 streams = [_make_stream(), _make_stream(), _make_stream()]
587 members = _make_playing_group(*streams)
588 leader, member_1, member_2 = members
589 provider = leader.provider
590 render = _make_render()
591 for member in members:
592 member.provider = provider
593 member.state.active_group = "syncgroup_1"
594 if member is not leader:
595 member.synced_to = leader.player_id
596 member.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
597 # the second member reports a span past the fallback the others assume:
598 # the shared instant must clear the LARGEST one for every sibling arm
599 largest_span_ms = AIRPLAY_ANNOUNCE_FALLBACK_SPAN_MS + 1000
600 streams[1].latency_lead_ms = largest_span_ms
601 announcement = _make_announcement()
602
603 before_ms = int(time.time() * 1000)
604 await asyncio.gather(
605 announce.play_announcement(leader, announcement, None),
606 announce.play_announcement(member_1, announcement, None),
607 announce.play_announcement(member_2, announcement, None),
608 )
609 after_ms = time.time() * 1000
610
611 instants = set()
612 for stream in streams:
613 stream.announce.assert_awaited_once()
614 stream.wait_announce_done.assert_awaited_once()
615 instants.add(stream.announce.await_args.args[1])
616 assert len(instants) == 1
617 # the instant is computed inside the call, so the clock reads either side
618 # of it bracket the lead however long the fan-out itself takes
619 expected_lead = largest_span_ms + AIRPLAY_ANNOUNCE_AT_MARGIN_MS
620 assert before_ms + expected_lead <= next(iter(instants)) <= after_ms + expected_lead
621
622
623@pytest.mark.asyncio
624async def test_group_entity_session_leader_announces_alone() -> None:
625 """
626 A group entity's session leader addressed individually announces alone.
627
628 The entity itself is the whole-group handle, so leading the underlying
629 session does not widen an individual announcement.
630 """
631 streams = [_make_stream(), _make_stream()]
632 members = _make_playing_group(*streams)
633 leader = members[0]
634 leader.state.active_group = "syncgroup_1"
635 members[1].synced_to = leader.player_id
636
637 await announce.play_announcement(leader, _make_announcement(), None)
638
639 streams[0].announce.assert_awaited_once()
640 streams[1].announce.assert_not_awaited()
641
642
643@pytest.mark.asyncio
644async def test_protocol_child_reads_group_ownership_from_its_parent() -> None:
645 """
646 A protocol child leading a syncgroup's session still announces alone.
647
648 Protocol players never carry active_group themselves - the model keeps the
649 group state on the device player they render for - so the whole-group
650 handle is found through the protocol parent (the regression heard as an
651 individual announcement playing on the whole syncgroup).
652 """
653 streams = [_make_stream(), _make_stream()]
654 members = _make_playing_group(*streams)
655 leader = members[0]
656 leader.protocol_parent_id = "milo_parent"
657 parent = MagicMock()
658 parent.state.active_group = "syncgroup_1"
659 leader.mass.players.get_player = MagicMock(return_value=parent)
660 members[1].synced_to = leader.player_id
661
662 await announce.play_announcement(leader, _make_announcement(), None)
663
664 streams[0].announce.assert_awaited_once()
665 streams[1].announce.assert_not_awaited()
666
667
668@pytest.mark.asyncio
669async def test_member_announced_directly_arms_itself() -> None:
670 """A direct announcement to one synced member mixes on just that member."""
671 stream = _make_stream()
672 members = _make_playing_group(stream)
673 member = members[0]
674 member.synced_to = "some_leader"
675
676 await announce.play_announcement(member, _make_announcement(), None)
677
678 stream.announce.assert_awaited_once()
679
680
681@pytest.mark.asyncio
682async def test_synced_member_without_live_playback_is_refused() -> None:
683 """
684 A parked group member has nothing to mix into, so its announcement is refused.
685
686 Its stream belongs to the leader's parked session, which is left untouched:
687 stopping it for a single-member announcement would silence the whole group.
688 """
689 parked_stream = _make_stream()
690 members = _make_playing_group(parked_stream)
691 member = members[0]
692 member.synced_to = "some_leader"
693 member.has_live_audio = False
694 parked_session = parked_stream.session
695 parked_session.stop = AsyncMock()
696
697 with pytest.raises(PlayerCommandFailed, match="no live playback"):
698 await announce.play_announcement(member, _make_announcement(), None)
699
700 parked_stream.announce.assert_not_awaited()
701 parked_session.stop.assert_not_awaited()
702
703
704@pytest.mark.asyncio
705async def test_return_holds_until_the_audible_end() -> None:
706 """
707 The call returns only once the clip is audibly over, not when it is mixed.
708
709 announce_done reports MIX completion at the delivery head - ahead of
710 audibility - and the caller re-mutes muted players the moment this returns.
711 """
712 # taken WITH the wall clock the ack is built from, so the hold is measured
713 # from that same instant however slow the setup below runs
714 started = time.monotonic()
715 now_unix_ms = int(time.time() * 1000)
716 stream = _make_stream(ack=(now_unix_ms + 250, 100))
717 members = _make_playing_group(stream)
718
719 with patch.object(announce, "AIRPLAY_ANNOUNCE_VOLUME_RESTORE_PAD_MS", 100):
720 await announce.play_announcement(members[0], _make_announcement(), None)
721 elapsed = time.monotonic() - started
722
723 # audible end = acked instant + clip duration (0.35s out) + the 0.1s pad
724 assert elapsed >= 0.4
725
726
727@pytest.mark.asyncio
728async def test_bridged_player_with_live_stream_arms_itself() -> None:
729 """A Sendspin-bridged player mixes the clip into the bridge-owned stream."""
730 stream = _make_stream()
731 stream.session = None
732 player = _make_player("bridged", stream=stream)
733 bridge = MagicMock(owns_airplay_stream=True)
734 player.provider.bridge_manager.get_bridge = MagicMock(return_value=bridge)
735
736 await announce.play_announcement(player, _make_announcement(), None)
737
738 stream.announce.assert_awaited_once()
739 stream.wait_announce_done.assert_awaited_once()
740
741
742@pytest.mark.asyncio
743async def test_bridge_configured_player_mixes_over_its_own_session() -> None:
744 """
745 A bridge that is merely configured never blocks the live mix.
746
747 The regression this pins: a player with a Sendspin bridge set up but
748 playing its own (session-backed) AirPlay stream mixes the clip like any
749 unbridged player - the idle bridge is a bystander.
750 """
751 (member,) = _make_playing_group(_make_stream())
752 bridge = MagicMock(owns_airplay_stream=False)
753 member.provider.bridge_manager.get_bridge = MagicMock(return_value=bridge)
754
755 await announce.play_announcement(member, _make_announcement(), None)
756
757 member.stream.announce.assert_awaited_once()
758 member.stream.wait_announce_done.assert_awaited_once()
759
760
761@pytest.mark.asyncio
762async def test_bridged_player_without_a_stream_to_mix_into_is_refused() -> None:
763 """
764 A bridged player Sendspin is not streaming through is refused, not seized.
765
766 Neither its own session nor the bridge is rendering audio here, so there is
767 nothing to mix the clip into and the device is left to whatever owns it.
768 """
769 stream = _make_stream()
770 stream.session = None
771 player = _make_player("bridged", stream=stream)
772 bridge = MagicMock(owns_airplay_stream=False)
773 player.provider.bridge_manager.get_bridge = MagicMock(return_value=bridge)
774
775 with pytest.raises(PlayerCommandFailed, match="no live playback"):
776 await announce.play_announcement(player, _make_announcement(), None)
777
778 stream.announce.assert_not_awaited()
779
780
781@pytest.mark.asyncio
782async def test_announcement_without_data_is_refused() -> None:
783 """An announcement without its announce data cannot be rendered."""
784 player = _make_player("solo")
785
786 with pytest.raises(PlayerCommandFailed, match="carries no announcement data"):
787 await announce.play_announcement(player, MagicMock(custom_data=None), None)
788
789
790@pytest.mark.asyncio
791async def test_announcement_without_audio_plays_nothing(
792 caplog: pytest.LogCaptureFixture,
793) -> None:
794 """A render that produced no audio is skipped, releasing the render regardless."""
795 stream = _make_stream()
796 members = _make_playing_group(stream)
797 player = members[0]
798 render = _make_render(duration=0.0)
799 player.mass.streams.announcement_renderer.acquire = MagicMock(return_value=render)
800
801 with caplog.at_level(logging.WARNING):
802 await announce.play_announcement(player, _make_announcement(), None)
803
804 stream.announce.assert_not_awaited()
805 assert "produced no audio" in caplog.text
806 player.mass.streams.announcement_renderer.release.assert_awaited_once()
807