diff --git a/core/ffmpeg.py b/core/ffmpeg.py index b0a8845..24ca487 100644 --- a/core/ffmpeg.py +++ b/core/ffmpeg.py @@ -201,12 +201,37 @@ def probe_duration(path: str) -> float | None: return None +def audio_edit_filters(duration: float, fade_in: float = 0.0, + fade_out: float = 0.0, normalize: bool = False, + gain_db: float = 0.0) -> list[str]: + """Compose an ffmpeg -af chain for the non-destructive audio edits. + + Trim is handled by the caller (it adjusts start/duration), so only + fade / normalize / gain appear here. Returns [] when nothing is set, so + the extract command stays byte-identical to the un-edited case.""" + chain: list[str] = [] + if fade_in > 0: + chain.append(f"afade=t=in:st=0:d={fade_in}") + if fade_out > 0: + st = round(max(0.0, duration - fade_out), 3) + chain.append(f"afade=t=out:st={st}:d={fade_out}") + if normalize: + # single-pass loudnorm (EBU R128 default target); fine for quick normalize + chain.append("loudnorm") + if gain_db != 0.0: + chain.append(f"volume={gain_db}dB") + return chain + + def build_audio_clip_command(input_path: str, start: float, duration: float, - out_path: str) -> list[str]: + out_path: str, + filters: list[str] | None = None) -> list[str]: """ffmpeg command to extract exactly *duration* seconds of audio starting - at *start*, re-encoded per *out_path*'s extension (wav/mp3/flac/…).""" + at *start*, re-encoded per *out_path*'s extension (wav/mp3/flac/…). + *filters* (if any) are joined into a single -af chain.""" ext = os.path.splitext(out_path)[1].lower() codec = _AUDIO_CODEC_BY_EXT.get(ext, []) + af = ["-af", ",".join(filters)] if filters else [] return [ _bin("ffmpeg"), "-y", "-ss", str(start), @@ -214,6 +239,7 @@ def build_audio_clip_command(input_path: str, start: float, duration: float, "-t", str(duration), "-vn", *codec, + *af, out_path, ] diff --git a/tests/test_utils.py b/tests/test_utils.py index 48b3c7b..4c833ce 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -76,6 +76,31 @@ def test_audio_clip_command_extension_case_insensitive(): assert "flac" in build_audio_clip_command("/in.mp4", 0, 1, "/o/A.FLAC") +def test_audio_edit_filters_empty_when_defaults(): + from core.ffmpeg import audio_edit_filters + assert audio_edit_filters(duration=3.0) == [] + +def test_audio_edit_filters_fade_normalize_gain(): + from core.ffmpeg import audio_edit_filters + f = audio_edit_filters(duration=10.0, fade_in=0.5, fade_out=2.0, + normalize=True, gain_db=-3.0) + assert "afade=t=in:st=0:d=0.5" in f + assert "afade=t=out:st=8.0:d=2.0" in f # fade-out starts at duration - fade_out + assert "loudnorm" in f + assert "volume=-3.0dB" in f + +def test_audio_clip_command_no_filters_unchanged(): + cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav") + assert "-af" not in cmd + +def test_audio_clip_command_appends_filter_chain(): + cmd = build_audio_clip_command("/in.mp4", 1.0, 2.0, "/o/a.wav", + filters=["afade=t=in:st=0:d=0.5", "loudnorm"]) + i = cmd.index("-af") + assert cmd[i + 1] == "afade=t=in:st=0:d=0.5,loudnorm" + assert i < len(cmd) - 1 and cmd[-1] == "/o/a.wav" + + # --- ProcessedDB --- def test_db_add_and_get_markers():