b400551321
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>
125 lines
3.9 KiB
Python
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()
|