Skip to content

Commit 9058d49

Browse files
authored
Fix standard crossfade falling back to a hard cut on some tracks (#4253)
1 parent 6746223 commit 9058d49

4 files changed

Lines changed: 142 additions & 15 deletions

File tree

music_assistant/controllers/streams/smart_fades/fades.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,9 @@ async def _drain_stderr() -> None:
210210

211211
if proc.returncode != 0:
212212
stderr_msg = "; ".join(stderr_lines) if stderr_lines else "(no stderr)"
213-
raise RuntimeError(
214-
f"Smart crossfade FFmpeg failed (rc={proc.returncode}): {stderr_msg}"
215-
)
213+
raise RuntimeError(f"Crossfade FFmpeg failed (rc={proc.returncode}): {stderr_msg}")
216214
if not got_output:
217-
msg = "Smart crossfade FFmpeg produced no output"
215+
msg = "Crossfade FFmpeg produced no output"
218216
if stderr_lines:
219217
msg += f": {'; '.join(stderr_lines)}"
220218
raise RuntimeError(msg)
@@ -768,6 +766,7 @@ def __init__(self, logger: logging.Logger, crossfade_duration: float = 10.0) ->
768766
super().__init__(logger)
769767
self.crossfade_duration = crossfade_duration
770768
self.trailing_silence_bytes: int = 0
769+
self.crossfade_size: int = 0
771770

772771
def _build(
773772
self,
@@ -780,14 +779,24 @@ def _build(
780779
fade_in_seconds = fade_in_bytes_len / pcm_format.pcm_sample_size
781780
# clamp CF to fit shorter inputs (defensive — normally full buffers)
782781
effective_cf = min(self.crossfade_duration, fade_out_seconds, fade_in_seconds)
782+
# Quantize the overlap to a whole number of PCM frames and drive both the
783+
# byte slice (in apply) and the acrossfade length from this one integer.
784+
# apply slices the buffers on frame boundaries, so a fractional effective_cf
785+
# leaves the rendered buffer a fraction of a sample short of the acrossfade
786+
# duration — and acrossfade then silently produces no output at all.
787+
frame_size = (pcm_format.bit_depth // 8) * pcm_format.channels
788+
crossfade_bytes = int(pcm_format.pcm_sample_size * effective_cf)
789+
self.crossfade_size = crossfade_bytes // frame_size * frame_size
790+
crossfade_samples = self.crossfade_size // frame_size
791+
effective_cf = self.crossfade_size / pcm_format.pcm_sample_size
783792
self.timing_info = CrossfadeTimingInfo(
784793
pre_crossfade_duration=max(0.0, fade_out_seconds - effective_cf),
785794
crossfade_duration=effective_cf,
786795
fadein_trimmed_duration=0.0,
787796
post_crossfade_duration=max(0.0, fade_in_seconds - effective_cf),
788797
)
789798
self.filters = [
790-
CrossfadeFilter(logger=self.logger, crossfade_duration=effective_cf),
799+
CrossfadeFilter(logger=self.logger, crossfade_samples=crossfade_samples),
791800
]
792801

793802
async def apply(
@@ -801,11 +810,16 @@ async def apply(
801810
802811
Only the overlapping portions are crossfaded, not the full buffers.
803812
"""
813+
# crossfade_size legitimately ends up 0 for a silent/tiny buffer, so guard on
814+
# the filter chain (set in _build) to still fail fast on apply-before-build,
815+
# consistent with SmartFade._get_ffmpeg_filters()
816+
if not self.filters:
817+
raise RuntimeError("SmartFade not built — call Mixer.build() first")
804818
if self.trailing_silence_bytes:
805819
fade_out_part = fade_out_part[: len(fade_out_part) - self.trailing_silence_bytes]
806-
frame_size = (pcm_format.bit_depth // 8) * pcm_format.channels
807-
crossfade_size = int(pcm_format.pcm_sample_size * self.timing_info.crossfade_duration)
808-
crossfade_size = (crossfade_size // frame_size) * frame_size
820+
# frame-aligned overlap computed once in _build, so it exactly matches the
821+
# acrossfade `ns=` length the filter was built with
822+
crossfade_size = self.crossfade_size
809823
if crossfade_size == 0:
810824
# nothing to blend — concatenate without spawning ffmpeg
811825
for pcm_slice in iter_pcm_slices(fade_out_part, pcm_format, 1000):

music_assistant/controllers/streams/smart_fades/filters.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,19 +239,40 @@ class CrossfadeFilter(Filter):
239239
output_fadeout_label: str = "crossfade"
240240
output_fadein_label: str = "crossfade"
241241

242-
def __init__(self, logger: logging.Logger, crossfade_duration: float):
243-
"""Initialize crossfade filter."""
242+
def __init__(
243+
self,
244+
logger: logging.Logger,
245+
crossfade_duration: float | None = None,
246+
crossfade_samples: int | None = None,
247+
):
248+
"""
249+
Initialize crossfade filter.
250+
251+
:param crossfade_duration: Overlap length in seconds (emits acrossfade ``d=``).
252+
:param crossfade_samples: Overlap length in PCM samples (emits acrossfade ``ns=``).
253+
Prefer this when the inputs are pre-trimmed to exactly the overlap region:
254+
acrossfade silently emits nothing when its requested length exceeds the
255+
buffer it is fed, and a fractional ``d`` can round just past a
256+
frame-aligned buffer. A sample count cannot.
257+
"""
258+
if (crossfade_duration is None) == (crossfade_samples is None):
259+
raise ValueError("Provide exactly one of crossfade_duration or crossfade_samples")
244260
self.crossfade_duration = crossfade_duration
261+
self.crossfade_samples = crossfade_samples
245262
super().__init__(logger)
246263

247264
def apply(self, input_fadein_label: str, input_fadeout_label: str) -> list[str]:
248265
"""Apply the acrossfade filter."""
266+
overlap = (
267+
f"ns={self.crossfade_samples}"
268+
if self.crossfade_samples is not None
269+
else f"d={self.crossfade_duration}"
270+
)
249271
# equal-power qsin curves; the default tri/tri dips ~3dB mid-fade on uncorrelated material
250-
return [
251-
f"{input_fadeout_label}{input_fadein_label}"
252-
f"acrossfade=d={self.crossfade_duration}:c1=qsin:c2=qsin"
253-
]
272+
return [f"{input_fadeout_label}{input_fadein_label}acrossfade={overlap}:c1=qsin:c2=qsin"]
254273

255274
def __repr__(self) -> str:
256275
"""Return string representation of CrossfadeFilter."""
276+
if self.crossfade_samples is not None:
277+
return f"Crossfade(ns={self.crossfade_samples})"
257278
return f"Crossfade(d={self.crossfade_duration:.1f}s)"

tests/controllers/streams/smart_fades/test_filters.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,3 +201,24 @@ def test_crossfade_uses_equal_power_curves() -> None:
201201
crossfade = CrossfadeFilter(logger=LOGGER, crossfade_duration=12.5)
202202
filter_strings = crossfade.apply("[fadein]", "[fadeout]")
203203
assert filter_strings == ["[fadeout][fadein]acrossfade=d=12.5:c1=qsin:c2=qsin"]
204+
205+
206+
def test_crossfade_sample_count_uses_ns() -> None:
207+
"""
208+
A sample-count crossfade must emit ``acrossfade=ns=`` rather than ``d=``.
209+
210+
ffmpeg's ``acrossfade`` silently produces no output when its requested length
211+
overruns the buffer it is fed; an integer sample count matches a frame-aligned
212+
buffer exactly, where a fractional ``d`` can round just past it.
213+
"""
214+
crossfade = CrossfadeFilter(logger=LOGGER, crossfade_samples=441000)
215+
filter_strings = crossfade.apply("[fadein]", "[fadeout]")
216+
assert filter_strings == ["[fadeout][fadein]acrossfade=ns=441000:c1=qsin:c2=qsin"]
217+
218+
219+
def test_crossfade_requires_exactly_one_length() -> None:
220+
"""CrossfadeFilter must reject ambiguous or missing length specifications."""
221+
with pytest.raises(ValueError, match="exactly one"):
222+
CrossfadeFilter(logger=LOGGER)
223+
with pytest.raises(ValueError, match="exactly one"):
224+
CrossfadeFilter(logger=LOGGER, crossfade_duration=5.0, crossfade_samples=220500)

tests/core/test_smartfade_transition_timings.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,32 @@ def test_filter_duration_matches_clamped_timing(self) -> None:
220220
assert fade.timing_info.crossfade_duration == pytest.approx(6.0)
221221
crossfade_filter = fade.filters[0]
222222
assert isinstance(crossfade_filter, CrossfadeFilter)
223-
assert crossfade_filter.crossfade_duration == pytest.approx(6.0)
223+
assert crossfade_filter.crossfade_samples == int(6.0 * PCM.sample_rate)
224+
225+
def test_fractional_overlap_keeps_filter_aligned_to_buffer(self) -> None:
226+
"""
227+
A non-integer clamped overlap keeps acrossfade ``ns=`` aligned to the buffer.
228+
229+
Regression for the silent "FFmpeg produced no output" fallback: a fractional
230+
effective crossfade made the byte slice a fraction of a sample shorter than the
231+
``d=`` the filter requested, so ffmpeg's acrossfade emitted nothing.
232+
"""
233+
frame_size = (PCM.bit_depth // 8) * PCM.channels
234+
# ~6.3333s of audible fade-out: a real PCM buffer is frame-aligned, yet still not a
235+
# whole number of seconds, so the effective crossfade stays fractional
236+
fade_out_len = _seconds(6.3333) // frame_size * frame_size
237+
fade = StandardCrossFade(logger=logging.getLogger(), crossfade_duration=10.0)
238+
fade._build(fade_out_len, _seconds(45), PCM)
239+
crossfade_filter = fade.filters[0]
240+
assert isinstance(crossfade_filter, CrossfadeFilter)
241+
# the source-of-truth byte size is frame-aligned ...
242+
assert fade.crossfade_size % frame_size == 0
243+
# ... and the acrossfade sample count is exactly that buffer, in samples
244+
assert crossfade_filter.crossfade_samples == fade.crossfade_size // frame_size
245+
# the timing duration round-trips from the same integer, never the other way
246+
assert fade.timing_info.crossfade_duration == pytest.approx(
247+
fade.crossfade_size / PCM.pcm_sample_size
248+
)
224249

225250

226251
# ---------------------------------------------------------------------------
@@ -258,6 +283,52 @@ async def fake_base_apply(
258283
# nothing precedes the crossfade — the 6s buffer is consumed entirely by the overlap
259284
assert chunks[0] == crossfade_marker
260285

286+
@pytest.mark.asyncio
287+
async def test_apply_feeds_exactly_the_filter_sample_count(
288+
self, monkeypatch: pytest.MonkeyPatch
289+
) -> None:
290+
"""
291+
apply() must feed the base mixer exactly the acrossfade ``ns=`` sample count.
292+
293+
Otherwise ffmpeg's acrossfade receives fewer samples than requested and emits
294+
nothing — the silent crossfade failure this regression guards against.
295+
"""
296+
captured: dict[str, bytes] = {}
297+
298+
async def fake_base_apply(
299+
_self: SmartFade,
300+
fade_out_part: bytes,
301+
fade_in_part: bytes | AsyncGenerator[bytes],
302+
_pcm_format: AudioFormat,
303+
) -> AsyncGenerator[bytes]:
304+
captured["fade_out"] = fade_out_part
305+
assert isinstance(fade_in_part, bytes)
306+
captured["fade_in"] = fade_in_part
307+
yield b"crossfade-output"
308+
309+
monkeypatch.setattr(SmartFade, "apply", fake_base_apply)
310+
frame_size = (PCM.bit_depth // 8) * PCM.channels
311+
# frame-aligned like a real PCM buffer, but a fractional number of seconds
312+
fade_out_len = _seconds(6.3333) // frame_size * frame_size
313+
fade = StandardCrossFade(logger=logging.getLogger(), crossfade_duration=10.0)
314+
fade._build(fade_out_len, _seconds(45), PCM)
315+
crossfade_filter = fade.filters[0]
316+
assert isinstance(crossfade_filter, CrossfadeFilter)
317+
assert crossfade_filter.crossfade_samples is not None
318+
async for _ in fade.apply(b"\x00" * fade_out_len, b"\x11" * _seconds(45), PCM):
319+
pass
320+
expected_bytes = crossfade_filter.crossfade_samples * frame_size
321+
assert len(captured["fade_out"]) == expected_bytes
322+
assert len(captured["fade_in"]) == expected_bytes
323+
324+
@pytest.mark.asyncio
325+
async def test_apply_before_build_fails_fast(self) -> None:
326+
"""apply() without a prior _build() must error, not silently hard-cut."""
327+
fade = StandardCrossFade(logger=logging.getLogger(), crossfade_duration=10.0)
328+
with pytest.raises(RuntimeError, match="not built"):
329+
async for _ in fade.apply(b"\x00" * _seconds(5), b"\x11" * _seconds(5), PCM):
330+
pass
331+
261332
@pytest.mark.asyncio
262333
async def test_zero_crossfade_skips_ffmpeg(self, monkeypatch: pytest.MonkeyPatch) -> None:
263334
"""When crossfade_duration == 0, apply() must concatenate without calling ffmpeg."""

0 commit comments

Comments
 (0)