From c57c07810083721c1113a4d8d92b5b03069e892b Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 15:04:21 +0200 Subject: [PATCH] feat: play/stop audition of the current audio region Co-Authored-By: Claude Opus 4.8 --- main.py | 92 +++++++++++++++++++++++++++++++++++--- tests/test_ui_structure.py | 16 +++++++ 2 files changed, 102 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index a3b119b..2b03989 100755 --- a/main.py +++ b/main.py @@ -4533,6 +4533,12 @@ class MainWindow(QMainWindow): self._btn_wave_refresh.setToolTip( "Decode the current audio area and draw its waveform") 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 # 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. g.addWidget(self._wave, 4, 0, 1, 4) 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.setColumnStretch(4, 1) # spare gutter column clusters label/field pairs to the left g.setRowStretch(7, 1) # anchor content to the top @@ -6523,6 +6530,84 @@ class MainWindow(QMainWindow): elif preview < dur: 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: """Extract an exact-length audio slice starting at the playhead and 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]: path += ext os.makedirs(os.path.dirname(path) or ".", exist_ok=True) - edit_filters = audio_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()) + edit_filters = self._current_edit_filters() cmd = build_audio_clip_command(self._file_path, start, dur, path, filters=edit_filters or None) self._btn_extract_audio.setEnabled(False) diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 8e1e6ec..d901484 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -386,3 +386,19 @@ def test_waveform_strip_present_and_safe(win): # refresh with no file loaded is a safe no-op win._file_path = "" 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