Refactor/interactive plotting #1

Merged
mikkeli merged 3 commits from refactor/interactive-plotting into main 2026-06-13 16:17:21 +00:00
7 changed files with 365 additions and 83 deletions
Showing only changes of commit a45bd4ba9b - Show all commits
+18 -7
View File
@@ -57,9 +57,18 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
#### `plot_control_widget.py` #### `plot_control_widget.py`
- Metric selector dropdown driven by the `metrics.METRICS` registry - Metric selector dropdown driven by the `metrics.METRICS` registry
- Log-frequency toggle (view-state; recompute-free, currently honoured by the - Log-frequency toggle and a time-axis mode selector — Absolute (seconds) vs
spectrogram) and the `Refresh Plot` button Relative (% of each track's own length) — both view-state, recompute-free
- Compare/overlay is *not* here — it is driven by the file-list checkboxes - `Refresh Plot` button. Compare/overlay membership is the file-list checkboxes;
reference lines have their own cluster
#### `ref_line_widget.py`
- `RefLineControlWidget`: side-panel list of custom reference lines with
Add / Edit… / Remove / Clear; a pure view over the `RefLineProps` list the
main window owns, emitting intents
- `RefLineDialog`: edits one line's value, colour, line style, and tag
- The plot draws each line with a triangle drag-handle; dragging writes the new
value back into the shared `RefLineProps` and refreshes the list
#### `metrics.py` #### `metrics.py`
- Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread, - Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread,
@@ -114,11 +123,13 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
(pyqtgraph ViewBox); log/linear frequency toggle. Scroll zooms both axes; (pyqtgraph ViewBox); log/linear frequency toggle. Scroll zooms both axes;
**Ctrl+scroll** zooms time only, **Shift+scroll** zooms the value axis only **Ctrl+scroll** zooms time only, **Shift+scroll** zooms the value axis only
(`_AxisZoomViewBox`); scrolling over an axis also zooms just that axis (`_AxisZoomViewBox`); scrolling over an axis also zooms just that axis
- **Custom reference lines**: "Add ref line" drops a draggable horizontal marker - **Time-axis mode**: Absolute (seconds) or Relative (% of each track's own
on any metric (e.g. an eyeballed effective average); lines persist across length), so tracks of very different durations line up by song position
redraws/overlay changes and are cleared automatically when the metric changes - **Custom reference lines**: side-panel list (Add/Edit/Remove/Clear) of draggable
horizontal markers with value/colour/style/tag; dragged via a triangle handle.
Persist across redraws/overlay changes; cleared when the metric changes
- **Font control**: Unified font selector with size control - **Font control**: Unified font selector with size control
- **Plot control**: Metric selector + log-frequency toggle + ref-line add/clear - **Plot control**: Metric selector + log-frequency toggle + time-axis mode
+ refresh-plot button + refresh-plot button
- **Analysis display**: Real-time visualization with metadata panels - **Analysis display**: Real-time visualization with metadata panels
- **Modular architecture**: Self-contained widgets for easy layout management - **Modular architecture**: Self-contained widgets for easy layout management
+69 -49
View File
@@ -11,8 +11,9 @@ Interaction notes:
- Plain scroll zooms both axes; Ctrl+scroll zooms time only; Shift+scroll zooms - Plain scroll zooms both axes; Ctrl+scroll zooms time only; Shift+scroll zooms
the value axis only (see `_AxisZoomViewBox`). Scrolling directly over an axis the value axis only (see `_AxisZoomViewBox`). Scrolling directly over an axis
also zooms just that axis (pyqtgraph default). also zooms just that axis (pyqtgraph default).
- User reference lines (`add_user_line`) are draggable, survive redraws within a - Reference lines (`set_reference_lines`) are draggable via a triangle handle,
metric, and are cleared by the GUI when the metric changes (units change). survive redraws, and write their position back into the GUI-owned RefLineProps;
the GUI clears them when the metric changes (units change).
Why the spectrogram is special: pyqtgraph's ImageItem is affine-only, so it does Why the spectrogram is special: pyqtgraph's ImageItem is affine-only, so it does
not follow a log-scaled axis. Log frequency is therefore realised by resampling not follow a log-scaled axis. Log frequency is therefore realised by resampling
@@ -24,9 +25,9 @@ import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from scipy.interpolate import interp1d from scipy.interpolate import interp1d
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt, pyqtSignal
from plotspec import PlotSpec, ViewState, DEFAULT_VIEW from plotspec import PlotSpec, ViewState, DEFAULT_VIEW, RefLineProps
# White canvas / black ink to match the previous matplotlib aesthetic. # White canvas / black ink to match the previous matplotlib aesthetic.
pg.setConfigOption("background", "w") pg.setConfigOption("background", "w")
@@ -45,9 +46,6 @@ _PEN_STYLE = {"solid": Qt.SolidLine, "dash": Qt.DashLine, "dot": Qt.DotLine}
# "Nice" frequencies to label on a log frequency axis, in Hz. # "Nice" frequencies to label on a log frequency axis, in Hz.
_LOG_FREQ_TICKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000] _LOG_FREQ_TICKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
# Colour for user-added reference lines (neutral so it reads on any metric).
_USER_LINE_COLOR = "#444444"
def dataset_color(index: int) -> str: def dataset_color(index: int) -> str:
"""Stable dataset colour for a given index (e.g. a file's row in the list).""" """Stable dataset colour for a given index (e.g. a file's row in the list)."""
@@ -83,9 +81,40 @@ class _AxisZoomViewBox(pg.ViewBox):
super().wheelEvent(ev, axis=axis) super().wheelEvent(ev, axis=axis)
class _RefLine(pg.InfiniteLine):
"""A draggable horizontal reference line bound to a RefLineProps.
Carries a triangle grab-handle at the left edge and writes its position back
into the props on drag, notifying the widget so the side-panel list refreshes.
"""
def __init__(self, index: int, props: RefLineProps, on_moved):
pen = pg.mkPen(props.color, width=1.4,
style=_PEN_STYLE.get(props.style, Qt.DashLine))
super().__init__(
pos=props.value, angle=0, movable=True, pen=pen,
label=props.label or "{value:.2f}",
labelOpts={"position": 0.06, "color": props.color,
"fill": (255, 255, 255, 180)},
)
self._index = index
self._props = props
self._on_moved = on_moved
self.addMarker("|>", position=0.0, size=12) # triangle handle at the start
self.sigPositionChangeFinished.connect(self._commit)
def _commit(self):
self._props.value = float(self.value())
self._on_moved(self._index)
class AudioVisualizationWidget(QWidget): class AudioVisualizationWidget(QWidget):
"""Persistent interactive plot. Call `show_specs` to (re)draw.""" """Persistent interactive plot. Call `show_specs` to (re)draw."""
# Emitted (with the line's index) when a reference line is dragged, so the
# side-panel list can refresh its displayed value.
referenceLineMoved = pyqtSignal(int)
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -101,10 +130,10 @@ class AudioVisualizationWidget(QWidget):
layout.addWidget(self.status_label) layout.addWidget(self.status_label)
self._colorbar = None self._colorbar = None
# User reference lines persist by value across redraws; the items are rebuilt # Reference lines are owned by the GUI controller (RefLineProps objects) and
# each render. Cleared by the GUI on metric change (units change). # passed in via set_reference_lines; the line items are rebuilt each render.
self._user_line_values: list[float] = [] self._ref_props: list[RefLineProps] = []
self._user_lines: list[pg.InfiniteLine] = [] self._ref_lines: list[_RefLine] = []
self._show_empty() self._show_empty()
# ---- public API --------------------------------------------------------- # ---- public API ---------------------------------------------------------
@@ -131,8 +160,8 @@ class AudioVisualizationWidget(QWidget):
self._render_heatmap(spec, view) self._render_heatmap(spec, view)
if len(specs) > 1: if len(specs) > 1:
self.set_status(f"{spec.title or label}: spectrogram shows one track at a time") self.set_status(f"{spec.title or label}: spectrogram shows one track at a time")
self._apply_axes(base_axes, log_y_image_handled=True) self._apply_axes(base_axes, single=True, log_y_image_handled=True)
self._draw_user_lines() self._draw_ref_lines()
return return
single = len(specs) == 1 single = len(specs) == 1
@@ -150,21 +179,18 @@ class AudioVisualizationWidget(QWidget):
for note in spec.annotations: for note in spec.annotations:
self._legend_note(prefix + note) self._legend_note(prefix + note)
self._apply_axes(base_axes) self._apply_axes(base_axes, single=single)
self._draw_user_lines() self._draw_ref_lines()
def add_user_line(self, value: float | None = None): def set_reference_lines(self, props: list[RefLineProps]):
"""Add a draggable horizontal reference line at `value` (default: view centre).""" """Set the reference-line set (RefLineProps owned by the GUI) and redraw them."""
if value is None: self._ref_props = props
(_, _), (y0, y1) = self.plot.viewRange() self._draw_ref_lines()
value = (y0 + y1) / 2.0
self._user_line_values.append(float(value))
self._draw_user_lines()
def clear_user_lines(self): def current_view_center_y(self) -> float:
"""Remove all user reference lines (called when the metric changes).""" """Mid-point of the current y view — a sane default position for a new line."""
self._user_line_values.clear() (_, _), (y0, y1) = self.plot.viewRange()
self._remove_user_line_items() return (y0 + y1) / 2.0
def set_status(self, message: str): def set_status(self, message: str):
self.status_label.setText(message) self.status_label.setText(message)
@@ -261,11 +287,16 @@ class AudioVisualizationWidget(QWidget):
self._colorbar.setImageItem(img) self._colorbar.setImageItem(img)
self.glw.addItem(self._colorbar, row=0, col=1) self.glw.addItem(self._colorbar, row=0, col=1)
def _apply_axes(self, axes, log_y_image_handled: bool = False): def _apply_axes(self, axes, single: bool, log_y_image_handled: bool = False):
self.plot.setLabel("bottom", axes.x_label) self.plot.setLabel("bottom", axes.x_label)
self.plot.setLabel("left", axes.y_label) self.plot.setLabel("left", axes.y_label)
if axes.x_range: # Frame x exactly only for a single dataset; overlaid tracks of different
# lengths (absolute mode) should autorange to their union rather than clip to
# the first one's span. In relative mode every spec is 0-100, so either works.
if axes.x_range and single:
self.plot.setXRange(*axes.x_range, padding=0) self.plot.setXRange(*axes.x_range, padding=0)
elif not single:
self.plot.enableAutoRange(axis=pg.ViewBox.XAxis)
if axes.y_range and not log_y_image_handled: if axes.y_range and not log_y_image_handled:
self.plot.setYRange(*axes.y_range, padding=0) self.plot.setYRange(*axes.y_range, padding=0)
if not log_y_image_handled: if not log_y_image_handled:
@@ -274,29 +305,18 @@ class AudioVisualizationWidget(QWidget):
# ---- user reference lines ----------------------------------------------- # ---- user reference lines -----------------------------------------------
def _draw_user_lines(self): def _draw_ref_lines(self):
"""(Re)create draggable lines from the stored values, preserving positions.""" """(Re)create draggable lines from the current RefLineProps set."""
self._remove_user_line_items() self._remove_ref_line_items()
for idx in range(len(self._user_line_values)): for idx, props in enumerate(self._ref_props):
line = pg.InfiniteLine( line = _RefLine(idx, props, on_moved=self.referenceLineMoved.emit)
pos=self._user_line_values[idx], angle=0, movable=True,
pen=pg.mkPen(_USER_LINE_COLOR, width=1.2, style=Qt.DashLine),
label="{value:.2f}",
labelOpts={"position": 0.05, "color": _USER_LINE_COLOR,
"fill": (255, 255, 255, 180)},
)
line.sigPositionChanged.connect(lambda ln, i=idx: self._on_user_line_moved(i, ln))
self.plot.addItem(line) self.plot.addItem(line)
self._user_lines.append(line) self._ref_lines.append(line)
def _on_user_line_moved(self, index: int, line: pg.InfiniteLine): def _remove_ref_line_items(self):
if 0 <= index < len(self._user_line_values): for line in self._ref_lines:
self._user_line_values[index] = float(line.value())
def _remove_user_line_items(self):
for line in self._user_lines:
self.plot.removeItem(line) self.plot.removeItem(line)
self._user_lines.clear() self._ref_lines.clear()
# ---- legend / lifecycle ------------------------------------------------- # ---- legend / lifecycle -------------------------------------------------
@@ -307,7 +327,7 @@ class AudioVisualizationWidget(QWidget):
self.legend.addItem(pg.PlotDataItem(pen=None), text) self.legend.addItem(pg.PlotDataItem(pen=None), text)
def _reset_plot(self): def _reset_plot(self):
self._remove_user_line_items() # cleared from scene; values persist for redraw self._remove_ref_line_items() # cleared from scene; props persist for redraw
self.plot.clear() self.plot.clear()
if self._colorbar is not None: if self._colorbar is not None:
try: try:
+50 -7
View File
@@ -12,7 +12,9 @@ from logger_setup import setup_logging, parse_log_args
from font_manager import initialize_fonts, get_font_manager from font_manager import initialize_fonts, get_font_manager
from font_control_widget import FontControlWidget from font_control_widget import FontControlWidget
from plot_control_widget import PlotControlWidget from plot_control_widget import PlotControlWidget
from ref_line_widget import RefLineControlWidget, RefLineDialog
from metrics import METRICS from metrics import METRICS
from plotspec import RefLineProps, apply_x_mode
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -24,6 +26,8 @@ class MainWindow(QMainWindow):
self.analysis_manager = AnalysisResultsManager() self.analysis_manager = AnalysisResultsManager()
# Guards programmatic list mutations from triggering re-render storms. # Guards programmatic list mutations from triggering re-render storms.
self._suppress_list_signals = False self._suppress_list_signals = False
# Custom reference lines, owned here and pushed to the plot each render.
self.ref_lines: list[RefLineProps] = []
self.initUI() self.initUI()
self.connect_signals() self.connect_signals()
@@ -74,10 +78,16 @@ class MainWindow(QMainWindow):
self.plot_control.metricChanged.connect(self.on_metric_changed) self.plot_control.metricChanged.connect(self.on_metric_changed)
self.plot_control.viewChanged.connect(self.on_view_changed) self.plot_control.viewChanged.connect(self.on_view_changed)
self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested) self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested)
self.plot_control.addReferenceLineRequested.connect(self.on_add_reference_line)
self.plot_control.clearReferenceLinesRequested.connect(self.on_clear_reference_lines)
layout.addWidget(self.plot_control) layout.addWidget(self.plot_control)
# Reference-line management cluster (list + add/edit/remove/clear).
self.ref_line_control = RefLineControlWidget()
self.ref_line_control.addRequested.connect(self.on_add_reference_line)
self.ref_line_control.editRequested.connect(self.on_edit_reference_line)
self.ref_line_control.removeRequested.connect(self.on_remove_reference_line)
self.ref_line_control.clearRequested.connect(self.on_clear_reference_lines)
layout.addWidget(self.ref_line_control)
# File list. Each item carries a checkbox: the checked set is the overlay # File list. Each item carries a checkbox: the checked set is the overlay
# set drawn on the graph; the highlighted item drives the metadata panel. # set drawn on the graph; the highlighted item drives the metadata panel.
self.file_list_label = QLabel("Analyzed Files (tick to overlay):") self.file_list_label = QLabel("Analyzed Files (tick to overlay):")
@@ -116,6 +126,7 @@ class MainWindow(QMainWindow):
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started) self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
self.analysis_manager.metricReady.connect(self.on_metric_ready) self.analysis_manager.metricReady.connect(self.on_metric_ready)
self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error) self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error)
self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved)
def dragEnterEvent(self, event): def dragEnterEvent(self, event):
"""Handle drag enter event for file drops.""" """Handle drag enter event for file drops."""
@@ -223,16 +234,47 @@ class MainWindow(QMainWindow):
self.logger.info(f"Metric changed via GUI: {metric_id}") self.logger.info(f"Metric changed via GUI: {metric_id}")
# The value axis units change with the metric, so custom reference lines # The value axis units change with the metric, so custom reference lines
# placed against the old metric no longer mean anything — drop them. # placed against the old metric no longer mean anything — drop them.
self.visualization_widget.clear_user_lines() self.ref_lines.clear()
self._sync_ref_lines()
self._refresh_view() self._refresh_view()
def on_add_reference_line(self): def on_add_reference_line(self):
"""Drop a draggable reference line on the current plot.""" """Add a reference line at the current view centre, then edit it."""
self.visualization_widget.add_user_line() value = self.visualization_widget.current_view_center_y()
props = RefLineProps(value=round(value, 2))
self.ref_lines.append(props)
self._sync_ref_lines()
# Open the editor immediately so colour/tag/value can be set right away.
self.on_edit_reference_line(len(self.ref_lines) - 1)
def on_edit_reference_line(self, index: int):
"""Open the properties dialog for a reference line."""
if not (0 <= index < len(self.ref_lines)):
return
dialog = RefLineDialog(self, self.ref_lines[index])
if dialog.exec_():
self.ref_lines[index] = dialog.result_props()
self._sync_ref_lines()
def on_remove_reference_line(self, index: int):
"""Delete a reference line."""
if 0 <= index < len(self.ref_lines):
del self.ref_lines[index]
self._sync_ref_lines()
def on_clear_reference_lines(self): def on_clear_reference_lines(self):
"""Remove all custom reference lines.""" """Remove all custom reference lines."""
self.visualization_widget.clear_user_lines() self.ref_lines.clear()
self._sync_ref_lines()
def on_reference_line_moved(self, index: int):
"""A line was dragged on the plot — its value is already updated; refresh list."""
self.ref_line_control.set_lines(self.ref_lines)
def _sync_ref_lines(self):
"""Push the current reference-line set to both the list view and the plot."""
self.ref_line_control.set_lines(self.ref_lines)
self.visualization_widget.set_reference_lines(self.ref_lines)
def on_view_changed(self): def on_view_changed(self):
"""Called when a view-scale toggle (lin/log) changes. Recompute-free redraw.""" """Called when a view-scale toggle (lin/log) changes. Recompute-free redraw."""
@@ -320,7 +362,8 @@ class MainWindow(QMainWindow):
# Colour is keyed to the file's row, not its position in the overlay # Colour is keyed to the file's row, not its position in the overlay
# subset, so a song keeps its colour as others are ticked/unticked. # subset, so a song keeps its colour as others are ticked/unticked.
color = dataset_color(self._row_index(path)) color = dataset_color(self._row_index(path))
specs.append((label, metric.build_spec(data, view), color)) spec = apply_x_mode(metric.build_spec(data, view), view.x_mode)
specs.append((label, spec, color))
if specs: if specs:
self.visualization_widget.show_specs(specs, view) self.visualization_widget.show_specs(specs, view)
+18 -20
View File
@@ -13,21 +13,20 @@ from PyQt5.QtWidgets import (
from PyQt5.QtCore import pyqtSignal from PyQt5.QtCore import pyqtSignal
from metrics import METRICS, DEFAULT_METRIC_ID from metrics import METRICS, DEFAULT_METRIC_ID
from plotspec import ViewState from plotspec import ViewState, X_ABSOLUTE, X_RELATIVE
class PlotControlWidget(QWidget): class PlotControlWidget(QWidget):
"""Metric selector, view-scale toggle, and manual plot refresh. """Metric selector, view-scale/x-mode toggles, and manual plot refresh.
Overlay/compare is driven by the file-list checkboxes, not here — this cluster Overlay/compare membership is driven by the file-list checkboxes and reference
only governs *what* metric and *how* its axes are scaled. lines by their own cluster; this one governs *what* metric and *how* its axes
are scaled (lin/log frequency) and laid out (absolute vs relative time).
""" """
metricChanged = pyqtSignal(str) # metric_id metricChanged = pyqtSignal(str) # metric_id
viewChanged = pyqtSignal() # view-state (scale) changed viewChanged = pyqtSignal() # view-state (scale / x-mode) changed
plotRefreshRequested = pyqtSignal() plotRefreshRequested = pyqtSignal()
addReferenceLineRequested = pyqtSignal()
clearReferenceLinesRequested = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -58,18 +57,14 @@ class PlotControlWidget(QWidget):
self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit()) self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit())
group_layout.addWidget(self.log_freq_check) group_layout.addWidget(self.log_freq_check)
# Custom reference lines: drop a draggable horizontal marker (e.g. an # Time axis: absolute seconds vs relative % of each track's own length, so
# eyeballed effective average) onto whatever metric is showing. # tracks of very different durations line up by song position when overlaid.
ref_row = QHBoxLayout() group_layout.addWidget(QLabel("Time axis:"))
self.add_ref_button = QPushButton("Add ref line") self.x_mode_combo = QComboBox()
self.add_ref_button.setToolTip("Drop a draggable horizontal reference line") self.x_mode_combo.addItem("Absolute (seconds)", X_ABSOLUTE)
self.add_ref_button.clicked.connect(self.addReferenceLineRequested.emit) self.x_mode_combo.addItem("Relative (%)", X_RELATIVE)
ref_row.addWidget(self.add_ref_button) self.x_mode_combo.currentIndexChanged.connect(lambda _: self.viewChanged.emit())
self.clear_ref_button = QPushButton("Clear") group_layout.addWidget(self.x_mode_combo)
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() button_row = QHBoxLayout()
self.refresh_button = QPushButton("Refresh Plot") self.refresh_button = QPushButton("Refresh Plot")
@@ -90,4 +85,7 @@ class PlotControlWidget(QWidget):
return self.metric_combo.currentData() or DEFAULT_METRIC_ID return self.metric_combo.currentData() or DEFAULT_METRIC_ID
def current_view_state(self) -> ViewState: def current_view_state(self) -> ViewState:
return ViewState(y_log=self.log_freq_check.isChecked()) return ViewState(
y_log=self.log_freq_check.isChecked(),
x_mode=self.x_mode_combo.currentData(),
)
+56
View File
@@ -102,6 +102,25 @@ class PlotSpec:
return self.heatmap is not None return self.heatmap is not None
@dataclass
class RefLineProps:
"""A user-defined horizontal reference line.
Owned by the GUI controller and passed to the renderer, which draws it as a
draggable line and writes `value` back on drag. Persists across redraws; the
GUI clears the set when the metric changes (the value axis units change).
"""
value: float
color: str = "#444444"
style: str = "dash" # 'solid' | 'dash' | 'dot'
label: str = "" # tag shown on the line; falls back to the value
# X-axis modes for comparison.
X_ABSOLUTE = "absolute" # time in seconds (native)
X_RELATIVE = "relative" # 0-100% of each track's own length
@dataclass @dataclass
class ViewState: class ViewState:
"""User-controlled, recompute-free view options. """User-controlled, recompute-free view options.
@@ -112,6 +131,7 @@ class ViewState:
""" """
y_log: Optional[bool] = None y_log: Optional[bool] = None
x_log: Optional[bool] = None x_log: Optional[bool] = None
x_mode: str = X_ABSOLUTE
def resolve_y_log(self, default: bool) -> bool: def resolve_y_log(self, default: bool) -> bool:
return self.y_log if self.y_log is not None else default return self.y_log if self.y_log is not None else default
@@ -120,5 +140,41 @@ class ViewState:
return self.x_log if self.x_log is not None else default return self.x_log if self.x_log is not None else default
def apply_x_mode(spec: PlotSpec, mode: str) -> PlotSpec:
"""Rewrite a spec's x-axis to relative position (0-100%) in place, if asked.
Each dataset is normalised to *its own* span, so tracks of different lengths
line up by song position — the point of relative mode. A pure view transform:
it reassigns the x arrays (cached data is left untouched) and relabels the
axis. No-op for absolute mode.
"""
if mode != X_RELATIVE:
return spec
xs = [c.x for c in spec.curves] + [b.x for b in spec.bands]
if spec.heatmap is not None:
xs.append(spec.heatmap.x)
xs = [x for x in xs if len(x)]
if not xs:
return spec
lo = min(float(x[0]) for x in xs)
hi = max(float(x[-1]) for x in xs)
span = (hi - lo) or 1.0
def rel(x):
return (x - lo) / span * 100.0
for c in spec.curves:
c.x = rel(c.x)
for b in spec.bands:
b.x = rel(b.x)
if spec.heatmap is not None:
spec.heatmap.x = rel(spec.heatmap.x)
spec.axes.x_label = "Position (%)"
spec.axes.x_range = (0.0, 100.0)
return spec
# A neutral default reused wherever a caller hasn't supplied view options. # A neutral default reused wherever a caller hasn't supplied view options.
DEFAULT_VIEW = ViewState() DEFAULT_VIEW = ViewState()
+1
View File
@@ -33,6 +33,7 @@ py-modules = [
"font_manager", "font_manager",
"font_control_widget", "font_control_widget",
"plot_control_widget", "plot_control_widget",
"ref_line_widget",
"logger_setup", "logger_setup",
"setup_fonts", "setup_fonts",
] ]
+153
View File
@@ -0,0 +1,153 @@
"""
Reference-line management: a side-panel list of custom horizontal markers plus a
properties dialog.
`RefLineControlWidget` is a pure view over a list of `RefLineProps` owned by the
main window: it renders the list and emits intents (add / edit / remove / clear).
`RefLineDialog` edits one line's value, colour, line style, and tag.
"""
import logging
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QListWidget, QPushButton,
QDialog, QFormLayout, QDoubleSpinBox, QComboBox, QLineEdit, QColorDialog,
QDialogButtonBox,
)
from PyQt5.QtGui import QColor
from PyQt5.QtCore import pyqtSignal
from plotspec import RefLineProps
_STYLE_CHOICES = [("Solid", "solid"), ("Dashed", "dash"), ("Dotted", "dot")]
class RefLineDialog(QDialog):
"""Edit one reference line's properties. Read the result via `result_props`."""
def __init__(self, parent, props: RefLineProps):
super().__init__(parent)
self.setWindowTitle("Reference line")
self._color = props.color
form = QFormLayout(self)
self.value_spin = QDoubleSpinBox()
self.value_spin.setRange(-1e6, 1e6)
self.value_spin.setDecimals(2)
self.value_spin.setValue(props.value)
form.addRow("Value:", self.value_spin)
self.color_button = QPushButton()
self.color_button.clicked.connect(self._pick_color)
self._refresh_color_button()
form.addRow("Colour:", self.color_button)
self.style_combo = QComboBox()
for label, key in _STYLE_CHOICES:
self.style_combo.addItem(label, key)
idx = self.style_combo.findData(props.style)
if idx >= 0:
self.style_combo.setCurrentIndex(idx)
form.addRow("Line style:", self.style_combo)
self.label_edit = QLineEdit(props.label)
self.label_edit.setPlaceholderText("(optional tag)")
form.addRow("Tag:", self.label_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
def _pick_color(self):
chosen = QColorDialog.getColor(QColor(self._color), self, "Reference line colour")
if chosen.isValid():
self._color = chosen.name()
self._refresh_color_button()
def _refresh_color_button(self):
self.color_button.setText(self._color)
# Show the colour as the button's background for a quick read.
self.color_button.setStyleSheet(f"background-color: {self._color};")
def result_props(self) -> RefLineProps:
return RefLineProps(
value=float(self.value_spin.value()),
color=self._color,
style=self.style_combo.currentData(),
label=self.label_edit.text().strip(),
)
class RefLineControlWidget(QWidget):
"""List of reference lines with Add / Edit / Remove / Clear controls."""
addRequested = pyqtSignal()
editRequested = pyqtSignal(int)
removeRequested = pyqtSignal(int)
clearRequested = 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("Reference lines")
group_layout = QVBoxLayout(group_box)
self.line_list = QListWidget()
self.line_list.setMaximumHeight(110)
self.line_list.itemDoubleClicked.connect(self._on_double_click)
group_layout.addWidget(self.line_list)
row = QHBoxLayout()
self.add_button = QPushButton("Add")
self.add_button.clicked.connect(self.addRequested.emit)
row.addWidget(self.add_button)
self.edit_button = QPushButton("Edit…")
self.edit_button.clicked.connect(self._emit_edit)
row.addWidget(self.edit_button)
self.remove_button = QPushButton("Remove")
self.remove_button.clicked.connect(self._emit_remove)
row.addWidget(self.remove_button)
self.clear_button = QPushButton("Clear")
self.clear_button.clicked.connect(self.clearRequested.emit)
row.addWidget(self.clear_button)
group_layout.addLayout(row)
layout.addWidget(group_box)
def set_lines(self, lines: list[RefLineProps]):
"""Repopulate the list display from the current props (preserving selection)."""
current = self.line_list.currentRow()
self.line_list.clear()
for p in lines:
tag = f" {p.label}" if p.label else ""
self.line_list.addItem(f"{p.value:.2f}{tag}")
if 0 <= current < self.line_list.count():
self.line_list.setCurrentRow(current)
def _selected_row(self) -> int:
return self.line_list.currentRow()
def _emit_edit(self):
row = self._selected_row()
if row >= 0:
self.editRequested.emit(row)
def _emit_remove(self):
row = self._selected_row()
if row >= 0:
self.removeRequested.emit(row)
def _on_double_click(self, _item):
self._emit_edit()