Merge pull request 'Refactor/interactive plotting' (#1) from refactor/interactive-plotting into main

Reviewed-on: http://novoyuuparosk.org:1551/mikkeli/uj-mastering-master/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-13 16:17:21 +00:00
12 changed files with 1196 additions and 750 deletions
+81 -30
View File
@@ -9,12 +9,14 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- **Pluggable Metrics**: Switchable visualizations (RMS Power, Waveform, LUFS, Crest Factor, PSR, True Peak, Spectrogram; DR next) via a `Metric` ABC - **Pluggable Metrics**: Switchable visualizations (RMS Power, Waveform, LUFS, Crest Factor, PSR, True Peak, Spectrogram; DR next) via a `Metric` ABC
- **Metadata Extraction**: Reads ID3 tags from MP3 files for better file identification - **Metadata Extraction**: Reads ID3 tags from MP3 files for better file identification
- **Modular GUI Architecture**: Complete PyQt5 interface with drag-and-drop and file dialog support - **Modular GUI Architecture**: Complete PyQt5 interface with drag-and-drop and file dialog support
- **Font Management**: Comprehensive CJK-compatible font system with user-provided font support - **Font Management**: CJK-capable, fixed UI font (M PLUS 1 Code @ 10pt) with system fallback
- **Threading & Logging**: Robust background processing with detailed logging system - **Threading & Logging**: Robust background processing with detailed logging system
### 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,22 +36,49 @@ 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`
#### `font_control_widget.py` & `font_manager.py` #### `plotspec.py`
- Unified font control system with clustered interface - Backend-agnostic drawing descriptors: `Curve`, `Band`, `HLine`, `Heatmap`,
- Auto-detection of custom fonts from `fonts/` directory `AxisSpec`, `PlotSpec`, plus the `ViewState` (recompute-free lin/log options)
- System font discovery and CJK compatibility - The seam that decouples metrics from the plotting library: metrics emit
- Font changes trigger a cheap re-render of the cached metric data *intent*, the renderer owns colour/layout/library specifics
#### `font_manager.py`
- Auto-detection of custom fonts from `fonts/` directory; CJK fallbacks
- `apply_fixed_font(family, size)` locks the Qt app font (used at startup to pin
the UI to **M PLUS 1 Code @ 10pt**, falling back to the system default if the
family isn't found). There is no runtime font picker — the old
`font_control_widget.py` was removed as wasted panel space
- pyqtgraph and the Qt widgets both read the app font, so this covers the plot
too (M PLUS 1 Code has full Japanese coverage, so titles stay CJK-safe)
#### `plot_control_widget.py` #### `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 and a time-axis mode selector — Absolute (seconds) vs
Relative (% of each track's own length) — both view-state, recompute-free
- `Refresh Plot` button. Compare/overlay membership is the file-list checkboxes;
reference lines have their own cluster
#### `ref_line_widget.py`
- `RefLineControlWidget`: side-panel list of custom reference lines with
Add / Edit… / Remove / Clear; a pure view over the `RefLineProps` list the
main window owns, emitting intents
- `RefLineDialog`: edits one line's value, colour, line style, and tag
- The plot draws each line with a triangle drag-handle; dragging writes the new
value back into the shared `RefLineProps` and refreshes the list
#### `metrics.py` #### `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 +87,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 +118,23 @@ 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
- **Font control**: Unified font selector with size control - **Compare/overlay**: each analysed file has a checkbox; the ticked set is
- **Plot control**: Metric selector + refresh-plot button 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
- **Time-axis mode**: a Relative-time toggle — off = seconds, on = 0-100% of each
track's own length, so tracks of very different durations line up by position
- **Custom reference lines**: side-panel list (Add/Edit/Remove/Clear) of draggable
horizontal markers with value/colour/style/tag; dragged via a triangle handle.
Kept **per metric** (so switching metrics doesn't lose them) and expressed in
the metric's own units — on the spectrogram they read and edit in **Hz** (the
renderer converts Hz<->row index, since the heatmap y-axis is a row index)
- **Plot control**: Metric selector + log-frequency toggle + relative-time toggle
+ refresh-plot button
- **Analysis display**: Real-time visualization with metadata panels - **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 +151,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 +166,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 +203,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)
+361 -54
View File
@@ -1,74 +1,381 @@
""" """
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).
- Reference lines (`set_reference_lines`) are draggable via a triangle handle,
survive redraws, and write their position back into the GUI-owned RefLineProps;
the GUI clears them when the metric changes (units change).
Why the spectrogram is special: pyqtgraph's ImageItem is affine-only, so it does
not follow a log-scaled axis. Log frequency is therefore realised by resampling
the STFT rows onto a log-spaced grid and labelling the axis by row index — see
`_render_heatmap`.
""" """
import numpy as np
import pyqtgraph as pg
from scipy.interpolate import interp1d
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from PyQt5.QtCore import Qt, pyqtSignal
from matplotlib.figure import Figure
from plotspec import PlotSpec, ViewState, DEFAULT_VIEW, RefLineProps
# White canvas / black ink to match the previous matplotlib aesthetic.
pg.setConfigOption("background", "w")
pg.setConfigOption("foreground", "k")
pg.setConfigOptions(antialias=True)
# Dataset colour cycle for overlay. First colour is the single-dataset default.
_PALETTE = [
"#3a7ad6", "#e76f51", "#2a9d8f", "#e09f3e",
"#7251b5", "#c1121f", "#588157", "#9d4edd",
]
# Pen styles for reference lines.
_PEN_STYLE = {"solid": Qt.SolidLine, "dash": Qt.DashLine, "dot": Qt.DotLine}
# "Nice" frequencies to label on a log frequency axis, in Hz.
_LOG_FREQ_TICKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
def dataset_color(index: int) -> str:
"""Stable dataset colour for a given index (e.g. a file's row in the list)."""
return _PALETTE[index % len(_PALETTE)]
def _colormap(name: str):
"""Fetch a colormap, preferring matplotlib's so 'magma' etc. resolve."""
try:
return pg.colormap.getFromMatplotlib(name)
except Exception:
return pg.colormap.get(name)
def _fmt_hz(hz: float) -> str:
return f"{hz / 1000:.0f}k" if hz >= 1000 else f"{hz:.0f}"
class _AxisZoomViewBox(pg.ViewBox):
"""ViewBox whose wheel zoom can be constrained to one axis via a modifier.
Plain scroll keeps pyqtgraph's both-axes zoom; Ctrl constrains to x (time),
Shift constrains to y (the metric's value axis). This answers the "scroll
zooms both axes, I want one" problem without taking away the default.
"""
def wheelEvent(self, ev, axis=None):
mods = ev.modifiers()
if mods & Qt.ControlModifier:
axis = 0 # x only
elif mods & Qt.ShiftModifier:
axis = 1 # y only
super().wheelEvent(ev, axis=axis)
class _RefLine(pg.InfiniteLine):
"""A draggable horizontal reference line bound to a RefLineProps.
`props.value` is in the metric's natural units (LUFS, dB, ... or Hz for the
spectrogram). The drawn y-position may differ from that value — the spectrogram
maps frequency to a row index — so `to_pos`/`from_pos` convert between the two.
For curve metrics these are identity. The label and the value written back on
drag are always in natural units.
"""
def __init__(self, index, props: RefLineProps, to_pos, from_pos, fmt, on_moved):
pen = pg.mkPen(props.color, width=1.4,
style=_PEN_STYLE.get(props.style, Qt.DashLine))
super().__init__(
pos=to_pos(props.value), angle=0, movable=True, pen=pen,
label="",
labelOpts={"position": 0.06, "color": props.color,
"fill": (255, 255, 255, 180)},
)
self._index = index
self._props = props
self._from_pos = from_pos
self._fmt = fmt
self._on_moved = on_moved
self.addMarker("|>", position=0.0, size=12) # triangle handle at the start
self._update_label()
self.sigPositionChanged.connect(self._update_label)
self.sigPositionChangeFinished.connect(self._commit)
def _update_label(self):
val = self._from_pos(self.value())
self.label.setFormat(self._props.label or self._fmt(val))
def _commit(self):
self._props.value = float(self._from_pos(self.value()))
self._on_moved(self._index)
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): # Emitted (with the line's index) when a reference line is dragged, so the
super().__init__(parent) # side-panel list can refresh its displayed value.
self.initUI() referenceLineMoved = pyqtSignal(int)
def initUI(self): def __init__(self, parent=None):
"""Initialize the UI components.""" super().__init__(parent)
layout = QVBoxLayout() layout = QVBoxLayout(self)
# Create matplotlib canvas self.glw = pg.GraphicsLayoutWidget()
self.figure = Figure(figsize=(10, 4), facecolor='white') self.plot = self.glw.addPlot(row=0, col=0, viewBox=_AxisZoomViewBox())
self.canvas = FigureCanvas(self.figure) 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)
# Add canvas to layout self.status_label = QLabel("Ready for audio analysis...")
layout.addWidget(self.canvas) layout.addWidget(self.status_label)
# Status label for feedback self._colorbar = None
self.status_label = QLabel("Ready for audio analysis...") # Reference lines are owned by the GUI controller (RefLineProps objects) and
layout.addWidget(self.status_label) # passed in via set_reference_lines; the line items are rebuilt each render.
self._ref_props: list[RefLineProps] = []
self._ref_lines: list[_RefLine] = []
# value<->drawn-position transforms for ref lines (identity for curve metrics;
# frequency<->row-index for the spectrogram). Reset each render.
self._ref_to_pos = lambda v: v
self._ref_from_pos = lambda p: p
self._ref_fmt = lambda v: f"{v:.2f}"
self._show_empty()
self.setLayout(layout) # ---- public API ---------------------------------------------------------
# Initialize with empty plot def show_specs(self, specs, view: ViewState = DEFAULT_VIEW):
self._create_empty_plot() """Render datasets onto the shared axes.
def _create_empty_plot(self): `specs` is a list of `(label, PlotSpec)` or `(label, PlotSpec, color)`. When
"""Creates an empty placeholder plot.""" no colour is given, the dataset's palette colour by position is used. All
self.figure.clear() specs are assumed to be the same metric (compare overlays one metric across
ax = self.figure.add_subplot(111) files), so axis labels/ranges come from the first spec.
ax.text(0.5, 0.5, 'Drop an audio file to see analysis', """
ha='center', va='center', transform=ax.transAxes, self._reset_plot()
fontsize=14, alpha=0.7) if not specs:
ax.set_xlim(0, 1) self._show_empty()
ax.set_ylim(0, 1) return
ax.set_xticks([])
ax.set_yticks([])
self.canvas.draw()
def display_figure_direct(self, figure): specs = [self._normalise(s, i) for i, s in enumerate(specs)]
""" base_axes = specs[0][1].axes
Display a figure by replacing our canvas figure entirely.
More reliable than copying elements.
Args: # Heatmaps do not overlay: render only the first dataset's heatmap.
figure: matplotlib.figure.Figure to display if specs[0][1].is_heatmap:
""" label, spec, _ = specs[0]
# Remove old canvas self._render_heatmap(spec, view)
layout = self.layout() if len(specs) > 1:
layout.removeWidget(self.canvas) self.set_status(f"{spec.title or label}: spectrogram shows one track at a time")
self.canvas.deleteLater() self._apply_axes(base_axes, single=True, log_y_image_handled=True)
self._draw_ref_lines()
return
# Create new canvas with the provided figure single = len(specs) == 1
self.figure = figure for label, spec, color in specs:
self.canvas = FigureCanvas(self.figure) prefix = "" if single else f"{label}: "
layout.insertWidget(0, self.canvas) # Insert at position 0 (before status label) self._render_curves_and_bands(spec, color, prefix, single=single)
self.canvas.draw() # Reference lines from the first spec only (identical across same-metric specs).
self.status_label.setText("Analysis complete - displaying power graph") for hl in specs[0][1].hlines:
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, single=single)
self._draw_ref_lines()
def set_reference_lines(self, props: list[RefLineProps]):
"""Set the reference-line set (RefLineProps owned by the GUI) and redraw them."""
self._ref_props = props
self._draw_ref_lines()
def current_view_center_value(self) -> float:
"""Natural-unit value at the current y-view centre — default for a new line.
Runs through `from_pos`, so on the spectrogram this returns a frequency, not
a row index.
"""
(_, _), (y0, y1) = self.plot.viewRange()
return float(self._ref_from_pos((y0 + y1) / 2.0))
def set_status(self, message: str):
self.status_label.setText(message)
# ---- rendering helpers --------------------------------------------------
def _normalise(self, spec_tuple, index: int):
"""Coerce a spec tuple to (label, PlotSpec, color), filling colour by index."""
if len(spec_tuple) == 3:
return spec_tuple
label, spec = spec_tuple
return label, spec, dataset_color(index)
def _render_curves_and_bands(self, spec: PlotSpec, color: str, prefix: str, single: bool):
for band in spec.bands:
lo = np.ascontiguousarray(np.broadcast_to(band.lo, band.x.shape), dtype=float)
hi = np.ascontiguousarray(np.broadcast_to(band.hi, band.x.shape), dtype=float)
# FillBetweenItem fills nothing if its child curves have no pen — give them
# a thin outline in the dataset colour (this is the RMS/Waveform fix).
edge = pg.mkPen(color, width=1.0)
c_lo = pg.PlotDataItem(band.x, lo, pen=edge)
c_hi = pg.PlotDataItem(band.x, hi, pen=edge)
self.plot.addItem(c_lo)
self.plot.addItem(c_hi)
# Build the colour with alpha up front: QBrush.color() returns a copy, so
# mutating its alpha after mkBrush would be a no-op (opaque overlay bug).
fill_color = pg.mkColor(color)
fill_color.setAlpha(200 if single else 90)
fill = pg.FillBetweenItem(c_lo, c_hi, brush=pg.mkBrush(fill_color))
self.plot.addItem(fill)
if band.label:
self._legend_swatch(prefix + band.label, color)
for curve in spec.curves:
pen = pg.mkPen(curve.color or color, width=curve.width)
item = self.plot.plot(curve.x, curve.y, pen=pen,
name=(prefix + curve.label) if curve.label else None,
connect="finite") # gaps at NaN (gated PSR)
item.setDownsampling(auto=True) # keep big series smooth under zoom
item.setClipToView(True)
def _render_hline(self, hl):
pen = pg.mkPen(hl.color, width=hl.width, style=_PEN_STYLE.get(hl.style, Qt.DotLine))
line = pg.InfiniteLine(
pos=hl.y, angle=0, pen=pen, movable=False,
label=hl.label or None,
labelOpts={"position": 0.95, "color": hl.color, "fill": (255, 255, 255, 150)},
)
self.plot.addItem(line)
def _render_heatmap(self, spec: PlotSpec, view: ViewState):
hm = spec.heatmap
t0, t1 = float(hm.x[0]), float(hm.x[-1])
f_lo = max(spec.axes.y_range[0] if spec.axes.y_range else hm.y[0], hm.y[0])
f_hi = spec.axes.y_range[1] if spec.axes.y_range else hm.y[-1]
y_log = view.resolve_y_log(default=spec.axes.y_log)
n_rows = len(hm.y)
if y_log:
f_grid = np.logspace(np.log10(max(f_lo, 1e-6)), np.log10(f_hi), n_rows)
else:
f_grid = np.linspace(f_lo, f_hi, n_rows)
# Resample every time column from native linear freq bins onto f_grid in one
# vectorised pass — this runs on each redraw and lin/log toggle, so the loop
# version would make the toggle feel laggy on long files.
interp = interp1d(hm.y, hm.z, axis=0, bounds_error=False,
fill_value=(hm.z[0], hm.z[-1]), assume_sorted=True)
z_grid = interp(f_grid).astype(np.float32)
img = pg.ImageItem()
img.setImage(z_grid.T, autoLevels=False) # ImageItem wants (x, y) -> transpose
img.setLevels((hm.z_min, hm.z_max))
img.setColorMap(_colormap(hm.cmap))
# Map image pixel space (time cols, freq rows) to data coords: x=time, y=row index.
img.setRect(pg.QtCore.QRectF(t0, 0.0, t1 - t0, float(n_rows)))
self.plot.addItem(img)
# Reference lines on the spectrogram are entered/shown in Hz but drawn at a
# row index — install the frequency<->row transforms for this f_grid.
rows = np.arange(n_rows)
self._ref_to_pos = lambda hz, fg=f_grid, r=rows: float(np.interp(hz, fg, r))
self._ref_from_pos = lambda pos, fg=f_grid, r=rows: float(np.interp(pos, r, fg))
self._ref_fmt = lambda v: f"{v:.0f} Hz"
# Label the row-index y-axis with real frequencies.
ticks = []
for hz in _LOG_FREQ_TICKS:
if f_lo <= hz <= f_hi:
row = float(np.searchsorted(f_grid, hz))
ticks.append((row, _fmt_hz(hz)))
self.plot.getAxis("left").setTicks([ticks])
self.plot.setYRange(0, n_rows, padding=0)
self.plot.setXRange(t0, t1, padding=0)
# Place the colourbar at a fixed layout cell and link it to the image. We
# add/remove it ourselves (rather than insert_in=) so it can't stack across
# repeated spectrogram renders.
self._colorbar = pg.ColorBarItem(values=(hm.z_min, hm.z_max),
colorMap=_colormap(hm.cmap), label=hm.label)
self._colorbar.setImageItem(img)
self.glw.addItem(self._colorbar, row=0, col=1)
def _apply_axes(self, axes, single: bool, log_y_image_handled: bool = False):
self.plot.setLabel("bottom", axes.x_label)
self.plot.setLabel("left", axes.y_label)
# Frame x exactly only for a single dataset; overlaid tracks of different
# lengths (absolute mode) should autorange to their union rather than clip to
# the first one's span. In relative mode every spec is 0-100, so either works.
if axes.x_range and single:
self.plot.setXRange(*axes.x_range, padding=0)
elif not single:
self.plot.enableAutoRange(axis=pg.ViewBox.XAxis)
if axes.y_range and not log_y_image_handled:
self.plot.setYRange(*axes.y_range, padding=0)
if not log_y_image_handled:
# Curve metrics: honour log mode if a spec ever opts in (none do today).
self.plot.setLogMode(x=axes.x_log, y=axes.y_log)
# ---- user reference lines -----------------------------------------------
def _draw_ref_lines(self):
"""(Re)create draggable lines from the current RefLineProps set."""
self._remove_ref_line_items()
for idx, props in enumerate(self._ref_props):
line = _RefLine(idx, props, self._ref_to_pos, self._ref_from_pos,
self._ref_fmt, on_moved=self.referenceLineMoved.emit)
self.plot.addItem(line)
self._ref_lines.append(line)
def _remove_ref_line_items(self):
for line in self._ref_lines:
self.plot.removeItem(line)
self._ref_lines.clear()
# ---- legend / lifecycle -------------------------------------------------
def _legend_swatch(self, name: str, color: str):
self.legend.addItem(pg.PlotDataItem(pen=pg.mkPen(color, width=3)), name)
def _legend_note(self, text: str):
self.legend.addItem(pg.PlotDataItem(pen=None), text)
def _reset_plot(self):
self._remove_ref_line_items() # cleared from scene; props persist for redraw
self.plot.clear()
if self._colorbar is not None:
try:
self.glw.removeItem(self._colorbar)
except Exception:
pass
self._colorbar = None
self.legend.clear()
self.plot.getAxis("left").setTicks(None) # drop heatmap freq ticks
self.plot.setLogMode(x=False, y=False)
# Back to identity; the heatmap path reinstalls Hz<->row if needed.
self._ref_to_pos = lambda v: v
self._ref_from_pos = lambda p: p
self._ref_fmt = lambda v: f"{v:.2f}"
def _show_empty(self):
text = pg.TextItem("Drop an audio file to see analysis", anchor=(0.5, 0.5),
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...")
-311
View File
@@ -1,311 +0,0 @@
"""
Unified font control widget clustering all font-related manipulators.
Self-contained widget for easy layout management.
"""
import logging
from typing import List, Dict, Optional
from PyQt5.QtWidgets import (QWidget, QComboBox, QVBoxLayout, QHBoxLayout,
QLabel, QSlider, QGroupBox)
from PyQt5.QtCore import pyqtSignal, Qt
from font_manager import get_font_manager
class FontControlWidget(QWidget):
"""
Unified font control widget containing all font-related manipulators.
Provides clustered interface for:
- Font selection (unified for Qt and matplotlib)
- Qt font size adjustment
"""
# Signals
fontChanged = pyqtSignal(str, str) # (font_name, font_type)
fontSizeChanged = pyqtSignal(int) # font_size
def __init__(self, parent=None):
"""Initialize the font control widget."""
super().__init__(parent)
self.logger = logging.getLogger(__name__)
self.font_manager = get_font_manager()
self.available_fonts: Dict[str, str] = {} # display_name -> actual_font_name
# Font size bounds (reasonable range for GUI fonts)
self.min_font_size = 9
self.max_font_size = 12
self.default_font_size = 10
self.initUI()
self.refresh_font_list()
def initUI(self):
"""Initialize the user interface."""
# Main layout
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5) # Minimal margins
# Group box for visual clustering
group_box = QGroupBox("Font Settings")
group_layout = QVBoxLayout(group_box)
# Font selector section
font_section = self._create_font_selector_section()
group_layout.addWidget(font_section)
# Font size section
size_section = self._create_font_size_section()
group_layout.addWidget(size_section)
layout.addWidget(group_box)
def _create_font_selector_section(self) -> QWidget:
"""Create the font selector section."""
section = QWidget()
layout = QVBoxLayout(section)
layout.setContentsMargins(0, 0, 0, 0)
# Font label and dropdown
font_label = QLabel("Font:")
layout.addWidget(font_label)
self.font_combo = QComboBox()
self.font_combo.currentTextChanged.connect(self.on_font_changed)
layout.addWidget(self.font_combo)
return section
def _create_font_size_section(self) -> QWidget:
"""Create the font size control section."""
section = QWidget()
layout = QVBoxLayout(section)
layout.setContentsMargins(0, 0, 0, 0)
# Size label
self.size_label = QLabel(f"Qt Font Size: {self.default_font_size}pt")
layout.addWidget(self.size_label)
# Size slider with value labels
slider_layout = QHBoxLayout()
# Min label
min_label = QLabel(str(self.min_font_size))
min_label.setFixedWidth(20)
slider_layout.addWidget(min_label)
# Slider
self.size_slider = QSlider(Qt.Horizontal)
self.size_slider.setMinimum(self.min_font_size)
self.size_slider.setMaximum(self.max_font_size)
self.size_slider.setValue(self.default_font_size)
self.size_slider.setTickPosition(QSlider.TicksBelow)
self.size_slider.setTickInterval(2)
self.size_slider.valueChanged.connect(self.on_font_size_changed)
slider_layout.addWidget(self.size_slider)
# Max label
max_label = QLabel(str(self.max_font_size))
max_label.setFixedWidth(20)
slider_layout.addWidget(max_label)
layout.addLayout(slider_layout)
return section
def refresh_font_list(self):
"""Refresh the list of available fonts."""
self.logger.debug("Refreshing font list...")
self.available_fonts.clear()
try:
# Get custom fonts from font manager
custom_fonts = self.font_manager.loaded_fonts
# Get system font candidates
system_fonts = self.font_manager.get_available_system_fonts()[:10]
# Clear combo box
self.font_combo.clear()
# Add custom fonts first (highest priority)
if custom_fonts:
for font_family, font_path in custom_fonts.items():
display_name = f"{font_family} (Custom)"
self.available_fonts[display_name] = font_family
self.font_combo.addItem(display_name)
self.logger.debug(f"Added custom font: {display_name}")
# Add system fonts
for font_name in system_fonts:
display_name = f"{font_name} (System)"
self.available_fonts[display_name] = font_name
self.font_combo.addItem(display_name)
self.logger.debug(f"Added system font: {display_name}")
# Add default option with actual system font name
system_font_name = self.font_manager.get_default_system_font_name()
default_name = f"Default ({system_font_name})"
self.available_fonts[default_name] = "default"
self.font_combo.addItem(default_name)
# Select startup font
self._select_startup_font()
self.logger.info(f"Font list refreshed: {len(self.available_fonts)} fonts available")
except Exception as e:
self.logger.error(f"Error refreshing font list: {e}")
# Fallback: add default option only
self.font_combo.clear()
self.font_combo.addItem("Default (System)")
self.available_fonts = {"Default (System)": "default"}
def _select_startup_font(self):
"""Select the appropriate font on startup."""
# Use font manager's startup font selection logic
startup_font_name, startup_font_type = self.font_manager.select_startup_font()
# Find the corresponding display name in our combo box
target_display_name = None
for display_name, actual_name in self.available_fonts.items():
if actual_name == startup_font_name:
target_display_name = display_name
break
# If we found the font, select it
if target_display_name:
index = self.font_combo.findText(target_display_name)
if index >= 0:
self.font_combo.setCurrentIndex(index)
self.logger.info(f"Startup font selected: {target_display_name}")
return
# Fallback to default if we couldn't find the startup font
for i in range(self.font_combo.count()):
item_text = self.font_combo.itemText(i)
if item_text.startswith("Default ("):
self.font_combo.setCurrentIndex(i)
self.logger.info(f"Startup font fallback: {item_text}")
return
def on_font_changed(self, display_name: str):
"""Handle font selection change."""
if not display_name or display_name not in self.available_fonts:
return
actual_font_name = self.available_fonts[display_name]
# Determine font type
if "(Custom)" in display_name:
font_type = "custom"
elif "(System)" in display_name:
font_type = "system"
else:
font_type = "default"
self.logger.info(f"Font changed: {display_name} -> {actual_font_name} ({font_type})")
# Apply the font change
self._apply_font_change(actual_font_name, font_type)
# Emit signal for external listeners (including auto-regeneration)
self.fontChanged.emit(actual_font_name, font_type)
def on_font_size_changed(self, size: int):
"""Handle font size change."""
self.size_label.setText(f"Qt Font Size: {size}pt")
self.logger.info(f"Qt font size changed: {size}pt")
# Apply Qt font size change
self._apply_font_size_change(size)
# Emit signal for external listeners
self.fontSizeChanged.emit(size)
def _apply_font_change(self, font_name: str, font_type: str):
"""Apply the font change to both Qt and matplotlib."""
try:
# Use font manager's centralized font application
success = self.font_manager.apply_font_selection(font_name, font_type)
if not success:
self.logger.warning(f"Font application may have failed: {font_name}")
except Exception as e:
self.logger.error(f"Error applying font change: {e}")
def _apply_font_size_change(self, size: int):
"""Apply Qt font size change."""
try:
from PyQt5.QtCore import QCoreApplication
from PyQt5.QtGui import QFont
app = QCoreApplication.instance()
if app:
current_font = app.font()
current_font.setPointSize(size)
app.setFont(current_font)
self.logger.debug(f"Qt font size set to: {size}pt")
except Exception as e:
self.logger.error(f"Error applying font size change: {e}")
def get_current_font(self) -> tuple[str, str]:
"""
Get currently selected font.
Returns:
tuple: (font_name, font_type)
"""
display_name = self.font_combo.currentText()
if display_name in self.available_fonts:
actual_font_name = self.available_fonts[display_name]
if "(Custom)" in display_name:
font_type = "custom"
elif "(System)" in display_name:
font_type = "system"
else:
font_type = "default"
return actual_font_name, font_type
return "default", "default"
def get_current_font_size(self) -> int:
"""
Get current Qt font size.
Returns:
int: Current font size in points
"""
return self.size_slider.value()
def set_font(self, font_name: str):
"""
Programmatically set the font selection.
Args:
font_name: Name of font to select
"""
# Find matching display name
for display_name, actual_name in self.available_fonts.items():
if actual_name == font_name:
index = self.font_combo.findText(display_name)
if index >= 0:
self.font_combo.setCurrentIndex(index)
return
self.logger.warning(f"Font not found in selector: {font_name}")
def set_font_size(self, size: int):
"""
Programmatically set the font size.
Args:
size: Font size in points (will be clamped to valid range)
"""
clamped_size = max(self.min_font_size, min(self.max_font_size, size))
self.size_slider.setValue(clamped_size)
if clamped_size != size:
self.logger.warning(f"Font size clamped: {size} -> {clamped_size}")
+31
View File
@@ -501,3 +501,34 @@ def safe_title(title: str) -> str:
str: Safe title for display str: Safe title for display
""" """
return get_font_manager().get_cjk_safe_title(title) return get_font_manager().get_cjk_safe_title(title)
def apply_fixed_font(family: str = "M PLUS 1 Code", size: int = 10) -> str:
"""Lock the Qt application font to `family` at `size`pt.
Falls back to the system default family if `family` isn't available (loaded
from fonts/ or installed). pyqtgraph and the Qt widgets both read the app
font, so this is all the plot/UI need. Returns the family actually used.
"""
logger = logging.getLogger(__name__)
app = QCoreApplication.instance()
if app is None:
logger.warning("apply_fixed_font called before QApplication exists")
return family
try:
available = family in set(QFontDatabase().families())
except Exception:
available = False
if available:
font = QFont(family)
chosen = family
else:
font = QFont() # system default family
chosen = font.defaultFamily()
logger.info(f"Font '{family}' not found; using system default '{chosen}'")
font.setPointSize(size)
app.setFont(font)
logger.info(f"Application font locked to '{chosen}' at {size}pt")
return chosen
+171 -83
View File
@@ -6,12 +6,14 @@ 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, apply_fixed_font
from font_control_widget import FontControlWidget
from plot_control_widget import PlotControlWidget from plot_control_widget import PlotControlWidget
from ref_line_widget import RefLineControlWidget, RefLineDialog
from metrics import METRICS
from plotspec import RefLineProps, apply_x_mode
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -21,8 +23,14 @@ 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
# Reference lines are kept per metric (a -14 LUFS line means nothing on a
# spectrogram), so they persist when you switch metrics and come back.
self.ref_lines_by_metric: dict[str, list[RefLineProps]] = {}
self.initUI() self.initUI()
self.connect_signals() self.connect_signals()
self._activate_ref_lines()
def initUI(self): def initUI(self):
"""Initialize the user interface.""" """Initialize the user interface."""
@@ -60,24 +68,29 @@ class MainWindow(QMainWindow):
self.open_file_button.clicked.connect(self.open_file_dialog) self.open_file_button.clicked.connect(self.open_file_dialog)
layout.addWidget(self.open_file_button) layout.addWidget(self.open_file_button)
# Font control cluster # Plot control cluster (metric selector + scale toggle + refresh)
self.font_control = FontControlWidget()
self.font_control.fontChanged.connect(self.on_font_changed)
self.font_control.fontSizeChanged.connect(self.on_font_size_changed)
layout.addWidget(self.font_control)
# Plot control cluster (metric selector + 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)
layout.addWidget(self.plot_control) layout.addWidget(self.plot_control)
# File list # Reference-line management cluster (list + add/edit/remove/clear).
self.file_list_label = QLabel("Analyzed Files:") self.ref_line_control = RefLineControlWidget()
self.ref_line_control.addRequested.connect(self.on_add_reference_line)
self.ref_line_control.editRequested.connect(self.on_edit_reference_line)
self.ref_line_control.removeRequested.connect(self.on_remove_reference_line)
self.ref_line_control.clearRequested.connect(self.on_clear_reference_lines)
layout.addWidget(self.ref_line_control)
# File list. Each item carries a checkbox: the checked set is the overlay
# set drawn on the graph; the highlighted item drives the metadata panel.
self.file_list_label = QLabel("Analyzed Files (tick to overlay):")
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
@@ -108,6 +121,7 @@ class MainWindow(QMainWindow):
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started) self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
self.analysis_manager.metricReady.connect(self.on_metric_ready) self.analysis_manager.metricReady.connect(self.on_metric_ready)
self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error) self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error)
self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved)
def dragEnterEvent(self, event): def dragEnterEvent(self, event):
"""Handle drag enter event for file drops.""" """Handle drag enter event for file drops."""
@@ -160,27 +174,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 +204,167 @@ 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
# Render the currently-selected metric (cached, or async-compute it) self._refresh_view()
self._render_or_request(file_path)
def on_font_changed(self, font_name: str, font_type: str):
"""Called when font selection changes."""
self.logger.info(f"Font changed via GUI: {font_name} ({font_type})")
# Cheap re-render — cached metric data, redraws under the new font.
self._render_or_request(self._current_file_path())
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
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()) # Reference lines are kept per metric, so swap in this metric's set rather
# than discarding — switch away and back and your lines are still there.
self._activate_ref_lines()
self._refresh_view()
def on_add_reference_line(self):
"""Add a reference line at the current view centre, then edit it."""
value = self.visualization_widget.current_view_center_value()
props = RefLineProps(value=round(value, 2))
self.ref_lines.append(props)
self._sync_ref_lines()
# Open the editor immediately so colour/tag/value can be set right away.
self.on_edit_reference_line(len(self.ref_lines) - 1)
def on_edit_reference_line(self, index: int):
"""Open the properties dialog for a reference line."""
if not (0 <= index < len(self.ref_lines)):
return
dialog = RefLineDialog(self, self.ref_lines[index], value_units=self._ref_value_units())
if dialog.exec_():
self.ref_lines[index] = dialog.result_props()
self._sync_ref_lines()
def on_remove_reference_line(self, index: int):
"""Delete a reference line."""
if 0 <= index < len(self.ref_lines):
del self.ref_lines[index]
self._sync_ref_lines()
def on_clear_reference_lines(self):
"""Remove all custom reference lines for the current metric."""
self.ref_lines.clear()
self._sync_ref_lines()
def on_reference_line_moved(self, index: int):
"""A line was dragged on the plot — its value is already updated; refresh list."""
self.ref_line_control.set_lines(self.ref_lines)
def _activate_ref_lines(self):
"""Point `self.ref_lines` at the current metric's set and sync the UI."""
metric_id = self.plot_control.current_metric_id()
self.ref_lines = self.ref_lines_by_metric.setdefault(metric_id, [])
self._sync_ref_lines()
def _ref_value_units(self) -> str:
"""Units a reference line's value is expressed in for the current metric."""
return "Hz" if self.plot_control.current_metric_id() == "spectrogram" else ""
def _sync_ref_lines(self):
"""Push the current reference-line set to both the list view and the plot."""
self.ref_line_control.set_lines(self.ref_lines)
self.visualization_widget.set_reference_lines(self.ref_lines)
def on_view_changed(self):
"""Called when a view-scale toggle (lin/log) changes. Recompute-free redraw."""
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))
spec = apply_x_mode(metric.build_spec(data, view), view.x_mode)
specs.append((label, spec, color))
if specs:
self.visualization_widget.show_specs(specs, view)
if pending:
self.visualization_widget.set_status(
f"Computing {metric.display_name} for {pending} file(s)..."
)
def main(): def main():
@@ -285,17 +378,12 @@ def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Initialize font system before creating any widgets # Initialize font system (loads any fonts/ files, configures fallbacks) then
font_success = initialize_fonts() # lock the UI font. M PLUS 1 Code has full Japanese coverage, so this stays
if font_success: # CJK-safe; falls back to the system default if the family isn't present.
logger.info("Font system initialized successfully") initialize_fonts()
# Log font status for debugging chosen = apply_fixed_font("M PLUS 1 Code", 10)
font_status = get_font_manager().get_status_report() logger.info(f"UI font locked to '{chosen}' at 10pt")
logger.debug(f"Font status: matplotlib={font_status['matplotlib_configured']}, "
f"qt={font_status['qt_configured']}, "
f"custom_fonts={font_status['custom_fonts_loaded']}")
else:
logger.warning("Font system initialization failed - CJK characters may not display properly")
# Set application style # Set application style
app.setStyle('Fusion') # Modern cross-platform style app.setStyle('Fusion') # Modern cross-platform style
+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 -4
View File
@@ -1,23 +1,31 @@
""" """
Plot control widget: pick which metric to display and refresh the current plot. Plot control widget: pick the metric, set axis scale/mode, refresh the plot.
Mirrors FontControlWidget's clustered-groupbox style so the two sit naturally A clustered groupbox for the left panel: metric selector, log-frequency toggle,
next to each other in the left panel. relative-time toggle, and a manual refresh button.
""" """
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, X_ABSOLUTE, X_RELATIVE
class PlotControlWidget(QWidget): class PlotControlWidget(QWidget):
"""Metric selector + manual plot refresh.""" """Metric selector, view-scale/x-mode toggles, and manual plot refresh.
Overlay/compare membership is driven by the file-list checkboxes and reference
lines by their own cluster; this one governs *what* metric and *how* its axes
are scaled (lin/log frequency) and laid out (absolute vs relative time).
"""
metricChanged = pyqtSignal(str) # metric_id metricChanged = pyqtSignal(str) # metric_id
viewChanged = pyqtSignal() # view-state (scale / x-mode) changed
plotRefreshRequested = pyqtSignal() plotRefreshRequested = pyqtSignal()
def __init__(self, parent=None): def __init__(self, parent=None):
@@ -42,6 +50,21 @@ 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)
# Time axis: off = absolute seconds, on = relative % of each track's own
# length, so tracks of very different durations line up by song position.
self.relative_time_check = QCheckBox("Relative time axis (%)")
self.relative_time_check.setToolTip(
"Off: time in seconds. On: 0-100% of each track's own length")
self.relative_time_check.toggled.connect(lambda _: self.viewChanged.emit())
group_layout.addWidget(self.relative_time_check)
button_row = QHBoxLayout() 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 +82,9 @@ 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(),
x_mode=X_RELATIVE if self.relative_time_check.isChecked() else X_ABSOLUTE,
)
+180
View File
@@ -0,0 +1,180 @@
"""
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 RefLineProps:
"""A user-defined horizontal reference line.
Owned by the GUI controller and passed to the renderer, which draws it as a
draggable line and writes `value` back on drag. Persists across redraws; the
GUI clears the set when the metric changes (the value axis units change).
"""
value: float
color: str = "#444444"
style: str = "dash" # 'solid' | 'dash' | 'dot'
label: str = "" # tag shown on the line; falls back to the value
# X-axis modes for comparison.
X_ABSOLUTE = "absolute" # time in seconds (native)
X_RELATIVE = "relative" # 0-100% of each track's own length
@dataclass
class ViewState:
"""User-controlled, recompute-free view options.
`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
x_mode: str = X_ABSOLUTE
def resolve_y_log(self, default: bool) -> bool:
return self.y_log if self.y_log is not None else default
def resolve_x_log(self, default: bool) -> bool:
return self.x_log if self.x_log is not None else default
def apply_x_mode(spec: PlotSpec, mode: str) -> PlotSpec:
"""Rewrite a spec's x-axis to relative position (0-100%) in place, if asked.
Each dataset is normalised to *its own* span, so tracks of different lengths
line up by song position — the point of relative mode. A pure view transform:
it reassigns the x arrays (cached data is left untouched) and relabels the
axis. No-op for absolute mode.
"""
if mode != X_RELATIVE:
return spec
xs = [c.x for c in spec.curves] + [b.x for b in spec.bands]
if spec.heatmap is not None:
xs.append(spec.heatmap.x)
xs = [x for x in xs if len(x)]
if not xs:
return spec
lo = min(float(x[0]) for x in xs)
hi = max(float(x[-1]) for x in xs)
span = (hi - lo) or 1.0
def rel(x):
return (x - lo) / span * 100.0
for c in spec.curves:
c.x = rel(c.x)
for b in spec.bands:
b.x = rel(b.x)
if spec.heatmap is not None:
spec.heatmap.x = rel(spec.heatmap.x)
spec.axes.x_label = "Position (%)"
spec.axes.x_range = (0.0, 100.0)
return spec
# A neutral default reused wherever a caller hasn't supplied view options.
DEFAULT_VIEW = ViewState()
+3 -1
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,9 +29,10 @@ py-modules = [
"audio_visualization_widget", "audio_visualization_widget",
"master_core", "master_core",
"metrics", "metrics",
"plotspec",
"font_manager", "font_manager",
"font_control_widget",
"plot_control_widget", "plot_control_widget",
"ref_line_widget",
"logger_setup", "logger_setup",
"setup_fonts", "setup_fonts",
] ]
+155
View File
@@ -0,0 +1,155 @@
"""
Reference-line management: a side-panel list of custom horizontal markers plus a
properties dialog.
`RefLineControlWidget` is a pure view over a list of `RefLineProps` owned by the
main window: it renders the list and emits intents (add / edit / remove / clear).
`RefLineDialog` edits one line's value, colour, line style, and tag.
"""
import logging
from PyQt5.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QListWidget, QPushButton,
QDialog, QFormLayout, QDoubleSpinBox, QComboBox, QLineEdit, QColorDialog,
QDialogButtonBox,
)
from PyQt5.QtGui import QColor
from PyQt5.QtCore import pyqtSignal
from plotspec import RefLineProps
_STYLE_CHOICES = [("Solid", "solid"), ("Dashed", "dash"), ("Dotted", "dot")]
class RefLineDialog(QDialog):
"""Edit one reference line's properties. Read the result via `result_props`."""
def __init__(self, parent, props: RefLineProps, value_units: str = ""):
super().__init__(parent)
self.setWindowTitle("Reference line")
self._color = props.color
form = QFormLayout(self)
self.value_spin = QDoubleSpinBox()
self.value_spin.setRange(-1e6, 1e6)
self.value_spin.setDecimals(2)
self.value_spin.setValue(props.value)
if value_units:
self.value_spin.setSuffix(f" {value_units}")
form.addRow("Value:", self.value_spin)
self.color_button = QPushButton()
self.color_button.clicked.connect(self._pick_color)
self._refresh_color_button()
form.addRow("Colour:", self.color_button)
self.style_combo = QComboBox()
for label, key in _STYLE_CHOICES:
self.style_combo.addItem(label, key)
idx = self.style_combo.findData(props.style)
if idx >= 0:
self.style_combo.setCurrentIndex(idx)
form.addRow("Line style:", self.style_combo)
self.label_edit = QLineEdit(props.label)
self.label_edit.setPlaceholderText("(optional tag)")
form.addRow("Tag:", self.label_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
def _pick_color(self):
chosen = QColorDialog.getColor(QColor(self._color), self, "Reference line colour")
if chosen.isValid():
self._color = chosen.name()
self._refresh_color_button()
def _refresh_color_button(self):
self.color_button.setText(self._color)
# Show the colour as the button's background for a quick read.
self.color_button.setStyleSheet(f"background-color: {self._color};")
def result_props(self) -> RefLineProps:
return RefLineProps(
value=float(self.value_spin.value()),
color=self._color,
style=self.style_combo.currentData(),
label=self.label_edit.text().strip(),
)
class RefLineControlWidget(QWidget):
"""List of reference lines with Add / Edit / Remove / Clear controls."""
addRequested = pyqtSignal()
editRequested = pyqtSignal(int)
removeRequested = pyqtSignal(int)
clearRequested = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.logger = logging.getLogger(__name__)
self.initUI()
def initUI(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
group_box = QGroupBox("Reference lines")
group_layout = QVBoxLayout(group_box)
self.line_list = QListWidget()
self.line_list.setMaximumHeight(110)
self.line_list.itemDoubleClicked.connect(self._on_double_click)
group_layout.addWidget(self.line_list)
row = QHBoxLayout()
self.add_button = QPushButton("Add")
self.add_button.clicked.connect(self.addRequested.emit)
row.addWidget(self.add_button)
self.edit_button = QPushButton("Edit…")
self.edit_button.clicked.connect(self._emit_edit)
row.addWidget(self.edit_button)
self.remove_button = QPushButton("Remove")
self.remove_button.clicked.connect(self._emit_remove)
row.addWidget(self.remove_button)
self.clear_button = QPushButton("Clear")
self.clear_button.clicked.connect(self.clearRequested.emit)
row.addWidget(self.clear_button)
group_layout.addLayout(row)
layout.addWidget(group_box)
def set_lines(self, lines: list[RefLineProps]):
"""Repopulate the list display from the current props (preserving selection)."""
current = self.line_list.currentRow()
self.line_list.clear()
for p in lines:
tag = f" {p.label}" if p.label else ""
self.line_list.addItem(f"{p.value:.2f}{tag}")
if 0 <= current < self.line_list.count():
self.line_list.setCurrentRow(current)
def _selected_row(self) -> int:
return self.line_list.currentRow()
def _emit_edit(self):
row = self._selected_row()
if row >= 0:
self.editRequested.emit(row)
def _emit_remove(self):
row = self._selected_row()
if row >= 0:
self.removeRequested.emit(row)
def _on_double_click(self, _item):
self._emit_edit()
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]]