2025-08-21 22:43:11 +09:00
|
|
|
"""
|
2026-06-14 00:35:10 +09:00
|
|
|
Interactive visualization widget built on pyqtgraph.
|
|
|
|
|
|
|
|
|
|
One persistent PlotItem that is *reused* across renders — never torn down — so
|
|
|
|
|
mouse zoom/pan, the view box, and scale toggles all survive redraws. Consumes a
|
|
|
|
|
list of `(label, PlotSpec)` pairs and draws them onto the same axes, using a
|
|
|
|
|
caller-supplied colour per dataset so a song keeps its colour regardless of which
|
|
|
|
|
others are overlaid.
|
|
|
|
|
|
|
|
|
|
Interaction notes:
|
|
|
|
|
- Plain scroll zooms both axes; Ctrl+scroll zooms time only; Shift+scroll zooms
|
|
|
|
|
the value axis only (see `_AxisZoomViewBox`). Scrolling directly over an axis
|
|
|
|
|
also zooms just that axis (pyqtgraph default).
|
2026-06-14 00:51:33 +09:00
|
|
|
- Reference lines (`set_reference_lines`) are draggable via a triangle handle,
|
|
|
|
|
survive redraws, and write their position back into the GUI-owned RefLineProps;
|
|
|
|
|
the GUI clears them when the metric changes (units change).
|
2026-06-14 00:35:10 +09:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
the STFT rows onto a log-spaced grid and labelling the axis by row index — see
|
|
|
|
|
`_render_heatmap`.
|
2025-08-21 22:43:11 +09:00
|
|
|
"""
|
|
|
|
|
|
2026-06-14 00:35:10 +09:00
|
|
|
import numpy as np
|
|
|
|
|
import pyqtgraph as pg
|
|
|
|
|
from scipy.interpolate import interp1d
|
2025-08-21 22:43:11 +09:00
|
|
|
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
2026-06-14 00:51:33 +09:00
|
|
|
from PyQt5.QtCore import Qt, pyqtSignal
|
2026-06-14 00:35:10 +09:00
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
from plotspec import PlotSpec, ViewState, DEFAULT_VIEW, RefLineProps
|
2026-06-14 00:35:10 +09:00
|
|
|
|
|
|
|
|
# White canvas / black ink to match the previous matplotlib aesthetic.
|
|
|
|
|
pg.setConfigOption("background", "w")
|
|
|
|
|
pg.setConfigOption("foreground", "k")
|
|
|
|
|
pg.setConfigOptions(antialias=True)
|
|
|
|
|
|
|
|
|
|
# Dataset colour cycle for overlay. First colour is the single-dataset default.
|
|
|
|
|
_PALETTE = [
|
|
|
|
|
"#3a7ad6", "#e76f51", "#2a9d8f", "#e09f3e",
|
|
|
|
|
"#7251b5", "#c1121f", "#588157", "#9d4edd",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
# Pen styles for reference lines.
|
|
|
|
|
_PEN_STYLE = {"solid": Qt.SolidLine, "dash": Qt.DashLine, "dot": Qt.DotLine}
|
|
|
|
|
|
|
|
|
|
# "Nice" frequencies to label on a log frequency axis, in Hz.
|
|
|
|
|
_LOG_FREQ_TICKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dataset_color(index: int) -> str:
|
|
|
|
|
"""Stable dataset colour for a given index (e.g. a file's row in the list)."""
|
|
|
|
|
return _PALETTE[index % len(_PALETTE)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _colormap(name: str):
|
|
|
|
|
"""Fetch a colormap, preferring matplotlib's so 'magma' etc. resolve."""
|
|
|
|
|
try:
|
|
|
|
|
return pg.colormap.getFromMatplotlib(name)
|
|
|
|
|
except Exception:
|
|
|
|
|
return pg.colormap.get(name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fmt_hz(hz: float) -> str:
|
|
|
|
|
return f"{hz / 1000:.0f}k" if hz >= 1000 else f"{hz:.0f}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _AxisZoomViewBox(pg.ViewBox):
|
|
|
|
|
"""ViewBox whose wheel zoom can be constrained to one axis via a modifier.
|
|
|
|
|
|
|
|
|
|
Plain scroll keeps pyqtgraph's both-axes zoom; Ctrl constrains to x (time),
|
|
|
|
|
Shift constrains to y (the metric's value axis). This answers the "scroll
|
|
|
|
|
zooms both axes, I want one" problem without taking away the default.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def wheelEvent(self, ev, axis=None):
|
|
|
|
|
mods = ev.modifiers()
|
|
|
|
|
if mods & Qt.ControlModifier:
|
|
|
|
|
axis = 0 # x only
|
|
|
|
|
elif mods & Qt.ShiftModifier:
|
|
|
|
|
axis = 1 # y only
|
|
|
|
|
super().wheelEvent(ev, axis=axis)
|
2025-08-21 22:43:11 +09:00
|
|
|
|
|
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2025-08-21 22:43:11 +09:00
|
|
|
class AudioVisualizationWidget(QWidget):
|
2026-06-14 00:35:10 +09:00
|
|
|
"""Persistent interactive plot. Call `show_specs` to (re)draw."""
|
|
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
# 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)
|
|
|
|
|
|
2026-06-14 00:35:10 +09:00
|
|
|
def __init__(self, parent=None):
|
|
|
|
|
super().__init__(parent)
|
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
|
|
|
|
|
|
self.glw = pg.GraphicsLayoutWidget()
|
|
|
|
|
self.plot = self.glw.addPlot(row=0, col=0, viewBox=_AxisZoomViewBox())
|
|
|
|
|
self.plot.showGrid(x=True, y=True, alpha=0.3)
|
|
|
|
|
self.plot.setMenuEnabled(True)
|
|
|
|
|
self.legend = self.plot.addLegend(offset=(-10, 10))
|
|
|
|
|
layout.addWidget(self.glw)
|
|
|
|
|
|
|
|
|
|
self.status_label = QLabel("Ready for audio analysis...")
|
|
|
|
|
layout.addWidget(self.status_label)
|
|
|
|
|
|
|
|
|
|
self._colorbar = None
|
2026-06-14 00:51:33 +09:00
|
|
|
# Reference lines are owned by the GUI controller (RefLineProps objects) and
|
|
|
|
|
# passed in via set_reference_lines; the line items are rebuilt each render.
|
|
|
|
|
self._ref_props: list[RefLineProps] = []
|
|
|
|
|
self._ref_lines: list[_RefLine] = []
|
2026-06-14 00:35:10 +09:00
|
|
|
self._show_empty()
|
|
|
|
|
|
|
|
|
|
# ---- public API ---------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def show_specs(self, specs, view: ViewState = DEFAULT_VIEW):
|
|
|
|
|
"""Render datasets onto the shared axes.
|
|
|
|
|
|
|
|
|
|
`specs` is a list of `(label, PlotSpec)` or `(label, PlotSpec, color)`. When
|
|
|
|
|
no colour is given, the dataset's palette colour by position is used. All
|
|
|
|
|
specs are assumed to be the same metric (compare overlays one metric across
|
|
|
|
|
files), so axis labels/ranges come from the first spec.
|
|
|
|
|
"""
|
|
|
|
|
self._reset_plot()
|
|
|
|
|
if not specs:
|
|
|
|
|
self._show_empty()
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
specs = [self._normalise(s, i) for i, s in enumerate(specs)]
|
|
|
|
|
base_axes = specs[0][1].axes
|
|
|
|
|
|
|
|
|
|
# Heatmaps do not overlay: render only the first dataset's heatmap.
|
|
|
|
|
if specs[0][1].is_heatmap:
|
|
|
|
|
label, spec, _ = specs[0]
|
|
|
|
|
self._render_heatmap(spec, view)
|
|
|
|
|
if len(specs) > 1:
|
|
|
|
|
self.set_status(f"{spec.title or label}: spectrogram shows one track at a time")
|
2026-06-14 00:51:33 +09:00
|
|
|
self._apply_axes(base_axes, single=True, log_y_image_handled=True)
|
|
|
|
|
self._draw_ref_lines()
|
2026-06-14 00:35:10 +09:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
single = len(specs) == 1
|
|
|
|
|
for label, spec, color in specs:
|
|
|
|
|
prefix = "" if single else f"{label}: "
|
|
|
|
|
self._render_curves_and_bands(spec, color, prefix, single=single)
|
|
|
|
|
|
|
|
|
|
# Reference lines from the first spec only (identical across same-metric specs).
|
|
|
|
|
for hl in specs[0][1].hlines:
|
|
|
|
|
self._render_hline(hl)
|
|
|
|
|
|
|
|
|
|
# Scalar readouts → legend-only proxy entries.
|
|
|
|
|
for label, spec, _ in specs:
|
|
|
|
|
prefix = "" if single else f"{label}: "
|
|
|
|
|
for note in spec.annotations:
|
|
|
|
|
self._legend_note(prefix + note)
|
|
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
self._apply_axes(base_axes, single=single)
|
|
|
|
|
self._draw_ref_lines()
|
2026-06-14 00:35:10 +09:00
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
def set_reference_lines(self, props: list[RefLineProps]):
|
|
|
|
|
"""Set the reference-line set (RefLineProps owned by the GUI) and redraw them."""
|
|
|
|
|
self._ref_props = props
|
|
|
|
|
self._draw_ref_lines()
|
2026-06-14 00:35:10 +09:00
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
def current_view_center_y(self) -> float:
|
|
|
|
|
"""Mid-point of the current y view — a sane default position for a new line."""
|
|
|
|
|
(_, _), (y0, y1) = self.plot.viewRange()
|
|
|
|
|
return (y0 + y1) / 2.0
|
2026-06-14 00:35:10 +09:00
|
|
|
|
|
|
|
|
def set_status(self, message: str):
|
|
|
|
|
self.status_label.setText(message)
|
|
|
|
|
|
|
|
|
|
# ---- rendering helpers --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _normalise(self, spec_tuple, index: int):
|
|
|
|
|
"""Coerce a spec tuple to (label, PlotSpec, color), filling colour by index."""
|
|
|
|
|
if len(spec_tuple) == 3:
|
|
|
|
|
return spec_tuple
|
|
|
|
|
label, spec = spec_tuple
|
|
|
|
|
return label, spec, dataset_color(index)
|
|
|
|
|
|
|
|
|
|
def _render_curves_and_bands(self, spec: PlotSpec, color: str, prefix: str, single: bool):
|
|
|
|
|
for band in spec.bands:
|
|
|
|
|
lo = np.ascontiguousarray(np.broadcast_to(band.lo, band.x.shape), dtype=float)
|
|
|
|
|
hi = np.ascontiguousarray(np.broadcast_to(band.hi, band.x.shape), dtype=float)
|
|
|
|
|
# FillBetweenItem fills nothing if its child curves have no pen — give them
|
|
|
|
|
# a thin outline in the dataset colour (this is the RMS/Waveform fix).
|
|
|
|
|
edge = pg.mkPen(color, width=1.0)
|
|
|
|
|
c_lo = pg.PlotDataItem(band.x, lo, pen=edge)
|
|
|
|
|
c_hi = pg.PlotDataItem(band.x, hi, pen=edge)
|
|
|
|
|
self.plot.addItem(c_lo)
|
|
|
|
|
self.plot.addItem(c_hi)
|
|
|
|
|
# Build the colour with alpha up front: QBrush.color() returns a copy, so
|
|
|
|
|
# mutating its alpha after mkBrush would be a no-op (opaque overlay bug).
|
|
|
|
|
fill_color = pg.mkColor(color)
|
|
|
|
|
fill_color.setAlpha(200 if single else 90)
|
|
|
|
|
fill = pg.FillBetweenItem(c_lo, c_hi, brush=pg.mkBrush(fill_color))
|
|
|
|
|
self.plot.addItem(fill)
|
|
|
|
|
if band.label:
|
|
|
|
|
self._legend_swatch(prefix + band.label, color)
|
|
|
|
|
|
|
|
|
|
for curve in spec.curves:
|
|
|
|
|
pen = pg.mkPen(curve.color or color, width=curve.width)
|
|
|
|
|
item = self.plot.plot(curve.x, curve.y, pen=pen,
|
|
|
|
|
name=(prefix + curve.label) if curve.label else None,
|
|
|
|
|
connect="finite") # gaps at NaN (gated PSR)
|
|
|
|
|
item.setDownsampling(auto=True) # keep big series smooth under zoom
|
|
|
|
|
item.setClipToView(True)
|
|
|
|
|
|
|
|
|
|
def _render_hline(self, hl):
|
|
|
|
|
pen = pg.mkPen(hl.color, width=hl.width, style=_PEN_STYLE.get(hl.style, Qt.DotLine))
|
|
|
|
|
line = pg.InfiniteLine(
|
|
|
|
|
pos=hl.y, angle=0, pen=pen, movable=False,
|
|
|
|
|
label=hl.label or None,
|
|
|
|
|
labelOpts={"position": 0.95, "color": hl.color, "fill": (255, 255, 255, 150)},
|
|
|
|
|
)
|
|
|
|
|
self.plot.addItem(line)
|
|
|
|
|
|
|
|
|
|
def _render_heatmap(self, spec: PlotSpec, view: ViewState):
|
|
|
|
|
hm = spec.heatmap
|
|
|
|
|
t0, t1 = float(hm.x[0]), float(hm.x[-1])
|
|
|
|
|
f_lo = max(spec.axes.y_range[0] if spec.axes.y_range else hm.y[0], hm.y[0])
|
|
|
|
|
f_hi = spec.axes.y_range[1] if spec.axes.y_range else hm.y[-1]
|
|
|
|
|
y_log = view.resolve_y_log(default=spec.axes.y_log)
|
|
|
|
|
|
|
|
|
|
n_rows = len(hm.y)
|
|
|
|
|
if y_log:
|
|
|
|
|
f_grid = np.logspace(np.log10(max(f_lo, 1e-6)), np.log10(f_hi), n_rows)
|
|
|
|
|
else:
|
|
|
|
|
f_grid = np.linspace(f_lo, f_hi, n_rows)
|
|
|
|
|
|
|
|
|
|
# Resample every time column from native linear freq bins onto f_grid in one
|
|
|
|
|
# vectorised pass — this runs on each redraw and lin/log toggle, so the loop
|
|
|
|
|
# version would make the toggle feel laggy on long files.
|
|
|
|
|
interp = interp1d(hm.y, hm.z, axis=0, bounds_error=False,
|
|
|
|
|
fill_value=(hm.z[0], hm.z[-1]), assume_sorted=True)
|
|
|
|
|
z_grid = interp(f_grid).astype(np.float32)
|
|
|
|
|
|
|
|
|
|
img = pg.ImageItem()
|
|
|
|
|
img.setImage(z_grid.T, autoLevels=False) # ImageItem wants (x, y) -> transpose
|
|
|
|
|
img.setLevels((hm.z_min, hm.z_max))
|
|
|
|
|
img.setColorMap(_colormap(hm.cmap))
|
|
|
|
|
# Map image pixel space (time cols, freq rows) to data coords: x=time, y=row index.
|
|
|
|
|
img.setRect(pg.QtCore.QRectF(t0, 0.0, t1 - t0, float(n_rows)))
|
|
|
|
|
self.plot.addItem(img)
|
|
|
|
|
|
|
|
|
|
# Label the row-index y-axis with real frequencies.
|
|
|
|
|
ticks = []
|
|
|
|
|
for hz in _LOG_FREQ_TICKS:
|
|
|
|
|
if f_lo <= hz <= f_hi:
|
|
|
|
|
row = float(np.searchsorted(f_grid, hz))
|
|
|
|
|
ticks.append((row, _fmt_hz(hz)))
|
|
|
|
|
self.plot.getAxis("left").setTicks([ticks])
|
|
|
|
|
self.plot.setYRange(0, n_rows, padding=0)
|
|
|
|
|
self.plot.setXRange(t0, t1, padding=0)
|
|
|
|
|
|
|
|
|
|
# Place the colourbar at a fixed layout cell and link it to the image. We
|
|
|
|
|
# add/remove it ourselves (rather than insert_in=) so it can't stack across
|
|
|
|
|
# repeated spectrogram renders.
|
|
|
|
|
self._colorbar = pg.ColorBarItem(values=(hm.z_min, hm.z_max),
|
|
|
|
|
colorMap=_colormap(hm.cmap), label=hm.label)
|
|
|
|
|
self._colorbar.setImageItem(img)
|
|
|
|
|
self.glw.addItem(self._colorbar, row=0, col=1)
|
|
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
def _apply_axes(self, axes, single: bool, log_y_image_handled: bool = False):
|
2026-06-14 00:35:10 +09:00
|
|
|
self.plot.setLabel("bottom", axes.x_label)
|
|
|
|
|
self.plot.setLabel("left", axes.y_label)
|
2026-06-14 00:51:33 +09:00
|
|
|
# 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:
|
2026-06-14 00:35:10 +09:00
|
|
|
self.plot.setXRange(*axes.x_range, padding=0)
|
2026-06-14 00:51:33 +09:00
|
|
|
elif not single:
|
|
|
|
|
self.plot.enableAutoRange(axis=pg.ViewBox.XAxis)
|
2026-06-14 00:35:10 +09:00
|
|
|
if axes.y_range and not log_y_image_handled:
|
|
|
|
|
self.plot.setYRange(*axes.y_range, padding=0)
|
|
|
|
|
if not log_y_image_handled:
|
|
|
|
|
# Curve metrics: honour log mode if a spec ever opts in (none do today).
|
|
|
|
|
self.plot.setLogMode(x=axes.x_log, y=axes.y_log)
|
|
|
|
|
|
|
|
|
|
# ---- user reference lines -----------------------------------------------
|
|
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
def _draw_ref_lines(self):
|
|
|
|
|
"""(Re)create draggable lines from the current RefLineProps set."""
|
|
|
|
|
self._remove_ref_line_items()
|
|
|
|
|
for idx, props in enumerate(self._ref_props):
|
|
|
|
|
line = _RefLine(idx, props, on_moved=self.referenceLineMoved.emit)
|
2026-06-14 00:35:10 +09:00
|
|
|
self.plot.addItem(line)
|
2026-06-14 00:51:33 +09:00
|
|
|
self._ref_lines.append(line)
|
2026-06-14 00:35:10 +09:00
|
|
|
|
2026-06-14 00:51:33 +09:00
|
|
|
def _remove_ref_line_items(self):
|
|
|
|
|
for line in self._ref_lines:
|
2026-06-14 00:35:10 +09:00
|
|
|
self.plot.removeItem(line)
|
2026-06-14 00:51:33 +09:00
|
|
|
self._ref_lines.clear()
|
2026-06-14 00:35:10 +09:00
|
|
|
|
|
|
|
|
# ---- legend / lifecycle -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _legend_swatch(self, name: str, color: str):
|
|
|
|
|
self.legend.addItem(pg.PlotDataItem(pen=pg.mkPen(color, width=3)), name)
|
|
|
|
|
|
|
|
|
|
def _legend_note(self, text: str):
|
|
|
|
|
self.legend.addItem(pg.PlotDataItem(pen=None), text)
|
|
|
|
|
|
|
|
|
|
def _reset_plot(self):
|
2026-06-14 00:51:33 +09:00
|
|
|
self._remove_ref_line_items() # cleared from scene; props persist for redraw
|
2026-06-14 00:35:10 +09:00
|
|
|
self.plot.clear()
|
|
|
|
|
if self._colorbar is not None:
|
|
|
|
|
try:
|
|
|
|
|
self.glw.removeItem(self._colorbar)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self._colorbar = None
|
|
|
|
|
self.legend.clear()
|
|
|
|
|
self.plot.getAxis("left").setTicks(None) # drop heatmap freq ticks
|
|
|
|
|
self.plot.setLogMode(x=False, y=False)
|
|
|
|
|
|
|
|
|
|
def _show_empty(self):
|
|
|
|
|
text = pg.TextItem("Drop an audio file to see analysis", anchor=(0.5, 0.5),
|
|
|
|
|
color=(120, 120, 120))
|
|
|
|
|
self.plot.addItem(text)
|
|
|
|
|
self.plot.setXRange(0, 1)
|
|
|
|
|
self.plot.setYRange(0, 1)
|
|
|
|
|
text.setPos(0.5, 0.5)
|
|
|
|
|
self.set_status("Ready for audio analysis...")
|