feat: play/stop audition of the current audio region
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user