From b4005513211a23046433c751051ea7b0644184f9 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Sun, 14 Jun 2026 00:35:10 +0900 Subject: [PATCH 1/3] Move plotting to pyqtgraph: interactive, overlay-capable render layer Replace the fire-and-forget matplotlib pipeline (render() -> throwaway Figure -> canvas teardown) with a three-stage architecture that supports zoom/pan, lin/log toggling, and multi-file overlay: compute(audio_file) -> data # heavy, worker thread, backend-neutral build_spec(data, view) -> PlotSpec # cheap, GUI thread, view-aware show_specs([(label, spec, color)]) # pyqtgraph, persistent PlotItem, overlay - plotspec.py: backend-agnostic descriptors (Curve, Band, HLine, Heatmap, AxisSpec, PlotSpec) + ViewState (recompute-free lin/log) - audio_visualization_widget.py: persistent pyqtgraph plot, never torn down; per-dataset colours for overlay; spectrogram log-freq via row resample (ImageItem is affine-only); ColorBarItem at a fixed cell - Compare/overlay driven by file-list checkboxes; stable per-song colour by row - Custom draggable reference lines (add/clear), persist across redraws - Axis-constrained scroll zoom: Ctrl=time, Shift=value (_AxisZoomViewBox) - RMS render no longer per-segment fill_between (was the slow path) Fixes found in review/testing: - FillBetweenItem needs penned child curves or it fills nothing (RMS/Waveform were blank); band fill verified by pixel count - band overlay alpha was a no-op (QBrush.color() returns a copy) - colorbar could stack across renders; now added/removed at a fixed layout cell Deferred (per scope): stereo retention, deep perf rewrites (eager beat_track, true-peak/crest loops, shared LUFS), per-song colour picker UI. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 82 +++++-- analysis_results_manager.py | 22 +- audio_visualization_widget.py | 389 ++++++++++++++++++++++++++++------ main.py | 172 ++++++++++----- metrics.py | 366 +++++++++++--------------------- plot_control_widget.py | 34 ++- plotspec.py | 124 +++++++++++ pyproject.toml | 2 + uv.lock | 24 +++ 9 files changed, 817 insertions(+), 398 deletions(-) create mode 100644 plotspec.py 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]] From a45bd4ba9bb0ca7b0f1ce4d4494285e746b2c0d6 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Sun, 14 Jun 2026 00:51:33 +0900 Subject: [PATCH 2/3] Reference-line manager, relative time axis, axis-aware ref-line UX Reference lines: - Side-panel cluster (ref_line_widget.py): list of lines with Add/Edit/Remove/ Clear and a properties dialog (value, colour, line style, tag) - Lines own their state as RefLineProps (held by MainWindow), drawn with a triangle drag-handle; dragging writes the value back and refreshes the list - Replaces the old centre-only add-and-drag-the-whole-line approach Comparison: - Time-axis mode selector: Absolute (seconds) vs Relative (% of each track's own length), so a long track and a short one line up by song position. Pure view transform (plotspec.apply_x_mode), no recompute - Overlaid different-length tracks now autorange their x union in absolute mode instead of clipping to the first track's span Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 25 ++++-- audio_visualization_widget.py | 118 +++++++++++++++----------- main.py | 57 +++++++++++-- plot_control_widget.py | 38 ++++----- plotspec.py | 56 +++++++++++++ pyproject.toml | 1 + ref_line_widget.py | 153 ++++++++++++++++++++++++++++++++++ 7 files changed, 365 insertions(+), 83 deletions(-) create mode 100644 ref_line_widget.py diff --git a/CLAUDE.md b/CLAUDE.md index 761529a..9092a7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,9 +57,18 @@ 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 -- 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 +- Log-frequency toggle and a time-axis mode selector — Absolute (seconds) vs + Relative (% of each track's own length) — both view-state, recompute-free +- `Refresh Plot` button. Compare/overlay membership is the file-list checkboxes; + reference lines have their own cluster + +#### `ref_line_widget.py` +- `RefLineControlWidget`: side-panel list of custom reference lines with + Add / Edit… / Remove / Clear; a pure view over the `RefLineProps` list the + main window owns, emitting intents +- `RefLineDialog`: edits one line's value, colour, line style, and tag +- The plot draws each line with a triangle drag-handle; dragging writes the new + value back into the shared `RefLineProps` and refreshes the list #### `metrics.py` - Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread, @@ -114,11 +123,13 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th (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 +- **Time-axis mode**: Absolute (seconds) or Relative (% of each track's own + length), so tracks of very different durations line up by song position +- **Custom reference lines**: side-panel list (Add/Edit/Remove/Clear) of draggable + horizontal markers with value/colour/style/tag; dragged via a triangle handle. + Persist across redraws/overlay changes; cleared when the metric changes - **Font control**: Unified font selector with size control -- **Plot control**: Metric selector + log-frequency toggle + ref-line add/clear +- **Plot control**: Metric selector + log-frequency toggle + time-axis mode + refresh-plot button - **Analysis display**: Real-time visualization with metadata panels - **Modular architecture**: Self-contained widgets for easy layout management diff --git a/audio_visualization_widget.py b/audio_visualization_widget.py index 0021e83..6148b37 100644 --- a/audio_visualization_widget.py +++ b/audio_visualization_widget.py @@ -11,8 +11,9 @@ 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). +- 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 @@ -24,9 +25,9 @@ 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 +from PyQt5.QtCore import Qt, pyqtSignal -from plotspec import PlotSpec, ViewState, DEFAULT_VIEW +from plotspec import PlotSpec, ViewState, DEFAULT_VIEW, RefLineProps # White canvas / black ink to match the previous matplotlib aesthetic. pg.setConfigOption("background", "w") @@ -45,9 +46,6 @@ _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).""" @@ -83,9 +81,40 @@ class _AxisZoomViewBox(pg.ViewBox): 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) @@ -101,10 +130,10 @@ class AudioVisualizationWidget(QWidget): 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] = [] + # 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 --------------------------------------------------------- @@ -131,8 +160,8 @@ class AudioVisualizationWidget(QWidget): 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() + self._apply_axes(base_axes, single=True, log_y_image_handled=True) + self._draw_ref_lines() return single = len(specs) == 1 @@ -150,21 +179,18 @@ class AudioVisualizationWidget(QWidget): for note in spec.annotations: self._legend_note(prefix + note) - self._apply_axes(base_axes) - self._draw_user_lines() + self._apply_axes(base_axes, single=single) + self._draw_ref_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 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 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 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) @@ -261,11 +287,16 @@ class AudioVisualizationWidget(QWidget): self._colorbar.setImageItem(img) self.glw.addItem(self._colorbar, row=0, col=1) - def _apply_axes(self, axes, log_y_image_handled: bool = False): + 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) - if axes.x_range: + # 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: @@ -274,29 +305,18 @@ class AudioVisualizationWidget(QWidget): # ---- 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)) + 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._user_lines.append(line) + self._ref_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: + def _remove_ref_line_items(self): + for line in self._ref_lines: self.plot.removeItem(line) - self._user_lines.clear() + self._ref_lines.clear() # ---- legend / lifecycle ------------------------------------------------- @@ -307,7 +327,7 @@ class AudioVisualizationWidget(QWidget): 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._remove_ref_line_items() # cleared from scene; props persist for redraw self.plot.clear() if self._colorbar is not None: try: diff --git a/main.py b/main.py index 70e5f9f..6582e5b 100644 --- a/main.py +++ b/main.py @@ -12,7 +12,9 @@ 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 ref_line_widget import RefLineControlWidget, RefLineDialog from metrics import METRICS +from plotspec import RefLineProps, apply_x_mode class MainWindow(QMainWindow): @@ -24,6 +26,8 @@ class MainWindow(QMainWindow): self.analysis_manager = AnalysisResultsManager() # Guards programmatic list mutations from triggering re-render storms. self._suppress_list_signals = False + # Custom reference lines, owned here and pushed to the plot each render. + self.ref_lines: list[RefLineProps] = [] self.initUI() self.connect_signals() @@ -74,10 +78,16 @@ class MainWindow(QMainWindow): 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) + # Reference-line management cluster (list + add/edit/remove/clear). + self.ref_line_control = RefLineControlWidget() + self.ref_line_control.addRequested.connect(self.on_add_reference_line) + self.ref_line_control.editRequested.connect(self.on_edit_reference_line) + self.ref_line_control.removeRequested.connect(self.on_remove_reference_line) + self.ref_line_control.clearRequested.connect(self.on_clear_reference_lines) + layout.addWidget(self.ref_line_control) + # 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):") @@ -116,6 +126,7 @@ class MainWindow(QMainWindow): self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started) self.analysis_manager.metricReady.connect(self.on_metric_ready) self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error) + self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved) def dragEnterEvent(self, event): """Handle drag enter event for file drops.""" @@ -223,16 +234,47 @@ class MainWindow(QMainWindow): self.logger.info(f"Metric changed via GUI: {metric_id}") # 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.ref_lines.clear() + self._sync_ref_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() + """Add a reference line at the current view centre, then edit it.""" + value = self.visualization_widget.current_view_center_y() + props = RefLineProps(value=round(value, 2)) + self.ref_lines.append(props) + self._sync_ref_lines() + # Open the editor immediately so colour/tag/value can be set right away. + self.on_edit_reference_line(len(self.ref_lines) - 1) + + def on_edit_reference_line(self, index: int): + """Open the properties dialog for a reference line.""" + if not (0 <= index < len(self.ref_lines)): + return + dialog = RefLineDialog(self, self.ref_lines[index]) + if dialog.exec_(): + self.ref_lines[index] = dialog.result_props() + self._sync_ref_lines() + + def on_remove_reference_line(self, index: int): + """Delete a reference line.""" + if 0 <= index < len(self.ref_lines): + del self.ref_lines[index] + self._sync_ref_lines() def on_clear_reference_lines(self): """Remove all custom reference lines.""" - self.visualization_widget.clear_user_lines() + self.ref_lines.clear() + self._sync_ref_lines() + + def on_reference_line_moved(self, index: int): + """A line was dragged on the plot — its value is already updated; refresh list.""" + self.ref_line_control.set_lines(self.ref_lines) + + def _sync_ref_lines(self): + """Push the current reference-line set to both the list view and the plot.""" + self.ref_line_control.set_lines(self.ref_lines) + self.visualization_widget.set_reference_lines(self.ref_lines) def on_view_changed(self): """Called when a view-scale toggle (lin/log) changes. Recompute-free redraw.""" @@ -320,7 +362,8 @@ class MainWindow(QMainWindow): # 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)) + spec = apply_x_mode(metric.build_spec(data, view), view.x_mode) + specs.append((label, spec, color)) if specs: self.visualization_widget.show_specs(specs, view) diff --git a/plot_control_widget.py b/plot_control_widget.py index 9b61f59..2c86c50 100644 --- a/plot_control_widget.py +++ b/plot_control_widget.py @@ -13,21 +13,20 @@ from PyQt5.QtWidgets import ( from PyQt5.QtCore import pyqtSignal from metrics import METRICS, DEFAULT_METRIC_ID -from plotspec import ViewState +from plotspec import ViewState, X_ABSOLUTE, X_RELATIVE class PlotControlWidget(QWidget): - """Metric selector, view-scale toggle, and manual plot refresh. + """Metric selector, view-scale/x-mode toggles, 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. + Overlay/compare membership is driven by the file-list checkboxes and reference + lines by their own cluster; this one governs *what* metric and *how* its axes + are scaled (lin/log frequency) and laid out (absolute vs relative time). """ metricChanged = pyqtSignal(str) # metric_id - viewChanged = pyqtSignal() # view-state (scale) changed + viewChanged = pyqtSignal() # view-state (scale / x-mode) changed plotRefreshRequested = pyqtSignal() - addReferenceLineRequested = pyqtSignal() - clearReferenceLinesRequested = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) @@ -58,18 +57,14 @@ class PlotControlWidget(QWidget): 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) + # Time axis: absolute seconds vs relative % of each track's own length, so + # tracks of very different durations line up by song position when overlaid. + group_layout.addWidget(QLabel("Time axis:")) + self.x_mode_combo = QComboBox() + self.x_mode_combo.addItem("Absolute (seconds)", X_ABSOLUTE) + self.x_mode_combo.addItem("Relative (%)", X_RELATIVE) + self.x_mode_combo.currentIndexChanged.connect(lambda _: self.viewChanged.emit()) + group_layout.addWidget(self.x_mode_combo) button_row = QHBoxLayout() self.refresh_button = QPushButton("Refresh Plot") @@ -90,4 +85,7 @@ class PlotControlWidget(QWidget): return self.metric_combo.currentData() or DEFAULT_METRIC_ID def current_view_state(self) -> ViewState: - return ViewState(y_log=self.log_freq_check.isChecked()) + return ViewState( + y_log=self.log_freq_check.isChecked(), + x_mode=self.x_mode_combo.currentData(), + ) diff --git a/plotspec.py b/plotspec.py index f849647..b66ca0a 100644 --- a/plotspec.py +++ b/plotspec.py @@ -102,6 +102,25 @@ class PlotSpec: return self.heatmap is not None +@dataclass +class RefLineProps: + """A user-defined horizontal reference line. + + Owned by the GUI controller and passed to the renderer, which draws it as a + draggable line and writes `value` back on drag. Persists across redraws; the + GUI clears the set when the metric changes (the value axis units change). + """ + value: float + color: str = "#444444" + style: str = "dash" # 'solid' | 'dash' | 'dot' + label: str = "" # tag shown on the line; falls back to the value + + +# X-axis modes for comparison. +X_ABSOLUTE = "absolute" # time in seconds (native) +X_RELATIVE = "relative" # 0-100% of each track's own length + + @dataclass class ViewState: """User-controlled, recompute-free view options. @@ -112,6 +131,7 @@ class ViewState: """ y_log: Optional[bool] = None x_log: Optional[bool] = None + x_mode: str = X_ABSOLUTE def resolve_y_log(self, default: bool) -> bool: return self.y_log if self.y_log is not None else default @@ -120,5 +140,41 @@ class ViewState: return self.x_log if self.x_log is not None else default +def apply_x_mode(spec: PlotSpec, mode: str) -> PlotSpec: + """Rewrite a spec's x-axis to relative position (0-100%) in place, if asked. + + Each dataset is normalised to *its own* span, so tracks of different lengths + line up by song position — the point of relative mode. A pure view transform: + it reassigns the x arrays (cached data is left untouched) and relabels the + axis. No-op for absolute mode. + """ + if mode != X_RELATIVE: + return spec + + xs = [c.x for c in spec.curves] + [b.x for b in spec.bands] + if spec.heatmap is not None: + xs.append(spec.heatmap.x) + xs = [x for x in xs if len(x)] + if not xs: + return spec + + lo = min(float(x[0]) for x in xs) + hi = max(float(x[-1]) for x in xs) + span = (hi - lo) or 1.0 + + def rel(x): + return (x - lo) / span * 100.0 + + for c in spec.curves: + c.x = rel(c.x) + for b in spec.bands: + b.x = rel(b.x) + if spec.heatmap is not None: + spec.heatmap.x = rel(spec.heatmap.x) + spec.axes.x_label = "Position (%)" + spec.axes.x_range = (0.0, 100.0) + return spec + + # A neutral default reused wherever a caller hasn't supplied view options. DEFAULT_VIEW = ViewState() diff --git a/pyproject.toml b/pyproject.toml index 7126fe0..ebb8690 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ py-modules = [ "font_manager", "font_control_widget", "plot_control_widget", + "ref_line_widget", "logger_setup", "setup_fonts", ] diff --git a/ref_line_widget.py b/ref_line_widget.py new file mode 100644 index 0000000..d9dc5a9 --- /dev/null +++ b/ref_line_widget.py @@ -0,0 +1,153 @@ +""" +Reference-line management: a side-panel list of custom horizontal markers plus a +properties dialog. + +`RefLineControlWidget` is a pure view over a list of `RefLineProps` owned by the +main window: it renders the list and emits intents (add / edit / remove / clear). +`RefLineDialog` edits one line's value, colour, line style, and tag. +""" + +import logging + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QListWidget, QPushButton, + QDialog, QFormLayout, QDoubleSpinBox, QComboBox, QLineEdit, QColorDialog, + QDialogButtonBox, +) +from PyQt5.QtGui import QColor +from PyQt5.QtCore import pyqtSignal + +from plotspec import RefLineProps + + +_STYLE_CHOICES = [("Solid", "solid"), ("Dashed", "dash"), ("Dotted", "dot")] + + +class RefLineDialog(QDialog): + """Edit one reference line's properties. Read the result via `result_props`.""" + + def __init__(self, parent, props: RefLineProps): + super().__init__(parent) + self.setWindowTitle("Reference line") + self._color = props.color + + form = QFormLayout(self) + + self.value_spin = QDoubleSpinBox() + self.value_spin.setRange(-1e6, 1e6) + self.value_spin.setDecimals(2) + self.value_spin.setValue(props.value) + form.addRow("Value:", self.value_spin) + + self.color_button = QPushButton() + self.color_button.clicked.connect(self._pick_color) + self._refresh_color_button() + form.addRow("Colour:", self.color_button) + + self.style_combo = QComboBox() + for label, key in _STYLE_CHOICES: + self.style_combo.addItem(label, key) + idx = self.style_combo.findData(props.style) + if idx >= 0: + self.style_combo.setCurrentIndex(idx) + form.addRow("Line style:", self.style_combo) + + self.label_edit = QLineEdit(props.label) + self.label_edit.setPlaceholderText("(optional tag)") + form.addRow("Tag:", self.label_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + form.addRow(buttons) + + def _pick_color(self): + chosen = QColorDialog.getColor(QColor(self._color), self, "Reference line colour") + if chosen.isValid(): + self._color = chosen.name() + self._refresh_color_button() + + def _refresh_color_button(self): + self.color_button.setText(self._color) + # Show the colour as the button's background for a quick read. + self.color_button.setStyleSheet(f"background-color: {self._color};") + + def result_props(self) -> RefLineProps: + return RefLineProps( + value=float(self.value_spin.value()), + color=self._color, + style=self.style_combo.currentData(), + label=self.label_edit.text().strip(), + ) + + +class RefLineControlWidget(QWidget): + """List of reference lines with Add / Edit / Remove / Clear controls.""" + + addRequested = pyqtSignal() + editRequested = pyqtSignal(int) + removeRequested = pyqtSignal(int) + clearRequested = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.logger = logging.getLogger(__name__) + self.initUI() + + def initUI(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + group_box = QGroupBox("Reference lines") + group_layout = QVBoxLayout(group_box) + + self.line_list = QListWidget() + self.line_list.setMaximumHeight(110) + self.line_list.itemDoubleClicked.connect(self._on_double_click) + group_layout.addWidget(self.line_list) + + row = QHBoxLayout() + self.add_button = QPushButton("Add") + self.add_button.clicked.connect(self.addRequested.emit) + row.addWidget(self.add_button) + + self.edit_button = QPushButton("Edit…") + self.edit_button.clicked.connect(self._emit_edit) + row.addWidget(self.edit_button) + + self.remove_button = QPushButton("Remove") + self.remove_button.clicked.connect(self._emit_remove) + row.addWidget(self.remove_button) + + self.clear_button = QPushButton("Clear") + self.clear_button.clicked.connect(self.clearRequested.emit) + row.addWidget(self.clear_button) + + group_layout.addLayout(row) + layout.addWidget(group_box) + + def set_lines(self, lines: list[RefLineProps]): + """Repopulate the list display from the current props (preserving selection).""" + current = self.line_list.currentRow() + self.line_list.clear() + for p in lines: + tag = f" {p.label}" if p.label else "" + self.line_list.addItem(f"{p.value:.2f}{tag}") + if 0 <= current < self.line_list.count(): + self.line_list.setCurrentRow(current) + + def _selected_row(self) -> int: + return self.line_list.currentRow() + + def _emit_edit(self): + row = self._selected_row() + if row >= 0: + self.editRequested.emit(row) + + def _emit_remove(self): + row = self._selected_row() + if row >= 0: + self.removeRequested.emit(row) + + def _on_double_click(self, _item): + self._emit_edit() From 22ecb672963832e5398408410c558063dad599e7 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Sun, 14 Jun 2026 01:05:23 +0900 Subject: [PATCH 3/3] Per-metric ref lines with Hz spectrogram units; relative-time toggle; fixed font Reference lines: - Kept per metric (dict keyed by metric_id) so switching metrics and coming back preserves them, instead of clearing on every switch - Spectrogram lines read/edit/drag in Hz: the renderer installs Hz<->row-index transforms (the heatmap y-axis is a row index), so value, label, the edit dialog, and the new-line default all speak frequency. Curve metrics stay identity. Round-trip verified (1000 Hz -> row -> 1000 Hz) - Line label and drag-readback always in the metric's natural units Controls: - Relative-time abs/rel is now a checkbox toggle, not a dropdown - Removed the font control panel; UI font is locked to M PLUS 1 Code @ 10pt via font_manager.apply_fixed_font (system-default fallback). Deleted font_control_widget.py Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 26 +-- audio_visualization_widget.py | 52 ++++-- font_control_widget.py | 311 ---------------------------------- font_manager.py | 37 +++- main.py | 65 +++---- plot_control_widget.py | 23 ++- pyproject.toml | 1 - ref_line_widget.py | 4 +- 8 files changed, 132 insertions(+), 387 deletions(-) delete mode 100644 font_control_widget.py diff --git a/CLAUDE.md b/CLAUDE.md index 9092a7a..a2923c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th - **Pluggable Metrics**: Switchable visualizations (RMS Power, Waveform, LUFS, Crest Factor, PSR, True Peak, Spectrogram; DR next) via a `Metric` ABC - **Metadata Extraction**: Reads ID3 tags from MP3 files for better file identification - **Modular GUI Architecture**: Complete PyQt5 interface with drag-and-drop and file dialog support -- **Font Management**: Comprehensive CJK-compatible font system with user-provided font support +- **Font Management**: CJK-capable, fixed UI font (M PLUS 1 Code @ 10pt) with system fallback - **Threading & Logging**: Robust background processing with detailed logging system ### Technical stack @@ -49,11 +49,14 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th - 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 -- Auto-detection of custom fonts from `fonts/` directory -- System font discovery and CJK compatibility -- Font changes trigger a cheap re-render of the cached metric data +#### `font_manager.py` +- Auto-detection of custom fonts from `fonts/` directory; CJK fallbacks +- `apply_fixed_font(family, size)` locks the Qt app font (used at startup to pin + the UI to **M PLUS 1 Code @ 10pt**, falling back to the system default if the + family isn't found). There is no runtime font picker — the old + `font_control_widget.py` was removed as wasted panel space +- pyqtgraph and the Qt widgets both read the app font, so this covers the plot + too (M PLUS 1 Code has full Japanese coverage, so titles stay CJK-safe) #### `plot_control_widget.py` - Metric selector dropdown driven by the `metrics.METRICS` registry @@ -123,13 +126,14 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th (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 -- **Time-axis mode**: Absolute (seconds) or Relative (% of each track's own - length), so tracks of very different durations line up by song position +- **Time-axis mode**: a Relative-time toggle — off = seconds, on = 0-100% of each + track's own length, so tracks of very different durations line up by position - **Custom reference lines**: side-panel list (Add/Edit/Remove/Clear) of draggable horizontal markers with value/colour/style/tag; dragged via a triangle handle. - Persist across redraws/overlay changes; cleared when the metric changes -- **Font control**: Unified font selector with size control -- **Plot control**: Metric selector + log-frequency toggle + time-axis mode + Kept **per metric** (so switching metrics doesn't lose them) and expressed in + the metric's own units — on the spectrogram they read and edit in **Hz** (the + renderer converts Hz<->row index, since the heatmap y-axis is a row index) +- **Plot control**: Metric selector + log-frequency toggle + relative-time toggle + refresh-plot button - **Analysis display**: Real-time visualization with metadata panels - **Modular architecture**: Self-contained widgets for easy layout management diff --git a/audio_visualization_widget.py b/audio_visualization_widget.py index 6148b37..0454003 100644 --- a/audio_visualization_widget.py +++ b/audio_visualization_widget.py @@ -84,27 +84,38 @@ class _AxisZoomViewBox(pg.ViewBox): 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. + `props.value` is in the metric's natural units (LUFS, dB, ... or Hz for the + spectrogram). The drawn y-position may differ from that value — the spectrogram + maps frequency to a row index — so `to_pos`/`from_pos` convert between the two. + For curve metrics these are identity. The label and the value written back on + drag are always in natural units. """ - def __init__(self, index: int, props: RefLineProps, on_moved): + def __init__(self, index, props: RefLineProps, to_pos, from_pos, fmt, 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}", + pos=to_pos(props.value), angle=0, movable=True, pen=pen, + label="", labelOpts={"position": 0.06, "color": props.color, "fill": (255, 255, 255, 180)}, ) self._index = index self._props = props + self._from_pos = from_pos + self._fmt = fmt self._on_moved = on_moved self.addMarker("|>", position=0.0, size=12) # triangle handle at the start + self._update_label() + self.sigPositionChanged.connect(self._update_label) self.sigPositionChangeFinished.connect(self._commit) + def _update_label(self): + val = self._from_pos(self.value()) + self.label.setFormat(self._props.label or self._fmt(val)) + def _commit(self): - self._props.value = float(self.value()) + self._props.value = float(self._from_pos(self.value())) self._on_moved(self._index) @@ -134,6 +145,11 @@ class AudioVisualizationWidget(QWidget): # passed in via set_reference_lines; the line items are rebuilt each render. self._ref_props: list[RefLineProps] = [] self._ref_lines: list[_RefLine] = [] + # value<->drawn-position transforms for ref lines (identity for curve metrics; + # frequency<->row-index for the spectrogram). Reset each render. + self._ref_to_pos = lambda v: v + self._ref_from_pos = lambda p: p + self._ref_fmt = lambda v: f"{v:.2f}" self._show_empty() # ---- public API --------------------------------------------------------- @@ -187,10 +203,14 @@ class AudioVisualizationWidget(QWidget): 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.""" + def current_view_center_value(self) -> float: + """Natural-unit value at the current y-view centre — default for a new line. + + Runs through `from_pos`, so on the spectrogram this returns a frequency, not + a row index. + """ (_, _), (y0, y1) = self.plot.viewRange() - return (y0 + y1) / 2.0 + return float(self._ref_from_pos((y0 + y1) / 2.0)) def set_status(self, message: str): self.status_label.setText(message) @@ -269,6 +289,13 @@ class AudioVisualizationWidget(QWidget): img.setRect(pg.QtCore.QRectF(t0, 0.0, t1 - t0, float(n_rows))) self.plot.addItem(img) + # Reference lines on the spectrogram are entered/shown in Hz but drawn at a + # row index — install the frequency<->row transforms for this f_grid. + rows = np.arange(n_rows) + self._ref_to_pos = lambda hz, fg=f_grid, r=rows: float(np.interp(hz, fg, r)) + self._ref_from_pos = lambda pos, fg=f_grid, r=rows: float(np.interp(pos, r, fg)) + self._ref_fmt = lambda v: f"{v:.0f} Hz" + # Label the row-index y-axis with real frequencies. ticks = [] for hz in _LOG_FREQ_TICKS: @@ -309,7 +336,8 @@ class AudioVisualizationWidget(QWidget): """(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) + line = _RefLine(idx, props, self._ref_to_pos, self._ref_from_pos, + self._ref_fmt, on_moved=self.referenceLineMoved.emit) self.plot.addItem(line) self._ref_lines.append(line) @@ -338,6 +366,10 @@ class AudioVisualizationWidget(QWidget): self.legend.clear() self.plot.getAxis("left").setTicks(None) # drop heatmap freq ticks self.plot.setLogMode(x=False, y=False) + # Back to identity; the heatmap path reinstalls Hz<->row if needed. + self._ref_to_pos = lambda v: v + self._ref_from_pos = lambda p: p + self._ref_fmt = lambda v: f"{v:.2f}" def _show_empty(self): text = pg.TextItem("Drop an audio file to see analysis", anchor=(0.5, 0.5), diff --git a/font_control_widget.py b/font_control_widget.py deleted file mode 100644 index d0a1eb7..0000000 --- a/font_control_widget.py +++ /dev/null @@ -1,311 +0,0 @@ -""" -Unified font control widget clustering all font-related manipulators. -Self-contained widget for easy layout management. -""" - -import logging -from typing import List, Dict, Optional -from PyQt5.QtWidgets import (QWidget, QComboBox, QVBoxLayout, QHBoxLayout, - QLabel, QSlider, QGroupBox) -from PyQt5.QtCore import pyqtSignal, Qt - -from font_manager import get_font_manager - - -class FontControlWidget(QWidget): - """ - Unified font control widget containing all font-related manipulators. - - Provides clustered interface for: - - Font selection (unified for Qt and matplotlib) - - Qt font size adjustment - """ - - # Signals - fontChanged = pyqtSignal(str, str) # (font_name, font_type) - fontSizeChanged = pyqtSignal(int) # font_size - - def __init__(self, parent=None): - """Initialize the font control widget.""" - super().__init__(parent) - self.logger = logging.getLogger(__name__) - self.font_manager = get_font_manager() - self.available_fonts: Dict[str, str] = {} # display_name -> actual_font_name - - # Font size bounds (reasonable range for GUI fonts) - self.min_font_size = 9 - self.max_font_size = 12 - self.default_font_size = 10 - - self.initUI() - self.refresh_font_list() - - def initUI(self): - """Initialize the user interface.""" - # Main layout - layout = QVBoxLayout(self) - layout.setContentsMargins(5, 5, 5, 5) # Minimal margins - - # Group box for visual clustering - group_box = QGroupBox("Font Settings") - group_layout = QVBoxLayout(group_box) - - # Font selector section - font_section = self._create_font_selector_section() - group_layout.addWidget(font_section) - - # Font size section - size_section = self._create_font_size_section() - group_layout.addWidget(size_section) - - layout.addWidget(group_box) - - def _create_font_selector_section(self) -> QWidget: - """Create the font selector section.""" - section = QWidget() - layout = QVBoxLayout(section) - layout.setContentsMargins(0, 0, 0, 0) - - # Font label and dropdown - font_label = QLabel("Font:") - layout.addWidget(font_label) - - self.font_combo = QComboBox() - self.font_combo.currentTextChanged.connect(self.on_font_changed) - layout.addWidget(self.font_combo) - - return section - - def _create_font_size_section(self) -> QWidget: - """Create the font size control section.""" - section = QWidget() - layout = QVBoxLayout(section) - layout.setContentsMargins(0, 0, 0, 0) - - # Size label - self.size_label = QLabel(f"Qt Font Size: {self.default_font_size}pt") - layout.addWidget(self.size_label) - - # Size slider with value labels - slider_layout = QHBoxLayout() - - # Min label - min_label = QLabel(str(self.min_font_size)) - min_label.setFixedWidth(20) - slider_layout.addWidget(min_label) - - # Slider - self.size_slider = QSlider(Qt.Horizontal) - self.size_slider.setMinimum(self.min_font_size) - self.size_slider.setMaximum(self.max_font_size) - self.size_slider.setValue(self.default_font_size) - self.size_slider.setTickPosition(QSlider.TicksBelow) - self.size_slider.setTickInterval(2) - self.size_slider.valueChanged.connect(self.on_font_size_changed) - slider_layout.addWidget(self.size_slider) - - # Max label - max_label = QLabel(str(self.max_font_size)) - max_label.setFixedWidth(20) - slider_layout.addWidget(max_label) - - layout.addLayout(slider_layout) - - return section - - def refresh_font_list(self): - """Refresh the list of available fonts.""" - self.logger.debug("Refreshing font list...") - self.available_fonts.clear() - - try: - # Get custom fonts from font manager - custom_fonts = self.font_manager.loaded_fonts - - # Get system font candidates - system_fonts = self.font_manager.get_available_system_fonts()[:10] - - # Clear combo box - self.font_combo.clear() - - # Add custom fonts first (highest priority) - if custom_fonts: - for font_family, font_path in custom_fonts.items(): - display_name = f"{font_family} (Custom)" - self.available_fonts[display_name] = font_family - self.font_combo.addItem(display_name) - self.logger.debug(f"Added custom font: {display_name}") - - # Add system fonts - for font_name in system_fonts: - display_name = f"{font_name} (System)" - self.available_fonts[display_name] = font_name - self.font_combo.addItem(display_name) - self.logger.debug(f"Added system font: {display_name}") - - # Add default option with actual system font name - system_font_name = self.font_manager.get_default_system_font_name() - default_name = f"Default ({system_font_name})" - self.available_fonts[default_name] = "default" - self.font_combo.addItem(default_name) - - # Select startup font - self._select_startup_font() - - self.logger.info(f"Font list refreshed: {len(self.available_fonts)} fonts available") - - except Exception as e: - self.logger.error(f"Error refreshing font list: {e}") - # Fallback: add default option only - self.font_combo.clear() - self.font_combo.addItem("Default (System)") - self.available_fonts = {"Default (System)": "default"} - - def _select_startup_font(self): - """Select the appropriate font on startup.""" - # Use font manager's startup font selection logic - startup_font_name, startup_font_type = self.font_manager.select_startup_font() - - # Find the corresponding display name in our combo box - target_display_name = None - for display_name, actual_name in self.available_fonts.items(): - if actual_name == startup_font_name: - target_display_name = display_name - break - - # If we found the font, select it - if target_display_name: - index = self.font_combo.findText(target_display_name) - if index >= 0: - self.font_combo.setCurrentIndex(index) - self.logger.info(f"Startup font selected: {target_display_name}") - return - - # Fallback to default if we couldn't find the startup font - for i in range(self.font_combo.count()): - item_text = self.font_combo.itemText(i) - if item_text.startswith("Default ("): - self.font_combo.setCurrentIndex(i) - self.logger.info(f"Startup font fallback: {item_text}") - return - - def on_font_changed(self, display_name: str): - """Handle font selection change.""" - if not display_name or display_name not in self.available_fonts: - return - - actual_font_name = self.available_fonts[display_name] - - # Determine font type - if "(Custom)" in display_name: - font_type = "custom" - elif "(System)" in display_name: - font_type = "system" - else: - font_type = "default" - - self.logger.info(f"Font changed: {display_name} -> {actual_font_name} ({font_type})") - - # Apply the font change - self._apply_font_change(actual_font_name, font_type) - - # Emit signal for external listeners (including auto-regeneration) - self.fontChanged.emit(actual_font_name, font_type) - - def on_font_size_changed(self, size: int): - """Handle font size change.""" - self.size_label.setText(f"Qt Font Size: {size}pt") - self.logger.info(f"Qt font size changed: {size}pt") - - # Apply Qt font size change - self._apply_font_size_change(size) - - # Emit signal for external listeners - self.fontSizeChanged.emit(size) - - def _apply_font_change(self, font_name: str, font_type: str): - """Apply the font change to both Qt and matplotlib.""" - try: - # Use font manager's centralized font application - success = self.font_manager.apply_font_selection(font_name, font_type) - if not success: - self.logger.warning(f"Font application may have failed: {font_name}") - - except Exception as e: - self.logger.error(f"Error applying font change: {e}") - - def _apply_font_size_change(self, size: int): - """Apply Qt font size change.""" - try: - from PyQt5.QtCore import QCoreApplication - from PyQt5.QtGui import QFont - - app = QCoreApplication.instance() - if app: - current_font = app.font() - current_font.setPointSize(size) - app.setFont(current_font) - self.logger.debug(f"Qt font size set to: {size}pt") - - except Exception as e: - self.logger.error(f"Error applying font size change: {e}") - - def get_current_font(self) -> tuple[str, str]: - """ - Get currently selected font. - - Returns: - tuple: (font_name, font_type) - """ - display_name = self.font_combo.currentText() - if display_name in self.available_fonts: - actual_font_name = self.available_fonts[display_name] - - if "(Custom)" in display_name: - font_type = "custom" - elif "(System)" in display_name: - font_type = "system" - else: - font_type = "default" - - return actual_font_name, font_type - - return "default", "default" - - def get_current_font_size(self) -> int: - """ - Get current Qt font size. - - Returns: - int: Current font size in points - """ - return self.size_slider.value() - - def set_font(self, font_name: str): - """ - Programmatically set the font selection. - - Args: - font_name: Name of font to select - """ - # Find matching display name - for display_name, actual_name in self.available_fonts.items(): - if actual_name == font_name: - index = self.font_combo.findText(display_name) - if index >= 0: - self.font_combo.setCurrentIndex(index) - return - - self.logger.warning(f"Font not found in selector: {font_name}") - - def set_font_size(self, size: int): - """ - Programmatically set the font size. - - Args: - size: Font size in points (will be clamped to valid range) - """ - clamped_size = max(self.min_font_size, min(self.max_font_size, size)) - self.size_slider.setValue(clamped_size) - if clamped_size != size: - self.logger.warning(f"Font size clamped: {size} -> {clamped_size}") \ No newline at end of file diff --git a/font_manager.py b/font_manager.py index dd43a56..633c939 100644 --- a/font_manager.py +++ b/font_manager.py @@ -493,11 +493,42 @@ def initialize_fonts() -> bool: def safe_title(title: str) -> str: """ Convenience function to get CJK-safe title. - + Args: title: Original title - + Returns: str: Safe title for display """ - return get_font_manager().get_cjk_safe_title(title) \ No newline at end of file + return get_font_manager().get_cjk_safe_title(title) + + +def apply_fixed_font(family: str = "M PLUS 1 Code", size: int = 10) -> str: + """Lock the Qt application font to `family` at `size`pt. + + Falls back to the system default family if `family` isn't available (loaded + from fonts/ or installed). pyqtgraph and the Qt widgets both read the app + font, so this is all the plot/UI need. Returns the family actually used. + """ + logger = logging.getLogger(__name__) + app = QCoreApplication.instance() + if app is None: + logger.warning("apply_fixed_font called before QApplication exists") + return family + + try: + available = family in set(QFontDatabase().families()) + except Exception: + available = False + + if available: + font = QFont(family) + chosen = family + else: + font = QFont() # system default family + chosen = font.defaultFamily() + logger.info(f"Font '{family}' not found; using system default '{chosen}'") + font.setPointSize(size) + app.setFont(font) + logger.info(f"Application font locked to '{chosen}' at {size}pt") + return chosen \ No newline at end of file diff --git a/main.py b/main.py index 6582e5b..08d1afb 100644 --- a/main.py +++ b/main.py @@ -9,8 +9,7 @@ from PyQt5.QtCore import Qt 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 font_manager import initialize_fonts, apply_fixed_font from plot_control_widget import PlotControlWidget from ref_line_widget import RefLineControlWidget, RefLineDialog from metrics import METRICS @@ -26,10 +25,12 @@ class MainWindow(QMainWindow): self.analysis_manager = AnalysisResultsManager() # Guards programmatic list mutations from triggering re-render storms. self._suppress_list_signals = False - # Custom reference lines, owned here and pushed to the plot each render. - self.ref_lines: list[RefLineProps] = [] + # Reference lines are kept per metric (a -14 LUFS line means nothing on a + # spectrogram), so they persist when you switch metrics and come back. + self.ref_lines_by_metric: dict[str, list[RefLineProps]] = {} self.initUI() self.connect_signals() + self._activate_ref_lines() def initUI(self): """Initialize the user interface.""" @@ -66,12 +67,6 @@ class MainWindow(QMainWindow): self.open_file_button = QPushButton("Open Audio File...") self.open_file_button.clicked.connect(self.open_file_dialog) layout.addWidget(self.open_file_button) - - # Font control cluster - self.font_control = FontControlWidget() - self.font_control.fontChanged.connect(self.on_font_changed) - self.font_control.fontSizeChanged.connect(self.on_font_size_changed) - layout.addWidget(self.font_control) # Plot control cluster (metric selector + scale toggle + refresh) self.plot_control = PlotControlWidget() @@ -219,28 +214,17 @@ class MainWindow(QMainWindow): return self._refresh_view() - 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})") - 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 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}") - # The value axis units change with the metric, so custom reference lines - # placed against the old metric no longer mean anything — drop them. - self.ref_lines.clear() - self._sync_ref_lines() + # Reference lines are kept per metric, so swap in this metric's set rather + # than discarding — switch away and back and your lines are still there. + self._activate_ref_lines() self._refresh_view() def on_add_reference_line(self): """Add a reference line at the current view centre, then edit it.""" - value = self.visualization_widget.current_view_center_y() + value = self.visualization_widget.current_view_center_value() props = RefLineProps(value=round(value, 2)) self.ref_lines.append(props) self._sync_ref_lines() @@ -251,7 +235,7 @@ class MainWindow(QMainWindow): """Open the properties dialog for a reference line.""" if not (0 <= index < len(self.ref_lines)): return - dialog = RefLineDialog(self, self.ref_lines[index]) + dialog = RefLineDialog(self, self.ref_lines[index], value_units=self._ref_value_units()) if dialog.exec_(): self.ref_lines[index] = dialog.result_props() self._sync_ref_lines() @@ -263,7 +247,7 @@ class MainWindow(QMainWindow): self._sync_ref_lines() def on_clear_reference_lines(self): - """Remove all custom reference lines.""" + """Remove all custom reference lines for the current metric.""" self.ref_lines.clear() self._sync_ref_lines() @@ -271,6 +255,16 @@ class MainWindow(QMainWindow): """A line was dragged on the plot — its value is already updated; refresh list.""" self.ref_line_control.set_lines(self.ref_lines) + def _activate_ref_lines(self): + """Point `self.ref_lines` at the current metric's set and sync the UI.""" + metric_id = self.plot_control.current_metric_id() + self.ref_lines = self.ref_lines_by_metric.setdefault(metric_id, []) + self._sync_ref_lines() + + def _ref_value_units(self) -> str: + """Units a reference line's value is expressed in for the current metric.""" + return "Hz" if self.plot_control.current_metric_id() == "spectrogram" else "" + def _sync_ref_lines(self): """Push the current reference-line set to both the list view and the plot.""" self.ref_line_control.set_lines(self.ref_lines) @@ -384,17 +378,12 @@ def main(): app = QApplication(sys.argv) - # Initialize font system before creating any widgets - font_success = initialize_fonts() - if font_success: - logger.info("Font system initialized successfully") - # Log font status for debugging - font_status = get_font_manager().get_status_report() - logger.debug(f"Font status: matplotlib={font_status['matplotlib_configured']}, " - f"qt={font_status['qt_configured']}, " - f"custom_fonts={font_status['custom_fonts_loaded']}") - else: - logger.warning("Font system initialization failed - CJK characters may not display properly") + # Initialize font system (loads any fonts/ files, configures fallbacks) then + # lock the UI font. M PLUS 1 Code has full Japanese coverage, so this stays + # CJK-safe; falls back to the system default if the family isn't present. + initialize_fonts() + chosen = apply_fixed_font("M PLUS 1 Code", 10) + logger.info(f"UI font locked to '{chosen}' at 10pt") # Set application style app.setStyle('Fusion') # Modern cross-platform style diff --git a/plot_control_widget.py b/plot_control_widget.py index 2c86c50..30ab5d2 100644 --- a/plot_control_widget.py +++ b/plot_control_widget.py @@ -1,8 +1,8 @@ """ -Plot control widget: pick which metric to display and refresh the current plot. +Plot control widget: pick the metric, set axis scale/mode, refresh the plot. -Mirrors FontControlWidget's clustered-groupbox style so the two sit naturally -next to each other in the left panel. +A clustered groupbox for the left panel: metric selector, log-frequency toggle, +relative-time toggle, and a manual refresh button. """ import logging @@ -57,14 +57,13 @@ class PlotControlWidget(QWidget): self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit()) group_layout.addWidget(self.log_freq_check) - # Time axis: absolute seconds vs relative % of each track's own length, so - # tracks of very different durations line up by song position when overlaid. - group_layout.addWidget(QLabel("Time axis:")) - self.x_mode_combo = QComboBox() - self.x_mode_combo.addItem("Absolute (seconds)", X_ABSOLUTE) - self.x_mode_combo.addItem("Relative (%)", X_RELATIVE) - self.x_mode_combo.currentIndexChanged.connect(lambda _: self.viewChanged.emit()) - group_layout.addWidget(self.x_mode_combo) + # Time axis: off = absolute seconds, on = relative % of each track's own + # length, so tracks of very different durations line up by song position. + self.relative_time_check = QCheckBox("Relative time axis (%)") + self.relative_time_check.setToolTip( + "Off: time in seconds. On: 0-100% of each track's own length") + self.relative_time_check.toggled.connect(lambda _: self.viewChanged.emit()) + group_layout.addWidget(self.relative_time_check) button_row = QHBoxLayout() self.refresh_button = QPushButton("Refresh Plot") @@ -87,5 +86,5 @@ class PlotControlWidget(QWidget): def current_view_state(self) -> ViewState: return ViewState( y_log=self.log_freq_check.isChecked(), - x_mode=self.x_mode_combo.currentData(), + x_mode=X_RELATIVE if self.relative_time_check.isChecked() else X_ABSOLUTE, ) diff --git a/pyproject.toml b/pyproject.toml index ebb8690..21bc906 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ py-modules = [ "metrics", "plotspec", "font_manager", - "font_control_widget", "plot_control_widget", "ref_line_widget", "logger_setup", diff --git a/ref_line_widget.py b/ref_line_widget.py index d9dc5a9..6a54b5b 100644 --- a/ref_line_widget.py +++ b/ref_line_widget.py @@ -26,7 +26,7 @@ _STYLE_CHOICES = [("Solid", "solid"), ("Dashed", "dash"), ("Dotted", "dot")] class RefLineDialog(QDialog): """Edit one reference line's properties. Read the result via `result_props`.""" - def __init__(self, parent, props: RefLineProps): + def __init__(self, parent, props: RefLineProps, value_units: str = ""): super().__init__(parent) self.setWindowTitle("Reference line") self._color = props.color @@ -37,6 +37,8 @@ class RefLineDialog(QDialog): self.value_spin.setRange(-1e6, 1e6) self.value_spin.setDecimals(2) self.value_spin.setValue(props.value) + if value_units: + self.value_spin.setSuffix(f" {value_units}") form.addRow("Value:", self.value_spin) self.color_button = QPushButton()