diff --git a/CLAUDE.md b/CLAUDE.md index 02b180e..761529a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,9 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th ### Technical stack - **Audio Processing**: librosa, numpy -- **Visualization**: matplotlib with custom colormaps and embedded Qt widgets +- **Visualization**: pyqtgraph — persistent, interactive (mouse zoom/pan, lin/log + toggle, multi-dataset overlay). matplotlib remains only for its colormaps + (consumed by pyqtgraph) and as a librosa dependency - **GUI Framework**: PyQt5 with modular widget architecture - **Metadata**: mutagen for audio tag reading - **Font Support**: Custom font loading system with CJK fallback @@ -34,8 +36,18 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th - Progress tracking and error handling #### `audio_visualization_widget.py` -- Embedded matplotlib visualization with Qt integration -- Real-time plot updates and status display +- Persistent pyqtgraph plot — the PlotItem is reused across renders, never torn + down, so mouse zoom/pan and scale toggles survive every redraw +- `show_specs([(label, PlotSpec), ...], view)` draws one or more datasets onto + the shared axes, assigning a distinct colour per dataset for overlay/compare +- Spectrogram log-frequency is realised by resampling STFT rows onto a log grid + (`ImageItem` is affine-only and won't follow a log axis) — see `_render_heatmap` + +#### `plotspec.py` +- Backend-agnostic drawing descriptors: `Curve`, `Band`, `HLine`, `Heatmap`, + `AxisSpec`, `PlotSpec`, plus the `ViewState` (recompute-free lin/log options) +- The seam that decouples metrics from the plotting library: metrics emit + *intent*, the renderer owns colour/layout/library specifics #### `font_control_widget.py` & `font_manager.py` - Unified font control system with clustered interface @@ -45,11 +57,16 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th #### `plot_control_widget.py` - Metric selector dropdown driven by the `metrics.METRICS` registry -- Houses the `Refresh Plot` button (foundation for upcoming style controls) +- Log-frequency toggle (view-state; recompute-free, currently honoured by the + spectrogram) and the `Refresh Plot` button +- Compare/overlay is *not* here — it is driven by the file-list checkboxes #### `metrics.py` -- Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread) - and `render(data, file_path) -> Figure` (cheap, GUI thread) +- Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread, + backend-neutral numpy/scalars) and `build_spec(data, view) -> PlotSpec` (cheap, + GUI thread, view-aware). Metrics no longer touch the plotting library +- Compute-time vs view-time split: scale (lin/log) is a `ViewState` argument to + `build_spec`, so toggling it never recomputes - Current registry: - `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale - `WaveformMetric` — min/max envelope, fixed ±1.1 y-range @@ -58,11 +75,13 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th - `PSRMetric` — sample-peak minus short-term LUFS (3 s window) - `TruePeakMetric` — 4× oversampled dBTP via `scipy.signal.resample_poly` - `SpectrogramMetric` — log-frequency STFT heatmap; adaptive hop caps time - bins at ~4000, `N_FFT=4096` -- Shared render helpers: `_show_axis_extents(ax)` forces each axis's exact - min/max onto the ticks (so log-axis extremes like 22 kHz are always - labelled); `_fmt_tick` keeps those labels compact -- Drop in new ones (DR, spectral balance) by appending an instance to `METRICS` + bins at ~4000, `N_FFT=4096`. Log/linear frequency is a view toggle +- Drop in new ones (DR, spectral balance) by appending an instance to `METRICS`; + return a `PlotSpec` from `build_spec` (curves overlay automatically; heatmaps + show one dataset at a time) +- Note: the old matplotlib `_show_axis_extents` exact-endpoint tick labelling is + gone with the matplotlib render path. If wanted back, it belongs in the + renderer, applied uniformly to every metric — not per-metric #### `master_core.py` - Defines the `AudioFile` class: librosa loading, rolling RMS power, BPM detection @@ -87,8 +106,20 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th ### GUI features - **File management**: Drag-and-drop and file dialog for audio selection +- **Compare/overlay**: each analysed file has a checkbox; the ticked set is + overlaid on one graph for the current metric (curve metrics overlay; the + spectrogram shows one track at a time). Highlighting a row drives the metadata + panel, independent of the overlay set +- **Interactive plot**: mouse drag-zoom, scroll-wheel zoom, pan, right-click menu + (pyqtgraph ViewBox); log/linear frequency toggle. Scroll zooms both axes; + **Ctrl+scroll** zooms time only, **Shift+scroll** zooms the value axis only + (`_AxisZoomViewBox`); scrolling over an axis also zooms just that axis +- **Custom reference lines**: "Add ref line" drops a draggable horizontal marker + on any metric (e.g. an eyeballed effective average); lines persist across + redraws/overlay changes and are cleared automatically when the metric changes - **Font control**: Unified font selector with size control -- **Plot control**: Metric selector + refresh-plot button +- **Plot control**: Metric selector + log-frequency toggle + ref-line add/clear + + refresh-plot button - **Analysis display**: Real-time visualization with metadata panels - **Modular architecture**: Self-contained widgets for easy layout management @@ -105,10 +136,9 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th - Long-term average spectrum (LTAS) / tonal-balance curve - Stereo metrics (correlation, mid/side) — needs `AudioFile` to retain stereo -2. **Interactive plot features** +2. **Interactive plot features** *(zoom/pan, axis-range select, lin/log done via + pyqtgraph)* - GUI-controllable plotting styles (colormap, visualization type) - - Select axis ranges on the fly with automatic graph updates - - Zoom/pan controls for detailed analysis - Export analysis results to CSV/JSON 3. **Advanced GUI controls** @@ -121,11 +151,14 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th - Graphical logging text box ### Mid-to-long-term (very not urgent) -1. **Audio comparison system** - - Reference vs. comparee audio file analysis - - Side-by-side track comparison interface +1. **Audio comparison system** *(multi-file overlay done via file-list checkboxes; + each song has a stable palette colour keyed to its list row)* + - Per-song colour picker: clickable swatch in the file list (overlay already + accepts a caller-supplied colour per dataset via `show_specs`, so this is a + UI + override-map addition, not a render change) + - Reference vs. comparee designation (vs. flat overlay) + - Side-by-side track comparison interface (incl. spectrogram, which can't overlay) - A/B testing for mastering versions - - Overlay visualization for comparative analysis 2. **Distribution & deployment** - Self-contained executable releases @@ -155,15 +188,18 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th ### Dependencies - librosa: Audio analysis and feature extraction - numpy: Numerical computations -- scipy: Signal processing (true-peak polyphase oversampling) +- scipy: Signal processing (true-peak polyphase oversampling, spectrogram + log-frequency resample) - pyloudnorm: BS.1770 loudness (LUFS, LRA) -- matplotlib: Plotting and visualization +- pyqtgraph: Interactive plotting (zoom/pan, overlay, lin/log) +- matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency - mutagen: Audio metadata extraction - PyQt5: GUI framework ### Architecture considerations -- Analysis (`metrics.compute`) and visualization (`metrics.render`) are split - across the `Metric` ABC; compute runs on a worker thread, render on the GUI +- Three-stage split: `metrics.compute` (heavy, worker thread, backend-neutral + data) → `metrics.build_spec` (cheap, GUI thread, view-aware `PlotSpec`) → + `AudioVisualizationWidget.show_specs` (pyqtgraph rendering, overlay, colours) - File path handling needs improvement for cross-platform compatibility - Error handling should be enhanced for production use - Consider moving from PyQt5 to PyQt6 or PySide for better licensing diff --git a/analysis_results_manager.py b/analysis_results_manager.py index 9309779..04ae1ba 100644 --- a/analysis_results_manager.py +++ b/analysis_results_manager.py @@ -214,22 +214,26 @@ class AnalysisResultsManager(QObject): self.metric_workers.pop((file_path, metric_id), None) self.metricComputeError.emit(file_path, metric_id, error_message) - def get_metric_figure(self, file_path: str, metric_id: str): - """Render a Figure from cached metric data. Returns None if not cached. + def get_metric_data(self, file_path: str, metric_id: str): + """Return cached metric data, or None if not computed yet. Never triggers compute — call `request_metric` first and listen for - `metricReady` if you need on-demand computation. + `metricReady` if you need on-demand computation. Spec/figure building is the + GUI layer's job (it owns the view-state), so this stays render-agnostic. """ result = self.results_cache.get(file_path) if result is None: return None - metric = METRICS.get(metric_id) - if metric is None: + if metric_id not in METRICS: return None - data = result.metric_data.get(metric_id) - if data is None: - return None - return metric.render(data, file_path) + return result.metric_data.get(metric_id) + + def display_label(self, file_path: str) -> str: + """Short human label for a file (song name if known, else basename).""" + result = self.results_cache.get(file_path) + if result is not None and result.song_name: + return result.song_name + return os.path.basename(file_path) def get_metadata_text(self, file_path: str) -> str: result = self.results_cache.get(file_path) diff --git a/audio_visualization_widget.py b/audio_visualization_widget.py index 99e8f36..0021e83 100644 --- a/audio_visualization_widget.py +++ b/audio_visualization_widget.py @@ -1,74 +1,329 @@ """ -Audio visualization widget with embedded matplotlib canvas. -Pure display responsibility - receives plotting data and shows graphs. +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). +- User reference lines (`add_user_line`) are draggable, survive redraws within a + metric, and are cleared by the GUI 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 matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas -from matplotlib.figure import Figure +from PyQt5.QtCore import Qt + +from plotspec import PlotSpec, ViewState, DEFAULT_VIEW + +# 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] + +# Colour for user-added reference lines (neutral so it reads on any metric). +_USER_LINE_COLOR = "#444444" + + +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 AudioVisualizationWidget(QWidget): - """Widget for displaying audio analysis graphs with embedded matplotlib.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.initUI() - - def initUI(self): - """Initialize the UI components.""" - layout = QVBoxLayout() - - # Create matplotlib canvas - self.figure = Figure(figsize=(10, 4), facecolor='white') - self.canvas = FigureCanvas(self.figure) - - # Add canvas to layout - layout.addWidget(self.canvas) - - # Status label for feedback - self.status_label = QLabel("Ready for audio analysis...") - layout.addWidget(self.status_label) - - self.setLayout(layout) - - # Initialize with empty plot - self._create_empty_plot() - - def _create_empty_plot(self): - """Creates an empty placeholder plot.""" - self.figure.clear() - ax = self.figure.add_subplot(111) - ax.text(0.5, 0.5, 'Drop an audio file to see analysis', - ha='center', va='center', transform=ax.transAxes, - fontsize=14, alpha=0.7) - ax.set_xlim(0, 1) - ax.set_ylim(0, 1) - ax.set_xticks([]) - ax.set_yticks([]) - self.canvas.draw() - - def display_figure_direct(self, figure): - """ - Display a figure by replacing our canvas figure entirely. - More reliable than copying elements. - - Args: - figure: matplotlib.figure.Figure to display - """ - # Remove old canvas - layout = self.layout() - layout.removeWidget(self.canvas) - self.canvas.deleteLater() - - # Create new canvas with the provided figure - self.figure = figure - self.canvas = FigureCanvas(self.figure) - layout.insertWidget(0, self.canvas) # Insert at position 0 (before status label) - - self.canvas.draw() - self.status_label.setText("Analysis complete - displaying power graph") - - def set_status(self, message): - """Update the status label.""" - self.status_label.setText(message) \ No newline at end of file + """Persistent interactive plot. Call `show_specs` to (re)draw.""" + + 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 + # User reference lines persist by value across redraws; the items are rebuilt + # each render. Cleared by the GUI on metric change (units change). + self._user_line_values: list[float] = [] + self._user_lines: list[pg.InfiniteLine] = [] + 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, log_y_image_handled=True) + self._draw_user_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) + self._draw_user_lines() + + def add_user_line(self, value: float | None = None): + """Add a draggable horizontal reference line at `value` (default: view centre).""" + if value is None: + (_, _), (y0, y1) = self.plot.viewRange() + value = (y0 + y1) / 2.0 + self._user_line_values.append(float(value)) + self._draw_user_lines() + + def clear_user_lines(self): + """Remove all user reference lines (called when the metric changes).""" + self._user_line_values.clear() + self._remove_user_line_items() + + 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, log_y_image_handled: bool = False): + self.plot.setLabel("bottom", axes.x_label) + self.plot.setLabel("left", axes.y_label) + if axes.x_range: + self.plot.setXRange(*axes.x_range, padding=0) + 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_user_lines(self): + """(Re)create draggable lines from the stored values, preserving positions.""" + self._remove_user_line_items() + for idx in range(len(self._user_line_values)): + line = pg.InfiniteLine( + pos=self._user_line_values[idx], angle=0, movable=True, + pen=pg.mkPen(_USER_LINE_COLOR, width=1.2, style=Qt.DashLine), + label="{value:.2f}", + labelOpts={"position": 0.05, "color": _USER_LINE_COLOR, + "fill": (255, 255, 255, 180)}, + ) + line.sigPositionChanged.connect(lambda ln, i=idx: self._on_user_line_moved(i, ln)) + self.plot.addItem(line) + self._user_lines.append(line) + + def _on_user_line_moved(self, index: int, line: pg.InfiniteLine): + if 0 <= index < len(self._user_line_values): + self._user_line_values[index] = float(line.value()) + + def _remove_user_line_items(self): + for line in self._user_lines: + self.plot.removeItem(line) + self._user_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_user_line_items() # cleared from scene; values 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...") diff --git a/main.py b/main.py index 67356cf..70e5f9f 100644 --- a/main.py +++ b/main.py @@ -6,12 +6,13 @@ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QTextEdit, QListWidgetItem, QPushButton, QFileDialog) from PyQt5.QtCore import Qt -from audio_visualization_widget import AudioVisualizationWidget +from audio_visualization_widget import AudioVisualizationWidget, dataset_color from analysis_results_manager import AnalysisResultsManager from logger_setup import setup_logging, parse_log_args from font_manager import initialize_fonts, get_font_manager from font_control_widget import FontControlWidget from plot_control_widget import PlotControlWidget +from metrics import METRICS class MainWindow(QMainWindow): @@ -21,6 +22,8 @@ class MainWindow(QMainWindow): super().__init__() self.logger = logging.getLogger(__name__) self.analysis_manager = AnalysisResultsManager() + # Guards programmatic list mutations from triggering re-render storms. + self._suppress_list_signals = False self.initUI() self.connect_signals() @@ -66,18 +69,23 @@ class MainWindow(QMainWindow): self.font_control.fontSizeChanged.connect(self.on_font_size_changed) layout.addWidget(self.font_control) - # Plot control cluster (metric selector + refresh) + # Plot control cluster (metric selector + scale toggle + refresh) self.plot_control = PlotControlWidget() self.plot_control.metricChanged.connect(self.on_metric_changed) + self.plot_control.viewChanged.connect(self.on_view_changed) self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested) + self.plot_control.addReferenceLineRequested.connect(self.on_add_reference_line) + self.plot_control.clearReferenceLinesRequested.connect(self.on_clear_reference_lines) layout.addWidget(self.plot_control) - - # File list - self.file_list_label = QLabel("Analyzed Files:") + + # File list. Each item carries a checkbox: the checked set is the overlay + # set drawn on the graph; the highlighted item drives the metadata panel. + self.file_list_label = QLabel("Analyzed Files (tick to overlay):") layout.addWidget(self.file_list_label) - + self.file_list = QListWidget() self.file_list.itemClicked.connect(self.on_file_selected) + self.file_list.itemChanged.connect(self.on_file_check_changed) layout.addWidget(self.file_list) # Metadata display @@ -160,27 +168,23 @@ class MainWindow(QMainWindow): """Called when analysis completes successfully.""" filename = os.path.basename(file_path) - # Add to file list if not already there - existing_items = [self.file_list.item(i).text() - for i in range(self.file_list.count())] - if filename not in existing_items: + # Add to file list (checked, so it joins the overlay set) if not present. + item = self._item_for_path(file_path) + if item is None: + self._suppress_list_signals = True item = QListWidgetItem(filename) item.setData(Qt.UserRole, file_path) # Store full path + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(Qt.Checked) self.file_list.addItem(item) + self._suppress_list_signals = False - # Update metadata display - metadata_text = self.analysis_manager.get_metadata_text(file_path) - self.metadata_display.setText(metadata_text) + # Update metadata display and highlight the analyzed file. + self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path)) + self.file_list.setCurrentItem(item) - # Select the analyzed file in the list - for i in range(self.file_list.count()): - item = self.file_list.item(i) - if item.data(Qt.UserRole) == file_path: - self.file_list.setCurrentItem(item) - break - - # Render the currently-selected metric (cached, or async-compute it) - self._render_or_request(file_path) + # Redraw the overlay set for the current metric. + self._refresh_view() def on_analysis_error(self, file_path, error_message): """Called when analysis fails.""" @@ -194,84 +198,136 @@ class MainWindow(QMainWindow): self.visualization_widget.set_status(f"{message} ({percentage}%)") def on_file_selected(self, item): - """Called when a file is selected from the list.""" + """Called when a file is highlighted (drives the metadata panel only).""" file_path = item.data(Qt.UserRole) + self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path)) - # Update metadata display - metadata_text = self.analysis_manager.get_metadata_text(file_path) - self.metadata_display.setText(metadata_text) + def on_file_check_changed(self, _item): + """A checkbox toggled — the overlay set changed; redraw.""" + if self._suppress_list_signals: + return + self._refresh_view() - # Render the currently-selected metric (cached, or async-compute it) - self._render_or_request(file_path) - def on_font_changed(self, font_name: str, font_type: str): """Called when font selection changes.""" self.logger.info(f"Font changed via GUI: {font_name} ({font_type})") - # Cheap re-render — cached metric data, redraws under the new font. - self._render_or_request(self._current_file_path()) + self._refresh_view() def on_font_size_changed(self, font_size: int): """Called when Qt font size changes.""" self.logger.info(f"Qt font size changed via GUI: {font_size}pt") - # Qt font size doesn't affect matplotlib plots, so no regeneration needed + # Qt font size doesn't affect the plot axes fonts directly; no redraw needed. def on_metric_changed(self, metric_id: str): """Called when the metric selector changes.""" self.logger.info(f"Metric changed via GUI: {metric_id}") - self._render_or_request(self._current_file_path()) + # The value axis units change with the metric, so custom reference lines + # placed against the old metric no longer mean anything — drop them. + self.visualization_widget.clear_user_lines() + self._refresh_view() + + def on_add_reference_line(self): + """Drop a draggable reference line on the current plot.""" + self.visualization_widget.add_user_line() + + def on_clear_reference_lines(self): + """Remove all custom reference lines.""" + self.visualization_widget.clear_user_lines() + + def on_view_changed(self): + """Called when a view-scale toggle (lin/log) changes. Recompute-free redraw.""" + self.logger.info("View scale changed via GUI") + self._refresh_view() def on_plot_refresh_requested(self): """Called when manual plot refresh is requested.""" self.logger.info("Manual plot refresh requested via GUI") - self._render_or_request(self._current_file_path()) + self._refresh_view() def on_metric_compute_started(self, file_path: str, metric_id: str): """Called when an off-thread metric compute starts.""" - if file_path != self._current_file_path(): - return # selection moved on; status bar shouldn't lie - from metrics import METRICS + if file_path not in self._overlay_paths(): + return # not in the drawn set; status bar shouldn't lie metric = METRICS.get(metric_id) display = metric.display_name if metric else metric_id self.visualization_widget.set_status(f"Computing {display}...") def on_metric_ready(self, file_path: str, metric_id: str): """Called when metric data is available (cached hit or async finish).""" - if file_path != self._current_file_path(): - return # stale — user moved on if metric_id != self.plot_control.current_metric_id(): return # user already switched to a different metric - figure = self.analysis_manager.get_metric_figure(file_path, metric_id) - if figure: - self.visualization_widget.display_figure_direct(figure) + if file_path not in self._overlay_paths(): + return # no longer part of the overlay set + self._refresh_view() def on_metric_compute_error(self, file_path: str, metric_id: str, error_message: str): self.logger.error(f"Metric compute failed ({metric_id} / {os.path.basename(file_path)}): {error_message}") - if file_path == self._current_file_path(): + if file_path in self._overlay_paths(): self.visualization_widget.set_status(f"Error computing {metric_id}: {error_message}") def _current_file_path(self): item = self.file_list.currentItem() return item.data(Qt.UserRole) if item else None - def _render_or_request(self, file_path): - """Render the current metric from cache, or kick off async compute if missing. + def _item_for_path(self, file_path): + for i in range(self.file_list.count()): + item = self.file_list.item(i) + if item.data(Qt.UserRole) == file_path: + return item + return None - Falls back to a full analyse_file if the file hasn't been processed yet - (e.g. font change on an empty session — defensive). + def _row_index(self, file_path) -> int: + for i in range(self.file_list.count()): + if self.file_list.item(i).data(Qt.UserRole) == file_path: + return i + return 0 + + def _overlay_paths(self): + """File paths whose checkbox is ticked — the set drawn on the graph.""" + return [ + self.file_list.item(i).data(Qt.UserRole) + for i in range(self.file_list.count()) + if self.file_list.item(i).checkState() == Qt.Checked + ] + + def _refresh_view(self): + """Redraw the checked overlay set for the current metric and view-state. + + Renders every dataset whose data is cached; for any that isn't, kicks off + an async compute (or a full load if the file was never analysed) and + leaves a status note. `on_metric_ready` calls back here when each lands. """ - if not file_path: - return + paths = self._overlay_paths() metric_id = self.plot_control.current_metric_id() - figure = self.analysis_manager.get_metric_figure(file_path, metric_id) - if figure: - self.visualization_widget.display_figure_direct(figure) + view = self.plot_control.current_view_state() + metric = METRICS.get(metric_id) + if not paths or metric is None: + self.visualization_widget.show_specs([]) return - # Not cached yet — try async compute if the file has been loaded. - if self.analysis_manager.is_file_analyzed(file_path): - self.analysis_manager.request_metric(file_path, metric_id) - else: - # No AudioFile yet either; kick off a full analysis with this metric. - self.analysis_manager.analyze_file(file_path, metric_id) + + specs = [] + pending = 0 + for path in paths: + data = self.analysis_manager.get_metric_data(path, metric_id) + if data is None: + if self.analysis_manager.is_file_analyzed(path): + self.analysis_manager.request_metric(path, metric_id) + else: + self.analysis_manager.analyze_file(path, metric_id) + pending += 1 + continue + label = self.analysis_manager.display_label(path) + # Colour is keyed to the file's row, not its position in the overlay + # subset, so a song keeps its colour as others are ticked/unticked. + color = dataset_color(self._row_index(path)) + specs.append((label, metric.build_spec(data, view), color)) + + if specs: + self.visualization_widget.show_specs(specs, view) + if pending: + self.visualization_widget.set_status( + f"Computing {metric.display_name} for {pending} file(s)..." + ) def main(): diff --git a/metrics.py b/metrics.py index 8e92691..1f53482 100644 --- a/metrics.py +++ b/metrics.py @@ -1,32 +1,35 @@ """ Pluggable analysis metrics. -A `Metric` knows how to compute a series from an `AudioFile` and how to render -that series into a matplotlib `Figure`. Compute is the heavy step (runs on the -worker thread); render is cheap and reruns on font / refresh. +A `Metric` computes a backend-neutral data object from an `AudioFile` and then +turns that data into a `PlotSpec` (declarative drawing intent). Compute is the +heavy step and runs on the worker thread; `build_spec` is cheap, view-aware, and +reruns on every scale toggle / overlay change without recomputation. -To add a metric: subclass `Metric`, implement `compute` and `render`, and +To add a metric: subclass `Metric`, implement `compute` and `build_spec`, and register the instance in `METRICS` at the bottom of this file. + +Note: metrics no longer touch matplotlib or know which library draws them. The +old `_show_axis_extents` endpoint-labelling lived in the matplotlib render path +and is gone for now; if exact-extent tick labels are wanted back, they belong in +the renderer, applied uniformly to every metric. """ from __future__ import annotations -import os import warnings from abc import ABC, abstractmethod from typing import Any import numpy as np -import matplotlib.colors as mcolors -import matplotlib.cm as cm -from matplotlib.figure import Figure -from matplotlib.ticker import FuncFormatter, NullFormatter import librosa import pyloudnorm as pyln from scipy import signal as scipy_signal -from font_manager import safe_title from master_core import AudioFile +from plotspec import ( + AxisSpec, Band, Curve, Heatmap, HLine, PlotSpec, ViewState, DEFAULT_VIEW, +) # Small constant to keep 20*log10(...) from blowing up on perfect silence. @@ -38,38 +41,6 @@ def _to_dbfs(linear: np.ndarray | float) -> np.ndarray | float: return 20.0 * np.log10(np.maximum(linear, _EPS)) -def _fmt_tick(v, _pos=None) -> str: - """Compact tick label: integer for big/whole values, trimmed decimals else.""" - av = abs(v) - if v == 0 or av >= 100: - return f"{v:.0f}" - if av >= 1: - return f"{v:.1f}".rstrip("0").rstrip(".") - return f"{v:.3f}".rstrip("0").rstrip(".") - - -def _show_axis_extents(ax) -> None: - """Force the exact min/max of each axis onto the tick list. - - Matplotlib's locators often omit the extreme values — most visibly on a log - frequency axis, where the top (e.g. 22050 Hz) falls between decade ticks and - goes unlabelled. Union the endpoints into the existing in-range ticks so you - can always read where a plot actually starts and stops. - """ - fmt = FuncFormatter(_fmt_tick) - for is_log, get_lim, set_lim, get_ticks, set_ticks, mpl_axis in ( - (ax.get_xscale() == "log", ax.get_xlim, ax.set_xlim, ax.get_xticks, ax.set_xticks, ax.xaxis), - (ax.get_yscale() == "log", ax.get_ylim, ax.set_ylim, ax.get_yticks, ax.set_yticks, ax.yaxis), - ): - lo, hi = get_lim() - inside = [t for t in get_ticks() if lo <= t <= hi] - mpl_axis.set_major_formatter(fmt) - if is_log: - mpl_axis.set_minor_formatter(NullFormatter()) # keep minor marks unlabelled - set_ticks(sorted(set(inside) | {lo, hi})) - set_lim(lo, hi) # set_ticks can nudge the view; restore exact limits - - class Metric(ABC): """A pluggable analysis metric.""" @@ -80,16 +51,22 @@ class Metric(ABC): def compute(self, audio_file: AudioFile) -> Any: """Compute and return the metric's data from a loaded AudioFile. - The returned object is cached and later passed to `render`. This is the - heavy step and runs on the worker thread. + The returned object must be backend-neutral (numpy arrays + scalars). It is + cached and later passed to `build_spec`. Heavy; runs on the worker thread. """ @abstractmethod - def render(self, data: Any, file_path: str, figsize=(10, 4)) -> Figure: - """Render a Figure from precomputed data. Cheap; runs on the GUI thread.""" + def build_spec(self, data: Any, view: ViewState = DEFAULT_VIEW) -> PlotSpec: + """Turn precomputed data into a PlotSpec. Cheap; runs on the GUI thread. + + `view` carries recompute-free options (lin/log). Titles are set by the + renderer per dataset, not here, so specs compose under overlay. + """ class RMSPowerMetric(Metric): + """Rolling RMS power as a filled area over time.""" + id = "rms_power" display_name = "RMS Power" @@ -101,35 +78,22 @@ class RMSPowerMetric(Metric): audio_file.get_energy_levels_over_time(window=self.window, hop=self.hop) return { "times": audio_file.get_times(), - "rms_array": audio_file.rms_array, + "rms": np.asarray(audio_file.rms_array).reshape(-1), } - def render(self, data, file_path, figsize=(10, 4)) -> Figure: + def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec: times = data["times"] - rms_array = data["rms_array"] - - # Adaptive colour scale: bump headroom for loud masters. - maxpower = 0.6 if np.max(rms_array) > 0.3 else 0.3 - norm = mcolors.Normalize(vmin=0, vmax=maxpower) - cmap = cm.autumn - - fig = Figure(figsize=figsize, facecolor="white") - ax = fig.add_subplot(111) - ax.set_ylim(0., maxpower) - for i in range(len(times) - 1): - ax.fill_between( - times[i:i + 2], 0, rms_array[0][i], - color=cmap(norm(rms_array[0][i])), edgecolor="none", - ) - sm = cm.ScalarMappable(cmap=cmap, norm=norm) - sm.set_array([]) - fig.colorbar(sm, ax=ax, label="RMS Power") - ax.set_ylabel("Power") - ax.set_xlabel("Time (seconds)") - ax.set_title(safe_title(os.path.basename(file_path))) - _show_axis_extents(ax) - fig.tight_layout() - return fig + rms = data["rms"] + # Adaptive headroom: loud masters get a taller scale. + ymax = 0.6 if (rms.size and np.max(rms) > 0.3) else 0.3 + return PlotSpec( + axes=AxisSpec( + x_label="Time (seconds)", y_label="Power", + y_range=(0.0, ymax), + x_range=(float(times[0]), float(times[-1])) if times.size else None, + ), + bands=[Band(x=times, lo=np.zeros_like(rms), hi=rms, label="RMS power")], + ) class WaveformMetric(Metric): @@ -157,38 +121,24 @@ class WaveformMetric(Metric): times = (np.arange(self.target_columns) * chunk + chunk / 2) / sr return {"times": times, "lo": lo, "hi": hi} - def render(self, data, file_path, figsize=(10, 4)) -> Figure: + def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec: times = data["times"] - lo = data["lo"] - hi = data["hi"] - - fig = Figure(figsize=figsize, facecolor="white") - ax = fig.add_subplot(111) - ax.fill_between(times, lo, hi, color="#3a7ad6", linewidth=0) - ax.axhline(0, color="black", linewidth=0.5, alpha=0.3) - # Fixed full-scale range with a touch of headroom for float-wav signals. - ax.set_ylim(-1.1, 1.1) - ax.set_xlim(times[0], times[-1]) - ax.set_ylabel("Amplitude") - ax.set_xlabel("Time (seconds)") - ax.set_title(safe_title(os.path.basename(file_path))) - _show_axis_extents(ax) - fig.tight_layout() - return fig + return PlotSpec( + axes=AxisSpec( + x_label="Time (seconds)", y_label="Amplitude", + y_range=(-1.1, 1.1), + x_range=(float(times[0]), float(times[-1])) if times.size else None, + ), + bands=[Band(x=times, lo=data["lo"], hi=data["hi"], label="Waveform")], + ) class LUFSMetric(Metric): - """ITU-R BS.1770 loudness: short-term (3 s) time series + integrated + LRA. - - Powered by pyloudnorm. The time series slides `meter.integrated_loudness` - across the track because pyloudnorm doesn't expose a per-block series. - Slightly redundant work, but the per-call cost is small. - """ + """ITU-R BS.1770 loudness: short-term (3 s) time series + integrated + LRA.""" id = "lufs" display_name = "LUFS" - # Short-term as defined by EBU R128 / BS.1770: 3-second window. WINDOW_S = 3.0 HOP_S = 0.5 SILENCE_FLOOR = -70.0 # BS.1770 absolute gate @@ -238,44 +188,33 @@ class LUFSMetric(Metric): except (ValueError, FloatingPointError): return float("-inf") - def render(self, data, file_path, figsize=(10, 4)) -> Figure: + def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec: times = data["times"] lufs = data["lufs"] integrated = data["integrated"] lra = data.get("lra", float("nan")) - fig = Figure(figsize=figsize, facecolor="white") - ax = fig.add_subplot(111) - ax.plot(times, lufs, color="#2a9d8f", linewidth=1.4, label="Short-term (3 s)") - + hlines = [ + HLine(y=-14.0, label="-14 LUFS (streaming target)", style="dot"), + ] + annotations = [] if np.isfinite(integrated): - ax.axhline( - integrated, color="#e76f51", linestyle="--", linewidth=1.5, - label=f"Integrated: {integrated:.1f} LUFS", - ) - + hlines.append(HLine(y=integrated, label=f"Integrated: {integrated:.1f} LUFS", + color="#e76f51", style="dash", width=1.5)) if np.isfinite(lra): - # Invisible plot entry to surface LRA in the legend without adding a line. - ax.plot([], [], " ", label=f"LRA: {lra:.1f} LU") + annotations.append(f"LRA: {lra:.1f} LU") - # Streaming target reference (Spotify normalises to -14 LUFS). - ax.axhline(-14.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) - ax.text( - times[-1], -14.0, " -14 LUFS (streaming target)", - va="center", ha="left", fontsize=8, alpha=0.6, + return PlotSpec( + axes=AxisSpec( + x_label="Time (seconds)", y_label="LUFS", + y_range=(-50.0, 0.0), + x_range=(float(times[0]), float(times[-1])) if times.size else None, + ), + curves=[Curve(x=times, y=lufs, label="Short-term (3 s)")], + hlines=hlines, + annotations=annotations, ) - ax.set_ylim(-50.0, 0.0) - ax.set_xlim(times[0], times[-1]) - ax.set_ylabel("LUFS") - ax.set_xlabel("Time (seconds)") - ax.set_title(safe_title(os.path.basename(file_path))) - ax.grid(True, alpha=0.3) - ax.legend(loc="lower right", fontsize=8) - _show_axis_extents(ax) - fig.tight_layout() - return fig - class CrestFactorMetric(Metric): """Crest factor = 20*log10(peak / RMS) per sliding window, in dB.""" @@ -317,38 +256,24 @@ class CrestFactorMetric(Metric): times = (starts + window_n / 2.0) / sr return {"times": times, "crest_db": crest_db} - def render(self, data, file_path, figsize=(10, 4)) -> Figure: + def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec: times = data["times"] - crest_db = data["crest_db"] - - fig = Figure(figsize=figsize, facecolor="white") - ax = fig.add_subplot(111) - ax.plot(times, crest_db, color="#e09f3e", linewidth=1.4, label=f"Crest factor (1 s)") - - # Rules of thumb: ~12 dB = roomy, ~6 dB = heavily limited. - ax.axhline(12.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) - ax.text(times[-1], 12.0, " 12 dB", va="center", ha="left", fontsize=8, alpha=0.6) - ax.axhline(6.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) - ax.text(times[-1], 6.0, " 6 dB (squashed)", va="center", ha="left", fontsize=8, alpha=0.6) - - ax.set_ylim(0.0, 25.0) - ax.set_xlim(times[0], times[-1]) - ax.set_ylabel("Crest factor (dB)") - ax.set_xlabel("Time (seconds)") - ax.set_title(safe_title(os.path.basename(file_path))) - ax.grid(True, alpha=0.3) - ax.legend(loc="lower right", fontsize=8) - _show_axis_extents(ax) - fig.tight_layout() - return fig + return PlotSpec( + axes=AxisSpec( + x_label="Time (seconds)", y_label="Crest factor (dB)", + y_range=(0.0, 25.0), + x_range=(float(times[0]), float(times[-1])) if times.size else None, + ), + curves=[Curve(x=times, y=data["crest_db"], label="Crest factor (1 s)")], + hlines=[ + HLine(y=12.0, label="12 dB", style="dot"), + HLine(y=6.0, label="6 dB (squashed)", style="dot"), + ], + ) class PSRMetric(Metric): - """Peak-to-Short-term LUFS Ratio (sample-peak variant), in LU. - - PSR = sample_peak_dBFS - short_term_LUFS over the same 3 s windows used by - LUFSMetric. High PSR = punchy transients; low PSR = heavily limited. - """ + """Peak-to-Short-term LUFS Ratio (sample-peak variant), in LU.""" id = "psr" display_name = "PSR" @@ -390,39 +315,24 @@ class PSRMetric(Metric): psr = np.where(valid, peaks_db - lufs_series, np.nan) return {"times": times, "psr": psr} - def render(self, data, file_path, figsize=(10, 4)) -> Figure: + def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec: times = data["times"] - psr = data["psr"] - - fig = Figure(figsize=figsize, facecolor="white") - ax = fig.add_subplot(111) - ax.plot(times, psr, color="#7251b5", linewidth=1.4, label="PSR (3 s)") - - # Ian Shepherd's rough thresholds. - ax.axhline(10.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) - ax.text(times[-1], 10.0, " 10 LU (good punch)", va="center", ha="left", fontsize=8, alpha=0.6) - ax.axhline(4.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) - ax.text(times[-1], 4.0, " 4 LU (squashed)", va="center", ha="left", fontsize=8, alpha=0.6) - - ax.set_ylim(0.0, 25.0) - ax.set_xlim(times[0], times[-1]) - ax.set_ylabel("PSR (LU)") - ax.set_xlabel("Time (seconds)") - ax.set_title(safe_title(os.path.basename(file_path))) - ax.grid(True, alpha=0.3) - ax.legend(loc="lower right", fontsize=8) - _show_axis_extents(ax) - fig.tight_layout() - return fig + return PlotSpec( + axes=AxisSpec( + x_label="Time (seconds)", y_label="PSR (LU)", + y_range=(0.0, 25.0), + x_range=(float(times[0]), float(times[-1])) if times.size else None, + ), + curves=[Curve(x=times, y=data["psr"], label="PSR (3 s)")], + hlines=[ + HLine(y=10.0, label="10 LU (good punch)", style="dot"), + HLine(y=4.0, label="4 LU (squashed)", style="dot"), + ], + ) class TruePeakMetric(Metric): - """ITU-R BS.1770 true peak via 4x polyphase oversampling, in dBTP. - - Per-window true peak with a moderate hop so it renders quickly. Windows are - oversampled independently — slight edge under-detection at window boundaries - is masked by the 60% overlap. - """ + """ITU-R BS.1770 true peak via 4x polyphase oversampling, in dBTP.""" id = "true_peak" display_name = "True Peak" @@ -458,61 +368,42 @@ class TruePeakMetric(Metric): integrated_tp_db = float(np.max(tp_db)) return {"times": times, "tp_db": tp_db, "integrated_tp_db": integrated_tp_db} - def render(self, data, file_path, figsize=(10, 4)) -> Figure: + def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec: times = data["times"] - tp_db = data["tp_db"] integrated = data.get("integrated_tp_db", float("nan")) - - fig = Figure(figsize=figsize, facecolor="white") - ax = fig.add_subplot(111) - ax.plot(times, tp_db, color="#c1121f", linewidth=1.0, label="True Peak (250 ms)") - - # 0 dBTP = sample-level clip; -1 dBTP a common mastering ceiling. - ax.axhline(0.0, color="black", linestyle="--", linewidth=1.0, alpha=0.8) - ax.text(times[-1], 0.0, " 0 dBTP (clip)", va="center", ha="left", fontsize=8, alpha=0.7) - ax.axhline(-1.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) - ax.text(times[-1], -1.0, " -1 dBTP (typical ceiling)", va="center", ha="left", fontsize=8, alpha=0.6) - + annotations = [] if np.isfinite(integrated): - ax.plot([], [], " ", label=f"Max: {integrated:.2f} dBTP") - - ax.set_ylim(-30.0, 6.0) - ax.set_xlim(times[0], times[-1]) - ax.set_ylabel("dBTP") - ax.set_xlabel("Time (seconds)") - ax.set_title(safe_title(os.path.basename(file_path))) - ax.grid(True, alpha=0.3) - ax.legend(loc="lower right", fontsize=8) - _show_axis_extents(ax) - fig.tight_layout() - return fig + annotations.append(f"Max: {integrated:.2f} dBTP") + return PlotSpec( + axes=AxisSpec( + x_label="Time (seconds)", y_label="dBTP", + y_range=(-30.0, 6.0), + x_range=(float(times[0]), float(times[-1])) if times.size else None, + ), + curves=[Curve(x=times, y=data["tp_db"], label="True Peak (250 ms)", width=1.0)], + hlines=[ + HLine(y=0.0, label="0 dBTP (clip)", color="#000000", style="dash", width=1.0), + HLine(y=-1.0, label="-1 dBTP (typical ceiling)", style="dot"), + ], + annotations=annotations, + ) class SpectrogramMetric(Metric): - """Log-frequency STFT spectrogram: frequency power distribution over time. - - Each column is the magnitude spectrum of a short window, plotted in serial - as a colour-coded heatmap. The hop is chosen adaptively so long tracks don't - produce tens of thousands of columns (which would stall the GUI redraw): for - typical song lengths the hop lands around 50 ms, coarsening gracefully on - very long files. - """ + """Log-frequency STFT spectrogram: frequency power distribution over time.""" id = "spectrogram" display_name = "Spectrogram" - N_FFT = 4096 # ~11 Hz bins at 44.1 kHz; keeps low-freq detail now - # that sr is native (nyquist ~22 kHz, not 11 kHz) - TARGET_COLUMNS = 4000 # cap on time bins, for render speed - DB_FLOOR = -80.0 # dynamic range shown, relative to peak - F_MIN = 20.0 # log axis can't show DC; clip the low edge here + N_FFT = 4096 + TARGET_COLUMNS = 4000 + DB_FLOOR = -80.0 + F_MIN = 20.0 # log axis can't show DC; clip the low edge here def compute(self, audio_file: AudioFile): y = audio_file.y_mono.astype(np.float32, copy=False) sr = audio_file.sr - # Pick a hop that keeps the column count near TARGET_COLUMNS, but never - # finer than n_fft//4 (the usual 75%-overlap floor). min_hop = self.N_FFT // 4 hop = max(min_hop, len(y) // self.TARGET_COLUMNS) @@ -525,7 +416,7 @@ class SpectrogramMetric(Metric): np.arange(s_db.shape[1]), sr=sr, hop_length=hop, n_fft=self.N_FFT ) - # Drop the DC bin (0 Hz) so the log frequency axis has no non-positive coord. + # Drop the DC bin (0 Hz) so a log frequency axis has no non-positive coord. return { "freqs": freqs[1:], "times": times, @@ -533,29 +424,24 @@ class SpectrogramMetric(Metric): "nyquist": sr / 2.0, } - def render(self, data, file_path, figsize=(10, 4)) -> Figure: + def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec: freqs = data["freqs"] times = data["times"] - s_db = data["s_db"] nyquist = data["nyquist"] + y_log = view.resolve_y_log(default=True) # log frequency by default - fig = Figure(figsize=figsize, facecolor="white") - ax = fig.add_subplot(111) - mesh = ax.pcolormesh( - times, freqs, s_db, - cmap="magma", vmin=self.DB_FLOOR, vmax=0.0, shading="auto", + return PlotSpec( + axes=AxisSpec( + x_label="Time (seconds)", y_label="Frequency (Hz)", + y_log=y_log, y_log_allowed=True, + y_range=(self.F_MIN, float(nyquist)), + x_range=(float(times[0]), float(times[-1])) if times.size else None, + ), + heatmap=Heatmap( + x=times, y=freqs, z=data["s_db"], + z_min=self.DB_FLOOR, z_max=0.0, cmap="magma", label="Power (dB)", + ), ) - fig.colorbar(mesh, ax=ax, label="Power (dB)") - - ax.set_yscale("log") - ax.set_ylim(self.F_MIN, nyquist) - ax.set_xlim(times[0], times[-1]) - ax.set_ylabel("Frequency (Hz)") - ax.set_xlabel("Time (seconds)") - ax.set_title(safe_title(os.path.basename(file_path))) - _show_axis_extents(ax) - fig.tight_layout() - return fig METRICS: dict[str, Metric] = { diff --git a/plot_control_widget.py b/plot_control_widget.py index 81c76ff..9b61f59 100644 --- a/plot_control_widget.py +++ b/plot_control_widget.py @@ -8,17 +8,26 @@ next to each other in the left panel. import logging from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QGroupBox, + QCheckBox, ) from PyQt5.QtCore import pyqtSignal from metrics import METRICS, DEFAULT_METRIC_ID +from plotspec import ViewState class PlotControlWidget(QWidget): - """Metric selector + manual plot refresh.""" + """Metric selector, view-scale toggle, and manual plot refresh. + + Overlay/compare is driven by the file-list checkboxes, not here — this cluster + only governs *what* metric and *how* its axes are scaled. + """ metricChanged = pyqtSignal(str) # metric_id + viewChanged = pyqtSignal() # view-state (scale) changed plotRefreshRequested = pyqtSignal() + addReferenceLineRequested = pyqtSignal() + clearReferenceLinesRequested = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -42,6 +51,26 @@ class PlotControlWidget(QWidget): self.metric_combo.currentIndexChanged.connect(self._on_metric_changed) group_layout.addWidget(self.metric_combo) + # Frequency-axis scale. Only the spectrogram honours it today; harmless + # elsewhere (build_spec ignores unsupported toggles). + self.log_freq_check = QCheckBox("Log frequency (spectrogram)") + self.log_freq_check.setChecked(True) + self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit()) + group_layout.addWidget(self.log_freq_check) + + # Custom reference lines: drop a draggable horizontal marker (e.g. an + # eyeballed effective average) onto whatever metric is showing. + ref_row = QHBoxLayout() + self.add_ref_button = QPushButton("Add ref line") + self.add_ref_button.setToolTip("Drop a draggable horizontal reference line") + self.add_ref_button.clicked.connect(self.addReferenceLineRequested.emit) + ref_row.addWidget(self.add_ref_button) + self.clear_ref_button = QPushButton("Clear") + self.clear_ref_button.setToolTip("Remove all custom reference lines") + self.clear_ref_button.clicked.connect(self.clearReferenceLinesRequested.emit) + ref_row.addWidget(self.clear_ref_button) + group_layout.addLayout(ref_row) + button_row = QHBoxLayout() self.refresh_button = QPushButton("Refresh Plot") self.refresh_button.setToolTip("Re-render the current plot with current settings") @@ -59,3 +88,6 @@ class PlotControlWidget(QWidget): def current_metric_id(self) -> str: return self.metric_combo.currentData() or DEFAULT_METRIC_ID + + def current_view_state(self) -> ViewState: + return ViewState(y_log=self.log_freq_check.isChecked()) diff --git a/plotspec.py b/plotspec.py new file mode 100644 index 0000000..f849647 --- /dev/null +++ b/plotspec.py @@ -0,0 +1,124 @@ +""" +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() diff --git a/pyproject.toml b/pyproject.toml index c33e5ef..7126fe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ # 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later # versions are Linux/macOS only. "PyQt5-Qt5==5.15.2 ; sys_platform == 'win32'", + "pyqtgraph>=0.14.0", ] [project.scripts] @@ -28,6 +29,7 @@ py-modules = [ "audio_visualization_widget", "master_core", "metrics", + "plotspec", "font_manager", "font_control_widget", "plot_control_widget", diff --git a/uv.lock b/uv.lock index f59ba63..3182abe 100644 --- a/uv.lock +++ b/uv.lock @@ -272,6 +272,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "contourpy" version = "1.3.2" @@ -1272,6 +1281,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/21/8486ed45977be615ec5371b24b47298b1cb0e1a455b419eddd0215078dba/pyqt5_sip-12.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:6d948f1be619c645cd3bda54952bfdc1aef7c79242dccea6a6858748e61114b9", size = 59622, upload-time = "2026-01-13T15:53:17.714Z" }, ] +[[package]] +name = "pyqtgraph" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1660,6 +1682,7 @@ dependencies = [ { name = "pyloudnorm" }, { name = "pyqt5" }, { name = "pyqt5-qt5", marker = "sys_platform == 'win32'" }, + { name = "pyqtgraph" }, ] [package.metadata] @@ -1671,6 +1694,7 @@ requires-dist = [ { name = "pyloudnorm" }, { name = "pyqt5", specifier = ">=5.15.10" }, { name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" }, + { name = "pyqtgraph", specifier = ">=0.14.0" }, ] [[package]]