""" 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). - 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). 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`. """ import numpy as np import pyqtgraph as pg from scipy.interpolate import interp1d from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel from PyQt5.QtCore import Qt, pyqtSignal from plotspec import PlotSpec, ViewState, DEFAULT_VIEW, RefLineProps # 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) 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): """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): 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 # 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] = [] 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") self._apply_axes(base_axes, single=True, log_y_image_handled=True) self._draw_ref_lines() 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) self._apply_axes(base_axes, single=single) self._draw_ref_lines() 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() 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 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) def _apply_axes(self, axes, single: bool, log_y_image_handled: bool = False): self.plot.setLabel("bottom", axes.x_label) self.plot.setLabel("left", axes.y_label) # 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) elif not single: self.plot.enableAutoRange(axis=pg.ViewBox.XAxis) 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 ----------------------------------------------- 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) self.plot.addItem(line) self._ref_lines.append(line) def _remove_ref_line_items(self): for line in self._ref_lines: self.plot.removeItem(line) self._ref_lines.clear() # ---- 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): self._remove_ref_line_items() # cleared from scene; props persist for redraw 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...")