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 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-14 00:35:10 +09:00
parent a322f08d0c
commit b400551321
9 changed files with 817 additions and 398 deletions
+59 -23
View File
@@ -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
+13 -9
View File
@@ -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)
+309 -54
View File
@@ -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."""
"""Persistent interactive plot. Call `show_specs` to (re)draw."""
def __init__(self, parent=None):
super().__init__(parent)
self.initUI()
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
def initUI(self):
"""Initialize the UI components."""
layout = QVBoxLayout()
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)
# Create matplotlib canvas
self.figure = Figure(figsize=(10, 4), facecolor='white')
self.canvas = FigureCanvas(self.figure)
self.status_label = QLabel("Ready for audio analysis...")
layout.addWidget(self.status_label)
# Add canvas to layout
layout.addWidget(self.canvas)
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()
# Status label for feedback
self.status_label = QLabel("Ready for audio analysis...")
layout.addWidget(self.status_label)
# ---- public API ---------------------------------------------------------
self.setLayout(layout)
def show_specs(self, specs, view: ViewState = DEFAULT_VIEW):
"""Render datasets onto the shared axes.
# Initialize with empty plot
self._create_empty_plot()
`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
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()
specs = [self._normalise(s, i) for i, s in enumerate(specs)]
base_axes = specs[0][1].axes
def display_figure_direct(self, figure):
"""
Display a figure by replacing our canvas figure entirely.
More reliable than copying elements.
# 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
Args:
figure: matplotlib.figure.Figure to display
"""
# Remove old canvas
layout = self.layout()
layout.removeWidget(self.canvas)
self.canvas.deleteLater()
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)
# 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)
# Reference lines from the first spec only (identical across same-metric specs).
for hl in specs[0][1].hlines:
self._render_hline(hl)
self.canvas.draw()
self.status_label.setText("Analysis complete - displaying power graph")
# 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)
def set_status(self, message):
"""Update the status label."""
self.status_label.setText(message)
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...")
+112 -56
View File
@@ -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)
# Render the currently-selected metric (cached, or async-compute it)
self._render_or_request(file_path)
def on_file_check_changed(self, _item):
"""A checkbox toggled — the overlay set changed; redraw."""
if self._suppress_list_signals:
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})")
# 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():
+126 -240
View File
@@ -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] = {
+33 -1
View File
@@ -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())
+124
View File
@@ -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()
+2
View File
@@ -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",
Generated
+24
View File
@@ -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]]