music-assistant-server
15.5 KB•PY
test_tap.py
15.5 KB • 385 lines • python
1"""Tests for the MilkDrop visualizer audio tap."""
2
3from __future__ import annotations
4
5import struct
6from unittest.mock import AsyncMock, Mock
7
8import numpy as np
9from music_assistant_models.media_items import AudioFormat, MediaItemPalette
10
11from music_assistant.models.audio_analysis import AudioAnalysisData
12from music_assistant.providers.milkdrop_visualizer.tap import (
13 WAVE_SAMPLES,
14 Tap,
15 TapManager,
16 TrackCursor,
17 ViewerQueue,
18 pack_wave_frame,
19 palette_payload,
20 pcm_to_mono,
21 server_now_us,
22)
23
24PCM_FORMAT = AudioFormat(sample_rate=44100, bit_depth=16, channels=2)
25
26
27def _stereo_pcm(mono_values: list[int], *, dangling_sample: bool = False) -> bytes:
28 """Build interleaved 16-bit stereo PCM from mono values, duplicated across L/R."""
29 samples: list[int] = []
30 for value in mono_values:
31 samples.extend((value, value))
32 if dangling_sample:
33 # One unpaired sample: the reshape would fail without the drop guard.
34 samples.append(0)
35 return struct.pack(f"<{len(samples)}h", *samples)
36
37
38def _manager() -> TapManager:
39 """Return a tap manager whose provider is inert."""
40 provider = Mock()
41 provider.logger.getChild.return_value = Mock()
42 manager = TapManager(provider)
43 manager._schedule_beats = Mock() # type: ignore[method-assign]
44 return manager
45
46
47def _cursor(next_chunk: int = 0, anchor_us: int = 0) -> TrackCursor:
48 """Return a cursor positioned at the start of a chunk."""
49 return TrackCursor(
50 item_id="item-1",
51 anchor_us=anchor_us,
52 next_chunk=next_chunk,
53 carry=np.zeros(0, dtype=np.float32),
54 carry_media=float(next_chunk),
55 )
56
57
58def test_mono_fold_averages_channels() -> None:
59 """A stereo chunk folds to one sample per frame, scaled into -1.0..1.0."""
60 mono = pcm_to_mono(_stereo_pcm([0, 16384, -16384]), PCM_FORMAT)
61 assert mono.size == 3
62 assert mono[0] == 0.0
63 assert round(float(mono[1]), 3) == 0.5
64 assert round(float(mono[2]), 3) == -0.5
65
66
67def test_mono_fold_drops_a_dangling_sample() -> None:
68 """A truncated chunk loses its unpaired sample instead of failing the reshape."""
69 assert pcm_to_mono(_stereo_pcm([0, 0], dangling_sample=True), PCM_FORMAT).size == 2
70
71
72def test_mono_fold_reads_packed_24_bit() -> None:
73 """24-bit PCM has no numpy dtype, so its sign handling is worth pinning down."""
74 fmt = AudioFormat(sample_rate=44100, bit_depth=24, channels=1)
75 # 0, +full scale - 1, -full scale
76 data = b"\x00\x00\x00" + b"\xff\xff\x7f" + b"\x00\x00\x80"
77 mono = pcm_to_mono(data, fmt)
78 assert mono[0] == 0.0
79 assert round(float(mono[1]), 3) == 1.0
80 assert round(float(mono[2]), 3) == -1.0
81
82
83def test_emits_one_frame_per_1024_samples() -> None:
84 """A one-second chunk yields a frame per full window, keeping the remainder back."""
85 manager = _manager()
86 tap = Tap("player-1")
87 cursor = _cursor()
88 manager._emit_chunk(tap, cursor, _stereo_pcm([0] * 44100), PCM_FORMAT)
89 assert len(tap.ring) == 44100 // WAVE_SAMPLES
90 assert cursor.carry.size == 44100 % WAVE_SAMPLES
91 assert cursor.next_chunk == 1
92
93
94def test_frame_is_stamped_at_the_end_of_its_window() -> None:
95 """A frame plays out at the anchor plus the media time its last sample sits at."""
96 manager = _manager()
97 tap = Tap("player-1")
98 cursor = _cursor(next_chunk=10, anchor_us=1_000_000)
99 manager._emit_chunk(tap, cursor, _stereo_pcm([0] * WAVE_SAMPLES), PCM_FORMAT)
100 tag, timestamp_us = struct.unpack(">Bq", tap.ring[0][:9])
101 assert tag == 22
102 assert len(tap.ring[0]) == 9 + WAVE_SAMPLES
103 # chunk 10 is media second 10, plus one 1024-sample window
104 expected_media = 10 + WAVE_SAMPLES / 44100
105 assert timestamp_us == 1_000_000 + int(expected_media * 1_000_000)
106
107
108def test_carry_continues_into_the_next_chunk() -> None:
109 """Samples left over from a chunk complete the first window of the next one."""
110 manager = _manager()
111 tap = Tap("player-1")
112 cursor = _cursor()
113 second = _stereo_pcm([0] * 44100)
114 manager._emit_chunk(tap, cursor, second, PCM_FORMAT)
115 manager._emit_chunk(tap, cursor, second, PCM_FORMAT)
116 # windows tile the two seconds end to end, rather than restarting per chunk
117 assert len(tap.ring) == (2 * 44100) // WAVE_SAMPLES
118 _, timestamp_us = struct.unpack(">Bq", tap.ring[-1][:9])
119 assert timestamp_us == int(len(tap.ring) * WAVE_SAMPLES / 44100 * 1_000_000)
120
121
122def test_carry_is_dropped_when_the_next_chunk_is_elsewhere() -> None:
123 """After a resync the leftover belongs to audio we are no longer continuing from."""
124 manager = _manager()
125 tap = Tap("player-1")
126 cursor = _cursor()
127 manager._emit_chunk(tap, cursor, _stereo_pcm([0] * 44100), PCM_FORMAT)
128 carried = cursor.carry.size
129 assert carried
130 cursor.next_chunk = 60
131 manager._emit_chunk(tap, cursor, _stereo_pcm([0] * 44100), PCM_FORMAT)
132 assert cursor.carry_media > 60
133
134
135def test_quantized_samples_are_offset_binary() -> None:
136 """Silence sits at 0x80, so a viewer reads the tail without knowing the scale."""
137 manager = _manager()
138 tap = Tap("player-1")
139 manager._emit_chunk(tap, _cursor(), _stereo_pcm([0] * WAVE_SAMPLES), PCM_FORMAT)
140 assert set(tap.ring[0][9:]) == {0x80}
141
142
143def test_align_keeps_a_cursor_that_still_matches() -> None:
144 """A cursor on the same track, in step with the queue, is left alone."""
145 manager = _manager()
146 tap = Tap("player-1")
147 item = Mock(queue_item_id="item-1")
148 buffer = Mock(first_buffered_chunk=0)
149 cursor = manager._align(tap, None, item, 5.0, buffer)
150 assert manager._align(tap, cursor, item, 5.0, buffer) is cursor
151
152
153def test_align_re_anchors_on_a_track_change() -> None:
154 """A new queue item drops what was scheduled from the old track's timeline."""
155 manager = _manager()
156 tap = Tap("player-1")
157 buffer = Mock(first_buffered_chunk=0)
158 cursor = manager._align(tap, None, Mock(queue_item_id="item-1"), 30.0, buffer)
159 tap.ring.append(b"stale")
160 queued = ViewerQueue()
161 tap.queues.add(queued)
162 new_cursor = manager._align(tap, cursor, Mock(queue_item_id="item-2"), 0.0, buffer)
163 assert new_cursor is not cursor
164 assert new_cursor.next_chunk == 0
165 assert not tap.ring
166 assert queued._items[0] == '{"type": "stream/clear"}'
167
168
169def test_align_re_anchors_on_a_seek() -> None:
170 """A playhead that jumps away from the anchored timeline restarts the cursor."""
171 manager = _manager()
172 tap = Tap("player-1")
173 item = Mock(queue_item_id="item-1")
174 buffer = Mock(first_buffered_chunk=0)
175 cursor = manager._align(tap, None, item, 5.0, buffer)
176 new_cursor = manager._align(tap, cursor, item, 120.0, buffer)
177 assert new_cursor is not cursor
178 assert new_cursor.next_chunk == 120
179
180
181def test_align_starts_inside_the_retained_window() -> None:
182 """A rolling buffer that has discarded the playhead is picked up where it starts."""
183 manager = _manager()
184 tap = Tap("player-1")
185 item = Mock(queue_item_id="item-1")
186 cursor = manager._align(tap, None, item, 5.0, Mock(first_buffered_chunk=90))
187 assert cursor.next_chunk == 90
188
189
190def test_playhead_tracks_playback_speed() -> None:
191 """At 2x, media time advances two seconds per wall-clock second from the anchor."""
192 cursor = _cursor(anchor_us=server_now_us() - 10_000_000)
193 cursor.speed = 2.0
194 assert abs(cursor.playhead() - 20.0) < 0.1
195 # and the inverse mapping stamps media second 20 at (roughly) now
196 assert abs(cursor.media_to_clock_us(20.0) - server_now_us()) < 100_000
197
198
199def test_align_keeps_a_speed_aware_cursor_in_step() -> None:
200 """A cursor anchored at 2x stays matched against a queue advancing in media-time."""
201 manager = _manager()
202 tap = Tap("player-1")
203 item = Mock(queue_item_id="item-1")
204 buffer = Mock(first_buffered_chunk=0)
205 cursor = manager._align(tap, None, item, 10.0, buffer, 2.0)
206 assert cursor.speed == 2.0
207 assert manager._align(tap, cursor, item, 10.0, buffer, 2.0) is cursor
208
209
210def test_align_scales_the_resync_threshold_by_speed() -> None:
211 """At 2x, report jitter inflates by the speed factor, so the threshold grows with it."""
212 manager = _manager()
213 tap = Tap("player-1")
214 item = Mock(queue_item_id="item-1")
215 buffer = Mock(first_buffered_chunk=0)
216 cursor = manager._align(tap, None, item, 10.0, buffer, 2.0)
217 # a 5s media-time gap is within the scaled 6s threshold, not a seek
218 assert manager._align(tap, cursor, item, 15.0, buffer, 2.0) is cursor
219 # beyond the scaled threshold it is a seek and re-anchors
220 assert manager._align(tap, cursor, item, 17.0, buffer, 2.0) is not cursor
221
222
223def test_align_re_anchors_on_a_speed_change() -> None:
224 """A playback speed change remaps media time to the clock, so the cursor restarts."""
225 manager = _manager()
226 tap = Tap("player-1")
227 item = Mock(queue_item_id="item-1")
228 buffer = Mock(first_buffered_chunk=0)
229 cursor = manager._align(tap, None, item, 10.0, buffer)
230 new_cursor = manager._align(tap, cursor, item, 10.0, buffer, 1.5)
231 assert new_cursor is not cursor
232 assert new_cursor.speed == 1.5
233
234
235def _beats_manager() -> TapManager:
236 """Return a tap manager with the real beat scheduling in place."""
237 provider = Mock()
238 provider.logger.getChild.return_value = Mock()
239 return TapManager(provider)
240
241
242def test_schedule_beats_rebuilds_from_cached_analysis() -> None:
243 """A re-anchor of an item whose analysis is cached reschedules in place, without a task."""
244 manager = _beats_manager()
245 tap = Tap("player-1")
246 tap.beats_analysis = ("item-1", AudioAnalysisData(beats=[1.0, 2.0], downbeats=[1.0]))
247 anchor_us = server_now_us()
248 manager._schedule_beats(tap, Mock(queue_item_id="item-1"), anchor_us)
249 manager.mass.create_task.assert_not_called() # type: ignore[attr-defined]
250 assert [timestamp_us for timestamp_us, _ in tap.beats] == [
251 anchor_us + 1_000_000,
252 anchor_us + 2_000_000,
253 ]
254 # the downbeat flag survives the rebuild
255 assert tap.beats[0][1][9] == 1
256 assert tap.beats[1][1][9] == 0
257
258
259def test_fan_out_beats_scales_media_time_by_speed() -> None:
260 """At 2x a beat at media second 2 sounds one wall-clock second after the anchor."""
261 manager = _beats_manager()
262 tap = Tap("player-1")
263 anchor_us = server_now_us()
264 manager._fan_out_beats(tap, AudioAnalysisData(beats=[2.0]), anchor_us, 2.0)
265 assert [timestamp_us for timestamp_us, _ in tap.beats] == [anchor_us + 1_000_000]
266
267
268def test_reset_cancels_an_in_flight_beat_hydration() -> None:
269 """A timeline reset stops a pending hydration from landing beats for a dead track."""
270 tap = Tap("player-1")
271 task = Mock()
272 tap.beats_task = task
273 tap.reset('{"type": "stream/end"}')
274 task.cancel.assert_called_once()
275 assert tap.beats_task is None
276
277
278def test_schedule_beats_does_not_serve_another_item_from_cache() -> None:
279 """A cached analysis belongs to one item; any other item hydrates freshly."""
280 manager = _beats_manager()
281 tap = Tap("player-1")
282 tap.beats_analysis = ("item-1", AudioAnalysisData(beats=[1.0]))
283 manager._schedule_beats(tap, Mock(queue_item_id="item-2"), server_now_us())
284 manager.mass.create_task.assert_called_once() # type: ignore[attr-defined]
285 assert not tap.beats
286
287
288async def test_hydrate_beats_caches_the_fetched_analysis() -> None:
289 """The fetched analysis is kept on the tap so the next re-anchor skips the query."""
290 manager = _beats_manager()
291 analysis = AudioAnalysisData(beats=[1.0])
292 manager.mass.streams.audio_analysis.get_audio_analysis = AsyncMock( # type: ignore[method-assign]
293 return_value=analysis
294 )
295 tap = Tap("player-1")
296 await manager._hydrate_beats(tap, Mock(queue_item_id="item-1"), server_now_us())
297 assert tap.beats_analysis == ("item-1", analysis)
298 assert len(tap.beats) == 1
299
300
301def test_ring_with_only_future_frames_is_reported_stale() -> None:
302 """A ring whose oldest frame is ahead of now has nothing a fresh viewer can draw."""
303 tap = Tap("player-1")
304 assert not tap.has_only_future_frames()
305 tap.ring.append(pack_wave_frame(server_now_us() - 1_000_000, b"\x80" * WAVE_SAMPLES))
306 assert not tap.has_only_future_frames()
307 tap.ring.clear()
308 tap.ring.append(pack_wave_frame(server_now_us() + 60_000_000, b"\x80" * WAVE_SAMPLES))
309 assert tap.has_only_future_frames()
310
311
312async def test_read_once_realigns_when_requested() -> None:
313 """A requested realign drops a pinned-ahead cursor and restarts at the playhead."""
314 manager = _manager()
315 manager.provider.config.get_value.return_value = False # type: ignore[attr-defined]
316 tap = Tap("player-1")
317 queue = Mock(corrected_elapsed_time=100.0, playback_speed=1.0)
318 item = Mock(queue_item_id="item-1")
319 buffer = Mock(first_buffered_chunk=0, pcm_format=PCM_FORMAT)
320 buffer.read_chunk_for_analysis = AsyncMock(return_value=_stereo_pcm([0] * 44100))
321 manager._playing_source = Mock(return_value=(queue, item, buffer)) # type: ignore[method-assign]
322 # a cursor pinned at the eviction edge but otherwise in sync with the queue
323 pinned = _cursor(next_chunk=200, anchor_us=server_now_us() - 100_000_000)
324 tap.realign_requested = True
325 cursor = await manager._read_once(tap, pinned)
326 assert cursor is not None
327 assert cursor is not pinned
328 assert cursor.next_chunk == 101
329 assert not tap.realign_requested
330
331
332async def test_read_once_ignores_realign_when_playhead_chunk_is_evicted() -> None:
333 """A realign past the eviction edge is dropped: healthy viewers keep their frames."""
334 manager = _manager()
335 manager.provider.config.get_value.return_value = False # type: ignore[attr-defined]
336 tap = Tap("player-1")
337 tap.ring.append(b"frame")
338 queue = Mock(corrected_elapsed_time=100.0, playback_speed=1.0)
339 item = Mock(queue_item_id="item-1")
340 buffer = Mock(first_buffered_chunk=200, pcm_format=PCM_FORMAT)
341 buffer.read_chunk_for_analysis = AsyncMock(return_value=_stereo_pcm([0] * 44100))
342 manager._playing_source = Mock(return_value=(queue, item, buffer)) # type: ignore[method-assign]
343 # a cursor pinned at the eviction edge but otherwise in sync with the queue
344 pinned = _cursor(next_chunk=200, anchor_us=server_now_us() - 100_000_000)
345 tap.realign_requested = True
346 cursor = await manager._read_once(tap, pinned)
347 assert cursor is pinned
348 assert b"frame" in tap.ring
349 assert not tap.realign_requested
350
351
352def test_palette_payload_maps_every_field() -> None:
353 """A palette becomes the color@v1 payload the wire format documents."""
354 payload = palette_payload(MediaItemPalette(primary=(1, 2, 3)))
355 assert payload["primary"] == [1, 2, 3]
356 assert payload["accent"] is None
357
358
359def test_palette_payload_nulls_everything_without_a_palette() -> None:
360 """A track with no palette clears the previous track's tint."""
361 payload = palette_payload(None)
362 assert payload
363 assert all(value is None for value in payload.values())
364
365
366def test_viewer_queue_evicts_oldest_binary_frame_when_full() -> None:
367 """A stalled viewer loses waveform frames rather than stalling the tap."""
368 queue = ViewerQueue(capacity=2)
369 queue.push(b"first")
370 queue.push(b"second")
371 queue.push('{"type": "stream/clear"}')
372 drained = [queue._items[index] for index in range(len(queue._items))]
373 assert drained == [b"second", '{"type": "stream/clear"}']
374
375
376def test_viewer_queue_evicts_control_only_when_no_binary_left() -> None:
377 """Control messages are kept while any waveform frame can be dropped instead."""
378 queue = ViewerQueue(capacity=2)
379 queue.push('{"type": "stream/start"}')
380 queue.push('{"type": "stream/clear"}')
381 queue.push(b"frame")
382 drained = [queue._items[index] for index in range(len(queue._items))]
383 assert len(drained) == 2
384 assert '{"type": "stream/clear"}' in drained
385