From 87ce2dc7e38820ff43247cd6537afca0e523951e Mon Sep 17 00:00:00 2001 From: Maxim Raznatovski Date: Fri, 20 Dec 2024 21:49:39 +0100 Subject: [PATCH] Add Configurable DSP with Parametric Equalizer (#1795) --- music_assistant/constants.py | 24 ++-- music_assistant/controllers/config.py | 69 +++++++++++ music_assistant/controllers/players.py | 10 ++ music_assistant/controllers/streams.py | 6 +- music_assistant/helpers/audio.py | 43 ++++--- music_assistant/helpers/dsp.py | 112 ++++++++++++++++++ music_assistant/providers/airplay/provider.py | 12 +- music_assistant/providers/airplay/raop.py | 2 +- .../providers/slimproto/__init__.py | 14 +-- .../providers/snapcast/__init__.py | 2 +- 10 files changed, 252 insertions(+), 42 deletions(-) create mode 100644 music_assistant/helpers/dsp.py diff --git a/music_assistant/constants.py b/music_assistant/constants.py index ae8565aa..70817afa 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -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 ) diff --git a/music_assistant/controllers/config.py b/music_assistant/controllers/config.py index e46a623c..6ff7924c 100644 --- a/music_assistant/controllers/config.py +++ b/music_assistant/controllers/config.py @@ -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, diff --git a/music_assistant/controllers/players.py b/music_assistant/controllers/players.py index f230fe62..0ee284f3 100644 --- a/music_assistant/controllers/players.py +++ b/music_assistant/controllers/players.py @@ -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) diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index cc7b9f8a..40e14290 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -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"], diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index dad3c5db..73aa7fbc 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -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 index 00000000..457825f1 --- /dev/null +++ b/music_assistant/helpers/dsp.py @@ -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 diff --git a/music_assistant/providers/airplay/provider.py b/music_assistant/providers/airplay/provider.py index b1beb7af..9fcfefd2 100644 --- a/music_assistant/providers/airplay/provider.py +++ b/music_assistant/providers/airplay/provider.py @@ -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, diff --git a/music_assistant/providers/airplay/raop.py b/music_assistant/providers/airplay/raop.py index e1b74942..98841c26 100644 --- a/music_assistant/providers/airplay/raop.py +++ b/music_assistant/providers/airplay/raop.py @@ -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() diff --git a/music_assistant/providers/slimproto/__init__.py b/music_assistant/providers/slimproto/__init__.py index edf7fa0e..234578e7 100644 --- a/music_assistant/providers/slimproto/__init__.py +++ b/music_assistant/providers/slimproto/__init__.py @@ -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, ): diff --git a/music_assistant/providers/snapcast/__init__.py b/music_assistant/providers/snapcast/__init__.py index b44371af..4991436d 100644 --- a/music_assistant/providers/snapcast/__init__.py +++ b/music_assistant/providers/snapcast/__init__.py @@ -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 -- 2.34.1