diff --git a/README.md b/README.md index c32aa05..f4a38f4 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,12 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets - **Ops** — Delete / Silence / Reverse / Trim-to-selection, with undo/redo - **Audition & Save** — play the result, then Save as (WAV/MP3/FLAC/…) +### Clip library + +- **Library tab** — a persistent tab in the scan-results column that collects extracted audio clips (auto-added on extract + editor Save) +- **Add / drag-drop** — import existing audio files +- **Per clip** — Edit (opens the clip editor), Re-export (save-as / transcode), Play, Remove + ### Audio scanning - **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs diff --git a/main.py b/main.py index bc31684..f8c2dc9 100755 --- a/main.py +++ b/main.py @@ -4208,6 +4208,7 @@ class AudioEditorDialog(QDialog): self._versions: list[str] = [path] self._ver_idx = 0 self._temps: set[str] = set() # rendered version temps to clean up + self._last_saved = None # path of the last Save-as (for library) self._play_proc = None # ffplay audition process self._clip_dur = 0.0 # true duration of the current version @@ -4431,6 +4432,7 @@ class AudioEditorDialog(QDialog): finally: QApplication.restoreOverrideCursor() if proc is not None and proc.returncode == 0 and os.path.exists(path): + self._last_saved = path self._set_status(f"Saved: {os.path.basename(path)}") else: self._set_status("Save failed") @@ -5308,6 +5310,10 @@ class MainWindow(QMainWindow): self._scan_panel.regions_edited.connect(self._on_scan_regions_edited) self._scan_panel.selection_changed.connect(self._update_scan_export_count) self._scan_panel.loaded.connect(self._on_scan_panel_loaded) + # lambda re-resolves _open_audio_editor at emit time so tests can + # monkeypatch it (a bare bound-method connect binds at connect time) + self._scan_panel._library.edit_requested.connect( + lambda p: self._open_audio_editor(p)) self._sld_threshold.valueChanged.connect(self._on_threshold_changed) # Menu bar — wires to the existing handler methods above. Built here, @@ -5691,13 +5697,21 @@ class MainWindow(QMainWindow): # ── Changelog ──────────────────────────────────────────── - APP_VERSION = "1.6" + APP_VERSION = "1.7" _SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync) _WAVE_MIN_VIEW = 3.0 # minimum waveform view window (seconds) _MERGE_CURVES = (("Triangular", "tri"), ("Exponential", "exp"), ("Logarithmic", "log"), ("Quarter sine", "qsin"), ("Half sine", "hsin")) CHANGELOG: list[tuple[str, list[str]]] = [ + ("1.7", [ + "Clip library — a persistent Library tab in the " + "scan-results column collects your extracted audio clips (and clips " + "you Save from the editor). Add files or drag them in; per clip: " + "Edit (opens the clip editor), Re-export (save-as / " + "transcode), Play, and Remove. Persists across " + "sessions and file switches.", + ]), ("1.6", [ "Destructive clip editor✎ Edit clip… in the Audio " "tab opens the current area in an editor: drag to select a region " @@ -7372,8 +7386,17 @@ class MainWindow(QMainWindow): self._show_status("Could not prepare clip", 3000) return self._merge_temps.add(tmp) # reuse the close-time temp sweep - dlg = AudioEditorDialog(tmp, parent=self) + self._open_audio_editor(tmp) + + def _open_audio_editor(self, path: str) -> None: + """Open the destructive editor on *path*; if the user Saves-as, add the + result to the clip library.""" + self._stop_audition() + dlg = AudioEditorDialog(path, parent=self) dlg.exec() + saved = getattr(dlg, "_last_saved", None) + if saved: + self._scan_panel._library.add_clip(saved) dlg.deleteLater() def _merge_move(self, delta: int) -> None: @@ -7743,6 +7766,7 @@ class MainWindow(QMainWindow): self._btn_extract_audio.setEnabled(True) if proc is not None and proc.returncode == 0 and os.path.exists(path): self._settings.setValue("audio_extract_dir", os.path.dirname(path)) + self._scan_panel._library.add_clip(path) actual = probe_duration(path) name = os.path.basename(path) if actual is not None and actual < dur - 0.1: diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index f6d1750..9a0cd5e 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -899,3 +899,28 @@ def test_scan_reload_selects_model_tab_not_library(win): assert sp._tabs.currentIndex() == 1 # a model tab is selected assert sp._current_table() is not None # regions resolve immediately assert sp.current_model_name() == "EAT_LARGE" + + +def test_open_editor_adds_saved_clip_to_library(win, tmp_path, monkeypatch): + import main as m + saved = tmp_path / "edited.wav"; saved.write_bytes(b"") + class FakeDlg: + def __init__(self, path, parent=None): + self._last_saved = str(saved) + def exec(self): return 0 + def deleteLater(self): pass + monkeypatch.setattr(m, "AudioEditorDialog", FakeDlg) + lib = win._scan_panel._library + lib._key = "audio_library_l3test" # throwaway, avoids polluting real settings + win._settings.setValue("audio_library_l3test", []) + lib._list.clear() + win._open_audio_editor("/x/in.wav") + assert str(saved) in lib.clips() + + +def test_library_edit_signal_opens_editor(win, tmp_path, monkeypatch): + import main as m + opened = [] + monkeypatch.setattr(win, "_open_audio_editor", lambda p: opened.append(p)) + win._scan_panel._library.edit_requested.emit("/some/clip.wav") + assert opened == ["/some/clip.wav"]