Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Configurable DSP with Parametric Equalizer #1795

Merged
merged 8 commits into from
Dec 20, 2024
24 changes: 15 additions & 9 deletions music_assistant/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@
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"
Expand Down Expand Up @@ -199,34 +200,39 @@
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
)


Expand Down
69 changes: 69 additions & 0 deletions music_assistant/controllers/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
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,
Expand All @@ -32,6 +33,10 @@

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,
Expand Down Expand Up @@ -431,6 +436,70 @@ async def remove_player_config(self, player_id: str) -> None:
# 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,
Expand Down
10 changes: 10 additions & 0 deletions music_assistant/controllers/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,16 @@ async def on_player_config_change(self, config: PlayerConfig, changed_keys: set[
)
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)
Expand Down
6 changes: 4 additions & 2 deletions music_assistant/controllers/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ async def serve_queue_item_stream(self, request: web.Request) -> web.Response:
),
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"],
):
Expand Down Expand Up @@ -473,7 +473,9 @@ async def serve_queue_flow_stream(self, request: web.Request) -> web.Response:
),
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"],
Expand Down
43 changes: 27 additions & 16 deletions music_assistant/helpers/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@
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,
Expand All @@ -39,6 +36,7 @@
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
Expand Down Expand Up @@ -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


Expand Down
112 changes: 112 additions & 0 deletions music_assistant/helpers/dsp.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading