diff --git a/core/ffmpeg.py b/core/ffmpeg.py index f3f178f..faebe28 100644 --- a/core/ffmpeg.py +++ b/core/ffmpeg.py @@ -314,6 +314,41 @@ def build_audio_delete_command(input_path: str, start: float, end: float, "-filter_complex", fc, "-map", "[out]", *codec, out_path] +def _auto_heal_crossfade(start: float, end: float, + requested: float | None = None) -> float: + if end <= start: + raise ValueError("heal delete end must be greater than start") + if requested is not None: + fade = max(0.0, float(requested)) + else: + fade = min(0.25, max(0.04, (end - start) * 0.25)) + # Without knowing total duration, clamp only to available pre-roll. + fade = min(fade, max(0.0, float(start))) + return round(fade, 3) + + +def build_audio_heal_delete_command(input_path: str, start: float, end: float, + out_path: str, + crossfade: float | None = None) -> list[str]: + """Remove [start, end] and heal the join with a short equal-power crossfade.""" + if end <= start: + raise ValueError("heal delete end must be greater than start") + s, e = round(start, 3), round(end, 3) + xf = _auto_heal_crossfade(s, e, crossfade) + ext = os.path.splitext(out_path)[1].lower() + codec = _AUDIO_CODEC_BY_EXT.get(ext, []) + if xf <= 0: + fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];" + f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];" + f"[a][b]concat=n=2:v=0:a=1[out]") + else: + fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];" + f"[0]atrim=start={e},asetpts=PTS-STARTPTS[b];" + f"[a][b]acrossfade=d={xf}:c1=qsin:c2=qsin[out]") + return [_bin("ffmpeg"), "-y", "-i", input_path, + "-filter_complex", fc, "-map", "[out]", *codec, out_path] + + def build_audio_silence_command(input_path: str, start: float, end: float, out_path: str) -> list[str]: """Silence the [start, end] region (volume=0 gated by an enable expr).""" diff --git a/tests/test_utils.py b/tests/test_utils.py index 7eacda2..026ca52 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -231,6 +231,42 @@ def test_audio_delete_empty_head(): assert "atrim=end=0.0" in fc and "atrim=start=2.0" in fc +def test_audio_heal_delete_command_crossfades_join(): + from core.ffmpeg import build_audio_heal_delete_command + cmd = build_audio_heal_delete_command("/in.wav", 2.0, 4.0, "/o/o.wav", crossfade=0.1) + assert cmd[0] == "ffmpeg" + assert cmd.count("-i") == 1 + fc = cmd[cmd.index("-filter_complex") + 1] + assert "atrim=end=2.0" in fc + assert "atrim=start=4.0" in fc + assert "acrossfade=d=0.1:c1=qsin:c2=qsin[out]" in fc + assert cmd[cmd.index("-map") + 1] == "[out]" + assert "pcm_s16le" in cmd + assert cmd[-1] == "/o/o.wav" + + +def test_audio_heal_delete_command_auto_crossfade_clamped(): + from core.ffmpeg import build_audio_heal_delete_command + cmd = build_audio_heal_delete_command("/in.wav", 10.0, 12.0, "/o/o.mp3") + fc = cmd[cmd.index("-filter_complex") + 1] + assert "acrossfade=d=0.25:c1=qsin:c2=qsin[out]" in fc + assert "libmp3lame" in cmd + + +def test_audio_heal_delete_command_near_start_shortens_crossfade(): + from core.ffmpeg import build_audio_heal_delete_command + cmd = build_audio_heal_delete_command("/in.wav", 0.03, 1.0, "/o/o.wav") + fc = cmd[cmd.index("-filter_complex") + 1] + assert "acrossfade=d=0.03:c1=qsin:c2=qsin[out]" in fc + + +def test_audio_heal_delete_command_rejects_invalid_region(): + import pytest + from core.ffmpeg import build_audio_heal_delete_command + with pytest.raises(ValueError): + build_audio_heal_delete_command("/in.wav", 3.0, 3.0, "/o/o.wav") + + # --- ProcessedDB --- def test_db_add_and_get_markers():