From add6364052194382660df39275d5a2e58e44f158 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 28 Aug 2024 12:27:58 +0200 Subject: [PATCH] Some fixes for unstable (HLS) radio streams (#1622) --- music_assistant/constants.py | 2 + music_assistant/server/controllers/streams.py | 42 ++++++++++++++++--- music_assistant/server/helpers/audio.py | 21 +++++++--- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 4732a768..2a8418ee 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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" diff --git a/music_assistant/server/controllers/streams.py b/music_assistant/server/controllers/streams.py index c39399a1..1ab5e2c2 100644 --- a/music_assistant/server/controllers/streams.py +++ b/music_assistant/server/controllers/streams.py @@ -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 diff --git a/music_assistant/server/helpers/audio.py b/music_assistant/server/helpers/audio.py index 3d7bb87e..63e3474f 100644 --- a/music_assistant/server/helpers/audio.py +++ b/music_assistant/server/helpers/audio.py @@ -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 -- 2.34.1