Feat: Move the output limiter option from DSP to Player Settings (#1981)
authorMaxim Raznatovski <nda.mr43@gmail.com>
Tue, 4 Mar 2025 21:03:29 +0000 (22:03 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 5 Mar 2025 20:51:59 +0000 (21:51 +0100)
* feat: add output limiter option to the player settings

* feat: use the `OUTPUT_LIMITER` option to determine if the limiter should be enabled

* feat: add migration logic

* fix: use default value from config key

* fix: move migration logic to _migrate

* refactor: delete migrated `output_limiter` key

music_assistant/constants.py
music_assistant/controllers/config.py
music_assistant/helpers/audio.py

index ebc24a6026714e5a7fa112e842acd5657467611a..e05ffea13613cb016b89b54664d2a2f0634d9d21 100644 (file)
@@ -47,6 +47,7 @@ CONF_USERNAME: Final[str] = "username"
 CONF_PASSWORD: Final[str] = "password"
 CONF_VOLUME_NORMALIZATION: Final[str] = "volume_normalization"
 CONF_VOLUME_NORMALIZATION_TARGET: Final[str] = "volume_normalization_target"
+CONF_OUTPUT_LIMITER: Final[str] = "output_limiter"
 CONF_DEPRECATED_EQ_BASS: Final[str] = "eq_bass"
 CONF_DEPRECATED_EQ_MID: Final[str] = "eq_mid"
 CONF_DEPRECATED_EQ_TREBLE: Final[str] = "eq_treble"
@@ -221,6 +222,15 @@ CONF_ENTRY_VOLUME_NORMALIZATION_TARGET = ConfigEntry(
     category="advanced",
 )
 
+CONF_ENTRY_OUTPUT_LIMITER = ConfigEntry(
+    key=CONF_OUTPUT_LIMITER,
+    type=ConfigEntryType.BOOLEAN,
+    label="Enable limiting to prevent clipping",
+    default_value=True,
+    description="Activates a limiter that prevents audio distortion by making loud peaks quieter.",
+    category="audio",
+)
+
 # These EQ Options are deprecated and will be removed in the future
 # To allow for automatic migration to the new DSP system, they are still included in the config
 CONF_ENTRY_DEPRECATED_EQ_BASS = ConfigEntry(
@@ -575,6 +585,7 @@ BASE_PLAYER_CONFIG_ENTRIES = (
     CONF_ENTRY_PLAYER_ICON,
     CONF_ENTRY_FLOW_MODE,
     CONF_ENTRY_VOLUME_NORMALIZATION,
+    CONF_ENTRY_OUTPUT_LIMITER,
     CONF_ENTRY_AUTO_PLAY,
     CONF_ENTRY_VOLUME_NORMALIZATION_TARGET,
     CONF_ENTRY_HIDE_PLAYER,
index 63e37d0050517f6d470b77f7d5979760f4d625a3..6cbee71ea8ccd8a6bcd8d31e8207cc5eaa244316 100644 (file)
@@ -38,6 +38,7 @@ from music_assistant.constants import (
     CONF_DEPRECATED_EQ_MID,
     CONF_DEPRECATED_EQ_TREBLE,
     CONF_ONBOARD_DONE,
+    CONF_OUTPUT_LIMITER,
     CONF_PLAYER_DSP,
     CONF_PLAYERS,
     CONF_PROVIDERS,
@@ -822,6 +823,25 @@ class ConfigController:
                 for x in sample_rates
             ]
             changed = True
+        # migrate DSPConfig.output_limiter
+        for player_id, dsp_config in list(self._data.get(CONF_PLAYER_DSP, {}).items()):
+            output_limiter = dsp_config.get("output_limiter")
+            enabled = dsp_config.get("enabled")
+            if output_limiter is None or enabled is None or output_limiter:
+                continue
+
+            if enabled:
+                # The DSP is enabled, and the user disabled the output limiter in a prior version
+                # Migrate the output limiter option to the player config
+                if (players := self._data.get(f"{CONF_PLAYERS}")) and (
+                    player := players.get(player_id)
+                ):
+                    player["values"][CONF_OUTPUT_LIMITER] = False
+            # Delete the old option, so this migration logic will never be called
+            # anymore for this player.
+            del dsp_config["output_limiter"]
+            changed = True
+
         # set 'onboard_done' flag if we have any (non default) provider configs
         if not self._data.get(CONF_ONBOARD_DONE):
             default_providers = {x.domain for x in self.mass.get_provider_manifests() if x.builtin}
index 8b7176f72b323679f7a74d72f44553adc067dbfa..d078f6501e2fb3ad4b3ef70a5a2f9b9fe461679e 100644 (file)
@@ -35,6 +35,7 @@ from music_assistant_models.helpers import get_global_cache_value, set_global_ca
 from music_assistant_models.streamdetails import AudioFormat
 
 from music_assistant.constants import (
+    CONF_ENTRY_OUTPUT_LIMITER,
     CONF_OUTPUT_CHANNELS,
     CONF_VOLUME_NORMALIZATION,
     CONF_VOLUME_NORMALIZATION_RADIO,
@@ -322,12 +323,13 @@ def get_player_dsp_details(
     # remove disabled filters
     dsp_config.filters = [x for x in dsp_config.filters if x.enabled]
 
+    output_limiter = is_output_limiter_enabled(mass, player)
     return DSPDetails(
         state=dsp_state,
         input_gain=dsp_config.input_gain,
         filters=dsp_config.filters,
         output_gain=dsp_config.output_gain,
-        output_limiter=dsp_config.output_limiter,
+        output_limiter=output_limiter,
         output_format=player.output_format,
     )
 
@@ -1145,6 +1147,27 @@ def is_grouping_preventing_dsp(player: Player) -> bool:
     return is_multiple_devices and not multi_device_dsp_supported
 
 
+def is_output_limiter_enabled(mass: MusicAssistant, player: Player) -> bool:
+    """Check if the player has the output limiter enabled.
+
+    Unlike DSP, the limiter is still configurable when synchronized without MULTI_DEVICE_DSP.
+    So in grouped scenarios without MULTI_DEVICE_DSP, the permanent sync group or the leader gets
+    decides if the limiter should be turned on or not.
+    """
+    deciding_player_id = player.player_id
+    if player.active_group:
+        # Syncgroup, get from the group player
+        deciding_player_id = player.active_group
+    elif player.synced_to:
+        # Not in sync group, but synced, get from the leader
+        deciding_player_id = player.synced_to
+    return mass.config.get_raw_player_config_value(
+        deciding_player_id,
+        CONF_ENTRY_OUTPUT_LIMITER.key,
+        CONF_ENTRY_OUTPUT_LIMITER.default_value,
+    )
+
+
 def get_player_filter_params(
     mass: MusicAssistant,
     player_id: str,
@@ -1155,6 +1178,7 @@ def get_player_filter_params(
     filter_params = []
 
     dsp = mass.config.get_player_dsp_config(player_id)
+    limiter_enabled = True
 
     if player := mass.players.get(player_id):
         if is_grouping_preventing_dsp(player):
@@ -1180,6 +1204,8 @@ def get_player_filter_params(
         # later be able to show this to the user in the UI.
         player.output_format = output_format
 
+        limiter_enabled = is_output_limiter_enabled(mass, player)
+
     if dsp.enabled:
         # Apply input gain
         if dsp.input_gain != 0:
@@ -1207,8 +1233,8 @@ def get_player_filter_params(
     elif conf_channels == "right":
         filter_params.append("pan=mono|c0=FR")
 
-    # Add safety limiter at the end, if not explicitly disabled
-    if not dsp.enabled or dsp.output_limiter:
+    # Add safety limiter at the end
+    if limiter_enabled:
         filter_params.append("alimiter=limit=-2dB:level=false:asc=true")
 
     LOGGER.debug("Generated ffmpeg params for player %s: %s", player_id, filter_params)