From 4a34a87d37cb946e17be4cb2db02d3c648f3443d Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 18:26:07 +0200 Subject: [PATCH] feat: editor playback + temp cleanup on close + docs (v1.6) Co-Authored-By: Claude Opus 4.8 --- README.md | 6 ++++ main.py | 66 ++++++++++++++++++++++++++++++++++++-- tests/test_ui_structure.py | 20 ++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) 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