/
/
1"""
2Tests for skipping volume normalization when the source already did it.
3
4A provider that hands over audio at a loudness target of its own declares that
5with ``MusicProvider.delivers_normalized_audio``; correcting such a level again
6would mean normalizing twice, the second time against a measurement of the
7source's own output. Also verified: the loudness analyzer declines to measure a
8stream whose normalization is disabled, so no measurement is stored for it.
9"""
10
11from __future__ import annotations
12
13from unittest.mock import MagicMock
14
15from music_assistant_models.enums import ContentType, MediaType, VolumeNormalizationMode
16from music_assistant_models.media_items import AudioFormat
17from music_assistant_models.streamdetails import StreamDetails
18
19from music_assistant.helpers.audio import get_normalization_mode
20
21PCM_FORMAT = AudioFormat(
22 content_type=ContentType.PCM_S16LE,
23 codec_type=ContentType.PCM_S16LE,
24 sample_rate=44100,
25 bit_depth=16,
26 channels=2,
27)
28
29
30def test_a_normalized_source_is_left_alone() -> None:
31 """A source that normalizes its own output takes MA out of the loudness path."""
32 streamdetails = _streamdetails()
33 assert (
34 get_normalization_mode(
35 VolumeNormalizationMode.FALLBACK_DYNAMIC, True, streamdetails, source_normalized=True
36 )
37 == VolumeNormalizationMode.DISABLED
38 )
39
40
41def test_an_unnormalized_source_still_falls_back_to_dynamic() -> None:
42 """Without a measurement or a normalizing source, the dynamic fallback still applies."""
43 streamdetails = _streamdetails()
44 assert (
45 get_normalization_mode(
46 VolumeNormalizationMode.FALLBACK_DYNAMIC, True, streamdetails, source_normalized=False
47 )
48 == VolumeNormalizationMode.DYNAMIC
49 )
50
51
52def test_a_stored_measurement_does_not_override_a_normalized_source() -> None:
53 """A measurement left over from before must not pull MA back into correcting."""
54 streamdetails = _streamdetails()
55 # e.g. measured while the source was not normalizing, or on the other backend
56 streamdetails.loudness = -7.2
57 assert (
58 get_normalization_mode(
59 VolumeNormalizationMode.FALLBACK_DYNAMIC, True, streamdetails, source_normalized=True
60 )
61 == VolumeNormalizationMode.DISABLED
62 )
63 assert (
64 get_normalization_mode(
65 VolumeNormalizationMode.FALLBACK_DYNAMIC, True, streamdetails, source_normalized=False
66 )
67 == VolumeNormalizationMode.MEASUREMENT_ONLY
68 )
69
70
71def test_the_queue_setting_still_wins() -> None:
72 """Normalization disabled for the queue stays disabled either way."""
73 streamdetails = _streamdetails()
74 assert (
75 get_normalization_mode(
76 VolumeNormalizationMode.FALLBACK_DYNAMIC, False, streamdetails, source_normalized=True
77 )
78 == VolumeNormalizationMode.DISABLED
79 )
80
81
82def test_a_music_provider_declares_nothing_by_default() -> None:
83 """The declaration is opt-in: nothing downstream verifies it."""
84 from music_assistant.models.music_provider import MusicProvider # noqa: PLC0415
85
86 provider = object.__new__(MusicProvider)
87 assert provider.delivers_normalized_audio is False
88
89
90async def test_the_analyzer_declines_a_stream_it_must_not_measure() -> None:
91 """
92 A disabled stream is not measured, so a normalized source stores no loudness.
93
94 That is what keeps a value measured on one backend's output from being applied
95 to the other's, without any erase step.
96 """
97 from music_assistant.providers.loudness_analysis.provider import ( # noqa: PLC0415
98 LoudnessAnalysisProvider,
99 )
100
101 provider = object.__new__(LoudnessAnalysisProvider)
102 provider.logger = MagicMock()
103 streamdetails = _streamdetails()
104 streamdetails.volume_normalization_mode = VolumeNormalizationMode.DISABLED
105 assert await provider._start_analysis("session-1", streamdetails, PCM_FORMAT) is False
106
107
108def _streamdetails() -> StreamDetails:
109 """Return StreamDetails for an ordinary track with a normalization target set."""
110 streamdetails = StreamDetails(
111 provider="test--1",
112 item_id="1",
113 audio_format=PCM_FORMAT,
114 media_type=MediaType.TRACK,
115 )
116 streamdetails.target_loudness = -14.0
117 return streamdetails
118