Files
uj-mastering-master/plot_control_widget.py
T
Mikkeli Matlock b400551321 Move plotting to pyqtgraph: interactive, overlay-capable render layer
Replace the fire-and-forget matplotlib pipeline (render() -> throwaway Figure ->
canvas teardown) with a three-stage architecture that supports zoom/pan, lin/log
toggling, and multi-file overlay:

  compute(audio_file) -> data        # heavy, worker thread, backend-neutral
  build_spec(data, view) -> PlotSpec # cheap, GUI thread, view-aware
  show_specs([(label, spec, color)]) # pyqtgraph, persistent PlotItem, overlay

- plotspec.py: backend-agnostic descriptors (Curve, Band, HLine, Heatmap,
  AxisSpec, PlotSpec) + ViewState (recompute-free lin/log)
- audio_visualization_widget.py: persistent pyqtgraph plot, never torn down;
  per-dataset colours for overlay; spectrogram log-freq via row resample
  (ImageItem is affine-only); ColorBarItem at a fixed cell
- Compare/overlay driven by file-list checkboxes; stable per-song colour by row
- Custom draggable reference lines (add/clear), persist across redraws
- Axis-constrained scroll zoom: Ctrl=time, Shift=value (_AxisZoomViewBox)
- RMS render no longer per-segment fill_between (was the slow path)

Fixes found in review/testing:
- FillBetweenItem needs penned child curves or it fills nothing (RMS/Waveform
  were blank); band fill verified by pixel count
- band overlay alpha was a no-op (QBrush.color() returns a copy)
- colorbar could stack across renders; now added/removed at a fixed layout cell

Deferred (per scope): stereo retention, deep perf rewrites (eager beat_track,
true-peak/crest loops, shared LUFS), per-song colour picker UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:35:10 +09:00

94 lines
3.5 KiB
Python

"""
Plot control widget: pick which metric to display and refresh the current plot.
Mirrors FontControlWidget's clustered-groupbox style so the two sit naturally
next to each other in the left panel.
"""
import logging
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QGroupBox,
QCheckBox,
)
from PyQt5.QtCore import pyqtSignal
from metrics import METRICS, DEFAULT_METRIC_ID
from plotspec import ViewState
class PlotControlWidget(QWidget):
"""Metric selector, view-scale toggle, and manual plot refresh.
Overlay/compare is driven by the file-list checkboxes, not here — this cluster
only governs *what* metric and *how* its axes are scaled.
"""
metricChanged = pyqtSignal(str) # metric_id
viewChanged = pyqtSignal() # view-state (scale) changed
plotRefreshRequested = pyqtSignal()
addReferenceLineRequested = pyqtSignal()
clearReferenceLinesRequested = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.logger = logging.getLogger(__name__)
self.initUI()
def initUI(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
group_box = QGroupBox("Plot")
group_layout = QVBoxLayout(group_box)
group_layout.addWidget(QLabel("Metric:"))
self.metric_combo = QComboBox()
for metric_id, metric in METRICS.items():
self.metric_combo.addItem(metric.display_name, metric_id)
default_idx = self.metric_combo.findData(DEFAULT_METRIC_ID)
if default_idx >= 0:
self.metric_combo.setCurrentIndex(default_idx)
self.metric_combo.currentIndexChanged.connect(self._on_metric_changed)
group_layout.addWidget(self.metric_combo)
# Frequency-axis scale. Only the spectrogram honours it today; harmless
# elsewhere (build_spec ignores unsupported toggles).
self.log_freq_check = QCheckBox("Log frequency (spectrogram)")
self.log_freq_check.setChecked(True)
self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit())
group_layout.addWidget(self.log_freq_check)
# Custom reference lines: drop a draggable horizontal marker (e.g. an
# eyeballed effective average) onto whatever metric is showing.
ref_row = QHBoxLayout()
self.add_ref_button = QPushButton("Add ref line")
self.add_ref_button.setToolTip("Drop a draggable horizontal reference line")
self.add_ref_button.clicked.connect(self.addReferenceLineRequested.emit)
ref_row.addWidget(self.add_ref_button)
self.clear_ref_button = QPushButton("Clear")
self.clear_ref_button.setToolTip("Remove all custom reference lines")
self.clear_ref_button.clicked.connect(self.clearReferenceLinesRequested.emit)
ref_row.addWidget(self.clear_ref_button)
group_layout.addLayout(ref_row)
button_row = QHBoxLayout()
self.refresh_button = QPushButton("Refresh Plot")
self.refresh_button.setToolTip("Re-render the current plot with current settings")
self.refresh_button.clicked.connect(self.plotRefreshRequested.emit)
button_row.addWidget(self.refresh_button)
group_layout.addLayout(button_row)
layout.addWidget(group_box)
def _on_metric_changed(self, _index: int):
metric_id = self.metric_combo.currentData()
if metric_id:
self.logger.info(f"Metric changed: {metric_id}")
self.metricChanged.emit(metric_id)
def current_metric_id(self) -> str:
return self.metric_combo.currentData() or DEFAULT_METRIC_ID
def current_view_state(self) -> ViewState:
return ViewState(y_log=self.log_freq_check.isChecked())