/
/
/
1"""Unit tests for sonic_analysis helpers (feature extraction + collapse)."""
2
3import math
4
5import numpy as np
6
7from music_assistant.helpers.json import json_dumps, json_loads
8from music_assistant.models.audio_analysis import AudioAnalysisData
9from music_assistant.providers.sonic_analysis.helpers import (
10 MIN_BLOCK_SAMPLES,
11 BlockFeatures,
12 collapse_to_analysis,
13 extract_block_features,
14 merge_block_features,
15)
16
17
18def _make_sine(freq: float = 440.0, duration: float = 5.0, sr: int = 22050) -> np.ndarray:
19 """Generate a mono sine wave for testing."""
20 t = np.linspace(0, duration, int(sr * duration), endpoint=False)
21 return np.sin(2 * np.pi * freq * t).astype(np.float32)
22
23
24def _make_noise(duration: float = 5.0, sr: int = 22050) -> np.ndarray:
25 """Generate mono white noise for testing using a fixed RNG seed."""
26 rng = np.random.default_rng(42)
27 return rng.standard_normal(int(sr * duration)).astype(np.float32)
28
29
30def test_extract_block_features_returns_block_features() -> None:
31 """Verify extract_block_features returns a BlockFeatures with correct shapes."""
32 audio = _make_sine(440.0, 10.0, 22050)
33 result = extract_block_features(audio, 22050)
34
35 assert isinstance(result, BlockFeatures)
36 assert len(result.chroma_frames) == 1
37 assert result.chroma_frames[0].shape[0] == 12
38
39 assert len(result.contrast_frames) == 1
40 assert result.contrast_frames[0].shape[0] == 7
41
42 assert len(result.centroid_frames) == 1
43 assert len(result.flatness_frames) == 1
44 assert len(result.rms_frames) == 1
45
46 assert len(result.onset_env_frames) == 1
47 assert result.onset_env_frames[0].ndim == 1
48
49
50def test_extract_block_features_too_short_returns_none() -> None:
51 """Verify audio shorter than MIN_BLOCK_SAMPLES returns None."""
52 audio = np.zeros(MIN_BLOCK_SAMPLES - 1, dtype=np.float32)
53 result = extract_block_features(audio, 22050)
54 assert result is None
55
56
57def test_merge_block_features() -> None:
58 """Verify merging two BlockFeatures doubles all frame lists."""
59 audio_a = _make_sine(440.0, 5.0, 22050)
60 audio_b = _make_sine(880.0, 5.0, 22050)
61 target = extract_block_features(audio_a, 22050)
62 source = extract_block_features(audio_b, 22050)
63
64 assert target is not None
65 assert source is not None
66 merge_block_features(target, source)
67
68 assert len(target.chroma_frames) == 2
69 assert len(target.contrast_frames) == 2
70 assert len(target.centroid_frames) == 2
71 assert len(target.flatness_frames) == 2
72 assert len(target.rms_frames) == 2
73 assert len(target.onset_env_frames) == 2
74
75
76def _make_analysis(
77 audio: np.ndarray | None = None, duration: float = 10.0, sr: int = 22050
78) -> AudioAnalysisData:
79 """Build AudioAnalysisData from a sine wave (or provided audio) via collapse_to_analysis."""
80 if audio is None:
81 audio = _make_sine(440.0, duration, sr)
82 bf = extract_block_features(audio, sr)
83 assert bf is not None
84 return collapse_to_analysis(bf, sr)
85
86
87def test_collapse_to_analysis_returns_audio_analysis_data() -> None:
88 """Verify collapse_to_analysis returns an AudioAnalysisData instance."""
89 result = _make_analysis()
90 assert isinstance(result, AudioAnalysisData)
91
92
93def test_collapse_to_analysis_scalars_in_unit_range() -> None:
94 """All 0-1 scalar fields must be within [0.0, 1.0]."""
95 result = _make_analysis()
96 scalar_fields = [
97 "energy",
98 "brightness",
99 "harmonic_complexity",
100 "roughness",
101 "rhythmic_regularity",
102 ]
103 for field_name in scalar_fields:
104 value = getattr(result, field_name)
105 assert value is not None, f"{field_name} should not be None"
106 assert 0.0 <= value <= 1.0, f"{field_name}={value!r} is outside [0.0, 1.0]"
107
108
109def test_collapse_to_analysis_loudness_values_finite() -> None:
110 """Loudness fields must be finite floats."""
111 result = _make_analysis()
112 assert result.loudness_integrated is not None
113 assert math.isfinite(result.loudness_integrated)
114 assert result.loudness_range is not None
115 assert math.isfinite(result.loudness_range)
116
117
118def test_collapse_to_analysis_time_series_populated() -> None:
119 """Time-series arrays must be populated and non-empty."""
120 result = _make_analysis()
121
122 assert result.rms_energy is not None
123 assert len(result.rms_energy) > 0
124
125 assert result.spectral_centroid is not None
126 assert len(result.spectral_centroid) > 0
127
128
129def test_collapse_to_analysis_replaces_non_finite_features() -> None:
130 """All Sonic numeric output remains finite and survives a JSON round-trip."""
131 features = extract_block_features(_make_sine(), 22050)
132 assert features is not None
133 non_finite = np.array([np.nan, np.inf, -np.inf], dtype=np.float32)
134 for frames in (
135 features.chroma_frames,
136 features.contrast_frames,
137 features.centroid_frames,
138 features.flatness_frames,
139 features.rms_frames,
140 features.onset_env_frames,
141 ):
142 frames[0] = frames[0].copy()
143 frames[0].flat[: len(non_finite)] = non_finite
144
145 result = collapse_to_analysis(features, 22050)
146 payload = result.to_dict()
147 numeric_values = [value for value in payload.values() if isinstance(value, int | float)]
148 numeric_values.extend(
149 item for value in payload.values() if isinstance(value, list) for item in value
150 )
151
152 assert numeric_values
153 assert all(math.isfinite(float(value)) for value in numeric_values)
154 restored = AudioAnalysisData.from_dict(json_loads(json_dumps(payload)))
155 assert restored == result
156
157
158def test_collapse_to_analysis_deterministic() -> None:
159 """Same input must produce identical output."""
160 audio = _make_sine(440.0, 10.0, 22050)
161 sr = 22050
162
163 bf_a = extract_block_features(audio, sr)
164 assert bf_a is not None
165 result_a = collapse_to_analysis(bf_a, sr)
166
167 bf_b = extract_block_features(audio, sr)
168 assert bf_b is not None
169 result_b = collapse_to_analysis(bf_b, sr)
170
171 assert result_a.energy == result_b.energy
172 assert result_a.brightness == result_b.brightness
173 assert result_a.harmonic_complexity == result_b.harmonic_complexity
174 assert result_a.roughness == result_b.roughness
175 assert result_a.rhythmic_regularity == result_b.rhythmic_regularity
176 assert result_a.loudness_integrated == result_b.loudness_integrated
177 assert result_a.loudness_range == result_b.loudness_range
178 np.testing.assert_array_equal(result_a.rms_energy, result_b.rms_energy)
179 np.testing.assert_array_equal(result_a.spectral_centroid, result_b.spectral_centroid)
180
181
182def test_collapse_to_analysis_noise_vs_sine_differ() -> None:
183 """Noise should produce higher roughness and brightness than a pure sine tone."""
184 sr = 22050
185 duration = 10.0
186
187 sine_result = _make_analysis(audio=_make_sine(440.0, duration, sr), sr=sr)
188 noise_result = _make_analysis(audio=_make_noise(duration, sr), sr=sr)
189
190 assert sine_result.roughness is not None
191 assert noise_result.roughness is not None
192 assert noise_result.roughness > sine_result.roughness, (
193 f"Expected noise roughness ({noise_result.roughness}) > "
194 f"sine roughness ({sine_result.roughness})"
195 )
196
197 assert sine_result.brightness is not None
198 assert noise_result.brightness is not None
199 assert noise_result.brightness > sine_result.brightness, (
200 f"Expected noise brightness ({noise_result.brightness}) > "
201 f"sine brightness ({sine_result.brightness})"
202 )
203
204
205def test_collapse_to_analysis_overlay_owned_fields_are_none() -> None:
206 """Fields owned by overlay providers must be left None by sonic_analysis."""
207 result = _make_analysis()
208 assert result.bpm is None
209 assert result.key is None
210 assert result.mode is None
211 assert result.danceability is None
212 assert result.valence is None
213 assert result.arousal is None
214 assert result.instrumentalness is None
215 assert result.acousticness is None
216 assert result.speechiness is None
217