feat: waveform playhead during audition + docs (v1.5)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 17:27:37 +02:00
co-authored by Claude Opus 4.8
parent 356cfcc7d7
commit 449b3dfa72
3 changed files with 34 additions and 1 deletions
+1
View File
@@ -39,6 +39,7 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
- **Output format** — WAV / MP3 / FLAC / M4A / OGG / OPUS, remembered between sessions - **Output format** — WAV / MP3 / FLAC / M4A / OGG / OPUS, remembered between sessions
- **Non-destructive edits** — fade in/out, EBU R128 normalize, and gain, applied via ffmpeg on extract (and audition) - **Non-destructive edits** — fade in/out, EBU R128 normalize, and gain, applied via ffmpeg on extract (and audition)
- **Waveform preview** — a read-only strip of the current area (↻ to refresh; decode length-capped so long areas don't stall) - **Waveform preview** — a read-only strip of the current area (↻ to refresh; decode length-capped so long areas don't stall)
- **Interactive waveform** — drag the in/out handles to set the exact clip region, wheel to zoom, with a playhead during audition
- **Audition** — Play/Stop the current area with the edits applied - **Audition** — Play/Stop the current area with the edits applied
### Audio merge (crossfade) ### Audio merge (crossfade)
+26 -1
View File
@@ -4672,6 +4672,12 @@ class MainWindow(QMainWindow):
"Audition the current audio area (with the edits applied)") "Audition the current audio area (with the edits applied)")
self._btn_audio_play.toggled.connect(self._on_audio_audition) self._btn_audio_play.toggled.connect(self._on_audio_audition)
self._audition_proc = None # QProcess while auditioning, else None self._audition_proc = None # QProcess while auditioning, else None
from PyQt6.QtCore import QElapsedTimer
self._audition_start_t = 0.0
self._audition_elapsed = QElapsedTimer()
self._audition_playhead_timer = QTimer(self)
self._audition_playhead_timer.setInterval(50)
self._audition_playhead_timer.timeout.connect(self._tick_audition_playhead)
# ── Merge (crossfade) pane widgets ─────────────────────────── # ── Merge (crossfade) pane widgets ───────────────────────────
self._merge_list = QListWidget() self._merge_list = QListWidget()
@@ -5164,10 +5170,17 @@ class MainWindow(QMainWindow):
# ── Changelog ──────────────────────────────────────────── # ── Changelog ────────────────────────────────────────────
APP_VERSION = "1.4" APP_VERSION = "1.5"
_SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync) _SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync)
_WAVE_MIN_VIEW = 3.0 # minimum waveform view window (seconds) _WAVE_MIN_VIEW = 3.0 # minimum waveform view window (seconds)
CHANGELOG: list[tuple[str, list[str]]] = [ CHANGELOG: list[tuple[str, list[str]]] = [
("1.5", [
"<b>Interactive waveform</b> — the Audio tab's waveform is now a "
"selection surface: <b>drag the in/out handles</b> (or drag a new "
"range) to set the exact clip, <b>wheel to zoom</b>, and a "
"<b>playhead</b> tracks audition. The selection stays in sync with "
"the length control both ways.",
]),
("1.4", [ ("1.4", [
"<b>Merge (crossfade)</b> — a new <b>Merge</b> pane in the Audio tab " "<b>Merge (crossfade)</b> — a new <b>Merge</b> pane in the Audio tab "
"assembles multiple clips into one, crossfading every join. Add the " "assembles multiple clips into one, crossfading every join. Add the "
@@ -6996,6 +7009,14 @@ class MainWindow(QMainWindow):
return return
self._play_file(tmp) self._play_file(tmp)
self._btn_audio_play.setText("■ Stop") self._btn_audio_play.setText("■ Stop")
# start the playhead timer AFTER _play_file (it calls _stop_audition, which stops this timer)
self._audition_start_t = start # start = self._cursor used for the render
self._audition_elapsed.restart()
self._audition_playhead_timer.start()
def _tick_audition_playhead(self) -> None:
secs = self._audition_elapsed.elapsed() / 1000.0
self._wave.set_playhead(self._audition_start_t + secs)
def _on_audition_error(self, _err) -> None: def _on_audition_error(self, _err) -> None:
self._show_status("Audio playback unavailable (ffplay not found)", 4000) self._show_status("Audio playback unavailable (ffplay not found)", 4000)
@@ -7010,6 +7031,8 @@ class MainWindow(QMainWindow):
if proc is not None: if proc is not None:
proc.deleteLater() proc.deleteLater()
self._btn_audio_play.setText("▶ Play") self._btn_audio_play.setText("▶ Play")
self._audition_playhead_timer.stop()
self._wave.set_playhead(None)
# Snap the toggle back without re-entering start logic. # Snap the toggle back without re-entering start logic.
if self._btn_audio_play.isChecked(): if self._btn_audio_play.isChecked():
self._btn_audio_play.blockSignals(True) self._btn_audio_play.blockSignals(True)
@@ -7029,6 +7052,8 @@ class MainWindow(QMainWindow):
proc.waitForFinished(100) proc.waitForFinished(100)
proc.deleteLater() proc.deleteLater()
self._btn_audio_play.setText("▶ Play") self._btn_audio_play.setText("▶ Play")
self._audition_playhead_timer.stop()
self._wave.set_playhead(None)
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
+7
View File
@@ -467,6 +467,13 @@ def test_audition_button_present_and_safe(win):
assert win._audition_proc is None assert win._audition_proc is None
def test_audition_clears_waveform_playhead(win):
win._wave.set_playhead(5.0)
win._stop_audition()
assert win._wave._playhead is None
assert not win._audition_playhead_timer.isActive()
def test_merge_list_add_remove_reorder(win, tmp_path): def test_merge_list_add_remove_reorder(win, tmp_path):
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; b = tmp_path / "b.wav" a = tmp_path / "a.wav"; b = tmp_path / "b.wav"