diff --git a/README.md b/README.md
index f3b48e3..c32aa05 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,12 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
- **Crossfade length** — one global crossfade applied at each join (0 = butt-join); clips shorter than the crossfade are flagged before rendering
- **Preview / Save** — audition the merged result, or render and save-as (WAV/MP3/FLAC/…)
+### Audio clip editor (destructive)
+
+- **Edit clip** — open the current audio area in an editor; drag to select a region
+- **Ops** — Delete / Silence / Reverse / Trim-to-selection, with undo/redo
+- **Audition & Save** — play the result, then Save as (WAV/MP3/FLAC/…)
+
### Audio scanning
- **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs
diff --git a/main.py b/main.py
index e296236..34b7f86 100755
--- a/main.py
+++ b/main.py
@@ -4112,6 +4112,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._play_proc = None # ffplay audition process
self._clip_dur = 0.0 # true duration of the current version
self._wave = AudioWaveform()
@@ -4250,7 +4251,61 @@ class AudioEditorDialog(QDialog):
self._ver_idx += 1
self._reload()
- def _on_play(self): pass
+ def _on_play(self, checked: bool) -> None:
+ if not checked:
+ self._stop_play()
+ return
+ 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()])
+ self._btn_play.setText("■ Stop")
+
+ def _on_play_error(self, _err) -> None:
+ self._set_status("Playback unavailable (ffplay not found)")
+ self._teardown_play()
+
+ def _on_play_finished(self, *_a) -> None:
+ self._teardown_play()
+
+ def _teardown_play(self) -> 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")
+
+ def closeEvent(self, ev) -> None:
+ self._stop_play()
+ for _t in list(self._temps):
+ try:
+ os.remove(_t)
+ except OSError:
+ pass
+ self._temps.clear()
+ super().closeEvent(ev)
def _on_save_as(self):
path, _sel = QFileDialog.getSaveFileName(
@@ -5364,13 +5419,20 @@ class MainWindow(QMainWindow):
# ── Changelog ────────────────────────────────────────────
- APP_VERSION = "1.5"
+ APP_VERSION = "1.6"
_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.6", [
+ "Destructive clip editor — ✎ Edit clip… in the Audio "
+ "tab opens the current area in an editor: drag to select a region "
+ "and Delete, Silence, Reverse, or Trim to "
+ "selection, with undo/redo and audition, then Save "
+ "as…. Non-destructive — the original is never touched.",
+ ]),
("1.5", [
"Interactive waveform — the Audio tab's waveform is now a "
"selection surface: drag the in/out handles (or drag a new "
diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py
index 377ee7b..a9a068d 100644
--- a/tests/test_ui_structure.py
+++ b/tests/test_ui_structure.py
@@ -1,3 +1,4 @@
+import os
import pytest
# Redirect QSettings to a throwaway dir BEFORE any MainWindow is constructed, so
@@ -729,3 +730,22 @@ def test_editor_undo_redo(win, tmp_path, monkeypatch):
dlg._on_redo(); assert dlg._ver_idx == 1
dlg._on_redo(); assert dlg._ver_idx == 2
dlg._on_redo(); assert dlg._ver_idx == 2 # clamped at top
+
+
+def test_editor_play_stop_safe(win, tmp_path):
+ import main as m
+ src = tmp_path / "v0.wav"; src.write_bytes(b"")
+ dlg = m.AudioEditorDialog(str(src), parent=win)
+ dlg._stop_play() # no proc -> safe no-op
+ assert dlg._btn_play.text() == "▶ Play"
+
+
+def test_editor_close_cleans_temps(win, tmp_path):
+ import main as m
+ src = tmp_path / "v0.wav"; src.write_bytes(b"")
+ t1 = tmp_path / "t1.wav"; t1.write_bytes(b"x")
+ dlg = m.AudioEditorDialog(str(src), parent=win)
+ dlg._temps.add(str(t1))
+ dlg.close()
+ assert not t1.exists()
+ assert os.path.exists(str(src)) # _versions[0] (entry temp) is NOT swept by the editor