2026-06-14 00:35:10 +09:00
|
|
|
"""
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 00:35:10 +09:00
|
|
|
@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
|
2026-06-14 00:51:33 +09:00
|
|
|
x_mode: str = X_ABSOLUTE
|
2026-06-14 00:35:10 +09:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 00:35:10 +09:00
|
|
|
# A neutral default reused wherever a caller hasn't supplied view options.
|
|
|
|
|
DEFAULT_VIEW = ViewState()
|