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, "
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."""
# 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
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"
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
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,
)
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):
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(
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}")
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()
[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,
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:
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:
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):
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
from music_assistant.common.helpers.util import get_ip, select_free_port, try_parse_bool
from music_assistant.common.models.config_entries import (
+ CONF_ENTRY_ENABLE_ICY_METADATA,
ConfigEntry,
ConfigValueOption,
ConfigValueType,
ANNOUNCE_ALERT_FILE,
CONF_BIND_IP,
CONF_BIND_PORT,
- CONF_BYPASS_NORMALIZATION_RADIO,
- CONF_BYPASS_NORMALIZATION_SHORT,
CONF_CROSSFADE,
CONF_CROSSFADE_DURATION,
- CONF_ENABLE_ICY_METADATA,
CONF_HTTP_PROFILE,
CONF_OUTPUT_CHANNELS,
CONF_PUBLISH_IP,
CONF_SAMPLE_RATES,
+ CONF_VOLUME_NORMALIZATION,
+ CONF_VOLUME_NORMALIZATION_RADIO,
MASS_LOGO_ONLINE,
SILENCE_FILE,
VERBOSE_LOG_LEVEL,
category="advanced",
),
ConfigEntry(
- key=CONF_BYPASS_NORMALIZATION_RADIO,
- type=ConfigEntryType.BOOLEAN,
- default_value=True,
- label="Bypass volume normalization for radio streams",
- description="Radio streams are often already normalized according "
- "to the EBU standard, so it doesn't make a lot of sense to normalize them again "
- "in Music Assistant unless you hear big jumps in volume during playback, "
- "such as commercials.",
- category="advanced",
- ),
- ConfigEntry(
- key=CONF_BYPASS_NORMALIZATION_SHORT,
- type=ConfigEntryType.BOOLEAN,
- default_value=True,
- label="Bypass volume normalization for effects and short sounds",
- description="The volume normalizer of ffmpeg (used in Music Assistant), "
- "is designed to work best with longer audio streams and can have troubles when "
- "its applied to very short sound clips (< 30 seconds), "
- "for example sound effects. With this option enabled, the volume normalizer "
- "will be bypassed for all audio that has a duration of less than 60 seconds.",
+ key=CONF_VOLUME_NORMALIZATION_RADIO,
+ type=ConfigEntryType.STRING,
+ default_value="standard",
+ label="Volume normalization method to use for radio streams",
+ description="Radio streams often have varying loudness levels, especially "
+ "during announcements and commercials. \n"
+ "You can choose to enforce dynamic volume normalization to radio streams, "
+ "even if a (average) loudness measurement for the radio station exists. \n\n"
+ "Options: \n"
+ "- Disabled - do not apply volume normalization at all \n"
+ "- Force dynamic - Enforce dynamic volume levelling at all times \n"
+ "- Standard - use normalization based on previous measurement, ",
+ options=(
+ ConfigValueOption("Disabled", "disabled"),
+ ConfigValueOption("Force dynamic", "dynamic"),
+ ConfigValueOption("Standard", "standard"),
+ ),
category="advanced",
),
)
queue.display_name,
)
self.mass.player_queues.track_loaded_in_buffer(queue_id, queue_item_id)
+
+ # pick pcm format based on the streamdetails and player capabilities
+ if self.mass.config.get_raw_player_config_value(queue_id, CONF_VOLUME_NORMALIZATION, True):
+ # prefer f32 when volume normalization is enabled
+ bit_depth = 32
+ floating_point = True
+ else:
+ bit_depth = queue_item.streamdetails.audio_format.bit_depth
+ floating_point = False
pcm_format = AudioFormat(
- content_type=ContentType.from_bit_depth(output_format.bit_depth),
+ content_type=ContentType.from_bit_depth(bit_depth, floating_point),
sample_rate=queue_item.streamdetails.audio_format.sample_rate,
- bit_depth=queue_item.streamdetails.audio_format.bit_depth,
+ bit_depth=bit_depth,
channels=2,
)
chunk_num = 0
)
# work out ICY metadata support
icy_preference = self.mass.config.get_raw_player_config_value(
- queue_id, CONF_ENABLE_ICY_METADATA, "basic"
+ queue_id,
+ CONF_ENTRY_ENABLE_ICY_METADATA.key,
+ CONF_ENTRY_ENABLE_ICY_METADATA.default_value,
)
enable_icy = request.headers.get("Icy-MetaData", "") == "1" and icy_preference != "disabled"
- icy_meta_interval = 16384
+ icy_meta_interval = 256000 if icy_preference == "full" else 16384
# prepare request, add some DLNA/UPNP compatible headers
http_profile: str = await self.mass.config.get_player_config_value(
crossfade_part = await crossfade_pcm_parts(
fadein_part,
last_fadeout_part,
- pcm_format.bit_depth,
- pcm_format.sample_rate,
+ pcm_format=pcm_format,
)
# send crossfade_part (as one big chunk)
bytes_written += len(crossfade_part)
# collect all arguments for ffmpeg
filter_params = []
extra_input_args = []
- # add loudnorm filter: volume normalization
- # more info: https://k.ylo.ph/2016/04/04/loudnorm.html
- if (
- streamdetails.target_loudness is not None
- and not streamdetails.bypass_loudness_normalization
- ):
- if streamdetails.loudness:
- # we have a measurement so we can do linear mode
- target_loudness = streamdetails.target_loudness
- # we must ensure that target loudness does not exceed the measured value
- # otherwise ffmpeg falls back to dynamic again
- # https://github.com/slhck/ffmpeg-normalize/issues/251
- target_loudness = min(
- streamdetails.target_loudness,
- streamdetails.loudness.integrated + streamdetails.loudness.lra - 1,
- )
- filter_rule = f"loudnorm=I={target_loudness}:TP=-2.0:LRA=7.0:linear=true"
- filter_rule += f":measured_I={streamdetails.loudness.integrated}"
- filter_rule += f":measured_LRA={streamdetails.loudness.lra}"
- filter_rule += f":measured_tp={streamdetails.loudness.true_peak}"
- filter_rule += f":measured_thresh={streamdetails.loudness.threshold}"
- if streamdetails.loudness.target_offset is not None:
- filter_rule += f":offset={streamdetails.loudness.target_offset}"
- else:
- # if we have no measurement, we use dynamic mode
+ # handle volume normalization
+ if streamdetails.enable_volume_normalization and streamdetails.target_loudness is not None:
+ if streamdetails.force_dynamic_volume_normalization or streamdetails.loudness is None:
+ # volume normalization with unknown loudness measurement
+ # use loudnorm filter in dynamic mode
# which also collects the measurement on the fly during playback
+ # more info: https://k.ylo.ph/2016/04/04/loudnorm.html
filter_rule = (
- f"loudnorm=I={streamdetails.target_loudness}:TP=-2.0:LRA=7.0:offset=0.0"
+ f"loudnorm=I={streamdetails.target_loudness}:TP=-2.0:LRA=10.0:offset=0.0"
)
- filter_rule += ":print_format=json"
- filter_params.append(filter_rule)
+ filter_rule += ":print_format=json"
+ filter_params.append(filter_rule)
+ else:
+ # volume normalization with known loudness measurement
+ # apply fixed volume/gain correction
+ gain_correct = streamdetails.target_loudness - streamdetails.loudness
+ gain_correct = round(gain_correct, 2)
+ filter_params.append(f"volume={gain_correct}dB")
if streamdetails.stream_type == StreamType.CUSTOM:
audio_source = self.mass.get_provider(streamdetails.provider).get_audio_stream(
streamdetails,
if sample_rate in supported_sample_rates:
output_sample_rate = sample_rate
break
- output_bit_depth = min(24, player_max_bit_depth)
+ if self.mass.config.get_raw_player_config_value(
+ player.player_id, CONF_VOLUME_NORMALIZATION, True
+ ):
+ # prefer f32 when volume normalization is enabled
+ output_bit_depth = 32
+ floating_point = True
+ else:
+ output_bit_depth = min(24, player_max_bit_depth)
+ floating_point = False
return AudioFormat(
- content_type=ContentType.from_bit_depth(output_bit_depth),
+ content_type=ContentType.from_bit_depth(output_bit_depth, floating_point),
sample_rate=output_sample_rate,
bit_depth=output_bit_depth,
channels=2,
MusicAssistantError,
)
from music_assistant.common.models.media_items import AudioFormat, ContentType
-from music_assistant.common.models.streamdetails import LoudnessMeasurement, StreamDetails
+from music_assistant.common.models.streamdetails import StreamDetails
from music_assistant.constants import (
- CONF_BYPASS_NORMALIZATION_RADIO,
- CONF_BYPASS_NORMALIZATION_SHORT,
CONF_EQ_BASS,
CONF_EQ_MID,
CONF_EQ_TREBLE,
CONF_OUTPUT_CHANNELS,
CONF_VOLUME_NORMALIZATION,
+ CONF_VOLUME_NORMALIZATION_RADIO,
CONF_VOLUME_NORMALIZATION_TARGET,
MASS_LOGGER_NAME,
VERBOSE_LOG_LEVEL,
async def crossfade_pcm_parts(
fade_in_part: bytes,
fade_out_part: bytes,
- bit_depth: int,
- sample_rate: int,
+ pcm_format: AudioFormat,
) -> bytes:
"""Crossfade two chunks of pcm/raw audio using ffmpeg."""
- sample_size = int(sample_rate * (bit_depth / 8) * 2)
- fmt = ContentType.from_bit_depth(bit_depth)
+ sample_size = pcm_format.pcm_sample_size
# calculate the fade_length from the smallest chunk
fade_length = min(len(fade_in_part), len(fade_out_part)) / sample_size
fadeoutfile = create_tempfile()
"quiet",
# fadeout part (as file)
"-acodec",
- fmt.name.lower(),
+ pcm_format.content_type.name.lower(),
"-f",
- fmt,
+ pcm_format.content_type.value,
"-ac",
- "2",
+ str(pcm_format.channels),
"-ar",
- str(sample_rate),
+ str(pcm_format.sample_rate),
"-i",
fadeoutfile.name,
# fade_in part (stdin)
"-acodec",
- fmt.name.lower(),
+ pcm_format.content_type.name.lower(),
"-f",
- fmt,
+ pcm_format.content_type.value,
"-ac",
- "2",
+ str(pcm_format.channels),
"-ar",
- str(sample_rate),
+ str(pcm_format.sample_rate),
"-i",
"-",
# filter args
f"[0][1]acrossfade=d={fade_length}",
# output args
"-f",
- fmt,
+ pcm_format.content_type.value,
"-",
]
_returncode, crossfaded_audio, _stderr = await communicate(args, fade_in_part)
async def strip_silence(
mass: MusicAssistant, # noqa: ARG001
audio_data: bytes,
- sample_rate: int,
- bit_depth: int,
+ pcm_format: AudioFormat,
reverse: bool = False,
) -> bytes:
"""Strip silence from begin or end of pcm audio using ffmpeg."""
- fmt = ContentType.from_bit_depth(bit_depth)
args = ["ffmpeg", "-hide_banner", "-loglevel", "quiet"]
args += [
"-acodec",
- fmt.name.lower(),
+ pcm_format.content_type.name.lower(),
"-f",
- fmt,
+ pcm_format.content_type.value,
"-ac",
- "2",
+ str(pcm_format.channels),
"-ar",
- str(sample_rate),
+ str(pcm_format.sample_rate),
"-i",
"-",
]
"atrim=start=0.2,silenceremove=start_periods=1:start_silence=0.1:start_threshold=0.02",
]
# output args
- args += ["-f", fmt, "-"]
+ args += ["-f", pcm_format.content_type.value, "-"]
_returncode, stripped_data, _stderr = await communicate(args, audio_data)
# return stripped audio
bytes_stripped = len(audio_data) - len(stripped_data)
if LOGGER.isEnabledFor(VERBOSE_LOG_LEVEL):
- pcm_sample_size = int(sample_rate * (bit_depth / 8) * 2)
- seconds_stripped = round(bytes_stripped / pcm_sample_size, 2)
+ seconds_stripped = round(bytes_stripped / pcm_format.pcm_sample_size, 2)
location = "end" if reverse else "begin"
LOGGER.log(
VERBOSE_LOG_LEVEL,
queue_item: QueueItem,
seek_position: int = 0,
fade_in: bool = False,
+ prefer_album_loudness: bool = False,
) -> StreamDetails:
"""Get streamdetails for the given QueueItem.
if not streamdetails.duration:
streamdetails.duration = queue_item.duration
# handle volume normalization details
- is_radio = streamdetails.media_type == MediaType.RADIO or not streamdetails.duration
- streamdetails.bypass_loudness_normalization = (
- is_radio
- and await mass.config.get_core_config_value("streams", CONF_BYPASS_NORMALIZATION_RADIO)
- ) or (
- streamdetails.duration is not None
- and streamdetails.duration < 30
- and await mass.config.get_core_config_value("streams", CONF_BYPASS_NORMALIZATION_SHORT)
- )
- if not streamdetails.loudness:
- streamdetails.loudness = await mass.music.get_track_loudness(
- streamdetails.item_id, streamdetails.provider
- )
+ if result := await mass.music.get_loudness(
+ streamdetails.item_id,
+ streamdetails.provider,
+ media_type=queue_item.media_type,
+ ):
+ streamdetails.loudness, streamdetails.loudness_album = result
+ streamdetails.prefer_album_loudness = prefer_album_loudness
player_settings = await mass.config.get_player_config(streamdetails.queue_id)
- if not player_settings.get_value(CONF_VOLUME_NORMALIZATION):
- streamdetails.target_loudness = None
- else:
- streamdetails.target_loudness = player_settings.get_value(CONF_VOLUME_NORMALIZATION_TARGET)
+ streamdetails.enable_volume_normalization = player_settings.get_value(CONF_VOLUME_NORMALIZATION)
+ streamdetails.target_loudness = player_settings.get_value(CONF_VOLUME_NORMALIZATION_TARGET)
+
+ radio_norm_pref = await mass.config.get_core_config_value(
+ "streams", CONF_VOLUME_NORMALIZATION_RADIO
+ )
+ if streamdetails.media_type == MediaType.RADIO and radio_norm_pref == "disabled":
+ streamdetails.enable_volume_normalization = False
+ elif streamdetails.media_type == MediaType.RADIO and radio_norm_pref == "dynamic":
+ streamdetails.force_dynamic_volume_normalization = True
+
process_time = int((time.time() - time_start) * 1000)
LOGGER.debug("retrieved streamdetails for %s in %s milliseconds", queue_item.uri, process_time)
return streamdetails
logger=logger,
) as ffmpeg_proc:
async for chunk in ffmpeg_proc.iter_chunked(pcm_format.pcm_sample_size):
+ # for radio streams we just yield all chunks directly
+ if streamdetails.media_type == MediaType.RADIO:
+ yield chunk
+ bytes_sent += len(chunk)
+ continue
+
chunk_number += 1
# determine buffer size dynamically
if chunk_number < 5 and strip_silence_begin:
if chunk_number == 5 and strip_silence_begin:
# strip silence from begin of audio
chunk = await strip_silence( # noqa: PLW2901
- mass, buffer, pcm_format.sample_rate, pcm_format.bit_depth
+ mass, buffer, pcm_format=pcm_format
)
bytes_sent += len(chunk)
yield chunk
buffer = await strip_silence(
mass,
buffer,
- sample_rate=pcm_format.sample_rate,
- bit_depth=pcm_format.bit_depth,
+ pcm_format=pcm_format,
reverse=True,
)
# send remaining bytes in buffer
streamdetails.duration = seconds_streamed
# parse loudnorm data if we have that collected
- if loudness_details := parse_loudnorm(" ".join(ffmpeg_proc.log_history)):
- required_seconds = 600 if streamdetails.media_type == MediaType.RADIO else 120
- if finished or (seconds_streamed >= required_seconds):
+ required_seconds = 600 if streamdetails.media_type == MediaType.RADIO else 120
+ if streamdetails.loudness is None and (finished or (seconds_streamed >= required_seconds)):
+ loudness_details = parse_loudnorm(" ".join(ffmpeg_proc.log_history))
+ if loudness_details is not None:
logger.debug(
- "Loudness measurement for %s: %s",
+ "Loudness measurement for %s: %s dB",
streamdetails.uri,
loudness_details,
)
streamdetails.loudness = loudness_details
mass.create_task(
- mass.music.set_track_loudness(
- streamdetails.item_id, streamdetails.provider, loudness_details
+ mass.music.set_loudness(
+ streamdetails.item_id,
+ streamdetails.provider,
+ loudness_details,
+ media_type=streamdetails.media_type,
)
)
# report playback
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")
+
return filter_params
resample_filter += f":osr={output_format.sample_rate}"
# bit depth conversion: apply dithering when going down to 16 bits
- if output_format.bit_depth < input_format.bit_depth:
+ if output_format.bit_depth == 16 and input_format.bit_depth > 16:
resample_filter += ":osf=s16:dither_method=triangular_hp"
filter_params.append(resample_filter)
return generic_args + input_args + extra_args + output_args
-def parse_loudnorm(raw_stderr: bytes | str) -> LoudnessMeasurement | None:
+def parse_loudnorm(raw_stderr: bytes | str) -> float | None:
"""Parse Loudness measurement from ffmpeg stderr output."""
stderr_data = raw_stderr.decode() if isinstance(raw_stderr, bytes) else raw_stderr
if "[Parsed_loudnorm_" not in stderr_data:
loudness_data = json_loads(stderr_data)
except JSON_DECODE_EXCEPTIONS:
return None
- return LoudnessMeasurement(
- integrated=float(loudness_data["input_i"]),
- true_peak=float(loudness_data["input_tp"]),
- lra=float(loudness_data["input_lra"]),
- threshold=float(loudness_data["input_thresh"]),
- target_offset=float(loudness_data["target_offset"]),
- )
+ return float(loudness_data["input_i"])
return value
return None
+ @property
+ def track_loudness(self) -> float | None:
+ """Try to read/calculate the integrated loudness from the tags."""
+ if (tag := self.tags.get("r128trackgain")) is not None:
+ return -23 - float(int(tag.split(" ")[0]) / 256)
+ if (tag := self.tags.get("replaygaintrackgain")) is not None:
+ return -18 - float(tag.split(" ")[0])
+ return None
+
+ @property
+ def track_album_loudness(self) -> float | None:
+ """Try to read/calculate the integrated loudness from the tags (album level)."""
+ if tag := self.tags.get("r128albumgain"):
+ return -23 - float(int(tag.split(" ")[0]) / 256)
+ if (tag := self.tags.get("replaygainalbumgain")) is not None:
+ return -18 - float(tag.split(" ")[0])
+ return None
+
@classmethod
def parse(cls, raw: dict) -> AudioTags:
"""Parse instance from raw ffmpeg info output."""
CACHE_KEY_PREV_VOLUME = "airplay_prev_volume"
FALLBACK_VOLUME = 20
+AIRPLAY_FLOW_PCM_FORMAT = AudioFormat(
+ content_type=ContentType.PCM_F32LE,
+ sample_rate=44100,
+ bit_depth=32,
+)
AIRPLAY_PCM_FORMAT = AudioFormat(
content_type=ContentType.from_bit_depth(16), sample_rate=44100, bit_depth=16
)
ugp_stream = ugp_provider.streams[media.queue_id]
input_format = ugp_stream.output_format
audio_source = ugp_stream.subscribe()
- elif media.media_type == MediaType.RADIO and media.queue_id and media.queue_item_id:
- # radio stream - consume media stream directly
- input_format = AIRPLAY_PCM_FORMAT
- queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id)
- audio_source = self.mass.streams.get_media_stream(
- streamdetails=queue_item.streamdetails,
- pcm_format=AIRPLAY_PCM_FORMAT,
- )
elif media.queue_id and media.queue_item_id:
# regular queue (flow) stream request
- input_format = AIRPLAY_PCM_FORMAT
+ input_format = AIRPLAY_FLOW_PCM_FORMAT
audio_source = self.mass.streams.get_flow_stream(
queue=self.mass.player_queues.get(media.queue_id),
start_queue_item=self.mass.player_queues.get_item(
media.queue_id, media.queue_item_id
),
- pcm_format=AIRPLAY_PCM_FORMAT,
+ pcm_format=input_format,
)
else:
# assume url or some other direct path
if tags.musicbrainz_recordingid:
track.mbid = tags.musicbrainz_recordingid
track.metadata.chapters = UniqueList(tags.chapters)
+ # handle (optional) loudness measurement tag(s)
+ if tags.track_loudness is not None:
+ await self.mass.music.set_loudness(
+ track.item_id, self.instance_id, tags.track_loudness, tags.track_album_loudness
+ )
return track
async def _parse_artist(
# this is a syncgroup, we need to handle this with a multi client stream
master_audio_format = AudioFormat(
- content_type=ContentType.from_bit_depth(24), sample_rate=48000, bit_depth=24
+ content_type=ContentType.PCM_F32LE,
+ sample_rate=48000,
+ bit_depth=32,
)
if media.media_type == MediaType.ANNOUNCEMENT:
# special case: stream announcement
channels=2,
)
+DEFAULT_SNAPCAST_PCM_FORMAT = AudioFormat(
+ # the format that is used as intermediate pcm stream,
+ # we prefer F32 here to account for volume normalization
+ content_type=ContentType.PCM_F32LE,
+ sample_rate=48000,
+ bit_depth=32,
+ channels=2,
+)
+
async def setup(
mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
await self._get_snapgroup(player_id).set_stream("default")
await self.cmd_stop(player_id=player_id)
- async def play_media(self, player_id: str, media: PlayerMedia) -> None: # noqa: PLR0915
+ async def play_media(self, player_id: str, media: PlayerMedia) -> None:
"""Handle PLAY MEDIA on given player."""
player = self.mass.players.get(player_id)
if player.synced_to:
ugp_stream = ugp_provider.streams[media.queue_id]
input_format = ugp_stream.output_format
audio_source = ugp_stream.subscribe()
- elif media.media_type == MediaType.RADIO and media.queue_id and media.queue_item_id:
- # radio stream - consume media stream directly
- input_format = DEFAULT_SNAPCAST_FORMAT
- queue_item = self.mass.player_queues.get_item(media.queue_id, media.queue_item_id)
- audio_source = self.mass.streams.get_media_stream(
- streamdetails=queue_item.streamdetails,
- pcm_format=DEFAULT_SNAPCAST_FORMAT,
- )
elif media.queue_id and media.queue_item_id:
# regular queue (flow) stream request
- input_format = DEFAULT_SNAPCAST_FORMAT
+ input_format = DEFAULT_SNAPCAST_PCM_FORMAT
audio_source = self.mass.streams.get_flow_stream(
queue=self.mass.player_queues.get(media.queue_id),
start_queue_item=self.mass.player_queues.get_item(
media.queue_id, media.queue_item_id
),
- pcm_format=DEFAULT_SNAPCAST_FORMAT,
+ pcm_format=input_format,
)
else:
# assume url or some other direct path
# ruff: noqa: ARG002
UGP_FORMAT = AudioFormat(
- content_type=ContentType.from_bit_depth(16), sample_rate=44100, bit_depth=16
+ content_type=ContentType.PCM_F32LE,
+ sample_rate=44100,
+ bit_depth=32,
)
UGP_PREFIX = "ugp_"