A few small fixes and optimizations to playback (#1232)
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 18 Apr 2024 08:50:29 +0000 (10:50 +0200)
committerGitHub <noreply@github.com>
Thu, 18 Apr 2024 08:50:29 +0000 (10:50 +0200)
music_assistant/server/controllers/music.py
music_assistant/server/controllers/streams.py
music_assistant/server/helpers/audio.py
music_assistant/server/providers/filesystem_local/base.py
music_assistant/server/providers/slimproto/__init__.py
music_assistant/server/providers/ugp/__init__.py

index 02d3da99b772f481353b22bf4f387ef611b86778..c31ad3c11365efb3e625508e841f43cefd2acef8 100644 (file)
@@ -552,6 +552,7 @@ class MusicController(CoreController):
                     true_peak=result["true_peak"],
                     lra=result["lra"],
                     threshold=result["threshold"],
+                    target_offset=result["target_offset"],
                 )
         return None
 
index e28a2c31176d4a64b17bd14cd82c202deefb8329..cdfdaf34c36b84ba52dc49f95813c6075ca78a5d 100644 (file)
@@ -295,15 +295,10 @@ class StreamsController(CoreController):
             input_format=pcm_format,
             output_format=output_format,
             filter_params=get_player_filter_params(self.mass, queue_player.player_id),
-            extra_input_args=[
-                # use readrate to limit buffering ahead too much
-                "-readrate",
-                "1.2",
-            ],
         ):
             try:
                 await resp.write(chunk)
-            except (BrokenPipeError, ConnectionResetError):
+            except (BrokenPipeError, ConnectionResetError, ConnectionError):
                 break
         if queue.stream_finished is not None:
             queue.stream_finished = True
@@ -372,15 +367,10 @@ class StreamsController(CoreController):
             output_format=output_format,
             filter_params=get_player_filter_params(self.mass, queue_player.player_id),
             chunk_size=icy_meta_interval if enable_icy else None,
-            extra_input_args=[
-                # use readrate to limit buffering ahead too much
-                "-readrate",
-                "1.2",
-            ],
         ):
             try:
                 await resp.write(chunk)
-            except (BrokenPipeError, ConnectionResetError):
+            except (BrokenPipeError, ConnectionResetError, ConnectionError):
                 # race condition
                 break
 
@@ -711,17 +701,32 @@ class StreamsController(CoreController):
         # collect all arguments for ffmpeg
         filter_params = []
         extra_input_args = []
+        # add loudnorm filter: volume normalization
+        # more info: https://k.ylo.ph/2016/04/04/loudnorm.html
         if streamdetails.target_loudness is not None:
-            # add loudnorm filters
-            filter_rule = f"loudnorm=I={streamdetails.target_loudness}:TP=-1.5:LRA=11"
             if streamdetails.loudness:
+                # we have a measurement so we can do linear mode
+                target_loudness = streamdetails.target_loudness
+                # we must ensure that target loudness does not exceed the measured value
+                # otherwise ffmpeg falls back to dynamic again
+                # https://github.com/slhck/ffmpeg-normalize/issues/251
+                target_loudness = min(
+                    streamdetails.target_loudness,
+                    streamdetails.loudness.integrated + streamdetails.loudness.lra - 1,
+                )
+                filter_rule = f"loudnorm=I={target_loudness}:TP=-2.0:LRA=7.0:linear=true"
                 filter_rule += f":measured_I={streamdetails.loudness.integrated}"
                 filter_rule += f":measured_LRA={streamdetails.loudness.lra}"
                 filter_rule += f":measured_tp={streamdetails.loudness.true_peak}"
                 filter_rule += f":measured_thresh={streamdetails.loudness.threshold}"
                 if streamdetails.loudness.target_offset is not None:
                     filter_rule += f":offset={streamdetails.loudness.target_offset}"
-                filter_rule += ":linear=true"
+            else:
+                # if we have no measurement, we use dynamic mode
+                # which also collects the measurement on the fly during playback
+                filter_rule = (
+                    f"loudnorm=I={streamdetails.target_loudness}:TP=-2.0:LRA=7.0:offset=0.0"
+                )
             filter_rule += ":print_format=json"
             filter_params.append(filter_rule)
         if streamdetails.fade_in:
@@ -751,13 +756,7 @@ class StreamsController(CoreController):
                 input_format=streamdetails.audio_format,
                 output_format=pcm_format,
                 filter_params=filter_params,
-                extra_input_args=[
-                    *extra_input_args,
-                    # we criple ffmpeg a bit on purpose with the filter_threads
-                    # option so it doesn't consume all cpu when calculating loudnorm
-                    "-filter_threads",
-                    "2",
-                ],
+                extra_input_args=extra_input_args,
                 collect_log_history=True,
                 logger=logger,
             ) as ffmpeg_proc:
index 107357117733fddcb9f8103153493b498de1ee27..c8e0cabf08a15cd296dc1f95556e4599547dee1c 100644 (file)
@@ -1039,18 +1039,22 @@ def get_ffmpeg_args(
     # determine if we need to do resampling
     if (
         input_format.sample_rate != output_format.sample_rate
-        or input_format.bit_depth != output_format.bit_depth
+        or input_format.bit_depth > output_format.bit_depth
     ):
         # prefer resampling with libsoxr due to its high quality
         if libsoxr_support:
-            resample_filter = "aresample=resampler=soxr:precision=28"
+            resample_filter = "aresample=resampler=soxr:precision=30"
         else:
             resample_filter = "aresample=resampler=swr"
-        if output_format.bit_depth < input_format.bit_depth:
-            # apply dithering when going down to 16 bits
-            resample_filter += ":osf=s16:dither_method=triangular_hp"
+
+        # sample rate conversion
         if input_format.sample_rate != output_format.sample_rate:
             resample_filter += f":osr={output_format.sample_rate}"
+
+        # bit depth conversion: apply dithering when going down to 16 bits
+        if output_format.bit_depth < input_format.bit_depth:
+            resample_filter += ":osf=s16:dither_method=triangular_hp"
+
         filter_params.append(resample_filter)
 
     if filter_params and "-filter_complex" not in extra_args:
index 6b6f491039e0641f0f80d121f87add9ee06b4919..e289c5da20d52f34d51bf2e068b316516cdd0a40 100644 (file)
@@ -614,8 +614,11 @@ class FileSystemProviderBase(MusicProvider):
             item_id, self.instance_id
         )
         if library_item is None:
-            msg = f"Item not found: {item_id}"
-            raise MediaNotFoundError(msg)
+            # this could be a file that has just been added, try parsing it
+            file_item = await self.resolve(item_id)
+            if not (library_item := await self._parse_track(file_item)):
+                msg = f"Item not found: {item_id}"
+                raise MediaNotFoundError(msg)
 
         prov_mapping = next(x for x in library_item.provider_mappings if x.item_id == item_id)
         file_item = await self.resolve(item_id)
index e15fc6999bf84034036262244ffe03c34a6f7b1f..55defd87de3b0a36a94f64eb7a6d160b7235ca9a 100644 (file)
@@ -466,7 +466,6 @@ class SlimprotoProvider(PlayerProvider):
         queue = self.mass.player_queues.get(media.queue_id or player_id)
         slimplayer.extra_data["playlist repeat"] = REPEATMODE_MAP[queue.repeat_mode]
         slimplayer.extra_data["playlist shuffle"] = int(queue.shuffle_enabled)
-        # slimplayer.extra_data["can_seek"] = 1 if queue_item else 0
         await slimplayer.play_url(
             url=url,
             mime_type=f"audio/{url.split('.')[-1].split('?')[0]}",
@@ -480,6 +479,24 @@ class SlimprotoProvider(PlayerProvider):
             # to coordinate a start of multiple synced players
             autostart=auto_play,
         )
+        # if queue is set to single track repeat,
+        # immediately set this track as the next
+        # this prevents race conditions with super short audio clips (on single repeat)
+        # https://github.com/music-assistant/hass-music-assistant/issues/2059
+        if queue.repeat_mode == RepeatMode.ONE:
+            self.mass.call_later(
+                0.2,
+                slimplayer.play_url(
+                    url=url,
+                    mime_type=f"audio/{url.split('.')[-1].split('?')[0]}",
+                    metadata=metadata,
+                    enqueue=True,
+                    send_flush=False,
+                    transition=SlimTransition.CROSSFADE if crossfade else SlimTransition.NONE,
+                    transition_duration=transition_duration,
+                    autostart=True,
+                ),
+            )
 
     async def cmd_pause(self, player_id: str) -> None:
         """Send PAUSE command to given player."""
index 2dc45bb01f062039d810ef8639f8adecc1738e71..f9ecb21471aaeff9c9c5069b9764355af1d8c762 100644 (file)
@@ -354,7 +354,7 @@ class UniversalGroupProvider(PlayerProvider):
             raise web.HTTPNotFound(reason=f"Unknown UGP player: {ugp_player_id}")
 
         if not (stream := self.streams.get(ugp_player_id, None)) or stream.done:
-            raise web.HTTPNotFound(f"There is no active UGP stream for {ugp_player_id}!")
+            raise web.HTTPNotFound(body=f"There is no active UGP stream for {ugp_player_id}!")
 
         resp = web.StreamResponse(
             status=200,