/
/
/
1"""Tests for the pure numpy frame aggregation helpers."""
2
3from __future__ import annotations
4
5import numpy as np
6import pytest
7
8from music_assistant.controllers.streams.smart_fades.models import BAND_RMS_BANDS
9from music_assistant.models.audio_analysis import AudioAnalysisData
10from music_assistant.providers.smart_fades.helpers import (
11 aggregate_series_to_bins,
12 compute_band_rms_frames,
13)
14
15
16class TestAggregateSeriesToBins:
17 """Anti-aliased frame-to-bin resampling (mean power per bin, not point samples)."""
18
19 def test_beat_ripple_does_not_alias(self) -> None:
20 """A beat-rate amplitude ripple averages out instead of aliasing into bins."""
21 # ripple period (~7 frames) << bin (~22 frames): the boxcar must flatten it
22 frames = (0.5 + 0.2 * np.sin(np.arange(40_000) * 0.9)).astype(np.float32)
23 bins = aggregate_series_to_bins(frames, 1800, power=True)
24 assert len(bins) == 1800
25 rms_of_ripple = np.sqrt(np.mean(frames.astype(np.float64) ** 2))
26 assert np.all(np.abs(bins - rms_of_ripple) < 0.02)
27
28 def test_constant_series_is_preserved(self) -> None:
29 """A flat series stays flat at the same level."""
30 bins = aggregate_series_to_bins(np.full(999, 0.4, dtype=np.float32), 1800)
31 assert bins == pytest.approx(np.full(1800, 0.4), abs=1e-6)
32
33 def test_step_stays_sharp(self) -> None:
34 """A cliff moves by at most one bin (no smearing beyond the boxcar)."""
35 frames = np.concatenate([np.ones(1000, np.float32), np.zeros(1000, np.float32)])
36 bins = aggregate_series_to_bins(frames, 200, power=True)
37 # exactly one transition bin may hold an intermediate value
38 assert np.sum((bins > 0.01) & (bins < 0.99)) <= 1
39
40 def test_upsampling_short_series(self) -> None:
41 """Fewer frames than bins (very short track) still yields n_bins values."""
42 bins = aggregate_series_to_bins(np.array([1.0, 0.0], dtype=np.float32), 10)
43 assert len(bins) == 10
44 assert bins[0] == pytest.approx(1.0)
45 assert bins[-1] == pytest.approx(0.0)
46
47
48class TestBandRmsFrames:
49 """Band-limited RMS frames: a pure tone lands in exactly one band."""
50
51 def _tone(self, freq: float, seconds: float = 2.0, sr: int = 22050) -> np.ndarray:
52 t = np.arange(int(sr * seconds)) / sr
53 return (0.5 * np.sin(2 * np.pi * freq * t)).astype(np.float32)
54
55 @pytest.mark.parametrize(
56 ("freq", "band"),
57 [(60.0, "low"), (250.0, "low_mid"), (1000.0, "mid"), (8000.0, "high")],
58 )
59 def test_tone_lands_in_its_band(self, freq: float, band: str) -> None:
60 """Each band's tone dominates its own envelope by an order of magnitude."""
61 frames = compute_band_rms_frames(self._tone(freq), 22050, 2205)
62 assert set(frames) == set(BAND_RMS_BANDS)
63 target = float(np.mean(frames[band]))
64 others = max(float(np.mean(v)) for k, v in frames.items() if k != band)
65 assert target > 10 * others
66
67 def test_band_sum_tracks_full_rms(self) -> None:
68 """Total band power approximates the plain time-domain RMS (Parseval)."""
69 pcm = self._tone(60.0) + self._tone(1000.0)
70 frames = compute_band_rms_frames(pcm, 22050, 2205)
71 total = np.sqrt(sum(np.mean(v.astype(np.float64) ** 2) for v in frames.values()))
72 expected = np.sqrt(np.mean(pcm.astype(np.float64) ** 2))
73 assert total == pytest.approx(expected, rel=0.05)
74
75 def test_partial_tail_window_included(self) -> None:
76 """A trailing partial window still yields a frame (parity with full-band RMS)."""
77 frames = compute_band_rms_frames(self._tone(1000.0, seconds=1.05), 22050, 2205)
78 assert len(frames["mid"]) == 11 # 10 full + 1 partial
79
80
81def test_extra_data_roundtrips_through_dict() -> None:
82 """Band envelopes survive to_dict/from_dict (plain lists, no ndarrays)."""
83 analysis = AudioAnalysisData(
84 duration=10.0,
85 extra_data={
86 "band_rms": {"low": [0.1, 0.2], "mid": [0.3, 0.4]},
87 "vocal_activity": [0.2, 0.8],
88 },
89 )
90 restored = AudioAnalysisData.from_dict(analysis.to_dict())
91 assert restored.extra_data == analysis.extra_data
92