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"
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
)
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,
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,
# 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,
)
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)
),
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"],
):
),
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"],
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,
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
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
--- /dev/null
+"""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
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,
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,
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()
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,
+ 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,
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,
):
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