Refactor/interactive plotting #1

Merged
mikkeli merged 3 commits from refactor/interactive-plotting into main 2026-06-13 16:17:21 +00:00
9 changed files with 817 additions and 398 deletions
Showing only changes of commit b400551321 - Show all commits
+59 -23
View File
@@ -14,7 +14,9 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
### Technical stack ### Technical stack
- **Audio Processing**: librosa, numpy - **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 - **GUI Framework**: PyQt5 with modular widget architecture
- **Metadata**: mutagen for audio tag reading - **Metadata**: mutagen for audio tag reading
- **Font Support**: Custom font loading system with CJK fallback - **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 - Progress tracking and error handling
#### `audio_visualization_widget.py` #### `audio_visualization_widget.py`
- Embedded matplotlib visualization with Qt integration - Persistent pyqtgraph plot — the PlotItem is reused across renders, never torn
- Real-time plot updates and status display 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` #### `font_control_widget.py` & `font_manager.py`
- Unified font control system with clustered interface - 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` #### `plot_control_widget.py`
- Metric selector dropdown driven by the `metrics.METRICS` registry - 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` #### `metrics.py`
- Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread) - Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread,
and `render(data, file_path) -> Figure` (cheap, GUI 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: - Current registry:
- `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale - `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale
- `WaveformMetric` — min/max envelope, fixed ±1.1 y-range - `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) - `PSRMetric` — sample-peak minus short-term LUFS (3 s window)
- `TruePeakMetric` — 4× oversampled dBTP via `scipy.signal.resample_poly` - `TruePeakMetric` — 4× oversampled dBTP via `scipy.signal.resample_poly`
- `SpectrogramMetric` — log-frequency STFT heatmap; adaptive hop caps time - `SpectrogramMetric` — log-frequency STFT heatmap; adaptive hop caps time
bins at ~4000, `N_FFT=4096` bins at ~4000, `N_FFT=4096`. Log/linear frequency is a view toggle
- Shared render helpers: `_show_axis_extents(ax)` forces each axis's exact - Drop in new ones (DR, spectral balance) by appending an instance to `METRICS`;
min/max onto the ticks (so log-axis extremes like 22 kHz are always return a `PlotSpec` from `build_spec` (curves overlay automatically; heatmaps
labelled); `_fmt_tick` keeps those labels compact show one dataset at a time)
- Drop in new ones (DR, spectral balance) by appending an instance to `METRICS` - 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` #### `master_core.py`
- Defines the `AudioFile` class: librosa loading, rolling RMS power, BPM detection - 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 ### GUI features
- **File management**: Drag-and-drop and file dialog for audio selection - **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 - **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 - **Analysis display**: Real-time visualization with metadata panels
- **Modular architecture**: Self-contained widgets for easy layout management - **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 - Long-term average spectrum (LTAS) / tonal-balance curve
- Stereo metrics (correlation, mid/side) — needs `AudioFile` to retain stereo - 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) - 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 - Export analysis results to CSV/JSON
3. **Advanced GUI controls** 3. **Advanced GUI controls**
@@ -121,11 +151,14 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- Graphical logging text box - Graphical logging text box
### Mid-to-long-term (very not urgent) ### Mid-to-long-term (very not urgent)
1. **Audio comparison system** 1. **Audio comparison system** *(multi-file overlay done via file-list checkboxes;
- Reference vs. comparee audio file analysis each song has a stable palette colour keyed to its list row)*
- Side-by-side track comparison interface - 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 - A/B testing for mastering versions
- Overlay visualization for comparative analysis
2. **Distribution & deployment** 2. **Distribution & deployment**
- Self-contained executable releases - Self-contained executable releases
@@ -155,15 +188,18 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
### Dependencies ### Dependencies
- librosa: Audio analysis and feature extraction - librosa: Audio analysis and feature extraction
- numpy: Numerical computations - 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) - 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 - mutagen: Audio metadata extraction
- PyQt5: GUI framework - PyQt5: GUI framework
### Architecture considerations ### Architecture considerations
- Analysis (`metrics.compute`) and visualization (`metrics.render`) are split - Three-stage split: `metrics.compute` (heavy, worker thread, backend-neutral
across the `Metric` ABC; compute runs on a worker thread, render on the GUI 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 - File path handling needs improvement for cross-platform compatibility
- Error handling should be enhanced for production use - Error handling should be enhanced for production use
- Consider moving from PyQt5 to PyQt6 or PySide for better licensing - 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.metric_workers.pop((file_path, metric_id), None)
self.metricComputeError.emit(file_path, metric_id, error_message) self.metricComputeError.emit(file_path, metric_id, error_message)
def get_metric_figure(self, file_path: str, metric_id: str): def get_metric_data(self, file_path: str, metric_id: str):
"""Render a Figure from cached metric data. Returns None if not cached. """Return cached metric data, or None if not computed yet.
Never triggers compute — call `request_metric` first and listen for 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) result = self.results_cache.get(file_path)
if result is None: if result is None:
return None return None
metric = METRICS.get(metric_id) if metric_id not in METRICS:
if metric is None:
return None return None
data = result.metric_data.get(metric_id) return result.metric_data.get(metric_id)
if data is None:
return None def display_label(self, file_path: str) -> str:
return metric.render(data, file_path) """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: def get_metadata_text(self, file_path: str) -> str:
result = self.results_cache.get(file_path) result = self.results_cache.get(file_path)
+322 -67
View File
@@ -1,74 +1,329 @@
""" """
Audio visualization widget with embedded matplotlib canvas. Interactive visualization widget built on pyqtgraph.
Pure display responsibility - receives plotting data and shows graphs.
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 PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from PyQt5.QtCore import Qt
from matplotlib.figure import Figure
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): 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): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.initUI() layout = QVBoxLayout(self)
def initUI(self): self.glw = pg.GraphicsLayoutWidget()
"""Initialize the UI components.""" self.plot = self.glw.addPlot(row=0, col=0, viewBox=_AxisZoomViewBox())
layout = QVBoxLayout() self.plot.showGrid(x=True, y=True, alpha=0.3)
self.plot.setMenuEnabled(True)
# Create matplotlib canvas self.legend = self.plot.addLegend(offset=(-10, 10))
self.figure = Figure(figsize=(10, 4), facecolor='white') layout.addWidget(self.glw)
self.canvas = FigureCanvas(self.figure)
self.status_label = QLabel("Ready for audio analysis...")
# Add canvas to layout layout.addWidget(self.status_label)
layout.addWidget(self.canvas)
self._colorbar = None
# Status label for feedback # User reference lines persist by value across redraws; the items are rebuilt
self.status_label = QLabel("Ready for audio analysis...") # each render. Cleared by the GUI on metric change (units change).
layout.addWidget(self.status_label) self._user_line_values: list[float] = []
self._user_lines: list[pg.InfiniteLine] = []
self.setLayout(layout) self._show_empty()
# Initialize with empty plot # ---- public API ---------------------------------------------------------
self._create_empty_plot()
def show_specs(self, specs, view: ViewState = DEFAULT_VIEW):
def _create_empty_plot(self): """Render datasets onto the shared axes.
"""Creates an empty placeholder plot."""
self.figure.clear() `specs` is a list of `(label, PlotSpec)` or `(label, PlotSpec, color)`. When
ax = self.figure.add_subplot(111) no colour is given, the dataset's palette colour by position is used. All
ax.text(0.5, 0.5, 'Drop an audio file to see analysis', specs are assumed to be the same metric (compare overlays one metric across
ha='center', va='center', transform=ax.transAxes, files), so axis labels/ranges come from the first spec.
fontsize=14, alpha=0.7) """
ax.set_xlim(0, 1) self._reset_plot()
ax.set_ylim(0, 1) if not specs:
ax.set_xticks([]) self._show_empty()
ax.set_yticks([]) return
self.canvas.draw()
specs = [self._normalise(s, i) for i, s in enumerate(specs)]
def display_figure_direct(self, figure): base_axes = specs[0][1].axes
"""
Display a figure by replacing our canvas figure entirely. # Heatmaps do not overlay: render only the first dataset's heatmap.
More reliable than copying elements. if specs[0][1].is_heatmap:
label, spec, _ = specs[0]
Args: self._render_heatmap(spec, view)
figure: matplotlib.figure.Figure to display if len(specs) > 1:
""" self.set_status(f"{spec.title or label}: spectrogram shows one track at a time")
# Remove old canvas self._apply_axes(base_axes, log_y_image_handled=True)
layout = self.layout() self._draw_user_lines()
layout.removeWidget(self.canvas) return
self.canvas.deleteLater()
single = len(specs) == 1
# Create new canvas with the provided figure for label, spec, color in specs:
self.figure = figure prefix = "" if single else f"{label}: "
self.canvas = FigureCanvas(self.figure) self._render_curves_and_bands(spec, color, prefix, single=single)
layout.insertWidget(0, self.canvas) # Insert at position 0 (before status label)
# Reference lines from the first spec only (identical across same-metric specs).
self.canvas.draw() for hl in specs[0][1].hlines:
self.status_label.setText("Analysis complete - displaying power graph") self._render_hline(hl)
def set_status(self, message): # Scalar readouts → legend-only proxy entries.
"""Update the status label.""" for label, spec, _ in specs:
self.status_label.setText(message) 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...")
+114 -58
View File
@@ -6,12 +6,13 @@ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QTextEdit, QListWidgetItem, QPushButton, QFileDialog) QTextEdit, QListWidgetItem, QPushButton, QFileDialog)
from PyQt5.QtCore import Qt 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 analysis_results_manager import AnalysisResultsManager
from logger_setup import setup_logging, parse_log_args from logger_setup import setup_logging, parse_log_args
from font_manager import initialize_fonts, get_font_manager from font_manager import initialize_fonts, get_font_manager
from font_control_widget import FontControlWidget from font_control_widget import FontControlWidget
from plot_control_widget import PlotControlWidget from plot_control_widget import PlotControlWidget
from metrics import METRICS
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -21,6 +22,8 @@ class MainWindow(QMainWindow):
super().__init__() super().__init__()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.analysis_manager = AnalysisResultsManager() self.analysis_manager = AnalysisResultsManager()
# Guards programmatic list mutations from triggering re-render storms.
self._suppress_list_signals = False
self.initUI() self.initUI()
self.connect_signals() self.connect_signals()
@@ -66,18 +69,23 @@ class MainWindow(QMainWindow):
self.font_control.fontSizeChanged.connect(self.on_font_size_changed) self.font_control.fontSizeChanged.connect(self.on_font_size_changed)
layout.addWidget(self.font_control) 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 = PlotControlWidget()
self.plot_control.metricChanged.connect(self.on_metric_changed) 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.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) layout.addWidget(self.plot_control)
# File list # File list. Each item carries a checkbox: the checked set is the overlay
self.file_list_label = QLabel("Analyzed Files:") # 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) layout.addWidget(self.file_list_label)
self.file_list = QListWidget() self.file_list = QListWidget()
self.file_list.itemClicked.connect(self.on_file_selected) self.file_list.itemClicked.connect(self.on_file_selected)
self.file_list.itemChanged.connect(self.on_file_check_changed)
layout.addWidget(self.file_list) layout.addWidget(self.file_list)
# Metadata display # Metadata display
@@ -160,27 +168,23 @@ class MainWindow(QMainWindow):
"""Called when analysis completes successfully.""" """Called when analysis completes successfully."""
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
# Add to file list if not already there # Add to file list (checked, so it joins the overlay set) if not present.
existing_items = [self.file_list.item(i).text() item = self._item_for_path(file_path)
for i in range(self.file_list.count())] if item is None:
if filename not in existing_items: self._suppress_list_signals = True
item = QListWidgetItem(filename) item = QListWidgetItem(filename)
item.setData(Qt.UserRole, file_path) # Store full path 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.file_list.addItem(item)
self._suppress_list_signals = False
# Update metadata display # Update metadata display and highlight the analyzed file.
metadata_text = self.analysis_manager.get_metadata_text(file_path) self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
self.metadata_display.setText(metadata_text) self.file_list.setCurrentItem(item)
# Select the analyzed file in the list # Redraw the overlay set for the current metric.
for i in range(self.file_list.count()): self._refresh_view()
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)
def on_analysis_error(self, file_path, error_message): def on_analysis_error(self, file_path, error_message):
"""Called when analysis fails.""" """Called when analysis fails."""
@@ -194,84 +198,136 @@ class MainWindow(QMainWindow):
self.visualization_widget.set_status(f"{message} ({percentage}%)") self.visualization_widget.set_status(f"{message} ({percentage}%)")
def on_file_selected(self, item): 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) file_path = item.data(Qt.UserRole)
self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
# Update metadata display def on_file_check_changed(self, _item):
metadata_text = self.analysis_manager.get_metadata_text(file_path) """A checkbox toggled — the overlay set changed; redraw."""
self.metadata_display.setText(metadata_text) 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): def on_font_changed(self, font_name: str, font_type: str):
"""Called when font selection changes.""" """Called when font selection changes."""
self.logger.info(f"Font changed via GUI: {font_name} ({font_type})") self.logger.info(f"Font changed via GUI: {font_name} ({font_type})")
# Cheap re-render — cached metric data, redraws under the new font. self._refresh_view()
self._render_or_request(self._current_file_path())
def on_font_size_changed(self, font_size: int): def on_font_size_changed(self, font_size: int):
"""Called when Qt font size changes.""" """Called when Qt font size changes."""
self.logger.info(f"Qt font size changed via GUI: {font_size}pt") 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): def on_metric_changed(self, metric_id: str):
"""Called when the metric selector changes.""" """Called when the metric selector changes."""
self.logger.info(f"Metric changed via GUI: {metric_id}") 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): def on_plot_refresh_requested(self):
"""Called when manual plot refresh is requested.""" """Called when manual plot refresh is requested."""
self.logger.info("Manual plot refresh requested via GUI") 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): def on_metric_compute_started(self, file_path: str, metric_id: str):
"""Called when an off-thread metric compute starts.""" """Called when an off-thread metric compute starts."""
if file_path != self._current_file_path(): if file_path not in self._overlay_paths():
return # selection moved on; status bar shouldn't lie return # not in the drawn set; status bar shouldn't lie
from metrics import METRICS
metric = METRICS.get(metric_id) metric = METRICS.get(metric_id)
display = metric.display_name if metric else metric_id display = metric.display_name if metric else metric_id
self.visualization_widget.set_status(f"Computing {display}...") self.visualization_widget.set_status(f"Computing {display}...")
def on_metric_ready(self, file_path: str, metric_id: str): def on_metric_ready(self, file_path: str, metric_id: str):
"""Called when metric data is available (cached hit or async finish).""" """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(): if metric_id != self.plot_control.current_metric_id():
return # user already switched to a different metric return # user already switched to a different metric
figure = self.analysis_manager.get_metric_figure(file_path, metric_id) if file_path not in self._overlay_paths():
if figure: return # no longer part of the overlay set
self.visualization_widget.display_figure_direct(figure) self._refresh_view()
def on_metric_compute_error(self, file_path: str, metric_id: str, error_message: str): 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}") 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}") self.visualization_widget.set_status(f"Error computing {metric_id}: {error_message}")
def _current_file_path(self): def _current_file_path(self):
item = self.file_list.currentItem() item = self.file_list.currentItem()
return item.data(Qt.UserRole) if item else None return item.data(Qt.UserRole) if item else None
def _render_or_request(self, file_path): def _item_for_path(self, file_path):
"""Render the current metric from cache, or kick off async compute if missing. 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 def _row_index(self, file_path) -> int:
(e.g. font change on an empty session — defensive). 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: paths = self._overlay_paths()
return
metric_id = self.plot_control.current_metric_id() metric_id = self.plot_control.current_metric_id()
figure = self.analysis_manager.get_metric_figure(file_path, metric_id) view = self.plot_control.current_view_state()
if figure: metric = METRICS.get(metric_id)
self.visualization_widget.display_figure_direct(figure) if not paths or metric is None:
self.visualization_widget.show_specs([])
return return
# Not cached yet — try async compute if the file has been loaded.
if self.analysis_manager.is_file_analyzed(file_path): specs = []
self.analysis_manager.request_metric(file_path, metric_id) pending = 0
else: for path in paths:
# No AudioFile yet either; kick off a full analysis with this metric. data = self.analysis_manager.get_metric_data(path, metric_id)
self.analysis_manager.analyze_file(file_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(): def main():
+126 -240
View File
@@ -1,32 +1,35 @@
""" """
Pluggable analysis metrics. Pluggable analysis metrics.
A `Metric` knows how to compute a series from an `AudioFile` and how to render A `Metric` computes a backend-neutral data object from an `AudioFile` and then
that series into a matplotlib `Figure`. Compute is the heavy step (runs on the turns that data into a `PlotSpec` (declarative drawing intent). Compute is the
worker thread); render is cheap and reruns on font / refresh. 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. 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 from __future__ import annotations
import os
import warnings import warnings
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any
import numpy as np 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 librosa
import pyloudnorm as pyln import pyloudnorm as pyln
from scipy import signal as scipy_signal from scipy import signal as scipy_signal
from font_manager import safe_title
from master_core import AudioFile 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. # 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)) 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): class Metric(ABC):
"""A pluggable analysis metric.""" """A pluggable analysis metric."""
@@ -80,16 +51,22 @@ class Metric(ABC):
def compute(self, audio_file: AudioFile) -> Any: def compute(self, audio_file: AudioFile) -> Any:
"""Compute and return the metric's data from a loaded AudioFile. """Compute and return the metric's data from a loaded AudioFile.
The returned object is cached and later passed to `render`. This is the The returned object must be backend-neutral (numpy arrays + scalars). It is
heavy step and runs on the worker thread. cached and later passed to `build_spec`. Heavy; runs on the worker thread.
""" """
@abstractmethod @abstractmethod
def render(self, data: Any, file_path: str, figsize=(10, 4)) -> Figure: def build_spec(self, data: Any, view: ViewState = DEFAULT_VIEW) -> PlotSpec:
"""Render a Figure from precomputed data. Cheap; runs on the GUI thread.""" """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): class RMSPowerMetric(Metric):
"""Rolling RMS power as a filled area over time."""
id = "rms_power" id = "rms_power"
display_name = "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) audio_file.get_energy_levels_over_time(window=self.window, hop=self.hop)
return { return {
"times": audio_file.get_times(), "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"] times = data["times"]
rms_array = data["rms_array"] rms = data["rms"]
# Adaptive headroom: loud masters get a taller scale.
# Adaptive colour scale: bump headroom for loud masters. ymax = 0.6 if (rms.size and np.max(rms) > 0.3) else 0.3
maxpower = 0.6 if np.max(rms_array) > 0.3 else 0.3 return PlotSpec(
norm = mcolors.Normalize(vmin=0, vmax=maxpower) axes=AxisSpec(
cmap = cm.autumn x_label="Time (seconds)", y_label="Power",
y_range=(0.0, ymax),
fig = Figure(figsize=figsize, facecolor="white") x_range=(float(times[0]), float(times[-1])) if times.size else None,
ax = fig.add_subplot(111) ),
ax.set_ylim(0., maxpower) bands=[Band(x=times, lo=np.zeros_like(rms), hi=rms, label="RMS power")],
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
class WaveformMetric(Metric): class WaveformMetric(Metric):
@@ -157,38 +121,24 @@ class WaveformMetric(Metric):
times = (np.arange(self.target_columns) * chunk + chunk / 2) / sr times = (np.arange(self.target_columns) * chunk + chunk / 2) / sr
return {"times": times, "lo": lo, "hi": hi} 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"] times = data["times"]
lo = data["lo"] return PlotSpec(
hi = data["hi"] axes=AxisSpec(
x_label="Time (seconds)", y_label="Amplitude",
fig = Figure(figsize=figsize, facecolor="white") y_range=(-1.1, 1.1),
ax = fig.add_subplot(111) x_range=(float(times[0]), float(times[-1])) if times.size else None,
ax.fill_between(times, lo, hi, color="#3a7ad6", linewidth=0) ),
ax.axhline(0, color="black", linewidth=0.5, alpha=0.3) bands=[Band(x=times, lo=data["lo"], hi=data["hi"], label="Waveform")],
# 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
class LUFSMetric(Metric): class LUFSMetric(Metric):
"""ITU-R BS.1770 loudness: short-term (3 s) time series + integrated + LRA. """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.
"""
id = "lufs" id = "lufs"
display_name = "LUFS" display_name = "LUFS"
# Short-term as defined by EBU R128 / BS.1770: 3-second window.
WINDOW_S = 3.0 WINDOW_S = 3.0
HOP_S = 0.5 HOP_S = 0.5
SILENCE_FLOOR = -70.0 # BS.1770 absolute gate SILENCE_FLOOR = -70.0 # BS.1770 absolute gate
@@ -238,44 +188,33 @@ class LUFSMetric(Metric):
except (ValueError, FloatingPointError): except (ValueError, FloatingPointError):
return float("-inf") 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"] times = data["times"]
lufs = data["lufs"] lufs = data["lufs"]
integrated = data["integrated"] integrated = data["integrated"]
lra = data.get("lra", float("nan")) lra = data.get("lra", float("nan"))
fig = Figure(figsize=figsize, facecolor="white") hlines = [
ax = fig.add_subplot(111) HLine(y=-14.0, label="-14 LUFS (streaming target)", style="dot"),
ax.plot(times, lufs, color="#2a9d8f", linewidth=1.4, label="Short-term (3 s)") ]
annotations = []
if np.isfinite(integrated): if np.isfinite(integrated):
ax.axhline( hlines.append(HLine(y=integrated, label=f"Integrated: {integrated:.1f} LUFS",
integrated, color="#e76f51", linestyle="--", linewidth=1.5, color="#e76f51", style="dash", width=1.5))
label=f"Integrated: {integrated:.1f} LUFS",
)
if np.isfinite(lra): if np.isfinite(lra):
# Invisible plot entry to surface LRA in the legend without adding a line. annotations.append(f"LRA: {lra:.1f} LU")
ax.plot([], [], " ", label=f"LRA: {lra:.1f} LU")
# Streaming target reference (Spotify normalises to -14 LUFS). return PlotSpec(
ax.axhline(-14.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) axes=AxisSpec(
ax.text( x_label="Time (seconds)", y_label="LUFS",
times[-1], -14.0, " -14 LUFS (streaming target)", y_range=(-50.0, 0.0),
va="center", ha="left", fontsize=8, alpha=0.6, 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): class CrestFactorMetric(Metric):
"""Crest factor = 20*log10(peak / RMS) per sliding window, in dB.""" """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 times = (starts + window_n / 2.0) / sr
return {"times": times, "crest_db": crest_db} 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"] times = data["times"]
crest_db = data["crest_db"] return PlotSpec(
axes=AxisSpec(
fig = Figure(figsize=figsize, facecolor="white") x_label="Time (seconds)", y_label="Crest factor (dB)",
ax = fig.add_subplot(111) y_range=(0.0, 25.0),
ax.plot(times, crest_db, color="#e09f3e", linewidth=1.4, label=f"Crest factor (1 s)") x_range=(float(times[0]), float(times[-1])) if times.size else None,
),
# Rules of thumb: ~12 dB = roomy, ~6 dB = heavily limited. curves=[Curve(x=times, y=data["crest_db"], label="Crest factor (1 s)")],
ax.axhline(12.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) hlines=[
ax.text(times[-1], 12.0, " 12 dB", va="center", ha="left", fontsize=8, alpha=0.6) HLine(y=12.0, label="12 dB", style="dot"),
ax.axhline(6.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) HLine(y=6.0, label="6 dB (squashed)", style="dot"),
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
class PSRMetric(Metric): class PSRMetric(Metric):
"""Peak-to-Short-term LUFS Ratio (sample-peak variant), in LU. """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.
"""
id = "psr" id = "psr"
display_name = "PSR" display_name = "PSR"
@@ -390,39 +315,24 @@ class PSRMetric(Metric):
psr = np.where(valid, peaks_db - lufs_series, np.nan) psr = np.where(valid, peaks_db - lufs_series, np.nan)
return {"times": times, "psr": psr} 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"] times = data["times"]
psr = data["psr"] return PlotSpec(
axes=AxisSpec(
fig = Figure(figsize=figsize, facecolor="white") x_label="Time (seconds)", y_label="PSR (LU)",
ax = fig.add_subplot(111) y_range=(0.0, 25.0),
ax.plot(times, psr, color="#7251b5", linewidth=1.4, label="PSR (3 s)") x_range=(float(times[0]), float(times[-1])) if times.size else None,
),
# Ian Shepherd's rough thresholds. curves=[Curve(x=times, y=data["psr"], label="PSR (3 s)")],
ax.axhline(10.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) hlines=[
ax.text(times[-1], 10.0, " 10 LU (good punch)", va="center", ha="left", fontsize=8, alpha=0.6) HLine(y=10.0, label="10 LU (good punch)", style="dot"),
ax.axhline(4.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6) HLine(y=4.0, label="4 LU (squashed)", style="dot"),
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
class TruePeakMetric(Metric): class TruePeakMetric(Metric):
"""ITU-R BS.1770 true peak via 4x polyphase oversampling, in dBTP. """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.
"""
id = "true_peak" id = "true_peak"
display_name = "True Peak" display_name = "True Peak"
@@ -458,61 +368,42 @@ class TruePeakMetric(Metric):
integrated_tp_db = float(np.max(tp_db)) integrated_tp_db = float(np.max(tp_db))
return {"times": times, "tp_db": tp_db, "integrated_tp_db": integrated_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"] times = data["times"]
tp_db = data["tp_db"]
integrated = data.get("integrated_tp_db", float("nan")) integrated = data.get("integrated_tp_db", float("nan"))
annotations = []
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)
if np.isfinite(integrated): if np.isfinite(integrated):
ax.plot([], [], " ", label=f"Max: {integrated:.2f} dBTP") annotations.append(f"Max: {integrated:.2f} dBTP")
return PlotSpec(
ax.set_ylim(-30.0, 6.0) axes=AxisSpec(
ax.set_xlim(times[0], times[-1]) x_label="Time (seconds)", y_label="dBTP",
ax.set_ylabel("dBTP") y_range=(-30.0, 6.0),
ax.set_xlabel("Time (seconds)") x_range=(float(times[0]), float(times[-1])) if times.size else None,
ax.set_title(safe_title(os.path.basename(file_path))) ),
ax.grid(True, alpha=0.3) curves=[Curve(x=times, y=data["tp_db"], label="True Peak (250 ms)", width=1.0)],
ax.legend(loc="lower right", fontsize=8) hlines=[
_show_axis_extents(ax) HLine(y=0.0, label="0 dBTP (clip)", color="#000000", style="dash", width=1.0),
fig.tight_layout() HLine(y=-1.0, label="-1 dBTP (typical ceiling)", style="dot"),
return fig ],
annotations=annotations,
)
class SpectrogramMetric(Metric): class SpectrogramMetric(Metric):
"""Log-frequency STFT spectrogram: frequency power distribution over time. """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.
"""
id = "spectrogram" id = "spectrogram"
display_name = "Spectrogram" display_name = "Spectrogram"
N_FFT = 4096 # ~11 Hz bins at 44.1 kHz; keeps low-freq detail now N_FFT = 4096
# that sr is native (nyquist ~22 kHz, not 11 kHz) TARGET_COLUMNS = 4000
TARGET_COLUMNS = 4000 # cap on time bins, for render speed DB_FLOOR = -80.0
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
F_MIN = 20.0 # log axis can't show DC; clip the low edge here
def compute(self, audio_file: AudioFile): def compute(self, audio_file: AudioFile):
y = audio_file.y_mono.astype(np.float32, copy=False) y = audio_file.y_mono.astype(np.float32, copy=False)
sr = audio_file.sr 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 min_hop = self.N_FFT // 4
hop = max(min_hop, len(y) // self.TARGET_COLUMNS) 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 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 { return {
"freqs": freqs[1:], "freqs": freqs[1:],
"times": times, "times": times,
@@ -533,29 +424,24 @@ class SpectrogramMetric(Metric):
"nyquist": sr / 2.0, "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"] freqs = data["freqs"]
times = data["times"] times = data["times"]
s_db = data["s_db"]
nyquist = data["nyquist"] nyquist = data["nyquist"]
y_log = view.resolve_y_log(default=True) # log frequency by default
fig = Figure(figsize=figsize, facecolor="white") return PlotSpec(
ax = fig.add_subplot(111) axes=AxisSpec(
mesh = ax.pcolormesh( x_label="Time (seconds)", y_label="Frequency (Hz)",
times, freqs, s_db, y_log=y_log, y_log_allowed=True,
cmap="magma", vmin=self.DB_FLOOR, vmax=0.0, shading="auto", 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] = { METRICS: dict[str, Metric] = {
+33 -1
View File
@@ -8,17 +8,26 @@ next to each other in the left panel.
import logging import logging
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QGroupBox, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QGroupBox,
QCheckBox,
) )
from PyQt5.QtCore import pyqtSignal from PyQt5.QtCore import pyqtSignal
from metrics import METRICS, DEFAULT_METRIC_ID from metrics import METRICS, DEFAULT_METRIC_ID
from plotspec import ViewState
class PlotControlWidget(QWidget): 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 metricChanged = pyqtSignal(str) # metric_id
viewChanged = pyqtSignal() # view-state (scale) changed
plotRefreshRequested = pyqtSignal() plotRefreshRequested = pyqtSignal()
addReferenceLineRequested = pyqtSignal()
clearReferenceLinesRequested = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -42,6 +51,26 @@ class PlotControlWidget(QWidget):
self.metric_combo.currentIndexChanged.connect(self._on_metric_changed) self.metric_combo.currentIndexChanged.connect(self._on_metric_changed)
group_layout.addWidget(self.metric_combo) 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() button_row = QHBoxLayout()
self.refresh_button = QPushButton("Refresh Plot") self.refresh_button = QPushButton("Refresh Plot")
self.refresh_button.setToolTip("Re-render the current plot with current settings") 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: def current_metric_id(self) -> str:
return self.metric_combo.currentData() or DEFAULT_METRIC_ID 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 # 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
# versions are Linux/macOS only. # versions are Linux/macOS only.
"PyQt5-Qt5==5.15.2 ; sys_platform == 'win32'", "PyQt5-Qt5==5.15.2 ; sys_platform == 'win32'",
"pyqtgraph>=0.14.0",
] ]
[project.scripts] [project.scripts]
@@ -28,6 +29,7 @@ py-modules = [
"audio_visualization_widget", "audio_visualization_widget",
"master_core", "master_core",
"metrics", "metrics",
"plotspec",
"font_manager", "font_manager",
"font_control_widget", "font_control_widget",
"plot_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" }, { 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]] [[package]]
name = "contourpy" name = "contourpy"
version = "1.3.2" 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" }, { 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]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -1660,6 +1682,7 @@ dependencies = [
{ name = "pyloudnorm" }, { name = "pyloudnorm" },
{ name = "pyqt5" }, { name = "pyqt5" },
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'" }, { name = "pyqt5-qt5", marker = "sys_platform == 'win32'" },
{ name = "pyqtgraph" },
] ]
[package.metadata] [package.metadata]
@@ -1671,6 +1694,7 @@ requires-dist = [
{ name = "pyloudnorm" }, { name = "pyloudnorm" },
{ name = "pyqt5", specifier = ">=5.15.10" }, { name = "pyqt5", specifier = ">=5.15.10" },
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" }, { name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" },
{ name = "pyqtgraph", specifier = ">=0.14.0" },
] ]
[[package]] [[package]]