From 1553d7faf0cd8fa0bb628134a3ea3608b9d9d8bd Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 17:54:11 +0200 Subject: [PATCH] feat: destructive audio op command builders (delete/silence/reverse region) --- core/ffmpeg.py | 39 +++++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 41 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/core/ffmpeg.py b/core/ffmpeg.py index c302ed4..f3f178f 100644 --- a/core/ffmpeg.py +++ b/core/ffmpeg.py @@ -301,6 +301,45 @@ def build_crossfade_merge_command(clips: list[str], crossfade, return cmd + ["-filter_complex", fc, "-map", "[out]", *codec, out_path] +def build_audio_delete_command(input_path: str, start: float, end: float, + out_path: str) -> list[str]: + """Remove [start, end] from the audio: keep [0,start] + [end,inf], concat.""" + s, e = round(start, 3), round(end, 3) + ext = os.path.splitext(out_path)[1].lower() + codec = _AUDIO_CODEC_BY_EXT.get(ext, []) + 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]") + 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).""" + s, e = round(start, 3), round(end, 3) + ext = os.path.splitext(out_path)[1].lower() + codec = _AUDIO_CODEC_BY_EXT.get(ext, []) + # commas inside between() must be escaped so the filtergraph parser doesn't + # treat them as filter separators. + af = f"volume=0:enable='between(t\\,{s}\\,{e})'" + return [_bin("ffmpeg"), "-y", "-i", input_path, "-af", af, *codec, out_path] + + +def build_audio_reverse_command(input_path: str, start: float, end: float, + out_path: str) -> list[str]: + """Reverse only the [start, end] segment; head and tail unchanged.""" + s, e = round(start, 3), round(end, 3) + ext = os.path.splitext(out_path)[1].lower() + codec = _AUDIO_CODEC_BY_EXT.get(ext, []) + fc = (f"[0]atrim=end={s},asetpts=PTS-STARTPTS[a];" + f"[0]atrim=start={s}:end={e},asetpts=PTS-STARTPTS,areverse[b];" + f"[0]atrim=start={e},asetpts=PTS-STARTPTS[c];" + f"[a][b][c]concat=n=3:v=0:a=1[out]") + return [_bin("ffmpeg"), "-y", "-i", input_path, + "-filter_complex", fc, "-map", "[out]", *codec, out_path] + + def detect_hw_encoders() -> list[str]: """Probe ffmpeg for available H.264 hardware encoders. diff --git a/tests/test_utils.py b/tests/test_utils.py index d0b6ddb..7eacda2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ import tempfile, os, json from main import build_export_path, format_time, build_ffmpeg_command, build_sequence_dir, build_audio_extract_command, resolve_keyframe, apply_keyframes_to_jobs -from core.ffmpeg import build_audio_clip_command, build_crossfade_merge_command +from core.ffmpeg import build_audio_clip_command, build_crossfade_merge_command, build_audio_delete_command, build_audio_silence_command, build_audio_reverse_command from core.annotations import build_annotation_json_path, upsert_clip_annotation from main import ProcessedDB @@ -192,6 +192,45 @@ def test_merge_three_clips_zero_concat_chain(): assert "acrossfade" not in fc +# --- destructive audio region ops (delete/silence/reverse) --- + +def test_audio_delete_command(): + cmd = build_audio_delete_command("/in.wav", 1.0, 3.0, "/o/o.wav") + assert cmd[0] == "ffmpeg" + assert cmd.count("-i") == 1 + fc = cmd[cmd.index("-filter_complex") + 1] + assert "atrim=end=1.0" in fc # keep [0,1] + assert "atrim=start=3.0" in fc # keep [3,end] + assert "concat=n=2:v=0:a=1[out]" in fc + assert cmd[cmd.index("-map") + 1] == "[out]" + assert "pcm_s16le" in cmd # codec by ext + assert cmd[-1] == "/o/o.wav" + +def test_audio_silence_command(): + cmd = build_audio_silence_command("/in.wav", 1.0, 3.0, "/o/o.mp3") + af = cmd[cmd.index("-af") + 1] + # volume=0 gated to the region; commas inside between() are escaped for the filtergraph + assert af == "volume=0:enable='between(t\\,1.0\\,3.0)'" + assert "libmp3lame" in cmd + assert cmd[-1] == "/o/o.mp3" + +def test_audio_reverse_command(): + cmd = build_audio_reverse_command("/in.wav", 1.0, 3.0, "/o/o.wav") + fc = cmd[cmd.index("-filter_complex") + 1] + assert "atrim=end=1.0" in fc # head [0,1] + assert "atrim=start=1.0:end=3.0" in fc # middle [1,3] + assert "areverse" in fc # reversed middle + assert "atrim=start=3.0" in fc # tail [3,end] + assert "concat=n=3:v=0:a=1[out]" in fc + assert cmd[cmd.index("-map") + 1] == "[out]" + +def test_audio_delete_empty_head(): + # deleting from 0 still produces a valid 2-branch concat (head is empty but harmless) + cmd = build_audio_delete_command("/in.wav", 0.0, 2.0, "/o/o.wav") + fc = cmd[cmd.index("-filter_complex") + 1] + assert "atrim=end=0.0" in fc and "atrim=start=2.0" in fc + + # --- ProcessedDB --- def test_db_add_and_get_markers():