Some fixes for unstable (HLS) radio streams (#1622)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 28 Aug 2024 10:27:58 +0000 (12:27 +0200)
committerGitHub <noreply@github.com>
Wed, 28 Aug 2024 10:27:58 +0000 (12:27 +0200)
music_assistant/constants.py
music_assistant/server/controllers/streams.py
music_assistant/server/helpers/audio.py

index 4732a768f35a4880f042c25923e0e84db35bfb0e..2a8418eef0ab766efd09a18026d9810275b8e098 100644 (file)
@@ -66,6 +66,8 @@ CONF_LANGUAGE: Final[str] = "language"
 CONF_SAMPLE_RATES: Final[str] = "sample_rates"
 CONF_HTTP_PROFILE: Final[str] = "http_profile"
 CONF_SYNC_LEADER: Final[str] = "sync_leader"
+CONF_BYPASS_NORMALIZATION_RADIO: Final[str] = "bypass_normalization_radio"
+CONF_BYPASS_NORMALIZATION_SHORT: Final[str] = "bypass_normalization_short"
 
 # config default values
 DEFAULT_HOST: Final[str] = "0.0.0.0"
index c39399a159b45828b73a045c27b42b04287257f9..1ab5e2c29b0102b54d531fb2b80bb16539c93500 100644 (file)
@@ -32,6 +32,8 @@ from music_assistant.constants import (
     ANNOUNCE_ALERT_FILE,
     CONF_BIND_IP,
     CONF_BIND_PORT,
+    CONF_BYPASS_NORMALIZATION_RADIO,
+    CONF_BYPASS_NORMALIZATION_SHORT,
     CONF_CROSSFADE,
     CONF_CROSSFADE_DURATION,
     CONF_HTTP_PROFILE,
@@ -80,6 +82,7 @@ DEFAULT_STREAM_HEADERS = {
 FLOW_DEFAULT_SAMPLE_RATE = 48000
 FLOW_DEFAULT_BIT_DEPTH = 24
 
+
 # pylint:disable=too-many-locals
 
 isfile = wrap(os.path.isfile)
@@ -164,6 +167,29 @@ class StreamsController(CoreController):
                 "not be adjusted in regular setups.",
                 category="advanced",
             ),
+            ConfigEntry(
+                key=CONF_BYPASS_NORMALIZATION_RADIO,
+                type=ConfigEntryType.BOOLEAN,
+                default_value=True,
+                label="Bypass volume normalization for radio streams",
+                description="Radio streams are often already normalized according "
+                "to the EBU standard, so it doesn't make a lot of sense to normalize them again "
+                "in Music Assistant unless you hear big jumps in volume during playback, "
+                "such as commercials.",
+                category="advanced",
+            ),
+            ConfigEntry(
+                key=CONF_BYPASS_NORMALIZATION_SHORT,
+                type=ConfigEntryType.BOOLEAN,
+                default_value=True,
+                label="Bypass volume normalization for effects and short sounds",
+                description="The volume normalizer of ffmpeg (used in Music Assistant), "
+                "is designed to work best with longer audio streams and can have troubles when "
+                "its applied to very short sound clips (< 60 seconds), "
+                "for example sound effects. With this option enabled, the volume normalizer "
+                "will be bypassed for all audio that has a duration of less than 60 seconds.",
+                category="advanced",
+            ),
         )
 
     async def setup(self, config: CoreConfig) -> None:
@@ -773,11 +799,6 @@ class StreamsController(CoreController):
             )
         elif streamdetails.stream_type == StreamType.ICY:
             audio_source = get_icy_stream(self.mass, streamdetails.path, streamdetails)
-            # pad some silence before the radio stream starts to create some headroom
-            # for radio stations that do not provide any look ahead buffer
-            # without this, some radio streams jitter a lot
-            async for chunk in get_silence(2, pcm_format):
-                yield chunk
         elif streamdetails.stream_type == StreamType.HLS:
             # we simply select the best quality substream here
             # if we ever want to support adaptive stream selection based on bandwidth
@@ -786,6 +807,10 @@ class StreamsController(CoreController):
             # the user wants the best quality possible at all times.
             substream = await get_hls_substream(self.mass, streamdetails.path)
             audio_source = substream.path
+            if streamdetails.media_type == MediaType.RADIO:
+                # ffmpeg sometimes has trouble with HLS radio streams stopping
+                # abruptly for no reason so this is a workaround to keep the stream alive
+                extra_input_args += ["-stream_loop", "-1"]
         elif streamdetails.stream_type == StreamType.ENCRYPTED_HTTP:
             audio_source = streamdetails.path
             extra_input_args += ["-decryption_key", streamdetails.decryption_key]
@@ -794,6 +819,13 @@ class StreamsController(CoreController):
             if streamdetails.seek_position:
                 extra_input_args += ["-ss", str(int(streamdetails.seek_position))]
 
+        if streamdetails.media_type == MediaType.RADIO:
+            # pad some silence before the radio stream starts to create some headroom
+            # for radio stations that do not provide any look ahead buffer
+            # without this, some radio streams jitter a lot
+            async for chunk in get_silence(2, pcm_format):
+                yield chunk
+
         logger.debug("start media stream for: %s", streamdetails.uri)
         bytes_sent = 0
         finished = False
index 3d7bb87e7894369636f711c4aa4f871d01f08fa4..63e3474fd72376e69d862fd12985ac553dda7b47 100644 (file)
@@ -33,6 +33,8 @@ from music_assistant.common.models.errors import (
 from music_assistant.common.models.media_items import AudioFormat, ContentType
 from music_assistant.common.models.streamdetails import LoudnessMeasurement, StreamDetails
 from music_assistant.constants import (
+    CONF_BYPASS_NORMALIZATION_RADIO,
+    CONF_BYPASS_NORMALIZATION_SHORT,
     CONF_EQ_BASS,
     CONF_EQ_MID,
     CONF_EQ_TREBLE,
@@ -382,15 +384,24 @@ async def get_stream_details(
     streamdetails.seek_position = seek_position
     streamdetails.fade_in = fade_in
     # handle volume normalization details
-    if not streamdetails.loudness:
+    is_radio = streamdetails.media_type == MediaType.RADIO or not streamdetails.duration
+    bypass_normalization = (
+        is_radio
+        and await mass.config.get_core_config_value("streams", CONF_BYPASS_NORMALIZATION_RADIO)
+    ) or (
+        streamdetails.duration is not None
+        and streamdetails.duration < 60
+        and await mass.config.get_core_config_value("streams", CONF_BYPASS_NORMALIZATION_SHORT)
+    )
+    if not bypass_normalization and not streamdetails.loudness:
         streamdetails.loudness = await mass.music.get_track_loudness(
             streamdetails.item_id, streamdetails.provider
         )
     player_settings = await mass.config.get_player_config(streamdetails.queue_id)
-    if player_settings.get_value(CONF_VOLUME_NORMALIZATION):
-        streamdetails.target_loudness = player_settings.get_value(CONF_VOLUME_NORMALIZATION_TARGET)
-    else:
+    if bypass_normalization or not player_settings.get_value(CONF_VOLUME_NORMALIZATION):
         streamdetails.target_loudness = None
+    else:
+        streamdetails.target_loudness = player_settings.get_value(CONF_VOLUME_NORMALIZATION_TARGET)
 
     if not streamdetails.duration:
         streamdetails.duration = queue_item.duration
@@ -897,8 +908,6 @@ def get_ffmpeg_args(
             "1",
             "-reconnect_streamed",
             "1",
-            "-reconnect_delay_max",
-            "10",
         ]
         if major_version > 4:
             # these options are only supported in ffmpeg > 5