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"]