feat: add heal cut editor action

This commit is contained in:
2026-07-04 20:17:15 +02:00
parent b52b3e19be
commit 9838a4fcfd
2 changed files with 41 additions and 5 deletions
+17 -3
View File
@@ -39,7 +39,7 @@ from core.ffmpeg import (
build_ffmpeg_command, build_audio_extract_command, build_audio_clip_command, build_ffmpeg_command, build_audio_extract_command, build_audio_clip_command,
build_crossfade_merge_command, build_crossfade_merge_command,
build_audio_delete_command, build_audio_silence_command, build_audio_delete_command, build_audio_silence_command,
build_audio_reverse_command, build_audio_reverse_command, build_audio_heal_delete_command,
audio_edit_filters, probe_duration, detect_hw_encoders, audio_edit_filters, probe_duration, detect_hw_encoders,
) )
from core.db import ProcessedDB from core.db import ProcessedDB
@@ -4248,8 +4248,10 @@ class AudioEditorDialog(QDialog):
self._last_saved = None # path of the last Save-as (for library) self._last_saved = None # path of the last Save-as (for library)
self._play_proc = None # ffplay audition process self._play_proc = None # ffplay audition process
self._clip_dur = 0.0 # true duration of the current version self._clip_dur = 0.0 # true duration of the current version
self._join_preview: tuple[float, float] | None = None
self._wave = AudioWaveform() self._wave = AudioWaveform()
self._btn_heal_cut = QPushButton("Heal Cut")
self._btn_delete = QPushButton("Delete") self._btn_delete = QPushButton("Delete")
self._btn_silence = QPushButton("Silence") self._btn_silence = QPushButton("Silence")
self._btn_reverse = QPushButton("Reverse") self._btn_reverse = QPushButton("Reverse")
@@ -4260,7 +4262,8 @@ class AudioEditorDialog(QDialog):
self._btn_play.setCheckable(True) self._btn_play.setCheckable(True)
self._btn_save_as = QPushButton("Save as…") self._btn_save_as = QPushButton("Save as…")
self._btn_close = QPushButton("Close") self._btn_close = QPushButton("Close")
for b, slot in ((self._btn_delete, self._on_delete), for b, slot in ((self._btn_heal_cut, self._on_heal_cut),
(self._btn_delete, self._on_delete),
(self._btn_silence, self._on_silence), (self._btn_silence, self._on_silence),
(self._btn_reverse, self._on_reverse), (self._btn_reverse, self._on_reverse),
(self._btn_trim, self._on_trim), (self._btn_trim, self._on_trim),
@@ -4274,6 +4277,7 @@ class AudioEditorDialog(QDialog):
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
outer.addWidget(self._wave) outer.addWidget(self._wave)
ops = QHBoxLayout() ops = QHBoxLayout()
ops.addWidget(self._btn_heal_cut)
for b in (self._btn_delete, self._btn_silence, self._btn_reverse, for b in (self._btn_delete, self._btn_silence, self._btn_reverse,
self._btn_trim): self._btn_trim):
ops.addWidget(b) ops.addWidget(b)
@@ -4326,7 +4330,8 @@ class AudioEditorDialog(QDialog):
return None return None
return (s, e) return (s, e)
def _apply_op(self, build_fn, whole_clip_ok: bool = True) -> None: def _apply_op(self, build_fn, whole_clip_ok: bool = True,
after_success=None) -> None:
se = self._selection_secs() se = self._selection_secs()
if se is None: if se is None:
self._set_status("Select a region first") self._set_status("Select a region first")
@@ -4354,6 +4359,8 @@ class AudioEditorDialog(QDialog):
self._ver_idx += 1 self._ver_idx += 1
self._temps.add(tmp) self._temps.add(tmp)
self._reload() self._reload()
if after_success is not None:
after_success(s)
self._set_status("") self._set_status("")
else: else:
try: try:
@@ -4362,6 +4369,13 @@ class AudioEditorDialog(QDialog):
pass pass
self._set_status("Edit failed") self._set_status("Edit failed")
def _prepare_join_preview(self, seam_t: float) -> None:
self._join_preview = (max(0.0, seam_t - 1.0), seam_t + 1.0)
def _on_heal_cut(self):
self._apply_op(build_audio_heal_delete_command, whole_clip_ok=False,
after_success=self._prepare_join_preview)
def _on_delete(self): def _on_delete(self):
self._apply_op(build_audio_delete_command, whole_clip_ok=False) self._apply_op(build_audio_delete_command, whole_clip_ok=False)
+24 -2
View File
@@ -795,8 +795,8 @@ def test_audio_editor_dialog_scaffold(win):
assert dlg._current() == "/nonexistent.wav" assert dlg._current() == "/nonexistent.wav"
# widgets present # widgets present
assert dlg._wave is not None assert dlg._wave is not None
for name in ("_btn_delete", "_btn_silence", "_btn_reverse", "_btn_trim", for name in ("_btn_heal_cut", "_btn_delete", "_btn_silence", "_btn_reverse",
"_btn_undo", "_btn_redo", "_btn_save_as"): "_btn_trim", "_btn_undo", "_btn_redo", "_btn_save_as"):
assert isinstance(getattr(dlg, name), QPushButton) assert isinstance(getattr(dlg, name), QPushButton)
# undo disabled at the base version, redo disabled with no forward history # undo disabled at the base version, redo disabled with no forward history
assert not dlg._btn_undo.isEnabled() assert not dlg._btn_undo.isEnabled()
@@ -821,6 +821,28 @@ def test_editor_delete_builds_command(win, tmp_path, monkeypatch):
assert seen["inp"] == str(src) and seen["s"] == 1.0 and seen["e"] == 3.0 assert seen["inp"] == str(src) and seen["s"] == 1.0 and seen["e"] == 3.0
def test_editor_heal_cut_builds_command(win, tmp_path, monkeypatch):
import main as m, pytest
src = tmp_path / "v0.wav"
src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 6.0
dlg._wave.set_view(0.0, 6.0)
dlg._wave.set_selection(2.0, 3.0)
seen = {}
class _Stop(Exception):
pass
def fake(inp, s, e, out):
seen.update(inp=inp, s=s, e=e, out=out)
raise _Stop
monkeypatch.setattr(m, "build_audio_heal_delete_command", fake)
with pytest.raises(_Stop):
dlg._on_heal_cut()
assert seen["inp"] == str(src)
assert seen["s"] == 2.0 and seen["e"] == 3.0
assert seen["out"].endswith(".wav")
def test_editor_op_no_region_is_safe(win, tmp_path, monkeypatch): def test_editor_op_no_region_is_safe(win, tmp_path, monkeypatch):
import main as m import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"") src = tmp_path / "v0.wav"; src.write_bytes(b"")