/
/
1"""Tests for music_assistant.helpers.audio."""
2
3from __future__ import annotations
4
5from types import SimpleNamespace
6from unittest.mock import MagicMock
7
8from music_assistant_models.enums import ContentType
9from music_assistant_models.media_items import AudioFormat
10
11from music_assistant.helpers.audio import (
12 build_concat_filelist,
13 calculate_content_length,
14 parse_loudnorm,
15 resolve_output_player_ids,
16)
17from music_assistant.helpers.ffmpeg import DEFAULT_MP3_BIT_RATE
18
19
20def test_resolve_output_player_ids_resolves_parents_and_duplicates() -> None:
21 """Output destinations use visible protocol parents without duplicates."""
22 mass = MagicMock()
23 players = {
24 "leader": SimpleNamespace(protocol_parent_id=None),
25 "protocol-child": SimpleNamespace(protocol_parent_id="child"),
26 "child": SimpleNamespace(protocol_parent_id=None),
27 }
28 mass.players.get_player.side_effect = players.get
29
30 result = resolve_output_player_ids(
31 mass,
32 ("leader", "leader", "protocol-child", "child", "missing", "missing"),
33 )
34
35 assert result == {"leader", "child", "missing"}
36
37
38def test_mp3_content_length_uses_encoder_bitrate() -> None:
39 """MP3 size estimation uses the bitrate configured for FFmpeg encoding."""
40 seconds = 2
41 assert calculate_content_length(
42 AudioFormat(content_type=ContentType.MP3),
43 seconds,
44 ) == int(((DEFAULT_MP3_BIT_RATE * 1000) / 8) * seconds)
45
46
47def test_build_concat_filelist_plain_paths() -> None:
48 """Paths without special characters are wrapped verbatim, one per line."""
49 result = build_concat_filelist(["/music/a.mp3", "/music/b.mp3"])
50 assert result == "file '/music/a.mp3'\nfile '/music/b.mp3'\n"
51
52
53def test_build_concat_filelist_escapes_apostrophes() -> None:
54 r"""
55 A single quote in the path is escaped as '\'' for the concat demuxer.
56
57 Regression test for multipart playback failing on paths such as
58 "Amelia Bedelia's", where the demuxer truncated the path at the apostrophe.
59 """
60 path = "/audiobooks/Herman Parish - Young Amelia Bedelia's Audio Collection/01.mp3"
61 result = build_concat_filelist([path])
62 assert (
63 result
64 == "file '/audiobooks/Herman Parish - Young Amelia Bedelia'\\''s Audio Collection/01.mp3'\n"
65 )
66 # The original apostrophe must survive once the escaping is unwrapped.
67 assert path in result.replace("'\\''", "'")
68
69
70def test_build_concat_filelist_escapes_multiple_apostrophes() -> None:
71 """Every apostrophe in a path is escaped, not just the first."""
72 result = build_concat_filelist(["/x/it's a, b's & c's.mp3"])
73 assert result == "file '/x/it'\\''s a, b'\\''s & c'\\''s.mp3'\n"
74
75
76# verbatim ffmpeg 7.1 output: the report is a block below the marker line, not inline
77FFMPEG_LOUDNORM_OUTPUT = b"""[out#0/null @ 0x93b] Output stream
78[Parsed_loudnorm_0 @ 0x93b41d440] \n{
79\t"input_i" : "-17.86",
80\t"input_tp" : "-1.89",
81\t"input_lra" : "0.40",
82\t"input_thresh" : "-27.86",
83\t"output_i" : "-23.92",
84\t"normalization_type" : "dynamic",
85\t"target_offset" : "-0.08"
86}
87size=N/A time=00:00:03.20 bitrate=N/A speed= 86x
88"""
89
90
91def test_parse_loudnorm_reads_the_measurement_ffmpeg_actually_prints() -> None:
92 """The integrated loudness is read from loudnorm's own JSON report block."""
93 assert parse_loudnorm(FFMPEG_LOUDNORM_OUTPUT) == -17.86
94
95
96def test_parse_loudnorm_accepts_a_decoded_string() -> None:
97 """Callers that already decoded the output get the same measurement."""
98 assert parse_loudnorm(FFMPEG_LOUDNORM_OUTPUT.decode()) == -17.86
99
100
101def test_parse_loudnorm_without_a_report_returns_none() -> None:
102 """Output from a run that never reached the filter carries no measurement."""
103 assert parse_loudnorm(b"ffmpeg: Invalid data found when processing input") is None
104
105
106def test_parse_loudnorm_with_a_truncated_report_returns_none() -> None:
107 """A report cut off mid-object is not mistaken for a measurement."""
108 assert parse_loudnorm(b'[Parsed_loudnorm_0 @ 0x1] \n{\n\t"input_i" : "-17.8') is None
109
110
111def test_parse_loudnorm_treats_digital_silence_as_no_measurement() -> None:
112 """A silent clip reports -inf, which is the absence of a level, not a level."""
113 silent = FFMPEG_LOUDNORM_OUTPUT.replace(b'"-17.86"', b'"-inf"')
114 assert parse_loudnorm(silent) is None
115
116
117def test_parse_loudnorm_reads_a_report_from_further_down_the_filter_chain() -> None:
118 """The marker carries the filter's position, which is not zero behind another filter."""
119 chained = FFMPEG_LOUDNORM_OUTPUT.replace(b"[Parsed_loudnorm_0 @", b"[Parsed_loudnorm_1 @")
120 assert parse_loudnorm(chained) == -17.86
121