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

Volume normalization improvements #1657

Merged
merged 7 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion music_assistant/common/models/config_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ class CoreConfig(Config):
ConfigValueOption("Profile 2 - full info (including image)", "full"),
),
depends_on=CONF_FLOW_MODE,
default_value="basic",
default_value="disabled",
label="Try to ingest metadata into stream (ICY)",
category="advanced",
description="Try to ingest metadata into the stream (ICY) to show track info on the player, "
Expand Down
18 changes: 5 additions & 13 deletions music_assistant/common/models/streamdetails.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,6 @@
from music_assistant.common.models.media_items import AudioFormat


@dataclass(kw_only=True)
class LoudnessMeasurement(DataClassDictMixin):
"""Model for EBU-R128 loudness measurement details."""

integrated: float
true_peak: float
lra: float
threshold: float
target_offset: float | None = None


@dataclass(kw_only=True)
class StreamDetails(DataClassDictMixin):
"""Model for streamdetails."""
Expand Down Expand Up @@ -55,11 +44,14 @@ class StreamDetails(DataClassDictMixin):
# the fields below will be set/controlled by the streamcontroller
seek_position: int = 0
fade_in: bool = False
loudness: LoudnessMeasurement | None = None
enable_volume_normalization: bool = False
loudness: float | None = None
loudness_album: float | None = None
prefer_album_loudness: bool = False
force_dynamic_volume_normalization: bool = False
queue_id: str | None = None
seconds_streamed: float | None = None
target_loudness: float | None = None
bypass_loudness_normalization: bool = False
strip_silence_begin: bool = False
strip_silence_end: bool = False
stream_error: bool | None = None
Expand Down
4 changes: 2 additions & 2 deletions music_assistant/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,16 @@
CONF_HTTP_PROFILE: Final[str] = "http_profile"
CONF_SYNC_LEADER: Final[str] = "sync_leader"
CONF_BYPASS_NORMALIZATION_RADIO: Final[str] = "bypass_normalization_radio"
CONF_BYPASS_NORMALIZATION_SHORT: Final[str] = "bypass_normalization_short"
CONF_PREVENT_SYNC_LEADER_OFF: Final[str] = "prevent_sync_leader_off"
CONF_SYNCGROUP_DEFAULT_ON: Final[str] = "syncgroup_default_on"
CONF_ENABLE_ICY_METADATA: Final[str] = "enable_icy_metadata"
CONF_VOLUME_NORMALIZATION_RADIO: Final[str] = "volume_normalization_radio"

# config default values
DEFAULT_HOST: Final[str] = "0.0.0.0"
DEFAULT_PORT: Final[int] = 8095

# common db tables
DB_TABLE_TRACK_LOUDNESS: Final[str] = "track_loudness"
DB_TABLE_PLAYLOG: Final[str] = "playlog"
DB_TABLE_ARTISTS: Final[str] = "artists"
DB_TABLE_ALBUMS: Final[str] = "albums"
Expand All @@ -91,6 +90,7 @@
DB_TABLE_ALBUM_TRACKS: Final[str] = "album_tracks"
DB_TABLE_TRACK_ARTISTS: Final[str] = "track_artists"
DB_TABLE_ALBUM_ARTISTS: Final[str] = "album_artists"
DB_TABLE_LOUDNESS_MEASUREMENTS: Final[str] = "loudness_measurements"


# all other
Expand Down
127 changes: 72 additions & 55 deletions music_assistant/server/controllers/music.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,18 @@
SearchResults,
)
from music_assistant.common.models.provider import SyncTask
from music_assistant.common.models.streamdetails import LoudnessMeasurement
from music_assistant.constants import (
DB_TABLE_ALBUM_ARTISTS,
DB_TABLE_ALBUM_TRACKS,
DB_TABLE_ALBUMS,
DB_TABLE_ARTISTS,
DB_TABLE_LOUDNESS_MEASUREMENTS,
DB_TABLE_PLAYLISTS,
DB_TABLE_PLAYLOG,
DB_TABLE_PROVIDER_MAPPINGS,
DB_TABLE_RADIOS,
DB_TABLE_SETTINGS,
DB_TABLE_TRACK_ARTISTS,
DB_TABLE_TRACK_LOUDNESS,
DB_TABLE_TRACKS,
PROVIDERS_WITH_SHAREABLE_URLS,
)
Expand All @@ -73,7 +72,7 @@
CONF_SYNC_INTERVAL = "sync_interval"
CONF_DELETED_PROVIDERS = "deleted_providers"
CONF_ADD_LIBRARY_ON_PLAY = "add_library_on_play"
DB_SCHEMA_VERSION: Final[int] = 8
DB_SCHEMA_VERSION: Final[int] = 9


class MusicController(CoreController):
Expand Down Expand Up @@ -662,47 +661,43 @@ async def refresh_item(
await self.mass.metadata.update_metadata(library_item, force_refresh=True)
return library_item

async def set_track_loudness(
self, item_id: str, provider_instance_id_or_domain: str, loudness: LoudnessMeasurement
async def set_loudness(
self,
item_id: str,
provider_instance_id_or_domain: str,
loudness: float,
album_loudness: float | None = None,
media_type: MediaType = MediaType.TRACK,
) -> None:
"""Store Loudness Measurement for a track in db."""
if provider := self.mass.get_provider(provider_instance_id_or_domain):
await self.database.insert(
DB_TABLE_TRACK_LOUDNESS,
{
"item_id": item_id,
"provider": provider.lookup_key,
"integrated": round(loudness.integrated, 2),
"true_peak": round(loudness.true_peak, 2),
"lra": round(loudness.lra, 2),
"threshold": round(loudness.threshold, 2),
"target_offset": round(loudness.target_offset, 2),
},
allow_replace=True,
)
"""Store (EBU-R128) Integrated Loudness Measurement for a mediaitem in db."""
values = {
"item_id": item_id,
"media_type": media_type.value,
"provider": provider_instance_id_or_domain,
"loudness": loudness,
}
if album_loudness is not None:
values["loudness_album"] = album_loudness
await self.database.insert_or_replace(DB_TABLE_LOUDNESS_MEASUREMENTS, values)

async def get_loudness(
self,
item_id: str,
provider_instance_id_or_domain: str,
media_type: MediaType = MediaType.TRACK,
) -> tuple[float, float] | None:
"""Get (EBU-R128) Integrated Loudness Measurement for a mediaitem in db."""
db_row = await self.database.get_row(
DB_TABLE_LOUDNESS_MEASUREMENTS,
{
"item_id": item_id,
"media_type": media_type.value,
"provider": provider_instance_id_or_domain,
},
)
if db_row and db_row["loudness"] != inf and db_row["loudness"] != -inf:
return (db_row["loudness"], db_row["loudness_album"])

async def get_track_loudness(
self, item_id: str, provider_instance_id_or_domain: str
) -> LoudnessMeasurement | None:
"""Get Loudness Measurement for a track in db."""
if provider := self.mass.get_provider(provider_instance_id_or_domain):
if result := await self.database.get_row(
DB_TABLE_TRACK_LOUDNESS,
{
"item_id": item_id,
"provider": provider.lookup_key,
},
):
if result["integrated"] == inf or result["integrated"] == -inf:
return None

return LoudnessMeasurement(
integrated=result["integrated"],
true_peak=result["true_peak"],
lra=result["lra"],
threshold=result["threshold"],
target_offset=result["target_offset"],
)
return None

async def mark_item_played(
Expand Down Expand Up @@ -1064,7 +1059,6 @@ async def __migrate_database(self, prev_version: int) -> None:
DB_TABLE_RADIOS,
DB_TABLE_ALBUM_TRACKS,
DB_TABLE_PLAYLOG,
DB_TABLE_TRACK_LOUDNESS,
DB_TABLE_PROVIDER_MAPPINGS,
):
await self.database.execute(f"DROP TABLE IF EXISTS {table}")
Expand Down Expand Up @@ -1098,6 +1092,24 @@ async def __migrate_database(self, prev_version: int) -> None:
if "duplicate column" not in str(err):
raise

if prev_version <= 8:
# migrate track_loudness --> loudness_measurements
async for db_row in self.database.iter_items("track_loudness"):
if db_row["integrated"] == inf or db_row["integrated"] == -inf:
continue
if db_row["provider"] in ("radiobrowser", "tunein"):
continue
await self.database.insert_or_replace(
DB_TABLE_LOUDNESS_MEASUREMENTS,
{
"item_id": db_row["item_id"],
"media_type": "track",
"provider": db_row["provider"],
"loudness": db_row["integrated"],
},
)
await self.database.execute("DROP TABLE IF EXISTS track_loudness")

# save changes
await self.database.commit()

Expand All @@ -1121,18 +1133,6 @@ async def __create_database_tables(self) -> None:
[type] TEXT
);"""
)
await self.database.execute(
f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_TRACK_LOUDNESS}(
[id] INTEGER PRIMARY KEY AUTOINCREMENT,
[item_id] TEXT NOT NULL,
[provider] TEXT NOT NULL,
[integrated] REAL,
[true_peak] REAL,
[lra] REAL,
[threshold] REAL,
[target_offset] REAL,
UNIQUE(item_id, provider));"""
)
await self.database.execute(
f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_PLAYLOG}(
[id] INTEGER PRIMARY KEY AUTOINCREMENT,
Expand Down Expand Up @@ -1270,6 +1270,18 @@ async def __create_database_tables(self) -> None:
UNIQUE(album_id, artist_id)
);"""
)

await self.database.execute(
f"""CREATE TABLE IF NOT EXISTS {DB_TABLE_LOUDNESS_MEASUREMENTS}(
[id] INTEGER PRIMARY KEY AUTOINCREMENT,
[media_type] TEXT NOT NULL,
[item_id] TEXT NOT NULL,
[provider] TEXT NOT NULL,
[loudness] REAL,
[loudness_album] REAL,
UNIQUE(media_type,item_id,provider));"""
)

await self.database.commit()

async def __create_database_indexes(self) -> None:
Expand Down Expand Up @@ -1366,6 +1378,11 @@ async def __create_database_indexes(self) -> None:
f"CREATE INDEX IF NOT EXISTS {DB_TABLE_ALBUM_ARTISTS}_artist_id_idx "
f"on {DB_TABLE_ALBUM_ARTISTS}(artist_id);"
)
# index on loudness measurements table
await self.database.execute(
f"CREATE INDEX IF NOT EXISTS {DB_TABLE_LOUDNESS_MEASUREMENTS}_idx "
f"on {DB_TABLE_LOUDNESS_MEASUREMENTS}(media_type,item_id,provider);"
)
await self.database.commit()

async def __create_database_triggers(self) -> None:
Expand Down
47 changes: 45 additions & 2 deletions music_assistant/server/controllers/player_queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -780,10 +780,34 @@ async def play_index(
queue.flow_mode = player_needs_flow_mode and next_index is not None
queue.stream_finished = False
queue.end_of_track_reached = False

# work out if we are playing an album and if we should prefer album loudness
if (
next_index is not None
and (next_item := self.get_item(queue_id, next_index))
and (
queue_item.media_item
and hasattr(queue_item.media_item, "album")
and hasattr(next_item.media_item, "album")
and queue_item.media_item.album
and next_item.media_item
and next_item.media_item.album
and queue_item.media_item.album.item_id == next_item.media_item.album.item_id
)
):
prefer_album_loudness = True
else:
prefer_album_loudness = False

# get streamdetails - do this here to catch unavailable items early
queue_item.streamdetails = await get_stream_details(
self.mass, queue_item, seek_position=seek_position, fade_in=fade_in
self.mass,
queue_item,
seek_position=seek_position,
fade_in=fade_in,
prefer_album_loudness=prefer_album_loudness,
)

# allow stripping silence from the end of the track if crossfade is enabled
# this will allow for smoother crossfades
if await self.mass.config.get_player_config_value(queue_id, CONF_CROSSFADE):
Expand Down Expand Up @@ -1049,11 +1073,30 @@ async def preload_next_item(
if next_index is None:
raise QueueEmpty("No more tracks left in the queue.")
queue_item = self.get_item(queue_id, next_index)

# work out if we are playing an album and if we should prefer album loudness
if (
next_index is not None
and (next_item := self.get_item(queue_id, next_index))
and (
queue_item.media_item
and queue_item.media_item.album
and next_item.media_item
and next_item.media_item.album
and queue_item.media_item.album.item_id == next_item.media_item.album.item_id
)
):
prefer_album_loudness = True
else:
prefer_album_loudness = False

try:
# Check if the QueueItem is playable. For example, YT Music returns Radio Items
# that are not playable which will stop playback.
queue_item.streamdetails = await get_stream_details(
mass=self.mass, queue_item=queue_item
mass=self.mass,
queue_item=queue_item,
prefer_album_loudness=prefer_album_loudness,
)
# Preload the full MediaItem for the QueueItem, making sure to get the
# maximum quality of thumbs
Expand Down
Loading