Skip to content

Commit

Permalink
Several bugfixes and enhancements to audio streaming (#1660)
Browse files Browse the repository at this point in the history
  • Loading branch information
marcelveldt authored Sep 14, 2024
1 parent edabfc9 commit c777cb5
Show file tree
Hide file tree
Showing 14 changed files with 683 additions and 501 deletions.
11 changes: 11 additions & 0 deletions music_assistant/common/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,3 +445,14 @@ class CacheCategory(IntEnum):
PLAYER_QUEUE_STATE = 7
MEDIA_INFO = 8
LIBRARY_ITEMS = 9


class VolumeNormalizationMode(StrEnum):
"""Enum with possible VolumeNormalization modes."""

DISABLED = "disabled"
DYNAMIC = "dynamic"
MEASUREMENT_ONLY = "measurement_only"
FALLBACK_FIXED_GAIN = "fallback_fixed_gain"
FIXED_GAIN = "fixed_gain"
FALLBACK_DYNAMIC = "fallback_dynamic"
5 changes: 2 additions & 3 deletions music_assistant/common/models/streamdetails.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from mashumaro import DataClassDictMixin

from music_assistant.common.models.enums import MediaType, StreamType
from music_assistant.common.models.enums import MediaType, StreamType, VolumeNormalizationMode
from music_assistant.common.models.media_items import AudioFormat


Expand Down Expand Up @@ -44,11 +44,10 @@ class StreamDetails(DataClassDictMixin):
# the fields below will be set/controlled by the streamcontroller
seek_position: int = 0
fade_in: bool = False
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
volume_normalization_mode: VolumeNormalizationMode | None = None
queue_id: str | None = None
seconds_streamed: float | None = None
target_loudness: float | None = None
Expand Down
3 changes: 3 additions & 0 deletions music_assistant/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@
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"
CONF_VOLUME_NORMALIZATION_TRACKS: Final[str] = "volume_normalization_tracks"
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_RADIO: Final[str] = "volume_normalization_fixed_gain_radio"
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS: Final[str] = "volume_normalization_fixed_gain_tracks"

# config default values
DEFAULT_HOST: Final[str] = "0.0.0.0"
Expand Down
21 changes: 13 additions & 8 deletions music_assistant/server/controllers/player_queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ class CompareState(TypedDict):
current_index: int | None
elapsed_time: int
stream_title: str | None
content_type: str | None


class PlayerQueuesController(CoreController):
Expand Down Expand Up @@ -659,7 +660,7 @@ async def next(self, queue_id: str) -> None:
while True:
try:
if (next_index := self._get_next_index(queue_id, idx, True)) is not None:
await self.play_index(queue_id, next_index)
await self.play_index(queue_id, next_index, debounce=True)
break
except MediaNotFoundError:
self.logger.warning(
Expand All @@ -683,7 +684,7 @@ async def previous(self, queue_id: str) -> None:
current_index = self._queues[queue_id].current_index
if current_index is None:
return
await self.play_index(queue_id, max(current_index - 1, 0))
await self.play_index(queue_id, max(current_index - 1, 0), debounce=True)

@api_command("player_queues/skip")
async def skip(self, queue_id: str, seconds: int = 10) -> None:
Expand Down Expand Up @@ -765,6 +766,7 @@ async def play_index(
index: int | str,
seek_position: int = 0,
fade_in: bool = False,
debounce: bool = False,
) -> None:
"""Play item at index (or item_id) X in queue."""
queue = self._queues[queue_id]
Expand All @@ -786,6 +788,9 @@ async def play_index(
queue.stream_finished = False
queue.end_of_track_reached = False

queue.current_item = queue_item
self.signal_update(queue_id)

# work out if we are playing an album and if we should prefer album loudness
if (
next_index is not None
Expand Down Expand Up @@ -821,13 +826,14 @@ async def play_index(
# NOTE that we debounce this a bit to account for someone hitting the next button
# like a madman. This will prevent the player from being overloaded with requests.
self.mass.call_later(
0.25,
1 if debounce else 0.1,
self.mass.players.play_media,
player_id=queue_id,
# transform into PlayerMedia to send to the actual player implementation
media=self.player_media_from_queue_item(queue_item, queue.flow_mode),
task_id=f"play_media_{queue_id}",
)
self.signal_update(queue_id)

@api_command("player_queues/transfer")
async def transfer_queue(
Expand Down Expand Up @@ -981,6 +987,9 @@ def on_player_update(
stream_title=queue.current_item.streamdetails.stream_title
if queue.current_item and queue.current_item.streamdetails
else None,
content_type=queue.current_item.streamdetails.audio_format.output_format_str
if queue.current_item and queue.current_item.streamdetails
else None,
)
changed_keys = get_changed_keys(prev_state, new_state)
# return early if nothing changed
Expand Down Expand Up @@ -1148,10 +1157,6 @@ def track_loaded_in_buffer(self, queue_id: str, item_id: str) -> None:
# enqueue the next track as soon as the player reports
# it has started buffering the given queue item
task_id = f"enqueue_next_{queue_id}"
self.mass.call_later(0.2, self._enqueue_next, queue, item_id, task_id=task_id)
# we repeat this task once more after 2 seconds to ensure the player
# received the command as it may be missed at the first attempt
# due to a race condition
self.mass.call_later(2, self._enqueue_next, queue, item_id, task_id=task_id)

# Main queue manipulation methods
Expand Down Expand Up @@ -1225,7 +1230,7 @@ def signal_update(self, queue_id: str, items_changed: bool = False) -> None:
if queue.index_in_buffer is not None:
task_id = f"enqueue_next_{queue.queue_id}"
self.mass.call_later(
1, self._enqueue_next, queue, queue.index_in_buffer, task_id=task_id
5, self._enqueue_next, queue, queue.index_in_buffer, task_id=task_id
)

# always send the base event
Expand Down
138 changes: 92 additions & 46 deletions music_assistant/server/controllers/streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@
ConfigValueOption,
ConfigValueType,
)
from music_assistant.common.models.enums import ConfigEntryType, ContentType, MediaType, StreamType
from music_assistant.common.models.enums import (
ConfigEntryType,
ContentType,
MediaType,
StreamType,
VolumeNormalizationMode,
)
from music_assistant.common.models.errors import QueueEmpty
from music_assistant.common.models.media_items import AudioFormat
from music_assistant.common.models.streamdetails import StreamDetails
Expand All @@ -39,7 +45,10 @@
CONF_PUBLISH_IP,
CONF_SAMPLE_RATES,
CONF_VOLUME_NORMALIZATION,
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_RADIO,
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS,
CONF_VOLUME_NORMALIZATION_RADIO,
CONF_VOLUME_NORMALIZATION_TRACKS,
MASS_LOGO_ONLINE,
SILENCE_FILE,
VERBOSE_LOG_LEVEL,
Expand All @@ -49,7 +58,6 @@
check_audio_support,
crossfade_pcm_parts,
get_chunksize,
get_ffmpeg_stream,
get_hls_radio_stream,
get_hls_substream,
get_icy_radio_stream,
Expand All @@ -58,6 +66,8 @@
get_silence,
get_stream_details,
)
from music_assistant.server.helpers.ffmpeg import LOGGER as FFMPEG_LOGGER
from music_assistant.server.helpers.ffmpeg import get_ffmpeg_stream
from music_assistant.server.helpers.util import get_ips
from music_assistant.server.helpers.webserver import Webserver
from music_assistant.server.models.core_controller import CoreController
Expand All @@ -70,12 +80,13 @@


DEFAULT_STREAM_HEADERS = {
"Server": "Music Assistant",
"transferMode.dlna.org": "Streaming",
"contentFeatures.dlna.org": "DLNA.ORG_OP=00;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=0d500000000000000000000000000000", # noqa: E501
"Cache-Control": "no-cache,must-revalidate",
"Pragma": "no-cache",
"Connection": "close",
"Accept-Ranges": "none",
"Connection": "close",
}
ICY_HEADERS = {
"icy-name": "Music Assistant",
Expand Down Expand Up @@ -146,6 +157,44 @@ async def get_config_entries(
"Make sure that this server can be reached "
"on the given IP and TCP port by players on the local network.",
),
ConfigEntry(
key=CONF_VOLUME_NORMALIZATION_RADIO,
type=ConfigEntryType.STRING,
default_value=VolumeNormalizationMode.FALLBACK_DYNAMIC,
label="Volume normalization method for radio streams",
options=(
ConfigValueOption(x.value.replace("_", " ").title(), x.value)
for x in VolumeNormalizationMode
),
category="audio",
),
ConfigEntry(
key=CONF_VOLUME_NORMALIZATION_TRACKS,
type=ConfigEntryType.STRING,
default_value=VolumeNormalizationMode.FALLBACK_DYNAMIC,
label="Volume normalization method for tracks",
options=(
ConfigValueOption(x.value.replace("_", " ").title(), x.value)
for x in VolumeNormalizationMode
),
category="audio",
),
ConfigEntry(
key=CONF_VOLUME_NORMALIZATION_FIXED_GAIN_RADIO,
type=ConfigEntryType.FLOAT,
range=(-20, 10),
default_value=-6,
label="Fixed/fallback gain adjustment for radio streams",
category="audio",
),
ConfigEntry(
key=CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS,
type=ConfigEntryType.FLOAT,
range=(-20, 10),
default_value=-6,
label="Fixed/fallback gain adjustment for tracks",
category="audio",
),
ConfigEntry(
key=CONF_PUBLISH_IP,
type=ConfigEntryType.STRING,
Expand All @@ -171,26 +220,6 @@ async def get_config_entries(
"not be adjusted in regular setups.",
category="advanced",
),
ConfigEntry(
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",
),
)

async def setup(self, config: CoreConfig) -> None:
Expand All @@ -211,8 +240,9 @@ async def setup(self, config: CoreConfig) -> None:
version,
"with libsoxr support" if libsoxr_support else "",
)
# copy log level to audio module
# copy log level to audio/ffmpeg loggers
AUDIO_LOGGER.setLevel(self.logger.level)
FFMPEG_LOGGER.setLevel(self.logger.level)
# start the webserver
self.publish_port = config.get_value(CONF_BIND_PORT)
self.publish_ip = config.get_value(CONF_PUBLISH_IP)
Expand Down Expand Up @@ -305,9 +335,6 @@ async def serve_queue_item_stream(self, request: web.Request) -> web.Response:
headers = {
**DEFAULT_STREAM_HEADERS,
"Content-Type": f"audio/{output_format.output_format_str}",
"Accept-Ranges": "none",
"Cache-Control": "no-cache",
"Connection": "close",
"icy-name": queue_item.name,
}
resp = web.StreamResponse(
Expand Down Expand Up @@ -769,23 +796,41 @@ async def get_media_stream(
filter_params = []
extra_input_args = []
# 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=10.0:offset=0.0"
)
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")
enable_volume_normalization = (
streamdetails.target_loudness is not None
and streamdetails.volume_normalization_mode != VolumeNormalizationMode.DISABLED
)
dynamic_volume_normalization = (
streamdetails.volume_normalization_mode == VolumeNormalizationMode.DYNAMIC
and enable_volume_normalization
)
if dynamic_volume_normalization:
# volume normalization using 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=10.0:offset=0.0"
filter_rule += ":print_format=json"
filter_params.append(filter_rule)
elif (
enable_volume_normalization
and streamdetails.volume_normalization_mode == VolumeNormalizationMode.FIXED_GAIN
):
# apply used defined fixed volume/gain correction
gain_correct: float = await self.mass.config.get_core_config_value(
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_RADIO
if streamdetails.media_type == MediaType.RADIO
else CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS,
)
gain_correct = round(gain_correct, 2)
filter_params.append(f"volume={gain_correct}dB")
elif enable_volume_normalization and streamdetails.loudness is not None:
# volume normalization with known loudness measurement
# apply volume/gain correction
gain_correct = streamdetails.target_loudness - streamdetails.loudness
gain_correct = round(gain_correct, 2)
filter_params.append(f"volume={gain_correct}dB")

# work out audio source for these streamdetails
if streamdetails.stream_type == StreamType.CUSTOM:
audio_source = self.mass.get_provider(streamdetails.provider).get_audio_stream(
streamdetails,
Expand Down Expand Up @@ -819,8 +864,9 @@ async def get_media_stream(
if streamdetails.media_type == MediaType.RADIO:
# pad some silence before the radio stream starts to create some headroom
# for radio stations that do not provide any look ahead buffer
# without this, some radio streams jitter a lot
async for chunk in get_silence(2, pcm_format):
# without this, some radio streams jitter a lot, especially with dynamic normalization
pad_seconds = 5 if dynamic_volume_normalization else 2
async for chunk in get_silence(pad_seconds, pcm_format):
yield chunk

async for chunk in get_media_stream(
Expand Down
Loading

0 comments on commit c777cb5

Please sign in to comment.