/
/
/
1"""Shared test helpers for the smart_fades test suite."""
2
3from __future__ import annotations
4
5from typing import cast
6
7import numpy as np
8
9from music_assistant.controllers.streams.smart_fades.models import (
10 PlanMetrics,
11 TransitionPlan,
12 TransitionTier,
13)
14from music_assistant.controllers.streams.smart_fades.planner.candidates import (
15 Candidate,
16 CandidateSpec,
17)
18from music_assistant.models.audio_analysis import AudioAnalysisData
19
20
21def build_test_candidate( # noqa: PLR0913
22 *,
23 bars: int = 8,
24 ideal: int = 8,
25 collision: float = 0.0,
26 weighted: float = 0.0,
27 trim: float = 0.0,
28 vocal_fade: float = 0.0,
29 on_downbeat: bool = True,
30 duration: float = 20.0,
31 tier: TransitionTier = TransitionTier.FULL_BLEND,
32 fadein_trim: float | None = None,
33 fade_end: float | None = None,
34) -> Candidate:
35 """Build a minimal ``Candidate`` with only the fields a policy under test reads."""
36 spec = CandidateSpec(tier=tier, bars=bars, anchor_s=None, entry_s=None)
37 plan = TransitionPlan(
38 tier=tier,
39 fade_out_window=fade_end if fade_end is not None else duration,
40 crossfade_duration=duration,
41 fadein_trim_start=fadein_trim,
42 )
43 metrics = PlanMetrics(
44 audible_outgoing_trim=trim,
45 outgoing_vocal_fade_seconds=vocal_fade,
46 anchor_on_downbeat=on_downbeat,
47 collision_seconds=collision,
48 weighted_collision_seconds=weighted,
49 )
50 return Candidate(spec=spec, plan=plan, metrics=metrics, ideal_bars=ideal)
51
52
53def _envelope(value: float | list[float] | np.ndarray) -> list[float]:
54 """Broadcast a scalar to a flat 1800-bin envelope, or pass an array through."""
55 if isinstance(value, (list, np.ndarray)):
56 arr = np.asarray(value, dtype=np.float32)
57 if len(arr) != 1800:
58 raise ValueError(f"band envelope arrays must have 1800 bins, got {len(arr)}")
59 return cast("list[float]", arr.tolist())
60 return np.full(1800, value, dtype=np.float32).tolist()
61
62
63def _analysis_with_bands(
64 low: float | list[float] | np.ndarray,
65 low_mid: float | list[float] | np.ndarray,
66 mid: float | list[float] | np.ndarray,
67 high: float | list[float] | np.ndarray,
68 duration: float = 240.0,
69 key: str | None = "A",
70 mode: str | None = "minor",
71) -> AudioAnalysisData:
72 """
73 Build an analysis row with v2 ``band_rms`` envelopes for band-profile tests.
74
75 Each band accepts either a constant level or a 1800-bin array, so callers
76 can vary a band's envelope over time (e.g. a kick that drops out midway).
77 Defaults to a self-compatible key so a clean pair earns the full-blend tier.
78
79 :param low: Low-band envelope, constant level or 1800-bin array.
80 :param low_mid: Low-mid-band envelope, constant level or 1800-bin array.
81 :param mid: Mid-band envelope, constant level or 1800-bin array.
82 :param high: High-band envelope, constant level or 1800-bin array.
83 :param duration: Track duration in seconds.
84 :param key: Detected key pitch class (Camelot key gating).
85 :param mode: Detected mode, "major" or "minor".
86 """
87 beats = np.arange(0.0, duration, 0.5, dtype=np.float32)
88 return AudioAnalysisData(
89 duration=duration,
90 bpm=120.0,
91 beats=beats.tolist(),
92 downbeats=beats[::4].tolist(),
93 rms_energy=np.full(1800, 0.5, dtype=np.float32).tolist(),
94 key=key,
95 mode=mode,
96 extra_data={
97 "band_rms": {
98 "low": _envelope(low),
99 "low_mid": _envelope(low_mid),
100 "mid": _envelope(mid),
101 "high": _envelope(high),
102 }
103 },
104 )
105