feat: play/stop audition of the current audio region

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:13:06 +02:00
co-authored by Claude Opus 4.8
parent 7501c4729b
commit c57c078100
2 changed files with 102 additions and 6 deletions
+86 -6
View File
@@ -4533,6 +4533,12 @@ class MainWindow(QMainWindow):
self._btn_wave_refresh.setToolTip( self._btn_wave_refresh.setToolTip(
"Decode the current audio area and draw its waveform") "Decode the current audio area and draw its waveform")
self._btn_wave_refresh.clicked.connect(self._on_wave_refresh) self._btn_wave_refresh.clicked.connect(self._on_wave_refresh)
self._btn_audio_play = QPushButton("▶ Play")
self._btn_audio_play.setCheckable(True)
self._btn_audio_play.setToolTip(
"Audition the current audio area (with the edits applied)")
self._btn_audio_play.toggled.connect(self._on_audio_audition)
self._audition_proc = None # QProcess while auditioning, else None
self._transport_row = transport_row self._transport_row = transport_row
# Row 1b — subcategory (subprofile) export buttons live on their own # Row 1b — subcategory (subprofile) export buttons live on their own
@@ -4838,6 +4844,7 @@ class MainWindow(QMainWindow):
# Row 4 — read-only waveform strip for the current audio area. # Row 4 — read-only waveform strip for the current audio area.
g.addWidget(self._wave, 4, 0, 1, 4) g.addWidget(self._wave, 4, 0, 1, 4)
g.addWidget(self._btn_wave_refresh, 5, 0, 1, 2) g.addWidget(self._btn_wave_refresh, 5, 0, 1, 2)
g.addWidget(self._btn_audio_play, 5, 2, 1, 2)
g.addWidget(self._btn_extract_audio, 6, 0, 1, 4) g.addWidget(self._btn_extract_audio, 6, 0, 1, 4)
g.setColumnStretch(4, 1) # spare gutter column clusters label/field pairs to the left g.setColumnStretch(4, 1) # spare gutter column clusters label/field pairs to the left
g.setRowStretch(7, 1) # anchor content to the top g.setRowStretch(7, 1) # anchor content to the top
@@ -6523,6 +6530,84 @@ class MainWindow(QMainWindow):
elif preview < dur: elif preview < dur:
self._show_status(f"Waveform shows first {preview:.0f}s of {dur:.0f}s", 4000) self._show_status(f"Waveform shows first {preview:.0f}s of {dur:.0f}s", 4000)
def _current_edit_filters(self) -> list[str]:
"""ffmpeg -af chain for the current edit controls (shared by extract +
audition so they never drift)."""
return audio_edit_filters(
duration=self._spn_audio_len.value(),
fade_in=self._spn_fade_in.value(),
fade_out=self._spn_fade_out.value(),
normalize=self._chk_normalize.isChecked(),
gain_db=self._spn_gain.value())
def _on_audio_audition(self, playing: bool) -> None:
"""Toggle audition of the current region (with edits) via ffplay."""
if not playing:
self._stop_audition()
return
if not self._file_path:
self._btn_audio_play.setChecked(False)
return
import tempfile
from PyQt6.QtCore import QProcess
start = self._cursor
dur = self._spn_audio_len.value()
edit_filters = self._current_edit_filters()
tmp = os.path.join(tempfile.gettempdir(), "8cut_audition.wav")
cmd = build_audio_clip_command(self._file_path, start, dur, tmp,
filters=edit_filters or None)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
self._show_status("Auditioning…")
try:
proc = subprocess.run(cmd, capture_output=True, timeout=120)
except Exception:
proc = None
finally:
QApplication.restoreOverrideCursor()
if proc is None or proc.returncode != 0 or not os.path.exists(tmp):
self._btn_audio_play.setChecked(False)
self._show_status("Audition render failed", 3000)
return
self._audition_proc = QProcess(self)
self._audition_proc.finished.connect(self._on_audition_finished)
self._audition_proc.errorOccurred.connect(self._on_audition_error)
self._audition_proc.start(
_bin("ffplay"), ["-autoexit", "-nodisp", "-loglevel", "error", tmp])
self._btn_audio_play.setText("■ Stop")
def _on_audition_error(self, _err) -> None:
self._show_status("Audio playback unavailable (ffplay not found)", 4000)
self._teardown_audition()
def _on_audition_finished(self, *_a) -> None:
self._teardown_audition()
def _teardown_audition(self) -> None:
proc = self._audition_proc
self._audition_proc = None
if proc is not None:
proc.deleteLater()
self._btn_audio_play.setText("▶ Play")
# Snap the toggle back without re-entering start logic.
if self._btn_audio_play.isChecked():
self._btn_audio_play.blockSignals(True)
self._btn_audio_play.setChecked(False)
self._btn_audio_play.blockSignals(False)
def _stop_audition(self) -> None:
proc = self._audition_proc
self._audition_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_audio_play.setText("▶ Play")
def _on_extract_audio(self) -> None: def _on_extract_audio(self) -> None:
"""Extract an exact-length audio slice starting at the playhead and """Extract an exact-length audio slice starting at the playhead and
prompt for where to save it (format follows the chosen extension).""" prompt for where to save it (format follows the chosen extension)."""
@@ -6556,12 +6641,7 @@ class MainWindow(QMainWindow):
if not os.path.splitext(path)[1]: if not os.path.splitext(path)[1]:
path += ext path += ext
os.makedirs(os.path.dirname(path) or ".", exist_ok=True) os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
edit_filters = audio_edit_filters( edit_filters = self._current_edit_filters()
duration=dur,
fade_in=self._spn_fade_in.value(),
fade_out=self._spn_fade_out.value(),
normalize=self._chk_normalize.isChecked(),
gain_db=self._spn_gain.value())
cmd = build_audio_clip_command(self._file_path, start, dur, path, cmd = build_audio_clip_command(self._file_path, start, dur, path,
filters=edit_filters or None) filters=edit_filters or None)
self._btn_extract_audio.setEnabled(False) self._btn_extract_audio.setEnabled(False)
+16
View File
@@ -386,3 +386,19 @@ def test_waveform_strip_present_and_safe(win):
# refresh with no file loaded is a safe no-op # refresh with no file loaded is a safe no-op
win._file_path = "" win._file_path = ""
win._on_wave_refresh() win._on_wave_refresh()
def test_audition_button_present_and_safe(win):
from PyQt6.QtWidgets import QPushButton
assert isinstance(win._btn_audio_play, QPushButton)
assert win._btn_audio_play.isCheckable()
assert win._btn_audio_play.text() == "▶ Play"
# No file loaded -> toggling on must be a safe no-op that snaps back to unchecked.
win._file_path = ""
win._btn_audio_play.setChecked(True) # fires _on_audio_audition(True)
assert win._btn_audio_play.isChecked() is False
# Stopping when nothing is playing must be safe.
win._stop_audition()
assert win._btn_audio_play.isChecked() is False
assert win._btn_audio_play.text() == "▶ Play"
assert win._audition_proc is None