Skip to content

Commit d6669fc

Browse files
authored
Fix several edge cases for streaming (with crossfade enabled) (#2547)
1 parent 7fb30f2 commit d6669fc

File tree

8 files changed

+237
-131
lines changed

8 files changed

+237
-131
lines changed

music_assistant/constants.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,18 @@ def create_output_codec_config_entry(
630630
}
631631
)
632632

633+
CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES = ConfigEntry(
634+
key="crossfade_different_sample_rates",
635+
type=ConfigEntryType.BOOLEAN,
636+
label="Allow crossfade between tracks with different sample rates",
637+
description="Enable this option to allow crossfading between tracks that have different "
638+
"sample rates (e.g. 44.1kHz to 48kHz). \n\n "
639+
"Only enable this option if your player actually support this, otherwise you may "
640+
"experience audio glitches during crossfades.",
641+
default_value=False,
642+
category="advanced",
643+
)
644+
633645
CONF_ENTRY_WARN_PREVIEW = ConfigEntry(
634646
key="preview_note",
635647
type=ConfigEntryType.ALERT,
@@ -929,13 +941,13 @@ def create_sample_rates_config_entry(
929941
"icy-logo": MASS_LOGO_ONLINE,
930942
}
931943

932-
DEFAULT_PCM_FORMAT = AudioFormat(
944+
INTERNAL_PCM_FORMAT = AudioFormat(
933945
# always prefer float32 as internal pcm format to create headroom
934946
# for filters such as dsp and volume normalization
935947
content_type=ContentType.PCM_F32LE,
936-
sample_rate=48000,
937-
bit_depth=32,
938-
channels=2,
948+
bit_depth=32, # related to float32
949+
sample_rate=48000, # static for flow stream, dynamic for anything else
950+
channels=2, # static for flow stream, dynamic for anything else
939951
)
940952

941953
# extra data / extra attributes keys

music_assistant/controllers/streams.py

Lines changed: 167 additions & 91 deletions
Large diffs are not rendered by default.

music_assistant/helpers/audio.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
from .audio_buffer import AudioBuffer
5353
from .datetime import utc
5454
from .dsp import filter_to_ffmpeg_params
55-
from .ffmpeg import FFMpeg, get_ffmpeg_stream
55+
from .ffmpeg import FFMpeg, get_ffmpeg_args, get_ffmpeg_stream
5656
from .playlists import IsHLSPlaylist, PlaylistItem, fetch_playlist, parse_m3u
5757
from .process import AsyncProcess, communicate
5858
from .util import detect_charset
@@ -421,7 +421,7 @@ async def get_stream_details(
421421
return streamdetails
422422

423423

424-
async def get_media_stream_with_buffer(
424+
async def get_buffered_media_stream(
425425
mass: MusicAssistant,
426426
streamdetails: StreamDetails,
427427
pcm_format: AudioFormat,
@@ -436,8 +436,8 @@ async def get_media_stream_with_buffer(
436436
seek_position,
437437
)
438438

439-
# checksum based on pcm_format and filter_params
440-
checksum = f"{pcm_format}-{filter_params}"
439+
# checksum based on filter_params
440+
checksum = f"{filter_params}"
441441

442442
async def fill_buffer_task() -> None:
443443
"""Background task to fill the audio buffer."""
@@ -528,6 +528,25 @@ async def fill_buffer_task() -> None:
528528
task = mass.loop.create_task(fill_buffer_task())
529529
audio_buffer.attach_fill_task(task)
530530

531+
# special case: pcm format mismatch, resample on the fly
532+
# this may happen in some special situations such as crossfading
533+
# and its a bit of a waste to throw away the existing buffer
534+
if audio_buffer.pcm_format != pcm_format:
535+
LOGGER.info(
536+
"buffered_media_stream: pcm format mismatch, resampling on the fly for %s - "
537+
"buffer format: %s - requested format: %s",
538+
streamdetails.uri,
539+
audio_buffer.pcm_format,
540+
pcm_format,
541+
)
542+
async for chunk in get_ffmpeg_stream(
543+
audio_input=audio_buffer.iter(seek_position=seek_position),
544+
input_format=audio_buffer.pcm_format,
545+
output_format=pcm_format,
546+
):
547+
yield chunk
548+
return
549+
531550
# yield data from the buffer
532551
chunk_count = 0
533552
try:
@@ -631,7 +650,7 @@ async def get_media_stream(
631650
first_chunk_received = True
632651
streamdetails.audio_format.codec_type = ffmpeg_proc.input_format.codec_type
633652
logger.debug(
634-
"First chunk received after %s seconds (codec detected: %s)",
653+
"First chunk received after %.2f seconds (codec detected: %s)",
635654
mass.loop.time() - stream_start,
636655
ffmpeg_proc.input_format.codec_type,
637656
)
@@ -1209,23 +1228,19 @@ async def get_silence(
12091228

12101229

12111230
async def resample_pcm_audio(
1212-
input_audio: bytes | AsyncGenerator[bytes, None],
1231+
input_audio: bytes,
12131232
input_format: AudioFormat,
12141233
output_format: AudioFormat,
1215-
) -> AsyncGenerator[bytes, None]:
1234+
) -> bytes:
12161235
"""Resample (a chunk of) PCM audio from input_format to output_format using ffmpeg."""
1217-
LOGGER.debug(f"Resampling audio from {input_format} to {output_format}")
1218-
1219-
async def _yielder() -> AsyncGenerator[bytes, None]:
1220-
yield input_audio # type: ignore[misc]
1221-
1222-
async for chunk in get_ffmpeg_stream(
1223-
audio_input=_yielder() if isinstance(input_audio, bytes) else input_audio,
1224-
input_format=input_format,
1225-
output_format=output_format,
1226-
raise_ffmpeg_exception=True,
1227-
):
1228-
yield chunk
1236+
if input_format == output_format:
1237+
return input_audio
1238+
LOGGER.log(VERBOSE_LOG_LEVEL, f"Resampling audio from {input_format} to {output_format}")
1239+
ffmpeg_args = get_ffmpeg_args(
1240+
input_format=input_format, output_format=output_format, filter_params=[]
1241+
)
1242+
_, stdout, _ = await communicate(ffmpeg_args, input_audio)
1243+
return stdout
12291244

12301245

12311246
def get_chunksize(

music_assistant/providers/airplay/constants.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from music_assistant_models.enums import ContentType
88
from music_assistant_models.media_items import AudioFormat
99

10-
from music_assistant.constants import DEFAULT_PCM_FORMAT
10+
from music_assistant.constants import INTERNAL_PCM_FORMAT
1111

1212
DOMAIN = "airplay"
1313

@@ -27,9 +27,9 @@
2727
FALLBACK_VOLUME: Final[int] = 20
2828

2929
AIRPLAY_FLOW_PCM_FORMAT = AudioFormat(
30-
content_type=DEFAULT_PCM_FORMAT.content_type,
30+
content_type=INTERNAL_PCM_FORMAT.content_type,
3131
sample_rate=44100,
32-
bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
32+
bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
3333
)
3434
AIRPLAY_PCM_FORMAT = AudioFormat(
3535
content_type=ContentType.from_bit_depth(16), sample_rate=44100, bit_depth=16

music_assistant/providers/builtin_player/player.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
CONF_MUTE_CONTROL,
2828
CONF_POWER_CONTROL,
2929
CONF_VOLUME_CONTROL,
30-
DEFAULT_PCM_FORMAT,
3130
DEFAULT_STREAM_HEADERS,
31+
INTERNAL_PCM_FORMAT,
3232
create_sample_rates_config_entry,
3333
)
3434
from music_assistant.helpers.audio import get_player_filter_params
@@ -274,9 +274,9 @@ async def _serve_audio_stream(self, request: web.Request) -> web.StreamResponse:
274274

275275
pcm_format = AudioFormat(
276276
sample_rate=stream_format.sample_rate,
277-
content_type=DEFAULT_PCM_FORMAT.content_type,
278-
bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
279-
channels=DEFAULT_PCM_FORMAT.channels,
277+
content_type=INTERNAL_PCM_FORMAT.content_type,
278+
bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
279+
channels=INTERNAL_PCM_FORMAT.channels,
280280
)
281281
async for chunk in get_ffmpeg_stream(
282282
audio_input=self.mass.streams.get_queue_flow_stream(

music_assistant/providers/snapcast/player.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
ATTR_ANNOUNCEMENT_IN_PROGRESS,
2020
CONF_ENTRY_FLOW_MODE_ENFORCED,
2121
CONF_ENTRY_OUTPUT_CODEC_HIDDEN,
22-
DEFAULT_PCM_FORMAT,
22+
INTERNAL_PCM_FORMAT,
2323
)
2424
from music_assistant.helpers.audio import get_player_filter_params
2525
from music_assistant.helpers.compare import create_safe_string
@@ -216,7 +216,7 @@ async def play_media(self, media: PlayerMedia) -> None:
216216
audio_source = self.mass.streams.get_queue_flow_stream(
217217
queue=queue,
218218
start_queue_item=start_queue_item,
219-
pcm_format=DEFAULT_PCM_FORMAT,
219+
pcm_format=INTERNAL_PCM_FORMAT,
220220
)
221221
else:
222222
# assume url or some other direct path

music_assistant/providers/squeezelite/player.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@
3333
CONF_ENTRY_DEPRECATED_EQ_TREBLE,
3434
CONF_ENTRY_HTTP_PROFILE_FORCED_2,
3535
CONF_ENTRY_OUTPUT_CODEC,
36+
CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES,
3637
CONF_ENTRY_SYNC_ADJUST,
37-
DEFAULT_PCM_FORMAT,
38+
INTERNAL_PCM_FORMAT,
3839
VERBOSE_LOG_LEVEL,
3940
create_sample_rates_config_entry,
4041
)
@@ -92,6 +93,7 @@ def __init__(
9293
PlayerFeature.VOLUME_MUTE,
9394
PlayerFeature.ENQUEUE,
9495
PlayerFeature.GAPLESS_PLAYBACK,
96+
PlayerFeature.GAPLESS_DIFFERENT_SAMPLERATE,
9597
}
9698
self._attr_can_group_with = {provider.lookup_key}
9799
self.multi_client_stream: MultiClientStream | None = None
@@ -162,6 +164,7 @@ async def get_config_entries(self) -> list[ConfigEntry]:
162164
create_sample_rates_config_entry(
163165
max_sample_rate=max_sample_rate, max_bit_depth=24, safe_max_bit_depth=24
164166
),
167+
CONF_ENTRY_SUPPORT_CROSSFADE_DIFFERENT_SAMPLE_RATES,
165168
]
166169

167170
async def power(self, powered: bool) -> None:
@@ -229,9 +232,9 @@ async def play_media(self, media: PlayerMedia) -> None:
229232

230233
# this is a syncgroup, we need to handle this with a multi client stream
231234
master_audio_format = AudioFormat(
232-
content_type=DEFAULT_PCM_FORMAT.content_type,
233-
sample_rate=DEFAULT_PCM_FORMAT.sample_rate,
234-
bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
235+
content_type=INTERNAL_PCM_FORMAT.content_type,
236+
sample_rate=INTERNAL_PCM_FORMAT.sample_rate,
237+
bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
235238
)
236239
if media.media_type == MediaType.ANNOUNCEMENT:
237240
# special case: stream announcement

music_assistant/providers/universal_group/constants.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from music_assistant_models.enums import ConfigEntryType
99
from music_assistant_models.media_items import AudioFormat
1010

11-
from music_assistant.constants import DEFAULT_PCM_FORMAT, create_sample_rates_config_entry
11+
from music_assistant.constants import INTERNAL_PCM_FORMAT, create_sample_rates_config_entry
1212

1313
UGP_PREFIX: Final[str] = "ugp_"
1414

@@ -29,7 +29,7 @@
2929

3030

3131
UGP_FORMAT = AudioFormat(
32-
content_type=DEFAULT_PCM_FORMAT.content_type,
33-
sample_rate=DEFAULT_PCM_FORMAT.sample_rate,
34-
bit_depth=DEFAULT_PCM_FORMAT.bit_depth,
32+
content_type=INTERNAL_PCM_FORMAT.content_type,
33+
sample_rate=INTERNAL_PCM_FORMAT.sample_rate,
34+
bit_depth=INTERNAL_PCM_FORMAT.bit_depth,
3535
)

0 commit comments

Comments
 (0)