/
/
/
1"""Tests for smart fades helper functions."""
2
3from __future__ import annotations
4
5import itertools
6
7import numpy as np
8import pytest
9
10from music_assistant.controllers.streams.smart_fades.helpers import (
11 db_ramp,
12 detect_effective_audio_end,
13 detect_groove_entry,
14)
15
16
17def _rms(track_duration: float, silent_tail: float, level: float = 0.5) -> np.ndarray:
18 """Build a 1800-bin peak-normalized rms array with a silent tail."""
19 bins = np.full(1800, level, dtype=np.float32)
20 bins[0] = 1.0 # peak normalization reference
21 if silent_tail > 0:
22 silent_bins = int(silent_tail / track_duration * 1800)
23 bins[-silent_bins:] = 0.001
24 return bins
25
26
27def test_no_rms_data_returns_buffer_duration() -> None:
28 """When no RMS data is provided, return the buffer duration."""
29 assert detect_effective_audio_end(None, 240.0, 45.0) == 45.0
30
31
32def test_no_trailing_silence_returns_buffer_duration() -> None:
33 """When there is no trailing silence, return the full buffer duration."""
34 end = detect_effective_audio_end(_rms(240.0, silent_tail=0.0), 240.0, 45.0)
35 assert end == pytest.approx(45.0, abs=0.2)
36
37
38def test_silent_tail_is_excluded() -> None:
39 """Trailing silence is excluded from the effective audio end."""
40 # 10s of silence at the end of a 240s track: audible content ends at 35s buffer-local
41 end = detect_effective_audio_end(_rms(240.0, silent_tail=10.0), 240.0, 45.0)
42 assert end == pytest.approx(35.0, abs=0.3)
43
44
45def test_fully_silent_tail_returns_zero() -> None:
46 """When the entire tail is silent, return 0.0."""
47 end = detect_effective_audio_end(_rms(240.0, silent_tail=50.0), 240.0, 45.0)
48 assert end == 0.0
49
50
51def test_quiet_but_musical_outro_is_kept() -> None:
52 """Quiet but intentional outros above the floor are not treated as silence."""
53 # outro at 30% of sustained level stays well above the silence floor
54 bins = _rms(240.0, silent_tail=0.0)
55 bins[-300:] = 0.15
56 end = detect_effective_audio_end(bins, 240.0, 45.0)
57 assert end == pytest.approx(45.0, abs=0.2)
58
59
60def test_hiss_tail_on_loud_track_counts_as_silence() -> None:
61 """On a loud track the floor scales up, so a hiss tail counts as silence."""
62 # sustained level 0.9 -> floor 0.045; the 0.03 hiss tail falls below it
63 bins = _rms(240.0, silent_tail=0.0, level=0.9)
64 bins[-75:] = 0.03
65 end = detect_effective_audio_end(bins, 240.0, 45.0)
66 assert end == pytest.approx(35.0, abs=0.3)
67
68
69def test_absolute_floor_applies_on_quiet_track() -> None:
70 """On a quiet track the absolute 0.02 floor still flags a near-silent tail."""
71 # sustained level 0.2 -> relative floor 0.01, clamped to absolute 0.02
72 bins = _rms(240.0, silent_tail=0.0, level=0.2)
73 bins[-75:] = 0.015
74 end = detect_effective_audio_end(bins, 240.0, 45.0)
75 assert end == pytest.approx(35.0, abs=0.3)
76
77
78def test_all_nan_rms_returns_buffer_duration() -> None:
79 """RMS data without any finite values fails open to the buffer duration."""
80 bins = np.full(1800, np.nan, dtype=np.float32)
81 assert detect_effective_audio_end(bins, 240.0, 45.0) == 45.0
82
83
84class TestGrooveEntry:
85 """Groove entry = first sustained per-bar energy step (the drums coming in)."""
86
87 def test_step_intro_detected_on_bar(self) -> None:
88 """A quiet 16s intro followed by full energy yields an entry at ~16s."""
89 duration = 240.0
90 bins = np.full(1800, 0.5, dtype=np.float32)
91 t = np.linspace(0, duration, 1800)
92 bins[t < 16.0] = 0.05
93 downbeats = np.arange(0.0, duration, 2.0, dtype=np.float32)
94 entry = detect_groove_entry(bins, duration, downbeats)
95 assert entry == pytest.approx(16.0, abs=2.1)
96
97 def test_flat_track_has_no_entry(self) -> None:
98 """A track that opens at full energy has no skippable intro."""
99 bins = np.full(1800, 0.5, dtype=np.float32)
100 downbeats = np.arange(0.0, 240.0, 2.0, dtype=np.float32)
101 assert detect_groove_entry(bins, 240.0, downbeats) == 0.0
102
103 def test_missing_data_has_no_entry(self) -> None:
104 """Without energy data or a usable grid there is no entry to detect."""
105 downbeats = np.arange(0.0, 240.0, 2.0, dtype=np.float32)
106 assert detect_groove_entry(None, 240.0, downbeats) == 0.0
107 bins = np.full(1800, 0.5, dtype=np.float32)
108 assert detect_groove_entry(bins, 240.0, downbeats[:4]) == 0.0
109
110
111class TestDbRamp:
112 """db_ramp builds linear-in-dB asendcmd schedules."""
113
114 def test_linear_ramp_endpoints_and_step(self) -> None:
115 """The ramp spans [start, start+duration] and steps every ~0.1s."""
116 steps = db_ramp(10.0, 2.0, 0.0, -26.0)
117 assert steps[0][0] == pytest.approx(10.0)
118 assert steps[0][1] == pytest.approx(0.0)
119 assert steps[-1][0] == pytest.approx(12.0)
120 assert steps[-1][1] == pytest.approx(-26.0)
121 deltas = [b[0] - a[0] for a, b in itertools.pairwise(steps)]
122 assert all(d == pytest.approx(0.1, abs=0.01) for d in deltas)
123
124 def test_short_ramp_has_at_least_two_steps(self) -> None:
125 """Even a sub-interval ramp produces a start and an end point."""
126 steps = db_ramp(0.0, 0.05, -26.0, 0.0)
127 assert len(steps) >= 2
128 assert steps[-1][1] == pytest.approx(0.0)
129