diff --git a/main.py b/main.py index 671c65e..1823fd7 100755 --- a/main.py +++ b/main.py @@ -4258,8 +4258,10 @@ class AudioEditorDialog(QDialog): self._btn_trim = QPushButton("Trim to selection") self._btn_undo = QPushButton("Undo") self._btn_redo = QPushButton("Redo") + self._btn_loop_join = QPushButton("Loop Join") self._btn_play = QPushButton("▶ Play") self._btn_play.setCheckable(True) + self._btn_save_library = QPushButton("Save to Library") self._btn_save_as = QPushButton("Save as…") self._btn_close = QPushButton("Close") 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_undo, self._on_undo), (self._btn_redo, self._on_redo), + (self._btn_loop_join, self._on_loop_join), (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_close, self.close)): b.clicked.connect(slot) @@ -4285,9 +4289,11 @@ class AudioEditorDialog(QDialog): self._status = QLabel("") outer.addWidget(self._status) 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.addStretch() + bottom.addWidget(self._btn_save_library) bottom.addWidget(self._btn_save_as) bottom.addWidget(self._btn_close) outer.addLayout(bottom) @@ -4403,15 +4409,33 @@ class AudioEditorDialog(QDialog): if not checked: self._stop_play() return + self._play_current() + + def _play_current(self, start: float | None = None, + end: float | None = None) -> None: from PyQt6.QtCore import QProcess self._stop_play() self._play_proc = QProcess(self) self._play_proc.finished.connect(self._on_play_finished) self._play_proc.errorOccurred.connect(self._on_play_error) - self._play_proc.start( - _bin("ffplay"), - ["-autoexit", "-nodisp", "-loglevel", "error", self._current()]) + args = ["-autoexit", "-nodisp", "-loglevel", "error"] + if start is not None: + 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") + 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: self._set_status("Playback unavailable (ffplay not found)") @@ -4488,6 +4512,44 @@ class AudioEditorDialog(QDialog): else: 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): """A persistent library of extracted audio clips: add (auto/manual/drag), diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index d6bb841..d77a65d 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -796,7 +796,8 @@ def test_audio_editor_dialog_scaffold(win): # widgets present assert dlg._wave is not None 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) # undo disabled at the base version, redo disabled with no forward history 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" +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): import main as m src = tmp_path / "v0.wav"; src.write_bytes(b"")