fix: make audio editor playback local
This commit is contained in:
@@ -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,8 +4443,28 @@ class AudioEditorDialog(QDialog):
|
||||
if not checked:
|
||||
self._stop_play()
|
||||
return
|
||||
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:
|
||||
from PyQt6.QtCore import QProcess
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user