From ccad721c98d81a4ff0f4f52868b75569df51697f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 4 Jul 2026 21:03:08 +0200 Subject: [PATCH] fix: make audio editor playback local --- main.py | 109 +++++++++++++++++++++++++++++++++---- tests/test_ui_structure.py | 96 ++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index 67ad298..429a02c 100755 --- a/main.py +++ b/main.py @@ -22,7 +22,10 @@ from PyQt6.QtWidgets import ( QToolBox, QGroupBox, QGridLayout, ) -from PyQt6.QtCore import Qt, QObject, QThread, QTimer, QRect, QSize, pyqtSignal, QSettings +from PyQt6.QtCore import ( + Qt, QObject, QThread, QTimer, QElapsedTimer, QRect, QSize, pyqtSignal, + QSettings, +) from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut, QIcon if sys.platform == "win32": # Help ctypes find libmpv-2.dll next to main.py or in frozen bundle @@ -4239,6 +4242,7 @@ class AudioEditorDialog(QDialog): version files.""" _EDIT_DECODE_CAP = 600.0 # seconds of audio to decode for the waveform + _EDITOR_WAVEFORM_BINS = 2000 def __init__(self, path: str, parent=None): super().__init__(parent) @@ -4249,6 +4253,13 @@ class AudioEditorDialog(QDialog): 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._play_speed = 1.0 + self._play_start = 0.0 + self._play_end: float | None = None + self._play_elapsed = QElapsedTimer() + self._playhead_timer = QTimer(self) + self._playhead_timer.setInterval(33) + self._playhead_timer.timeout.connect(self._tick_playhead) self._clip_dur = 0.0 # true duration of the current version self._join_preview: tuple[float, float] | None = None @@ -4263,6 +4274,14 @@ class AudioEditorDialog(QDialog): self._btn_loop_join = QPushButton("Loop Join") self._btn_play = QPushButton("▶ Play") self._btn_play.setCheckable(True) + self._btn_speed2 = QPushButton("x2") + self._btn_speed2.setCheckable(True) + self._btn_speed2.setFixedWidth(32) + self._btn_speed2.setToolTip("Editor playback at 2x speed") + self._btn_speed4 = QPushButton("x4") + self._btn_speed4.setCheckable(True) + self._btn_speed4.setFixedWidth(32) + self._btn_speed4.setToolTip("Editor playback at 4x speed") self._btn_save_library = QPushButton("Save to Library") self._btn_save_as = QPushButton("Save as…") self._btn_close = QPushButton("Close") @@ -4275,6 +4294,8 @@ class AudioEditorDialog(QDialog): (self._btn_redo, self._on_redo), (self._btn_loop_join, self._on_loop_join), (self._btn_play, self._on_play), + (self._btn_speed2, lambda: self._set_playback_speed(2.0)), + (self._btn_speed4, lambda: self._set_playback_speed(4.0)), (self._btn_save_library, self._on_save_to_library), (self._btn_save_as, self._on_save_as), (self._btn_close, self.close)): @@ -4292,7 +4313,7 @@ class AudioEditorDialog(QDialog): outer.addWidget(self._status) bottom = QHBoxLayout() for b in (self._btn_undo, self._btn_redo, self._btn_loop_join, - self._btn_play): + self._btn_play, self._btn_speed2, self._btn_speed4): bottom.addWidget(b) bottom.addStretch() bottom.addWidget(self._btn_save_library) @@ -4300,6 +4321,17 @@ class AudioEditorDialog(QDialog): bottom.addWidget(self._btn_close) outer.addLayout(bottom) + self._play_shortcuts = [] + for key in ("Space", "P"): + sc = QShortcut(QKeySequence(key), self) + sc.setContext(Qt.ShortcutContext.WindowShortcut) + sc.activated.connect(self._btn_play.click) + self._play_shortcuts.append(sc) + sc_stop = QShortcut(QKeySequence("K"), self) + sc_stop.setContext(Qt.ShortcutContext.WindowShortcut) + sc_stop.activated.connect(self._stop_play) + self._play_shortcuts.append(sc_stop) + self._reload() def _current(self) -> str: @@ -4314,7 +4346,7 @@ class AudioEditorDialog(QDialog): if dur > 0: samples = load_region_samples(path, 0.0, min(dur, self._EDIT_DECODE_CAP)) self._wave.set_view(0.0, dur) - self._wave.set_peaks(peaks(samples)) + self._wave.set_peaks(peaks(samples, buckets=self._EDITOR_WAVEFORM_BINS)) self._wave.set_selection(0.0, dur) else: self._wave.set_view(0.0, 0.0) @@ -4411,7 +4443,27 @@ class AudioEditorDialog(QDialog): if not checked: self._stop_play() return - self._play_current() + se = self._selection_secs() + if se is not None: + self._play_current(*se) + else: + self._play_current() + + def _play_args(self, start: float | None = None, + end: float | None = None) -> list[str]: + args = ["-autoexit", "-nodisp", "-loglevel", "error"] + if start is not None: + args += ["-ss", str(start)] + if end is not None and start is not None and end > start: + args += ["-t", str(end - start)] + if self._play_speed > 1.0: + if self._play_speed == 4.0: + tempo = "atempo=2.0,atempo=2.0" + else: + tempo = f"atempo={self._play_speed:.1f}" + args += ["-af", tempo] + args.append(self._current()) + return args def _play_current(self, start: float | None = None, end: float | None = None) -> None: @@ -4420,12 +4472,17 @@ class AudioEditorDialog(QDialog): self._play_proc = QProcess(self) self._play_proc.finished.connect(self._on_play_finished) self._play_proc.errorOccurred.connect(self._on_play_error) - args = ["-autoexit", "-nodisp", "-loglevel", "error"] - if start is not None: - args += ["-ss", str(start)] - if end is not None and start is not None and end > start: - args += ["-t", str(end - start)] - args.append(self._current()) + args = self._play_args(start, end) + self._play_start = max(0.0, start if start is not None else 0.0) + if end is not None and end > self._play_start: + self._play_end = end + elif self._clip_dur > 0: + self._play_end = self._clip_dur + else: + self._play_end = None + self._wave.set_playhead(self._play_start) + self._play_elapsed.restart() + self._playhead_timer.start() self._play_proc.start(_bin("ffplay"), args) self._btn_play.setText("■ Stop") if not self._btn_play.isChecked(): @@ -4439,6 +4496,30 @@ class AudioEditorDialog(QDialog): return self._play_current(*self._join_preview) + def _set_playback_speed(self, speed: float) -> None: + if speed == 2.0 and self._btn_speed2.isChecked(): + self._btn_speed4.setChecked(False) + elif speed == 4.0 and self._btn_speed4.isChecked(): + self._btn_speed2.setChecked(False) + if self._btn_speed4.isChecked(): + eff = 4.0 + elif self._btn_speed2.isChecked(): + eff = 2.0 + else: + eff = 1.0 + if eff == self._play_speed: + return + self._play_speed = eff + if self._play_proc is not None: + self._play_current(self._play_start, self._play_end) + + def _tick_playhead(self) -> None: + elapsed = self._play_elapsed.elapsed() / 1000.0 + t = self._play_start + elapsed * self._play_speed + if self._play_end is not None: + t = min(t, self._play_end) + self._wave.set_playhead(t) + def _on_play_error(self, _err) -> None: self._set_status("Playback unavailable (ffplay not found)") self._teardown_play() @@ -4449,6 +4530,8 @@ class AudioEditorDialog(QDialog): def _teardown_play(self) -> None: proc = self._play_proc self._play_proc = None + self._playhead_timer.stop() + self._wave.set_playhead(None) if proc is not None: proc.deleteLater() self._btn_play.setText("▶ Play") @@ -4460,6 +4543,8 @@ class AudioEditorDialog(QDialog): def _stop_play(self) -> None: proc = self._play_proc self._play_proc = None + self._playhead_timer.stop() + self._wave.set_playhead(None) if proc is not None: try: proc.finished.disconnect() @@ -4470,6 +4555,10 @@ class AudioEditorDialog(QDialog): proc.waitForFinished(100) 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 _cleanup(self) -> None: self._stop_play() diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 9f2fdca..70adac7 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -994,6 +994,102 @@ def test_editor_play_stop_safe(win, tmp_path): assert dlg._btn_play.text() == "▶ Play" +def test_editor_stop_play_unchecks_button(win, tmp_path): + import main as m + src = tmp_path / "v0.wav"; src.write_bytes(b"") + dlg = m.AudioEditorDialog(str(src), parent=win) + dlg._btn_play.setChecked(True) + dlg._stop_play() + assert not dlg._btn_play.isChecked() + + +def test_editor_play_button_auditions_selection(win, tmp_path, monkeypatch): + import main as m + src = tmp_path / "v0.wav"; src.write_bytes(b"") + dlg = m.AudioEditorDialog(str(src), parent=win) + dlg._clip_dur = 5.0 + dlg._wave.set_view(0.0, 5.0) + dlg._wave.set_selection(1.25, 2.75) + seen = {} + monkeypatch.setattr( + dlg, "_play_current", + lambda start=None, end=None: seen.update(start=start, end=end)) + dlg._on_play(True) + assert seen == {"start": 1.25, "end": 2.75} + + +def test_editor_has_local_speed_controls(win, tmp_path): + import main as m + from PyQt6.QtWidgets import QPushButton + src = tmp_path / "v0.wav"; src.write_bytes(b"") + dlg = m.AudioEditorDialog(str(src), parent=win) + assert isinstance(dlg._btn_speed2, QPushButton) + assert isinstance(dlg._btn_speed4, QPushButton) + dlg._btn_speed2.setChecked(True) + dlg._set_playback_speed(2.0) + assert dlg._play_speed == 2.0 + dlg._btn_speed4.setChecked(True) + dlg._set_playback_speed(4.0) + assert dlg._play_speed == 4.0 + assert not dlg._btn_speed2.isChecked() + + +def test_editor_has_local_playback_shortcuts(win, tmp_path): + import main as m + from PyQt6.QtGui import QShortcut + src = tmp_path / "v0.wav"; src.write_bytes(b"") + dlg = m.AudioEditorDialog(str(src), parent=win) + keys = {sc.key().toString() for sc in dlg.findChildren(QShortcut)} + assert {"Space", "P", "K"} <= keys + + +def test_editor_play_args_include_selection_and_speed(win, tmp_path): + import main as m + src = tmp_path / "v0.wav"; src.write_bytes(b"") + dlg = m.AudioEditorDialog(str(src), parent=win) + dlg._play_speed = 2.0 + args = dlg._play_args(start=1.0, end=3.5) + assert args[:3] == ["-autoexit", "-nodisp", "-loglevel"] + assert "-ss" in args and args[args.index("-ss") + 1] == "1.0" + assert "-t" in args and args[args.index("-t") + 1] == "2.5" + assert "-af" in args and args[args.index("-af") + 1] == "atempo=2.0" + assert args[-1] == str(src) + + +def test_editor_playhead_tracks_editor_range_and_speed(win, tmp_path): + import main as m + src = tmp_path / "v0.wav"; src.write_bytes(b"") + dlg = m.AudioEditorDialog(str(src), parent=win) + class FakeElapsed: + def elapsed(self): + return 1500 + dlg._play_start = 2.0 + dlg._play_end = 6.0 + dlg._play_speed = 2.0 + dlg._play_elapsed = FakeElapsed() + dlg._tick_playhead() + assert dlg._wave._playhead == 5.0 + + +def test_editor_reload_uses_dense_waveform_buckets(win, tmp_path, monkeypatch): + import main as m + import core.waveform as wf + import numpy as np + src = tmp_path / "v0.wav"; src.write_bytes(b"") + calls = {} + monkeypatch.setattr(m, "probe_duration", lambda _p: 4.0) + monkeypatch.setattr( + wf, "load_region_samples", + lambda path, start, dur: np.ones(32000, dtype="float32")) + def fake_peaks(samples, buckets=128): + calls["buckets"] = buckets + return [0.1] * buckets + monkeypatch.setattr(wf, "peaks", fake_peaks) + dlg = m.AudioEditorDialog(str(src), parent=win) + assert calls["buckets"] >= 1000 + assert len(dlg._wave._peaks) == calls["buckets"] + + def test_editor_loop_join_plays_preview_range(win, tmp_path, monkeypatch): import main as m src = tmp_path / "v0.wav"