feat: read-only waveform strip + manual refresh in the Audio tab
This commit is contained in:
@@ -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)."""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user