@@ -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