feat: read-only waveform strip + manual refresh in the Audio tab

This commit is contained in:
2026-07-02 14:53:32 +02:00
parent 4c5c4276c1
commit 12eaa944a7
2 changed files with 72 additions and 3 deletions
+58 -3
View File
@@ -3937,6 +3937,39 @@ def main():
sys.exit(ret) sys.exit(ret)
class AudioWaveform(QWidget):
"""Read-only waveform strip: paints normalized peak bars for the current
audio region. Populated on demand via manual refresh (no implicit UI-thread
decode)."""
def __init__(self, parent=None):
super().__init__(parent)
self._peaks: list[float] = []
self.setFixedHeight(48)
self.setToolTip("Waveform of the current audio area (↻ to refresh)")
def set_peaks(self, peaks: list[float]) -> None:
self._peaks = list(peaks or [])
self.update()
def clear(self) -> None:
self._peaks = []
self.update()
def paintEvent(self, _ev):
p = QPainter(self)
p.fillRect(self.rect(), QColor(30, 30, 30))
if not self._peaks:
return
w = self.width(); h = self.height(); mid = h / 2
n = len(self._peaks)
p.setPen(QPen(QColor(0, 220, 190)))
for i, v in enumerate(self._peaks):
x = int(i * w / n)
bar_h = v * (h - 4) / 2
p.drawLine(x, int(mid - bar_h), x, int(mid + bar_h))
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -4495,6 +4528,11 @@ class MainWindow(QMainWindow):
self._spn_gain.setSingleStep(0.5) self._spn_gain.setSingleStep(0.5)
self._spn_gain.setSuffix(" dB") self._spn_gain.setSuffix(" dB")
self._spn_gain.setToolTip("Gain applied to the extracted audio (0 = unchanged)") self._spn_gain.setToolTip("Gain applied to the extracted audio (0 = unchanged)")
self._wave = AudioWaveform()
self._btn_wave_refresh = QPushButton("↻ Waveform")
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._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
@@ -4797,10 +4835,12 @@ class MainWindow(QMainWindow):
g.addWidget(self._chk_normalize, 3, 0, 1, 2) g.addWidget(self._chk_normalize, 3, 0, 1, 2)
g.addWidget(QLabel("Gain:"), 3, 2) g.addWidget(QLabel("Gain:"), 3, 2)
g.addWidget(self._spn_gain, 3, 3) g.addWidget(self._spn_gain, 3, 3)
# Row 4 reserved for the waveform strip (added in a later task). # Row 4 — read-only waveform strip for the current audio area.
g.addWidget(self._btn_extract_audio, 5, 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_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(6, 1) # anchor content to the top (row 4 stays reserved for the waveform) g.setRowStretch(7, 1) # anchor content to the top
tb.addItem(extract_page, "Extract & Edit") tb.addItem(extract_page, "Extract & Edit")
# ── Page 2: Scan / Classify (moved verbatim) ───────────── # ── Page 2: Scan / Classify (moved verbatim) ─────────────
@@ -6463,6 +6503,21 @@ class MainWindow(QMainWindow):
start = self._cursor start = self._cursor
self._timeline.set_audio_region(start, start + self._spn_audio_len.value()) self._timeline.set_audio_region(start, start + self._spn_audio_len.value())
def _on_wave_refresh(self) -> None:
"""Decode the current audio area and repaint the waveform strip."""
if not self._file_path:
self._wave.clear()
return
from core.waveform import load_region_samples, peaks
start = self._cursor
dur = self._spn_audio_len.value()
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
samples = load_region_samples(self._file_path, start, dur)
finally:
QApplication.restoreOverrideCursor()
self._wave.set_peaks(peaks(samples))
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)."""
+14
View File
@@ -372,3 +372,17 @@ def test_extract_no_edits_passes_no_filters(win, monkeypatch, tmp_path):
with pytest.raises(_Stop): with pytest.raises(_Stop):
win._on_extract_audio() win._on_extract_audio()
assert seen["filters"] is None assert seen["filters"] is None
def test_waveform_strip_present_and_safe(win):
from PyQt6.QtWidgets import QPushButton
assert win._wave is not None
assert isinstance(win._btn_wave_refresh, QPushButton)
# set_peaks stores + doesn't crash
win._wave.set_peaks([0.1, 0.5, 0.9])
assert list(win._wave._peaks) == [0.1, 0.5, 0.9]
win._wave.clear()
assert win._wave._peaks == []
# refresh with no file loaded is a safe no-op
win._file_path = ""
win._on_wave_refresh()