CONF_BIND_PORT,
CONF_CROSSFADE_DURATION,
CONF_ENTRY_ENABLE_ICY_METADATA,
+ CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES,
CONF_HTTP_PROFILE,
CONF_OUTPUT_CHANNELS,
CONF_OUTPUT_CODEC,
CONF_VOLUME_NORMALIZATION_FIXED_GAIN_TRACKS,
CONF_VOLUME_NORMALIZATION_RADIO,
CONF_VOLUME_NORMALIZATION_TRACKS,
- DEFAULT_PCM_FORMAT,
DEFAULT_STREAM_HEADERS,
ICY_HEADERS,
+ INTERNAL_PCM_FORMAT,
SILENCE_FILE,
VERBOSE_LOG_LEVEL,
)
from music_assistant.controllers.players.player_controller import AnnounceData
from music_assistant.helpers.audio import LOGGER as AUDIO_LOGGER
from music_assistant.helpers.audio import (
+ get_buffered_media_stream,
get_chunksize,
get_media_stream,
- get_media_stream_with_buffer,
get_player_filter_params,
get_silence,
get_stream_details,
isfile = wrap(os.path.isfile)
CONF_ALLOW_BUFFER: Final[str] = "allow_buffering"
+CONF_ALLOW_CROSSFADE_SAME_ALBUM: Final[str] = "allow_crossfade_same_album"
def parse_pcm_info(content_type: str) -> tuple[int, int, int]:
label="Fixed/fallback gain adjustment for tracks",
category="audio",
),
+ ConfigEntry(
+ key=CONF_ALLOW_CROSSFADE_SAME_ALBUM,
+ type=ConfigEntryType.BOOLEAN,
+ default_value=False,
+ label="Allow crossfade between tracks from the same album",
+ description="Enabling this option allows for crossfading between tracks "
+ "that are part of the same album.",
+ category="audio",
+ ),
ConfigEntry(
key=CONF_BIND_IP,
type=ConfigEntryType.STRING,
player=queue_player,
content_sample_rate=queue_item.streamdetails.audio_format.sample_rate,
# always use f32 internally for extra headroom for filters etc
- content_bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
+ content_bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
)
# prepare request, add some DLNA/UPNP compatible headers
)
smart_fades_mode = SmartFadesMode.DISABLED
- # work out pcm format based on output format
+ # work out pcm format based on streamdetails
pcm_format = AudioFormat(
- sample_rate=output_format.sample_rate,
+ sample_rate=queue_item.streamdetails.audio_format.sample_rate,
# always use f32 internally for extra headroom for filters etc
- content_type=ContentType.PCM_F32LE,
- bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
- channels=2,
+ content_type=INTERNAL_PCM_FORMAT.content_type,
+ bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
+ channels=queue_item.streamdetails.audio_format.channels,
)
if smart_fades_mode != SmartFadesMode.DISABLED:
# crossfade is enabled, use special crossfaded single item stream
# where the crossfade of the next track is present in the stream of
- # a single track. This only works if the player supports gapless playback.
-
+ # a single track. This only works if the player supports gapless playback!
audio_input = self.get_queue_item_stream_with_smartfade(
queue_item=queue_item,
pcm_format=pcm_format,
# append to play log so the queue controller can work out which track is playing
play_log_entry = PlayLogEntry(queue_track.queue_item_id)
queue.flow_mode_stream_log.append(play_log_entry)
- if smart_fades_mode == SmartFadesMode.SMART_FADES:
- crossfade_size = int(pcm_format.pcm_sample_size * SMART_CROSSFADE_DURATION)
- else:
- crossfade_size = int(pcm_format.pcm_sample_size * standard_crossfade_duration + 4)
+
+ # calculate crossfade buffer size
+ crossfade_buffer_duration = (
+ SMART_CROSSFADE_DURATION
+ if smart_fades_mode == SmartFadesMode.SMART_FADES
+ else standard_crossfade_duration
+ )
+ crossfade_buffer_duration = min(
+ crossfade_buffer_duration,
+ int(queue_track.streamdetails.duration / 2)
+ if queue_track.streamdetails.duration
+ else crossfade_buffer_duration,
+ )
+ crossfade_buffer_size = int(pcm_format.pcm_sample_size * crossfade_buffer_duration)
+
bytes_written = 0
buffer = b""
# handle incoming audio chunks
req_buffer_size = (
pcm_sample_size
if smart_fades_mode == SmartFadesMode.DISABLED
- else crossfade_size
+ else crossfade_buffer_size
)
# ALWAYS APPEND CHUNK TO BUFFER
#### HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
if last_fadeout_part and last_streamdetails:
# perform crossfade
- fadein_part = buffer[:crossfade_size]
- remaining_bytes = buffer[crossfade_size:]
+ fadein_part = buffer[:crossfade_buffer_size]
+ remaining_bytes = buffer[crossfade_buffer_size:]
# Use the mixer to handle all crossfade logic
crossfade_part = await self._smart_fades_mixer.mix(
fade_in_part=fadein_part,
queue_track, smart_fades_mode=smart_fades_mode, flow_mode=True
):
# if crossfade is enabled, save fadeout part to pickup for next track
- last_fadeout_part = buffer[-crossfade_size:]
+ last_fadeout_part = buffer[-crossfade_buffer_size:]
last_streamdetails = queue_track.streamdetails
last_play_log_entry = play_log_entry
- remaining_bytes = buffer[:-crossfade_size]
+ remaining_bytes = buffer[:-crossfade_buffer_size]
if remaining_bytes:
yield remaining_bytes
bytes_written += len(remaining_bytes)
streamdetails.volume_normalization_mode,
)
if allow_buffer:
- media_stream_gen = get_media_stream_with_buffer(
+ media_stream_gen = get_buffered_media_stream(
self.mass,
streamdetails=streamdetails,
pcm_format=pcm_format,
seconds_streamed = bytes_received / pcm_format.pcm_sample_size
streamdetails.seconds_streamed = seconds_streamed
self.logger.debug(
- "stream %s for %s in %.2f seconds - seconds streamed: %s",
+ "stream %s for %s in %.2f seconds - seconds streamed: %.2f",
"aborted" if aborted else "finished",
streamdetails.uri,
asyncio.get_event_loop().time() - stream_started_at,
smart_fades_mode: SmartFadesMode = SmartFadesMode.SMART_FADES,
standard_crossfade_duration: int = 10,
) -> AsyncGenerator[bytes, None]:
- """Get the audio stream for a single queue item with crossfade to the next item."""
+ """Get the audio stream for a single queue item with (smart) crossfade to the next item."""
queue = self.mass.player_queues.get(queue_item.queue_id)
if not queue:
raise RuntimeError(f"Queue {queue_item.queue_id} not found")
assert streamdetails
crossfade_data = self._crossfade_data.get(queue.queue_id)
+ if crossfade_data and crossfade_data.session_id != session_id:
+ # invalidate expired crossfade data
+ crossfade_data = None
+
self.logger.debug(
- "Start Streaming queue track: %s (%s) for queue %s - crossfade: %s",
+ "Start Streaming queue track: %s (%s) for queue %s "
+ "- crossfade mode: %s "
+ "- crossfading from previous track: %s ",
queue_item.streamdetails.uri if queue_item.streamdetails else "Unknown URI",
queue_item.name,
queue.display_name,
smart_fades_mode,
+ "true" if crossfade_data else "false",
)
- if crossfade_data and crossfade_data.session_id != session_id:
- # invalidate expired crossfade data
- crossfade_data = None
-
buffer = b""
bytes_written = 0
- if smart_fades_mode == SmartFadesMode.SMART_FADES:
- crossfade_size = int(pcm_format.pcm_sample_size * SMART_CROSSFADE_DURATION)
- else:
- crossfade_size = int(pcm_format.pcm_sample_size * standard_crossfade_duration + 4)
+ # calculate crossfade buffer size
+ crossfade_buffer_duration = (
+ SMART_CROSSFADE_DURATION
+ if smart_fades_mode == SmartFadesMode.SMART_FADES
+ else standard_crossfade_duration
+ )
+ crossfade_buffer_duration = min(
+ crossfade_buffer_duration,
+ int(streamdetails.duration / 2)
+ if streamdetails.duration
+ else crossfade_buffer_duration,
+ )
+ crossfade_buffer_size = int(pcm_format.pcm_sample_size * crossfade_buffer_duration)
fade_out_data: bytes | None = None
if crossfade_data:
- discard_seconds = int(crossfade_data.fade_in_size / pcm_format.pcm_sample_size) - 1
+ discard_seconds = (
+ int(crossfade_data.fade_in_size / crossfade_data.pcm_format.pcm_sample_size) - 1
+ )
discard_bytes = int(discard_seconds * pcm_format.pcm_sample_size)
discard_leftover = int(crossfade_data.fade_in_size - discard_bytes)
else:
async for chunk in self.get_queue_item_stream(
queue_item, pcm_format, seek_position=discard_seconds
):
+ if discard_leftover:
+ # discard leftover bytes from crossfade data
+ chunk = chunk[discard_leftover:] # noqa: PLW2901
+ discard_leftover = 0
# ALWAYS APPEND CHUNK TO BUFFER
buffer += chunk
del chunk
- if len(buffer) < crossfade_size:
+ if len(buffer) < crossfade_buffer_size:
# buffer is not full enough, move on
continue
#### HANDLE CROSSFADE DATA FROM PREVIOUS TRACK
if crossfade_data:
- # discard the fade_in_part from the crossfade data (minus what we already seeked)
- buffer = buffer[discard_leftover:]
# send the (second half of the) crossfade data
if crossfade_data.pcm_format != pcm_format:
- # pcm format mismatch, we need to resample the crossfade data
- async for _crossfade_chunk in resample_pcm_audio(
- crossfade_data.data, crossfade_data.pcm_format, pcm_format
- ):
- yield _crossfade_chunk
- bytes_written += len(_crossfade_chunk)
- del _crossfade_chunk
- else:
- yield crossfade_data.data
- bytes_written += len(crossfade_data.data)
+ # edge case: pcm format mismatch, we need to resample
+ crossfade_data.data = await resample_pcm_audio(
+ crossfade_data.data,
+ crossfade_data.pcm_format,
+ pcm_format,
+ )
+ yield crossfade_data.data
+ bytes_written += len(crossfade_data.data)
# clear vars
crossfade_data = None
#### OTHER: enough data in buffer, feed to output
- while len(buffer) > crossfade_size:
+ while len(buffer) > crossfade_buffer_size:
yield buffer[: pcm_format.pcm_sample_size]
bytes_written += pcm_format.pcm_sample_size
buffer = buffer[pcm_format.pcm_sample_size :]
#### HANDLE END OF TRACK
+ if crossfade_data:
+ # edge case: we did not get enough data to send the crossfade data
+ # send the (second half of the) crossfade data
+ if crossfade_data.pcm_format != pcm_format:
+ # (yet another) edge case: pcm format mismatch, we need to resample
+ crossfade_data.data = await resample_pcm_audio(
+ crossfade_data.data,
+ crossfade_data.pcm_format,
+ pcm_format,
+ )
+ yield crossfade_data.data
+ bytes_written += len(crossfade_data.data)
+ crossfade_data = None
if not self._crossfade_allowed(
queue_item, smart_fades_mode=smart_fades_mode, flow_mode=False
):
bytes_written += len(buffer)
yield buffer
else:
- # if crossfade is enabled, save fadeout part to pickup for next track
- fade_out_data = buffer[-crossfade_size:]
- remaining_bytes = buffer[:-crossfade_size]
- if remaining_bytes:
- yield remaining_bytes
- bytes_written += len(remaining_bytes)
- del remaining_bytes
+ # if crossfade is enabled, save fadeout part in buffer to pickup for next track
+ fade_out_data = buffer
buffer = b""
# get next track for crossfade
try:
next_queue_item = await self.mass.player_queues.load_next_queue_item(
queue.queue_id, queue_item.queue_item_id
)
- async for chunk in self.get_queue_item_stream(next_queue_item, pcm_format):
- # ALWAYS APPEND CHUNK TO BUFFER
+ next_queue_item_pcm_format = AudioFormat(
+ content_type=INTERNAL_PCM_FORMAT.content_type,
+ bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
+ sample_rate=next_queue_item.streamdetails.audio_format.sample_rate,
+ channels=next_queue_item.streamdetails.audio_format.channels,
+ )
+ async for chunk in self.get_queue_item_stream(
+ next_queue_item, next_queue_item_pcm_format
+ ):
+ # append to buffer until we reach crossfade size
+ # we only need the first X seconds of the NEXT track so we can
+ # perform the crossfade.
+ # the crossfaded audio of the previous and next track will be
+ # sent in two equal parts: first half now, second half
+ # when the next track starts. We use CrossfadeData to store
+ # the second half to be picked up by the next track's stream generator.
+ # Note that we more or less expect the user to have enabled the in-memory
+ # buffer so we can keep the next track's audio data in memory.
buffer += chunk
del chunk
- if len(buffer) < crossfade_size:
- # buffer is not full enough, move on
- continue
- #### HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
- crossfade_data = await self._smart_fades_mixer.mix(
- fade_in_part=buffer,
- fade_out_part=fade_out_data,
- fade_in_streamdetails=next_queue_item.streamdetails,
- fade_out_streamdetails=queue_item.streamdetails,
- pcm_format=pcm_format,
- standard_crossfade_duration=standard_crossfade_duration,
- mode=smart_fades_mode,
- )
- # send half of the crossfade_part (= approx the fadeout part)
- crossfade_first, crossfade_second = (
- crossfade_data[: len(crossfade_data) // 2 + len(crossfade_data) % 2],
- crossfade_data[len(crossfade_data) // 2 + len(crossfade_data) % 2 :],
- )
- bytes_written += len(crossfade_first)
- yield crossfade_first
- del crossfade_first
- # store the other half for the next track
- self._crossfade_data[queue_item.queue_id] = CrossfadeData(
- data=crossfade_second,
- fade_in_size=len(buffer),
- pcm_format=pcm_format,
- queue_item_id=next_queue_item.queue_item_id,
- session_id=session_id,
+ if len(buffer) >= crossfade_buffer_size:
+ break
+ #### HANDLE CROSSFADE OF PREVIOUS TRACK AND NEW TRACK
+ if next_queue_item_pcm_format != pcm_format:
+ # edge case: pcm format mismatch, we need to resample the next track's
+ # beginning part before crossfading
+ buffer = await resample_pcm_audio(
+ buffer,
+ next_queue_item_pcm_format,
+ pcm_format,
)
- # clear vars and break out of loop
- del crossfade_data
- break
+ crossfade_bytes = await self._smart_fades_mixer.mix(
+ fade_in_part=buffer,
+ fade_out_part=fade_out_data,
+ fade_in_streamdetails=next_queue_item.streamdetails,
+ fade_out_streamdetails=queue_item.streamdetails,
+ pcm_format=pcm_format,
+ standard_crossfade_duration=standard_crossfade_duration,
+ mode=smart_fades_mode,
+ )
+ # send half of the crossfade_part (= approx the fadeout part)
+ crossfade_first, crossfade_second = (
+ crossfade_bytes[: len(crossfade_bytes) // 2 + len(crossfade_bytes) % 2],
+ crossfade_bytes[len(crossfade_bytes) // 2 + len(crossfade_bytes) % 2 :],
+ )
+ del crossfade_bytes
+ bytes_written += len(crossfade_first)
+ yield crossfade_first
+ del crossfade_first
+ # store the other half for the next track
+ self._crossfade_data[queue_item.queue_id] = CrossfadeData(
+ data=crossfade_second,
+ fade_in_size=len(buffer),
+ pcm_format=pcm_format,
+ queue_item_id=next_queue_item.queue_item_id,
+ session_id=session_id,
+ )
+
except QueueEmpty:
# end of queue reached or crossfade failed - no crossfade possible
yield fade_out_data
player.player_id, CONF_SAMPLE_RATES, unpack_splitted_values=True
)
supported_sample_rates: tuple[int] = tuple(int(x[0]) for x in supported_rates_conf)
- output_sample_rate = DEFAULT_PCM_FORMAT.sample_rate
+ output_sample_rate = INTERNAL_PCM_FORMAT.sample_rate
for sample_rate in (192000, 96000, 48000, 44100):
if sample_rate in supported_sample_rates:
output_sample_rate = sample_rate
break
return AudioFormat(
- content_type=DEFAULT_PCM_FORMAT.content_type,
+ content_type=INTERNAL_PCM_FORMAT.content_type,
sample_rate=output_sample_rate,
- bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
+ bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
channels=2,
)
queue_item.queue_id, queue_item.queue_item_id
)
if not next_item:
+ # there is no next item!
return False
# check if next item is a track
if next_item.media_type != MediaType.TRACK:
and next_item.media_item
and next_item.media_item.album
and queue_item.media_item.album == next_item.media_item.album
+ and not self.mass.config.get_raw_core_config_value(
+ self.domain, CONF_ALLOW_CROSSFADE_SAME_ALBUM, False
+ )
):
# in general, crossfade is not desired for tracks of the same (gapless) album
# because we have no accurate way to determine if the album is gapless or not,
!= next_item.streamdetails.audio_format.sample_rate
)
and (queue_player := self.mass.players.get(queue_item.queue_id))
- and PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE not in queue_player.supported_features
+ and not (
+ PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE in queue_player.supported_features
+ or self.mass.config.get_raw_player_config_value(
+ queue_player.player_id,
+ CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES.key,
+ CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES.default_value,
+ )
+ )
):
self.logger.debug("Skipping crossfade: sample rate mismatch")
- return 0
+ return False
# all checks passed, crossfade is enabled/allowed
return True
from .audio_buffer import AudioBuffer
from .datetime import utc
from .dsp import filter_to_ffmpeg_params
-from .ffmpeg import FFMpeg, get_ffmpeg_stream
+from .ffmpeg import FFMpeg, get_ffmpeg_args, get_ffmpeg_stream
from .playlists import IsHLSPlaylist, PlaylistItem, fetch_playlist, parse_m3u
from .process import AsyncProcess, communicate
from .util import detect_charset
return streamdetails
-async def get_media_stream_with_buffer(
+async def get_buffered_media_stream(
mass: MusicAssistant,
streamdetails: StreamDetails,
pcm_format: AudioFormat,
seek_position,
)
- # checksum based on pcm_format and filter_params
- checksum = f"{pcm_format}-{filter_params}"
+ # checksum based on filter_params
+ checksum = f"{filter_params}"
async def fill_buffer_task() -> None:
"""Background task to fill the audio buffer."""
task = mass.loop.create_task(fill_buffer_task())
audio_buffer.attach_fill_task(task)
+ # special case: pcm format mismatch, resample on the fly
+ # this may happen in some special situations such as crossfading
+ # and its a bit of a waste to throw away the existing buffer
+ if audio_buffer.pcm_format != pcm_format:
+ LOGGER.info(
+ "buffered_media_stream: pcm format mismatch, resampling on the fly for %s - "
+ "buffer format: %s - requested format: %s",
+ streamdetails.uri,
+ audio_buffer.pcm_format,
+ pcm_format,
+ )
+ async for chunk in get_ffmpeg_stream(
+ audio_input=audio_buffer.iter(seek_position=seek_position),
+ input_format=audio_buffer.pcm_format,
+ output_format=pcm_format,
+ ):
+ yield chunk
+ return
+
# yield data from the buffer
chunk_count = 0
try:
first_chunk_received = True
streamdetails.audio_format.codec_type = ffmpeg_proc.input_format.codec_type
logger.debug(
- "First chunk received after %s seconds (codec detected: %s)",
+ "First chunk received after %.2f seconds (codec detected: %s)",
mass.loop.time() - stream_start,
ffmpeg_proc.input_format.codec_type,
)
async def resample_pcm_audio(
- input_audio: bytes | AsyncGenerator[bytes, None],
+ input_audio: bytes,
input_format: AudioFormat,
output_format: AudioFormat,
-) -> AsyncGenerator[bytes, None]:
+) -> bytes:
"""Resample (a chunk of) PCM audio from input_format to output_format using ffmpeg."""
- LOGGER.debug(f"Resampling audio from {input_format} to {output_format}")
-
- async def _yielder() -> AsyncGenerator[bytes, None]:
- yield input_audio # type: ignore[misc]
-
- async for chunk in get_ffmpeg_stream(
- audio_input=_yielder() if isinstance(input_audio, bytes) else input_audio,
- input_format=input_format,
- output_format=output_format,
- raise_ffmpeg_exception=True,
- ):
- yield chunk
+ if input_format == output_format:
+ return input_audio
+ LOGGER.log(VERBOSE_LOG_LEVEL, f"Resampling audio from {input_format} to {output_format}")
+ ffmpeg_args = get_ffmpeg_args(
+ input_format=input_format, output_format=output_format, filter_params=[]
+ )
+ _, stdout, _ = await communicate(ffmpeg_args, input_audio)
+ return stdout
def get_chunksize(