From 5dc7499d7f6f263d2cd68df2010e012cde59baa9 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Jul 2026 00:00:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20AudioLibraryTab=20=E2=80=94=20persisten?= =?UTF-8?q?t=20extracted-clip=20library=20(add/edit/re-export/play/remove)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 164 +++++++++++++++++++++++++++++++++++++ tests/test_ui_structure.py | 46 +++++++++++ 2 files changed, 210 insertions(+) diff --git a/main.py b/main.py index 1cc3aaa..626ff69 100755 --- a/main.py +++ b/main.py @@ -4416,6 +4416,170 @@ class AudioEditorDialog(QDialog): self._set_status("Save failed") +class AudioLibraryTab(QWidget): + """A persistent library of extracted audio clips: add (auto/manual/drag), + Edit (emits edit_requested), Re-export (save-as/transcode), Play, Remove. + Backed by a QSettings string-list under *key*.""" + + edit_requested = pyqtSignal(str) + + def __init__(self, settings, key: str = "audio_library", parent=None): + super().__init__(parent) + self._settings = settings + self._key = key + self._play_proc = None + self.setAcceptDrops(True) + + self._list = QListWidget() + self._list.itemDoubleClicked.connect(lambda _i: self._on_edit()) + self._btn_add = QPushButton("+ Add files…"); self._btn_add.clicked.connect(self._on_add_files) + self._btn_edit = QPushButton("✎ Edit"); self._btn_edit.clicked.connect(self._on_edit) + self._btn_reexport = QPushButton("⬇ Re-export"); self._btn_reexport.clicked.connect(self._on_reexport) + self._btn_play = QPushButton("▶ Play"); self._btn_play.setCheckable(True); self._btn_play.toggled.connect(self._on_play) + self._btn_remove = QPushButton("✕ Remove"); self._btn_remove.clicked.connect(self._on_remove) + + v = QVBoxLayout(self) + v.setContentsMargins(6, 6, 6, 6) + v.addWidget(self._list) + row = QHBoxLayout() + for b in (self._btn_add, self._btn_edit, self._btn_reexport, self._btn_play, self._btn_remove): + row.addWidget(b) + v.addLayout(row) + + self._load() + + # ── data ──────────────────────────────────────────────────────── + def clips(self) -> list[str]: + return [self._list.item(i).data(Qt.ItemDataRole.UserRole) + for i in range(self._list.count())] + + def add_clip(self, path: str) -> None: + ap = os.path.abspath(path) + if ap in self.clips(): + return + dur = probe_duration(ap) + text = (f"{os.path.basename(ap)} ({dur:.2f}s)" + if dur is not None else os.path.basename(ap)) + it = QListWidgetItem(text) + it.setData(Qt.ItemDataRole.UserRole, ap) + it.setToolTip(ap) + self._list.addItem(it) + self._persist() + + def _current_path(self): + it = self._list.currentItem() + return it.data(Qt.ItemDataRole.UserRole) if it else None + + def _persist(self) -> None: + self._settings.setValue(self._key, self.clips()) + + def _load(self) -> None: + stored = self._settings.value(self._key, []) or [] + if isinstance(stored, str): # QSettings can hand back a bare str for a 1-elem list + stored = [stored] + for p in stored: + if p and os.path.exists(p): + self.add_clip(p) # add_clip re-persists (drops the missing ones) + self._persist() # prune stale paths even if none survived + + # ── actions ───────────────────────────────────────────────────── + def _on_add_files(self) -> None: + default_dir = self._settings.value("audio_extract_dir", "") or "" + paths, _sel = QFileDialog.getOpenFileNames( + self, "Add audio clips", default_dir, + "Audio (*.wav *.mp3 *.flac *.m4a *.ogg *.opus *.aac);;All files (*)") + for p in paths: + self.add_clip(p) + + def _on_edit(self) -> None: + p = self._current_path() + if p: + self.edit_requested.emit(p) + + def _on_remove(self) -> None: + row = self._list.currentRow() + if row >= 0: + self._list.takeItem(row) + self._persist() + + def _on_reexport(self) -> None: + p = self._current_path() + if not p: + return + dur = probe_duration(p) or 0.0 + if dur <= 0: + QMessageBox.warning(self, "Re-export failed", + "Could not read the clip's duration.") + return + stem = os.path.splitext(os.path.basename(p))[0] + default_dir = self._settings.value("audio_extract_dir", "") or os.path.dirname(p) + out, _sel = QFileDialog.getSaveFileName( + self, "Re-export clip", os.path.join(default_dir, stem + ".wav"), + "WAV (*.wav);;MP3 (*.mp3);;FLAC (*.flac);;All files (*)") + if not out: + return + if not os.path.splitext(out)[1]: + out += ".wav" + cmd = build_audio_clip_command(p, 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 not (proc is not None and proc.returncode == 0 and os.path.exists(out)): + QMessageBox.warning(self, "Re-export failed", "Could not re-export the clip.") + + # ── playback (self-contained ffplay) ──────────────────────────── + def _on_play(self, checked: bool) -> None: + if not checked: + self._stop_play(); return + p = self._current_path() + if not p: + self._btn_play.setChecked(False); return + from PyQt6.QtCore import QProcess + self._stop_play() + self._play_proc = QProcess(self) + self._play_proc.finished.connect(self._on_play_done) + self._play_proc.errorOccurred.connect(self._on_play_done) + self._play_proc.start(_bin("ffplay"), ["-autoexit", "-nodisp", "-loglevel", "error", p]) + self._btn_play.setText("■ Stop") + + def _on_play_done(self, *_a) -> None: + proc = self._play_proc + self._play_proc = None + if proc is not None: + proc.deleteLater() + self._btn_play.setText("▶ Play") + if self._btn_play.isChecked(): + self._btn_play.blockSignals(True); self._btn_play.setChecked(False); self._btn_play.blockSignals(False) + + def _stop_play(self) -> None: + proc = self._play_proc + self._play_proc = None + if proc is not None: + try: + proc.finished.disconnect(); proc.errorOccurred.disconnect() + except Exception: + pass + proc.kill(); proc.waitForFinished(100); proc.deleteLater() + self._btn_play.setText("▶ Play") + + # ── drag & drop audio files ───────────────────────────────────── + def dragEnterEvent(self, e): + if e.mimeData().hasUrls(): + e.acceptProposedAction() + + def dropEvent(self, e): + for url in e.mimeData().urls(): + p = url.toLocalFile() + if p and os.path.splitext(p)[1].lower() in ( + ".wav", ".mp3", ".flac", ".m4a", ".ogg", ".opus", ".aac"): + self.add_clip(p) + e.acceptProposedAction() + + class MainWindow(QMainWindow): def __init__(self): super().__init__() diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 2dc12d0..c7d4852 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -823,3 +823,49 @@ def test_editor_reject_cleans_temps(win, tmp_path): dlg.reject() # Escape path assert not t1.exists() # op temp swept assert os.path.exists(str(src)) # entry temp (_versions[0]) survives + + +def test_audio_library_add_dedup_and_persist(win, tmp_path): + import main as m + a = tmp_path / "a.wav"; a.write_bytes(b"") + b = tmp_path / "b.wav"; b.write_bytes(b"") + win._settings.setValue("audio_library_test", []) + lib = m.AudioLibraryTab(win._settings, key="audio_library_test") + assert lib.clips() == [] + lib.add_clip(str(a)); lib.add_clip(str(b)); lib.add_clip(str(a)) # dup ignored + assert lib.clips() == [str(a), str(b)] # order preserved, deduped + assert lib._list.count() == 2 + # a fresh instance restores from the same key + lib2 = m.AudioLibraryTab(win._settings, key="audio_library_test") + assert lib2.clips() == [str(a), str(b)] + + +def test_audio_library_drops_missing_on_load(win, tmp_path): + import main as m + good = tmp_path / "g.wav"; good.write_bytes(b"") + win._settings.setValue("audio_library_test2", [str(good), "/no/such/file.wav"]) + lib = m.AudioLibraryTab(win._settings, key="audio_library_test2") + assert lib.clips() == [str(good)] # missing path dropped on load + + +def test_audio_library_remove(win, tmp_path): + import main as m + a = tmp_path / "a.wav"; a.write_bytes(b""); b = tmp_path / "b.wav"; b.write_bytes(b"") + win._settings.setValue("audio_library_test3", []) + lib = m.AudioLibraryTab(win._settings, key="audio_library_test3") + lib.add_clip(str(a)); lib.add_clip(str(b)) + lib._list.setCurrentRow(0) + lib._on_remove() + assert lib.clips() == [str(b)] + + +def test_audio_library_edit_emits(win, tmp_path): + import main as m + a = tmp_path / "a.wav"; a.write_bytes(b"") + win._settings.setValue("audio_library_test4", []) + lib = m.AudioLibraryTab(win._settings, key="audio_library_test4") + lib.add_clip(str(a)); lib._list.setCurrentRow(0) + got = [] + lib.edit_requested.connect(lambda p: got.append(p)) + lib._on_edit() + assert got == [str(a)]