Add Configurable DSP with Parametric Equalizer (#1795)
authorMaxim Raznatovski <nda.mr43@gmail.com>
Fri, 20 Dec 2024 20:49:39 +0000 (21:49 +0100)
committerGitHub <noreply@github.com>
Fri, 20 Dec 2024 20:49:39 +0000 (21:49 +0100)
music_assistant/constants.py
music_assistant/controllers/config.py
music_assistant/controllers/players.py
music_assistant/controllers/streams.py
music_assistant/helpers/audio.py
music_assistant/helpers/dsp.py [new file with mode: 0644]
music_assistant/providers/airplay/provider.py
music_assistant/providers/airplay/raop.py
music_assistant/providers/slimproto/__init__.py
music_assistant/providers/snapcast/__init__.py

index ae8565aaaa06b815aaaac192b4a315e11212f948..70817afa2f9e14bf636532b93ef3454be6f9e310 100644 (file)
@@ -39,9 +39,10 @@ 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_EQ_BASS: Final[str] = "eq_bass"
-CONF_EQ_MID: Final[str] = "eq_mid"
-CONF_EQ_TREBLE: Final[str] = "eq_treble"
+CONF_DEPRECATED_EQ_BASS: Final[str] = "eq_bass"
+CONF_DEPRECATED_EQ_MID: Final[str] = "eq_mid"
+CONF_DEPRECATED_EQ_TREBLE: Final[str] = "eq_treble"
+CONF_PLAYER_DSP: Final[str] = "player_dsp"
 CONF_OUTPUT_CHANNELS: Final[str] = "output_channels"
 CONF_FLOW_MODE: Final[str] = "flow_mode"
 CONF_LOG_LEVEL: Final[str] = "log_level"
@@ -199,34 +200,39 @@ CONF_ENTRY_VOLUME_NORMALIZATION_TARGET = ConfigEntry(
     category="advanced",
 )
 
-CONF_ENTRY_EQ_BASS = ConfigEntry(
-    key=CONF_EQ_BASS,
+# 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(
+    key=CONF_DEPRECATED_EQ_BASS,
     type=ConfigEntryType.INTEGER,
     range=(-10, 10),
     default_value=0,
     label="Equalizer: bass",
     description="Use the builtin basic equalizer to adjust the bass of audio.",
     category="audio",
+    hidden=True,  # Hidden, use DSP instead
 )
 
-CONF_ENTRY_EQ_MID = ConfigEntry(
-    key=CONF_EQ_MID,
+CONF_ENTRY_DEPRECATED_EQ_MID = ConfigEntry(
+    key=CONF_DEPRECATED_EQ_MID,
     type=ConfigEntryType.INTEGER,
     range=(-10, 10),
     default_value=0,
     label="Equalizer: midrange",
     description="Use the builtin basic equalizer to adjust the midrange of audio.",
     category="audio",
+    hidden=True,  # Hidden, use DSP instead
 )
 
-CONF_ENTRY_EQ_TREBLE = ConfigEntry(
-    key=CONF_EQ_TREBLE,
+CONF_ENTRY_DEPRECATED_EQ_TREBLE = ConfigEntry(
+    key=CONF_DEPRECATED_EQ_TREBLE,
     type=ConfigEntryType.INTEGER,
     range=(-10, 10),
     default_value=0,
     label="Equalizer: treble",
     description="Use the builtin basic equalizer to adjust the treble of audio.",
     category="audio",
+    hidden=True,  # Hidden, use DSP instead
 )
 
 
index e46a623c52be4397d4efb0f7ac1579020939ce41..6ff7924c710fd74f152057e3ed7a9a0b74fbcab6 100644 (file)
@@ -21,6 +21,7 @@ from music_assistant_models.config_entries import (
     PlayerConfig,
     ProviderConfig,
 )
+from music_assistant_models.dsp import DSPConfig, ToneControlFilter
 from music_assistant_models.enums import EventType, ProviderFeature, ProviderType
 from music_assistant_models.errors import (
     ActionUnavailable,
@@ -32,6 +33,10 @@ from music_assistant_models.helpers import get_global_cache_value
 
 from music_assistant.constants import (
     CONF_CORE,
+    CONF_DEPRECATED_EQ_BASS,
+    CONF_DEPRECATED_EQ_MID,
+    CONF_DEPRECATED_EQ_TREBLE,
+    CONF_PLAYER_DSP,
     CONF_PLAYERS,
     CONF_PROVIDERS,
     CONF_SERVER_ID,
@@ -431,6 +436,70 @@ class ConfigController:
         # remove the actual config if all of the above passed
         self.remove(conf_key)
 
+    @api_command("config/players/dsp/get")
+    def get_player_dsp_config(self, player_id: str) -> DSPConfig:
+        """
+        Return the DSP Configuration for a player.
+
+        In case the player does not have a DSP configuration, a default one is returned.
+        """
+        if raw_conf := self.get(f"{CONF_PLAYER_DSP}/{player_id}"):
+            return DSPConfig.from_dict(raw_conf)
+        else:
+            # return default DSP config
+            dsp_config = DSPConfig()
+
+            deprecated_eq_bass = self.mass.config.get_raw_player_config_value(
+                player_id, CONF_DEPRECATED_EQ_BASS, 0
+            )
+            deprecated_eq_mid = self.mass.config.get_raw_player_config_value(
+                player_id, CONF_DEPRECATED_EQ_MID, 0
+            )
+            deprecated_eq_treble = self.mass.config.get_raw_player_config_value(
+                player_id, CONF_DEPRECATED_EQ_TREBLE, 0
+            )
+            if deprecated_eq_bass != 0 or deprecated_eq_mid != 0 or deprecated_eq_treble != 0:
+                # the user previously used the now deprecated EQ settings:
+                # add a tone control filter with the old values, reset the deprecated values and
+                # save this as the new DSP config
+                # TODO: remove this in a future release
+                dsp_config.filters.append(
+                    ToneControlFilter(
+                        enabled=True,
+                        bass_level=deprecated_eq_bass,
+                        mid_level=deprecated_eq_mid,
+                        treble_level=deprecated_eq_treble,
+                    )
+                )
+
+                deprecated_eq_keys = [
+                    CONF_DEPRECATED_EQ_BASS,
+                    CONF_DEPRECATED_EQ_MID,
+                    CONF_DEPRECATED_EQ_TREBLE,
+                ]
+                for key in deprecated_eq_keys:
+                    if self.mass.config.get_raw_player_config_value(player_id, key, 0) != 0:
+                        self.mass.config.set_raw_player_config_value(player_id, key, 0)
+
+                self.set(f"{CONF_PLAYER_DSP}/{player_id}", dsp_config.to_dict())
+
+            return dsp_config
+
+    @api_command("config/players/dsp/save")
+    async def save_dsp_config(self, player_id: str, config: DSPConfig) -> DSPConfig:
+        """
+        Save/update DSPConfig for a player.
+
+        This method will validate the config and apply it to the player.
+        """
+        # validate the new config
+        config.validate()
+
+        # Save and apply the new config to the player
+        self.set(f"{CONF_PLAYER_DSP}/{player_id}", config.to_dict())
+        await self.mass.players.on_player_dsp_change(player_id)
+        return config
+
     def create_default_player_config(
         self,
         player_id: str,
index f230fe62117d453e6d85f3056c8588be2d0c498b..0ee284f3664d025ce60fb762110ed29a345e7c8f 100644 (file)
@@ -1112,6 +1112,16 @@ class PlayerController(CoreController):
                 )
         player.enabled = config.enabled
 
+    async def on_player_dsp_change(self, player_id: str) -> None:
+        """Call (by config manager) when the DSP settings of a player change."""
+        # signal player provider that the config changed
+        if not (player := self.get(player_id)):
+            return
+        if player.state == PlayerState.PLAYING:
+            self.logger.info("Restarting playback of Player %s after DSP change", player_id)
+            # this will restart ffmpeg with the new settings
+            self.mass.call_later(0, self.mass.player_queues.resume, player.active_source)
+
     def _get_player_with_redirect(self, player_id: str) -> Player:
         """Get player with check if playback related command should be redirected."""
         player = self.get(player_id, True)
index cc7b9f8a613187f8e94b0c10f543c9900b3b58c8..40e14290fee5d3c251e58dc44d63ec3d465cfdb1 100644 (file)
@@ -383,7 +383,7 @@ class StreamsController(CoreController):
             ),
             input_format=pcm_format,
             output_format=output_format,
-            filter_params=get_player_filter_params(self.mass, queue_player.player_id),
+            filter_params=get_player_filter_params(self.mass, queue_player.player_id, pcm_format),
             # we don't allow the player to buffer too much ahead so we use readrate limiting
             extra_input_args=["-readrate", "1.1", "-readrate_initial_burst", "10"],
         ):
@@ -473,7 +473,9 @@ class StreamsController(CoreController):
             ),
             input_format=flow_pcm_format,
             output_format=output_format,
-            filter_params=get_player_filter_params(self.mass, queue_player.player_id),
+            filter_params=get_player_filter_params(
+                self.mass, queue_player.player_id, flow_pcm_format
+            ),
             chunk_size=icy_meta_interval if enable_icy else None,
             # we don't allow the player to buffer too much ahead so we use readrate limiting
             extra_input_args=["-readrate", "1.1", "-readrate_initial_burst", "10"],
index dad3c5db41fd316f83475947dc30eb44a4a27bf1..73aa7fbc6007741011156287bd850416e5fed011 100644 (file)
@@ -25,9 +25,6 @@ from music_assistant_models.helpers import set_global_cache_values
 from music_assistant_models.streamdetails import AudioFormat
 
 from music_assistant.constants import (
-    CONF_EQ_BASS,
-    CONF_EQ_MID,
-    CONF_EQ_TREBLE,
     CONF_OUTPUT_CHANNELS,
     CONF_VOLUME_NORMALIZATION,
     CONF_VOLUME_NORMALIZATION_RADIO,
@@ -39,6 +36,7 @@ from music_assistant.constants import (
 from music_assistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads
 from music_assistant.helpers.util import clean_stream_title
 
+from .dsp import filter_to_ffmpeg_params
 from .ffmpeg import FFMpeg, get_ffmpeg_stream
 from .playlists import IsHLSPlaylist, PlaylistItem, fetch_playlist, parse_m3u
 from .process import AsyncProcess, check_output, communicate
@@ -834,32 +832,45 @@ def get_chunksize(
 def get_player_filter_params(
     mass: MusicAssistant,
     player_id: str,
+    input_format: AudioFormat,
 ) -> list[str]:
     """Get player specific filter parameters for ffmpeg (if any)."""
-    # collect all players-specific filter args
-    # TODO: add convolution/DSP/roomcorrections here?!
     filter_params = []
 
-    # the below is a very basic 3-band equalizer,
-    # this could be a lot more sophisticated at some point
-    if (eq_bass := mass.config.get_raw_player_config_value(player_id, CONF_EQ_BASS, 0)) != 0:
-        filter_params.append(f"equalizer=frequency=100:width=200:width_type=h:gain={eq_bass}")
-    if (eq_mid := mass.config.get_raw_player_config_value(player_id, CONF_EQ_MID, 0)) != 0:
-        filter_params.append(f"equalizer=frequency=900:width=1800:width_type=h:gain={eq_mid}")
-    if (eq_treble := mass.config.get_raw_player_config_value(player_id, CONF_EQ_TREBLE, 0)) != 0:
-        filter_params.append(f"equalizer=frequency=9000:width=18000:width_type=h:gain={eq_treble}")
-    # handle output mixing only left or right
+    dsp = mass.config.get_player_dsp_config(player_id)
+
+    if dsp.enabled:
+        # Apply input gain
+        if dsp.input_gain != 0:
+            filter_params.append(f"volume={dsp.input_gain}dB")
+
+        # Process each DSP filter sequentially
+        for f in dsp.filters:
+            if not f.enabled:
+                continue
+
+            # Apply filter
+            filter_params.extend(filter_to_ffmpeg_params(f, input_format))
+
+        # Apply output gain
+        if dsp.output_gain != 0:
+            filter_params.append(f"volume={dsp.output_gain}dB")
+
     conf_channels = mass.config.get_raw_player_config_value(
         player_id, CONF_OUTPUT_CHANNELS, "stereo"
     )
+
+    # handle output mixing only left or right
     if conf_channels == "left":
         filter_params.append("pan=mono|c0=FL")
     elif conf_channels == "right":
         filter_params.append("pan=mono|c0=FR")
 
-    # add a peak limiter at the end of the filter chain
-    filter_params.append("alimiter=limit=-2dB:level=false:asc=true")
+    # Add safety limiter at the end, if not explicitly disabled
+    if not dsp.enabled or dsp.output_limiter:
+        filter_params.append("alimiter=limit=-2dB:level=false:asc=true")
 
+    LOGGER.debug("Generated ffmpeg params for player %s: %s", player_id, filter_params)
     return filter_params
 
 
diff --git a/music_assistant/helpers/dsp.py b/music_assistant/helpers/dsp.py
new file mode 100644 (file)
index 0000000..457825f
--- /dev/null
@@ -0,0 +1,112 @@
+"""Helper functions for DSP filters."""
+
+import math
+
+from music_assistant_models.dsp import (
+    DSPFilter,
+    ParametricEQBandType,
+    ParametricEQFilter,
+    ToneControlFilter,
+)
+from music_assistant_models.streamdetails import AudioFormat
+
+# ruff: noqa: PLR0915
+
+
+def filter_to_ffmpeg_params(dsp_filter: DSPFilter, input_format: AudioFormat) -> list[str]:
+    """Convert a DSP filter model to FFmpeg filter parameters.
+
+    Args:
+        dsp_filter: DSP filter configuration (ParametricEQ or ToneControl)
+        input_format: Audio format containing sample rate
+
+    Returns:
+        List of FFmpeg filter parameter strings
+    """
+    filter_params = []
+
+    if isinstance(dsp_filter, ParametricEQFilter):
+        for b in dsp_filter.bands:
+            if not b.enabled:
+                continue
+            # From https://webaudio.github.io/Audio-EQ-Cookbook/audio-eq-cookbook.html
+
+            f_s = input_format.sample_rate
+            f_0 = b.frequency
+            db_gain = b.gain
+            q = b.q
+
+            a = math.sqrt(10 ** (db_gain / 20))
+            w_0 = 2 * math.pi * f_0 / f_s
+            alpha = math.sin(w_0) / (2 * q)
+
+            if b.type == ParametricEQBandType.PEAK:
+                b0 = 1 + alpha * a
+                b1 = -2 * math.cos(w_0)
+                b2 = 1 - alpha * a
+                a0 = 1 + alpha / a
+                a1 = -2 * math.cos(w_0)
+                a2 = 1 - alpha / a
+
+                filter_params.append(f"biquad=b0={b0}:b1={b1}:b2={b2}:a0={a0}:a1={a1}:a2={a2}")
+            elif b.type == ParametricEQBandType.LOW_SHELF:
+                b0 = a * ((a + 1) - (a - 1) * math.cos(w_0) + 2 * math.sqrt(a) * alpha)
+                b1 = 2 * a * ((a - 1) - (a + 1) * math.cos(w_0))
+                b2 = a * ((a + 1) - (a - 1) * math.cos(w_0) - 2 * math.sqrt(a) * alpha)
+                a0 = (a + 1) + (a - 1) * math.cos(w_0) + 2 * math.sqrt(a) * alpha
+                a1 = -2 * ((a - 1) + (a + 1) * math.cos(w_0))
+                a2 = (a + 1) + (a - 1) * math.cos(w_0) - 2 * math.sqrt(a) * alpha
+
+                filter_params.append(f"biquad=b0={b0}:b1={b1}:b2={b2}:a0={a0}:a1={a1}:a2={a2}")
+            elif b.type == ParametricEQBandType.HIGH_SHELF:
+                b0 = a * ((a + 1) + (a - 1) * math.cos(w_0) + 2 * math.sqrt(a) * alpha)
+                b1 = -2 * a * ((a - 1) + (a + 1) * math.cos(w_0))
+                b2 = a * ((a + 1) + (a - 1) * math.cos(w_0) - 2 * math.sqrt(a) * alpha)
+                a0 = (a + 1) - (a - 1) * math.cos(w_0) + 2 * math.sqrt(a) * alpha
+                a1 = 2 * ((a - 1) - (a + 1) * math.cos(w_0))
+                a2 = (a + 1) - (a - 1) * math.cos(w_0) - 2 * math.sqrt(a) * alpha
+
+                filter_params.append(f"biquad=b0={b0}:b1={b1}:b2={b2}:a0={a0}:a1={a1}:a2={a2}")
+            elif b.type == ParametricEQBandType.HIGH_PASS:
+                b0 = (1 + math.cos(w_0)) / 2
+                b1 = -(1 + math.cos(w_0))
+                b2 = (1 + math.cos(w_0)) / 2
+                a0 = 1 + alpha
+                a1 = -2 * math.cos(w_0)
+                a2 = 1 - alpha
+
+                filter_params.append(f"biquad=b0={b0}:b1={b1}:b2={b2}:a0={a0}:a1={a1}:a2={a2}")
+            elif b.type == ParametricEQBandType.LOW_PASS:
+                b0 = (1 - math.cos(w_0)) / 2
+                b1 = 1 - math.cos(w_0)
+                b2 = (1 - math.cos(w_0)) / 2
+                a0 = 1 + alpha
+                a1 = -2 * math.cos(w_0)
+                a2 = 1 - alpha
+
+                filter_params.append(f"biquad=b0={b0}:b1={b1}:b2={b2}:a0={a0}:a1={a1}:a2={a2}")
+            elif b.type == ParametricEQBandType.NOTCH:
+                b0 = 1
+                b1 = -2 * math.cos(w_0)
+                b2 = 1
+                a0 = 1 + alpha
+                a1 = -2 * math.cos(w_0)
+                a2 = 1 - alpha
+
+                filter_params.append(f"biquad=b0={b0}:b1={b1}:b2={b2}:a0={a0}:a1={a1}:a2={a2}")
+    if isinstance(dsp_filter, ToneControlFilter):
+        # A basic 3-band equalizer
+        if dsp_filter.bass_level != 0:
+            filter_params.append(
+                f"equalizer=frequency=100:width=200:width_type=h:gain={dsp_filter.bass_level}"
+            )
+        if dsp_filter.mid_level != 0:
+            filter_params.append(
+                f"equalizer=frequency=900:width=1800:width_type=h:gain={dsp_filter.mid_level}"
+            )
+        if dsp_filter.treble_level != 0:
+            filter_params.append(
+                f"equalizer=frequency=9000:width=18000:width_type=h:gain={dsp_filter.treble_level}"
+            )
+
+    return filter_params
index b1beb7af7f1b8f86b7a439181f59c06651d6c1c5..9fcfefd26ab8985e75ef2a0d3cadf6b7eaafab8b 100644 (file)
@@ -28,9 +28,9 @@ from zeroconf.asyncio import AsyncServiceInfo
 from music_assistant.constants import (
     CONF_ENTRY_CROSSFADE,
     CONF_ENTRY_CROSSFADE_DURATION,
-    CONF_ENTRY_EQ_BASS,
-    CONF_ENTRY_EQ_MID,
-    CONF_ENTRY_EQ_TREBLE,
+    CONF_ENTRY_DEPRECATED_EQ_BASS,
+    CONF_ENTRY_DEPRECATED_EQ_MID,
+    CONF_ENTRY_DEPRECATED_EQ_TREBLE,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_OUTPUT_CHANNELS,
     CONF_ENTRY_SYNC_ADJUST,
@@ -69,9 +69,9 @@ PLAYER_CONFIG_ENTRIES = (
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_CROSSFADE,
     CONF_ENTRY_CROSSFADE_DURATION,
-    CONF_ENTRY_EQ_BASS,
-    CONF_ENTRY_EQ_MID,
-    CONF_ENTRY_EQ_TREBLE,
+    CONF_ENTRY_DEPRECATED_EQ_BASS,
+    CONF_ENTRY_DEPRECATED_EQ_MID,
+    CONF_ENTRY_DEPRECATED_EQ_TREBLE,
     CONF_ENTRY_OUTPUT_CHANNELS,
     ConfigEntry(
         key=CONF_ENCRYPTION,
index e1b74942bbaec05e2ac329de4184a7203f2dbe1d..98841c263f9ee84893ef8776ef12a26e9904cde9 100644 (file)
@@ -203,7 +203,7 @@ class RaopStream:
             audio_input="-",
             input_format=self.session.input_format,
             output_format=AIRPLAY_PCM_FORMAT,
-            filter_params=get_player_filter_params(self.mass, player_id),
+            filter_params=get_player_filter_params(self.mass, player_id, self.session.input_format),
             audio_output=write,
         )
         await self._ffmpeg_proc.start()
index edf7fa0e877da81f9f334f0f5eb5d86ea122f72b..234578e770aa6432b4fb631d82e762a4b9d0f981 100644 (file)
@@ -45,10 +45,10 @@ from music_assistant.constants import (
     CONF_ENFORCE_MP3,
     CONF_ENTRY_CROSSFADE,
     CONF_ENTRY_CROSSFADE_DURATION,
+    CONF_ENTRY_DEPRECATED_EQ_BASS,
+    CONF_ENTRY_DEPRECATED_EQ_MID,
+    CONF_ENTRY_DEPRECATED_EQ_TREBLE,
     CONF_ENTRY_ENFORCE_MP3,
-    CONF_ENTRY_EQ_BASS,
-    CONF_ENTRY_EQ_MID,
-    CONF_ENTRY_EQ_TREBLE,
     CONF_ENTRY_HTTP_PROFILE_FORCED_2,
     CONF_ENTRY_OUTPUT_CHANNELS,
     CONF_ENTRY_SYNC_ADJUST,
@@ -305,9 +305,9 @@ class SlimprotoProvider(PlayerProvider):
             + preset_entries
             + (
                 CONF_ENTRY_CROSSFADE,
-                CONF_ENTRY_EQ_BASS,
-                CONF_ENTRY_EQ_MID,
-                CONF_ENTRY_EQ_TREBLE,
+                CONF_ENTRY_DEPRECATED_EQ_BASS,
+                CONF_ENTRY_DEPRECATED_EQ_MID,
+                CONF_ENTRY_DEPRECATED_EQ_TREBLE,
                 CONF_ENTRY_OUTPUT_CHANNELS,
                 CONF_ENTRY_CROSSFADE_DURATION,
                 CONF_ENTRY_ENFORCE_MP3,
@@ -962,7 +962,7 @@ class SlimprotoProvider(PlayerProvider):
 
         async for chunk in stream.get_stream(
             output_format=AudioFormat(content_type=ContentType.try_parse(fmt)),
-            filter_params=get_player_filter_params(self.mass, child_player_id)
+            filter_params=get_player_filter_params(self.mass, child_player_id, stream.audio_format)
             if child_player_id
             else None,
         ):
index b44371af1b9fa8f9dc62e227adf54709d231e778..4991436d20933ffc516de3886adcb15ac4726953 100644 (file)
@@ -541,7 +541,7 @@ class SnapCastProvider(PlayerProvider):
                     audio_input=audio_source,
                     input_format=input_format,
                     output_format=DEFAULT_SNAPCAST_FORMAT,
-                    filter_params=get_player_filter_params(self.mass, player_id),
+                    filter_params=get_player_filter_params(self.mass, player_id, input_format),
                     audio_output=stream_path,
                 ) as ffmpeg_proc:
                     player.state = PlayerState.PLAYING