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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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...")
|
||||||
|
|||||||
@@ -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}")
|
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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]]
|
||||||
|
|||||||
Reference in New Issue
Block a user