feat: preview healed joins and save editor clips to library
This commit is contained in:
@@ -4258,8 +4258,10 @@ class AudioEditorDialog(QDialog):
|
|||||||
self._btn_trim = QPushButton("Trim to selection")
|
self._btn_trim = QPushButton("Trim to selection")
|
||||||
self._btn_undo = QPushButton("Undo")
|
self._btn_undo = QPushButton("Undo")
|
||||||
self._btn_redo = QPushButton("Redo")
|
self._btn_redo = QPushButton("Redo")
|
||||||
|
self._btn_loop_join = QPushButton("Loop Join")
|
||||||
self._btn_play = QPushButton("▶ Play")
|
self._btn_play = QPushButton("▶ Play")
|
||||||
self._btn_play.setCheckable(True)
|
self._btn_play.setCheckable(True)
|
||||||
|
self._btn_save_library = QPushButton("Save to Library")
|
||||||
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_heal_cut, self._on_heal_cut),
|
for b, slot in ((self._btn_heal_cut, self._on_heal_cut),
|
||||||
@@ -4269,7 +4271,9 @@ class AudioEditorDialog(QDialog):
|
|||||||
(self._btn_trim, self._on_trim),
|
(self._btn_trim, self._on_trim),
|
||||||
(self._btn_undo, self._on_undo),
|
(self._btn_undo, self._on_undo),
|
||||||
(self._btn_redo, self._on_redo),
|
(self._btn_redo, self._on_redo),
|
||||||
|
(self._btn_loop_join, self._on_loop_join),
|
||||||
(self._btn_play, self._on_play),
|
(self._btn_play, self._on_play),
|
||||||
|
(self._btn_save_library, self._on_save_to_library),
|
||||||
(self._btn_save_as, self._on_save_as),
|
(self._btn_save_as, self._on_save_as),
|
||||||
(self._btn_close, self.close)):
|
(self._btn_close, self.close)):
|
||||||
b.clicked.connect(slot)
|
b.clicked.connect(slot)
|
||||||
@@ -4285,9 +4289,11 @@ class AudioEditorDialog(QDialog):
|
|||||||
self._status = QLabel("")
|
self._status = QLabel("")
|
||||||
outer.addWidget(self._status)
|
outer.addWidget(self._status)
|
||||||
bottom = QHBoxLayout()
|
bottom = QHBoxLayout()
|
||||||
for b in (self._btn_undo, self._btn_redo, self._btn_play):
|
for b in (self._btn_undo, self._btn_redo, self._btn_loop_join,
|
||||||
|
self._btn_play):
|
||||||
bottom.addWidget(b)
|
bottom.addWidget(b)
|
||||||
bottom.addStretch()
|
bottom.addStretch()
|
||||||
|
bottom.addWidget(self._btn_save_library)
|
||||||
bottom.addWidget(self._btn_save_as)
|
bottom.addWidget(self._btn_save_as)
|
||||||
bottom.addWidget(self._btn_close)
|
bottom.addWidget(self._btn_close)
|
||||||
outer.addLayout(bottom)
|
outer.addLayout(bottom)
|
||||||
@@ -4403,15 +4409,33 @@ class AudioEditorDialog(QDialog):
|
|||||||
if not checked:
|
if not checked:
|
||||||
self._stop_play()
|
self._stop_play()
|
||||||
return
|
return
|
||||||
|
self._play_current()
|
||||||
|
|
||||||
|
def _play_current(self, start: float | None = None,
|
||||||
|
end: float | None = None) -> None:
|
||||||
from PyQt6.QtCore import QProcess
|
from PyQt6.QtCore import QProcess
|
||||||
self._stop_play()
|
self._stop_play()
|
||||||
self._play_proc = QProcess(self)
|
self._play_proc = QProcess(self)
|
||||||
self._play_proc.finished.connect(self._on_play_finished)
|
self._play_proc.finished.connect(self._on_play_finished)
|
||||||
self._play_proc.errorOccurred.connect(self._on_play_error)
|
self._play_proc.errorOccurred.connect(self._on_play_error)
|
||||||
self._play_proc.start(
|
args = ["-autoexit", "-nodisp", "-loglevel", "error"]
|
||||||
_bin("ffplay"),
|
if start is not None:
|
||||||
["-autoexit", "-nodisp", "-loglevel", "error", self._current()])
|
args += ["-ss", str(start)]
|
||||||
|
if end is not None and start is not None and end > start:
|
||||||
|
args += ["-t", str(end - start)]
|
||||||
|
args.append(self._current())
|
||||||
|
self._play_proc.start(_bin("ffplay"), args)
|
||||||
self._btn_play.setText("■ Stop")
|
self._btn_play.setText("■ Stop")
|
||||||
|
if not self._btn_play.isChecked():
|
||||||
|
self._btn_play.blockSignals(True)
|
||||||
|
self._btn_play.setChecked(True)
|
||||||
|
self._btn_play.blockSignals(False)
|
||||||
|
|
||||||
|
def _on_loop_join(self) -> None:
|
||||||
|
if not self._join_preview:
|
||||||
|
self._set_status("No healed join to preview")
|
||||||
|
return
|
||||||
|
self._play_current(*self._join_preview)
|
||||||
|
|
||||||
def _on_play_error(self, _err) -> None:
|
def _on_play_error(self, _err) -> None:
|
||||||
self._set_status("Playback unavailable (ffplay not found)")
|
self._set_status("Playback unavailable (ffplay not found)")
|
||||||
@@ -4488,6 +4512,44 @@ class AudioEditorDialog(QDialog):
|
|||||||
else:
|
else:
|
||||||
self._set_status("Save failed")
|
self._set_status("Save failed")
|
||||||
|
|
||||||
|
def _on_save_to_library(self) -> None:
|
||||||
|
parent = self.parent()
|
||||||
|
settings = getattr(parent, "_settings", None)
|
||||||
|
scan_panel = getattr(parent, "_scan_panel", None)
|
||||||
|
if settings is None or scan_panel is None:
|
||||||
|
self._set_status("Library unavailable")
|
||||||
|
return
|
||||||
|
base = settings.value("audio_library_dir", "")
|
||||||
|
if not base:
|
||||||
|
base = os.path.join(str(Path.home()), "8cut_audio_library")
|
||||||
|
settings.setValue("audio_library_dir", base)
|
||||||
|
try:
|
||||||
|
os.makedirs(base, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
self._set_status("Could not create library folder")
|
||||||
|
return
|
||||||
|
stem = os.path.splitext(os.path.basename(self._current()))[0] or "clip"
|
||||||
|
out = os.path.join(base, f"{stem}_edited.wav")
|
||||||
|
i = 1
|
||||||
|
while os.path.exists(out):
|
||||||
|
out = os.path.join(base, f"{stem}_edited_{i}.wav")
|
||||||
|
i += 1
|
||||||
|
dur = probe_duration(self._current()) or self._clip_dur or 0.0
|
||||||
|
cmd = build_audio_clip_command(self._current(), 0.0, dur, out)
|
||||||
|
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, timeout=300)
|
||||||
|
except Exception:
|
||||||
|
proc = None
|
||||||
|
finally:
|
||||||
|
QApplication.restoreOverrideCursor()
|
||||||
|
if proc is not None and proc.returncode == 0 and os.path.exists(out):
|
||||||
|
scan_panel._library.add_clip(out)
|
||||||
|
self._last_saved = out
|
||||||
|
self._set_status(f"Saved to library: {os.path.basename(out)}")
|
||||||
|
else:
|
||||||
|
self._set_status("Save to library failed")
|
||||||
|
|
||||||
|
|
||||||
class AudioLibraryTab(QWidget):
|
class AudioLibraryTab(QWidget):
|
||||||
"""A persistent library of extracted audio clips: add (auto/manual/drag),
|
"""A persistent library of extracted audio clips: add (auto/manual/drag),
|
||||||
|
|||||||
@@ -796,7 +796,8 @@ def test_audio_editor_dialog_scaffold(win):
|
|||||||
# widgets present
|
# widgets present
|
||||||
assert dlg._wave is not None
|
assert dlg._wave is not None
|
||||||
for name in ("_btn_heal_cut", "_btn_delete", "_btn_silence", "_btn_reverse",
|
for name in ("_btn_heal_cut", "_btn_delete", "_btn_silence", "_btn_reverse",
|
||||||
"_btn_trim", "_btn_undo", "_btn_redo", "_btn_save_as"):
|
"_btn_trim", "_btn_undo", "_btn_redo", "_btn_loop_join",
|
||||||
|
"_btn_save_library", "_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()
|
||||||
@@ -907,6 +908,49 @@ def test_editor_play_stop_safe(win, tmp_path):
|
|||||||
assert dlg._btn_play.text() == "▶ Play"
|
assert dlg._btn_play.text() == "▶ Play"
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_loop_join_plays_preview_range(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"
|
||||||
|
src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._join_preview = (1.0, 3.0)
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dlg, "_play_current",
|
||||||
|
lambda start=None, end=None: seen.update(start=start, end=end))
|
||||||
|
dlg._on_loop_join()
|
||||||
|
assert seen == {"start": 1.0, "end": 3.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_save_to_library_adds_current_version(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"
|
||||||
|
src.write_bytes(b"")
|
||||||
|
out_dir = tmp_path / "library"
|
||||||
|
win._settings.setValue("audio_library_dir", str(out_dir))
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._clip_dur = 2.0
|
||||||
|
saved = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
m, "build_audio_clip_command",
|
||||||
|
lambda inp, start, dur, out: ["ffmpeg", out])
|
||||||
|
|
||||||
|
def fake_run(cmd, capture_output=True, timeout=300):
|
||||||
|
target = cmd[-1]
|
||||||
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
|
with open(target, "wb") as f:
|
||||||
|
f.write(b"x")
|
||||||
|
class Proc:
|
||||||
|
returncode = 0
|
||||||
|
return Proc()
|
||||||
|
|
||||||
|
monkeypatch.setattr(m.subprocess, "run", fake_run)
|
||||||
|
win._scan_panel._library.add_clip = lambda p: saved.append(p)
|
||||||
|
dlg._on_save_to_library()
|
||||||
|
assert saved
|
||||||
|
assert saved[0].startswith(str(out_dir))
|
||||||
|
|
||||||
|
|
||||||
def test_editor_close_cleans_temps(win, tmp_path):
|
def test_editor_close_cleans_temps(win, tmp_path):
|
||||||
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"")
|
||||||
|
|||||||
Reference in New Issue
Block a user