From: Marcel van der Veldt Date: Sat, 25 Oct 2025 15:20:10 +0000 (+0200) Subject: Fix several edge cases for streaming (with crossfade enabled) (#2547) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=d6669fc4fdf5440a3b620c1abe3d267a09e65c57;p=music-assistant-server.git Fix several edge cases for streaming (with crossfade enabled) (#2547) --- diff --git a/music_assistant/constants.py b/music_assistant/constants.py index 9cfaa561..3ead2c86 100644 --- a/music_assistant/constants.py +++ b/music_assistant/constants.py @@ -630,6 +630,18 @@ CONF_ENTRY_ICY_METADATA_HIDDEN_DISABLED = ConfigEntry.from_dict( } ) +CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES = ConfigEntry( + key="crossfade_different_sample_rates", + type=ConfigEntryType.BOOLEAN, + label="Allow crossfade between tracks with different sample rates", + description="Enable this option to allow crossfading between tracks that have different " + "sample rates (e.g. 44.1kHz to 48kHz). \n\n " + "Only enable this option if your player actually support this, otherwise you may " + "experience audio glitches during crossfades.", + default_value=False, + category="advanced", +) + CONF_ENTRY_WARN_PREVIEW = ConfigEntry( key="preview_note", type=ConfigEntryType.ALERT, @@ -929,13 +941,13 @@ ICY_HEADERS = { "icy-logo": MASS_LOGO_ONLINE, } -DEFAULT_PCM_FORMAT = AudioFormat( +INTERNAL_PCM_FORMAT = AudioFormat( # always prefer float32 as internal pcm format to create headroom # for filters such as dsp and volume normalization content_type=ContentType.PCM_F32LE, - sample_rate=48000, - bit_depth=32, - channels=2, + bit_depth=32, # related to float32 + sample_rate=48000, # static for flow stream, dynamic for anything else + channels=2, # static for flow stream, dynamic for anything else ) # extra data / extra attributes keys diff --git a/music_assistant/controllers/streams.py b/music_assistant/controllers/streams.py index fda52039..8982606f 100644 --- a/music_assistant/controllers/streams.py +++ b/music_assistant/controllers/streams.py @@ -38,6 +38,7 @@ from music_assistant.constants import ( 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, @@ -48,18 +49,18 @@ from music_assistant.constants import ( 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, @@ -92,6 +93,7 @@ if TYPE_CHECKING: 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]: @@ -231,6 +233,15 @@ class StreamsController(CoreController): 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, @@ -380,7 +391,7 @@ class StreamsController(CoreController): 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 @@ -435,19 +446,18 @@ class StreamsController(CoreController): ) 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, @@ -865,10 +875,21 @@ class StreamsController(CoreController): # 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 @@ -881,7 +902,7 @@ class StreamsController(CoreController): req_buffer_size = ( pcm_sample_size if smart_fades_mode == SmartFadesMode.DISABLED - else crossfade_size + else crossfade_buffer_size ) # ALWAYS APPEND CHUNK TO BUFFER @@ -894,8 +915,8 @@ class StreamsController(CoreController): #### 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, @@ -944,10 +965,10 @@ class StreamsController(CoreController): 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) @@ -1127,7 +1148,7 @@ class StreamsController(CoreController): 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, @@ -1191,7 +1212,7 @@ class StreamsController(CoreController): 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, @@ -1217,7 +1238,7 @@ class StreamsController(CoreController): 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") @@ -1226,28 +1247,42 @@ class StreamsController(CoreController): 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: @@ -1257,40 +1292,53 @@ class StreamsController(CoreController): 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 ): @@ -1298,55 +1346,72 @@ class StreamsController(CoreController): 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 @@ -1433,15 +1498,15 @@ class StreamsController(CoreController): 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, ) @@ -1461,6 +1526,7 @@ class StreamsController(CoreController): 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: @@ -1474,6 +1540,9 @@ class StreamsController(CoreController): 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, @@ -1490,9 +1559,16 @@ class StreamsController(CoreController): != 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 diff --git a/music_assistant/helpers/audio.py b/music_assistant/helpers/audio.py index 3f8d3929..fede30dd 100644 --- a/music_assistant/helpers/audio.py +++ b/music_assistant/helpers/audio.py @@ -52,7 +52,7 @@ from music_assistant.helpers.util import clean_stream_title, remove_file 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 @@ -421,7 +421,7 @@ async def get_stream_details( return streamdetails -async def get_media_stream_with_buffer( +async def get_buffered_media_stream( mass: MusicAssistant, streamdetails: StreamDetails, pcm_format: AudioFormat, @@ -436,8 +436,8 @@ async def get_media_stream_with_buffer( 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.""" @@ -528,6 +528,25 @@ async def get_media_stream_with_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: @@ -631,7 +650,7 @@ async def get_media_stream( 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, ) @@ -1209,23 +1228,19 @@ async def get_silence( 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( diff --git a/music_assistant/providers/airplay/constants.py b/music_assistant/providers/airplay/constants.py index edca96a1..e02a5982 100644 --- a/music_assistant/providers/airplay/constants.py +++ b/music_assistant/providers/airplay/constants.py @@ -7,7 +7,7 @@ from typing import Final from music_assistant_models.enums import ContentType from music_assistant_models.media_items import AudioFormat -from music_assistant.constants import DEFAULT_PCM_FORMAT +from music_assistant.constants import INTERNAL_PCM_FORMAT DOMAIN = "airplay" @@ -27,9 +27,9 @@ BACKOFF_TIME_UPPER_LIMIT: Final[int] = 300 # Five minutes FALLBACK_VOLUME: Final[int] = 20 AIRPLAY_FLOW_PCM_FORMAT = AudioFormat( - content_type=DEFAULT_PCM_FORMAT.content_type, + content_type=INTERNAL_PCM_FORMAT.content_type, sample_rate=44100, - bit_depth=DEFAULT_PCM_FORMAT.bit_depth, + bit_depth=INTERNAL_PCM_FORMAT.bit_depth, ) AIRPLAY_PCM_FORMAT = AudioFormat( content_type=ContentType.from_bit_depth(16), sample_rate=44100, bit_depth=16 diff --git a/music_assistant/providers/builtin_player/player.py b/music_assistant/providers/builtin_player/player.py index 92548e7e..25160c1e 100644 --- a/music_assistant/providers/builtin_player/player.py +++ b/music_assistant/providers/builtin_player/player.py @@ -27,8 +27,8 @@ from music_assistant.constants import ( CONF_MUTE_CONTROL, CONF_POWER_CONTROL, CONF_VOLUME_CONTROL, - DEFAULT_PCM_FORMAT, DEFAULT_STREAM_HEADERS, + INTERNAL_PCM_FORMAT, create_sample_rates_config_entry, ) from music_assistant.helpers.audio import get_player_filter_params @@ -274,9 +274,9 @@ class BuiltinPlayer(Player): pcm_format = AudioFormat( sample_rate=stream_format.sample_rate, - content_type=DEFAULT_PCM_FORMAT.content_type, - bit_depth=DEFAULT_PCM_FORMAT.bit_depth, - channels=DEFAULT_PCM_FORMAT.channels, + content_type=INTERNAL_PCM_FORMAT.content_type, + bit_depth=INTERNAL_PCM_FORMAT.bit_depth, + channels=INTERNAL_PCM_FORMAT.channels, ) async for chunk in get_ffmpeg_stream( audio_input=self.mass.streams.get_queue_flow_stream( diff --git a/music_assistant/providers/snapcast/player.py b/music_assistant/providers/snapcast/player.py index a216603c..eab60597 100644 --- a/music_assistant/providers/snapcast/player.py +++ b/music_assistant/providers/snapcast/player.py @@ -19,7 +19,7 @@ from music_assistant.constants import ( ATTR_ANNOUNCEMENT_IN_PROGRESS, CONF_ENTRY_FLOW_MODE_ENFORCED, CONF_ENTRY_OUTPUT_CODEC_HIDDEN, - DEFAULT_PCM_FORMAT, + INTERNAL_PCM_FORMAT, ) from music_assistant.helpers.audio import get_player_filter_params from music_assistant.helpers.compare import create_safe_string @@ -216,7 +216,7 @@ class SnapCastPlayer(Player): audio_source = self.mass.streams.get_queue_flow_stream( queue=queue, start_queue_item=start_queue_item, - pcm_format=DEFAULT_PCM_FORMAT, + pcm_format=INTERNAL_PCM_FORMAT, ) else: # assume url or some other direct path diff --git a/music_assistant/providers/squeezelite/player.py b/music_assistant/providers/squeezelite/player.py index 965d2af6..16a4617e 100644 --- a/music_assistant/providers/squeezelite/player.py +++ b/music_assistant/providers/squeezelite/player.py @@ -33,8 +33,9 @@ from music_assistant.constants import ( CONF_ENTRY_DEPRECATED_EQ_TREBLE, CONF_ENTRY_HTTP_PROFILE_FORCED_2, CONF_ENTRY_OUTPUT_CODEC, + CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES, CONF_ENTRY_SYNC_ADJUST, - DEFAULT_PCM_FORMAT, + INTERNAL_PCM_FORMAT, VERBOSE_LOG_LEVEL, create_sample_rates_config_entry, ) @@ -92,6 +93,7 @@ class SqueezelitePlayer(Player): PlayerFeature.VOLUME_MUTE, PlayerFeature.ENQUEUE, PlayerFeature.GAPLESS_PLAYBACK, + PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE, } self._attr_can_group_with = {provider.lookup_key} self.multi_client_stream: MultiClientStream | None = None @@ -162,6 +164,7 @@ class SqueezelitePlayer(Player): create_sample_rates_config_entry( max_sample_rate=max_sample_rate, max_bit_depth=24, safe_max_bit_depth=24 ), + CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES, ] async def power(self, powered: bool) -> None: @@ -229,9 +232,9 @@ class SqueezelitePlayer(Player): # this is a syncgroup, we need to handle this with a multi client stream master_audio_format = AudioFormat( - content_type=DEFAULT_PCM_FORMAT.content_type, - sample_rate=DEFAULT_PCM_FORMAT.sample_rate, - bit_depth=DEFAULT_PCM_FORMAT.bit_depth, + content_type=INTERNAL_PCM_FORMAT.content_type, + sample_rate=INTERNAL_PCM_FORMAT.sample_rate, + bit_depth=INTERNAL_PCM_FORMAT.bit_depth, ) if media.media_type == MediaType.ANNOUNCEMENT: # special case: stream announcement diff --git a/music_assistant/providers/universal_group/constants.py b/music_assistant/providers/universal_group/constants.py index 27d1a795..46f4b705 100644 --- a/music_assistant/providers/universal_group/constants.py +++ b/music_assistant/providers/universal_group/constants.py @@ -8,7 +8,7 @@ from music_assistant_models.config_entries import ConfigEntry from music_assistant_models.enums import ConfigEntryType from music_assistant_models.media_items import AudioFormat -from music_assistant.constants import DEFAULT_PCM_FORMAT, create_sample_rates_config_entry +from music_assistant.constants import INTERNAL_PCM_FORMAT, create_sample_rates_config_entry UGP_PREFIX: Final[str] = "ugp_" @@ -29,7 +29,7 @@ CONFIG_ENTRY_UGP_NOTE = ConfigEntry( UGP_FORMAT = AudioFormat( - content_type=DEFAULT_PCM_FORMAT.content_type, - sample_rate=DEFAULT_PCM_FORMAT.sample_rate, - bit_depth=DEFAULT_PCM_FORMAT.bit_depth, + content_type=INTERNAL_PCM_FORMAT.content_type, + sample_rate=INTERNAL_PCM_FORMAT.sample_rate, + bit_depth=INTERNAL_PCM_FORMAT.bit_depth, )