feat: editor playback + temp cleanup on close + docs (v1.6)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
- **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/…)
|
- **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
|
### Audio scanning
|
||||||
|
|
||||||
- **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs
|
- **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs
|
||||||
|
|||||||
@@ -4112,6 +4112,7 @@ class AudioEditorDialog(QDialog):
|
|||||||
self._versions: list[str] = [path]
|
self._versions: list[str] = [path]
|
||||||
self._ver_idx = 0
|
self._ver_idx = 0
|
||||||
self._temps: set[str] = set() # rendered version temps to clean up
|
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._clip_dur = 0.0 # true duration of the current version
|
||||||
|
|
||||||
self._wave = AudioWaveform()
|
self._wave = AudioWaveform()
|
||||||
@@ -4250,7 +4251,61 @@ class AudioEditorDialog(QDialog):
|
|||||||
self._ver_idx += 1
|
self._ver_idx += 1
|
||||||
self._reload()
|
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):
|
def _on_save_as(self):
|
||||||
path, _sel = QFileDialog.getSaveFileName(
|
path, _sel = QFileDialog.getSaveFileName(
|
||||||
@@ -5364,13 +5419,20 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# ── Changelog ────────────────────────────────────────────
|
# ── Changelog ────────────────────────────────────────────
|
||||||
|
|
||||||
APP_VERSION = "1.5"
|
APP_VERSION = "1.6"
|
||||||
_SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync)
|
_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)
|
_WAVE_MIN_VIEW = 3.0 # minimum waveform view window (seconds)
|
||||||
_MERGE_CURVES = (("Triangular", "tri"), ("Exponential", "exp"),
|
_MERGE_CURVES = (("Triangular", "tri"), ("Exponential", "exp"),
|
||||||
("Logarithmic", "log"), ("Quarter sine", "qsin"),
|
("Logarithmic", "log"), ("Quarter sine", "qsin"),
|
||||||
("Half sine", "hsin"))
|
("Half sine", "hsin"))
|
||||||
CHANGELOG: list[tuple[str, list[str]]] = [
|
CHANGELOG: list[tuple[str, list[str]]] = [
|
||||||
|
("1.6", [
|
||||||
|
"<b>Destructive clip editor</b> — <b>✎ Edit clip…</b> in the Audio "
|
||||||
|
"tab opens the current area in an editor: drag to select a region "
|
||||||
|
"and <b>Delete</b>, <b>Silence</b>, <b>Reverse</b>, or <b>Trim to "
|
||||||
|
"selection</b>, with <b>undo/redo</b> and audition, then <b>Save "
|
||||||
|
"as…</b>. Non-destructive — the original is never touched.",
|
||||||
|
]),
|
||||||
("1.5", [
|
("1.5", [
|
||||||
"<b>Interactive waveform</b> — the Audio tab's waveform is now a "
|
"<b>Interactive waveform</b> — the Audio tab's waveform is now a "
|
||||||
"selection surface: <b>drag the in/out handles</b> (or drag a new "
|
"selection surface: <b>drag the in/out handles</b> (or drag a new "
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Redirect QSettings to a throwaway dir BEFORE any MainWindow is constructed, so
|
# 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 == 1
|
||||||
dlg._on_redo(); assert dlg._ver_idx == 2
|
dlg._on_redo(); assert dlg._ver_idx == 2
|
||||||
dlg._on_redo(); assert dlg._ver_idx == 2 # clamped at top
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user