Files
uj-mastering-master/plotspec.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

125 lines
3.9 KiB
Python

"""
Backend-agnostic plot descriptors.
A metric's `build_spec` turns precomputed data into a `PlotSpec`: a declarative
description of *what* to draw (curves, reference lines, an optional heatmap) and
*how the axes should behave* (labels, default scale, which lin/log toggles are
legal). It says nothing about the plotting library, colours, or widget layout —
that is the renderer's job.
This seam is what makes overlay/compare cheap: drawing N datasets on one axis is
"render N specs," and the renderer owns the colour cycle so overlaid curves stay
distinct. It is also what makes lin/log a pure view toggle — `build_spec` takes a
`ViewState`, so switching scale never touches `compute`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
@dataclass
class Curve:
"""A single x/y line. Colour is assigned by the renderer for overlay distinctness."""
x: np.ndarray
y: np.ndarray
label: str = ""
width: float = 1.4
# Explicit colour overrides the dataset colour cycle. Leave None for overlay.
color: Optional[str] = None
@dataclass
class HLine:
"""A horizontal reference line with an attached label.
The label rides on the line itself (renderer places it), so reference markers
no longer need anchoring at `times[-1]` — overlaid tracks of different lengths
stop fighting over label position.
"""
y: float
label: str = ""
color: str = "#888888"
style: str = "dot" # 'solid' | 'dash' | 'dot'
width: float = 0.8
@dataclass
class Band:
"""A filled envelope between `lo` and `hi` over `x` (RMS area, waveform min/max).
One drawn primitive instead of thousands of per-segment fills, and overlay-safe:
the renderer gives each dataset's band a translucent dataset colour.
"""
x: np.ndarray
lo: np.ndarray # scalar-broadcast or per-x lower edge
hi: np.ndarray # per-x upper edge
label: str = ""
color: Optional[str] = None
@dataclass
class Heatmap:
"""A 2-D field (e.g. a spectrogram). Heatmaps do not overlay — at most one."""
x: np.ndarray # column axis (time)
y: np.ndarray # row axis (frequency), linear; renderer handles log
z: np.ndarray # shape (len(y), len(x))
z_min: float
z_max: float
cmap: str = "magma"
label: str = "" # colourbar label
@dataclass
class AxisSpec:
x_label: str = ""
y_label: str = ""
y_log: bool = False # this metric's natural default scale
x_log: bool = False
y_range: Optional[tuple[float, float]] = None
x_range: Optional[tuple[float, float]] = None
y_log_allowed: bool = False # is a lin/log toggle meaningful on this axis?
x_log_allowed: bool = False
@dataclass
class PlotSpec:
"""Everything the renderer needs to draw one dataset of one metric."""
title: str = ""
axes: AxisSpec = field(default_factory=AxisSpec)
curves: list[Curve] = field(default_factory=list)
bands: list[Band] = field(default_factory=list)
hlines: list[HLine] = field(default_factory=list)
heatmap: Optional[Heatmap] = None
# Scalar readouts (integrated LUFS, LRA, max dBTP) surfaced in the legend.
annotations: list[str] = field(default_factory=list)
@property
def is_heatmap(self) -> bool:
return self.heatmap is not None
@dataclass
class ViewState:
"""User-controlled, recompute-free view options.
`None` means "use the metric's default for this axis." `build_spec` resolves
the concrete scale via `resolve_*`, so a metric never has to special-case the
unset state.
"""
y_log: Optional[bool] = None
x_log: Optional[bool] = None
def resolve_y_log(self, default: bool) -> bool:
return self.y_log if self.y_log is not None else default
def resolve_x_log(self, default: bool) -> bool:
return self.x_log if self.x_log is not None else default
# A neutral default reused wherever a caller hasn't supplied view options.
DEFAULT_VIEW = ViewState()