Per-metric ref lines with Hz spectrogram units; relative-time toggle; fixed font
Reference lines: - Kept per metric (dict keyed by metric_id) so switching metrics and coming back preserves them, instead of clearing on every switch - Spectrogram lines read/edit/drag in Hz: the renderer installs Hz<->row-index transforms (the heatmap y-axis is a row index), so value, label, the edit dialog, and the new-line default all speak frequency. Curve metrics stay identity. Round-trip verified (1000 Hz -> row -> 1000 Hz) - Line label and drag-readback always in the metric's natural units Controls: - Relative-time abs/rel is now a checkbox toggle, not a dropdown - Removed the font control panel; UI font is locked to M PLUS 1 Code @ 10pt via font_manager.apply_fixed_font (system-default fallback). Deleted font_control_widget.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
- **Pluggable Metrics**: Switchable visualizations (RMS Power, Waveform, LUFS, Crest Factor, PSR, True Peak, Spectrogram; DR next) via a `Metric` ABC
|
- **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
|
||||||
@@ -49,11 +49,14 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
- The seam that decouples metrics from the plotting library: metrics emit
|
- The seam that decouples metrics from the plotting library: metrics emit
|
||||||
*intent*, the renderer owns colour/layout/library specifics
|
*intent*, the renderer owns colour/layout/library specifics
|
||||||
|
|
||||||
#### `font_control_widget.py` & `font_manager.py`
|
#### `font_manager.py`
|
||||||
- Unified font control system with clustered interface
|
- Auto-detection of custom fonts from `fonts/` directory; CJK fallbacks
|
||||||
- Auto-detection of custom fonts from `fonts/` directory
|
- `apply_fixed_font(family, size)` locks the Qt app font (used at startup to pin
|
||||||
- System font discovery and CJK compatibility
|
the UI to **M PLUS 1 Code @ 10pt**, falling back to the system default if the
|
||||||
- Font changes trigger a cheap re-render of the cached metric data
|
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
|
||||||
@@ -123,13 +126,14 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
(pyqtgraph ViewBox); log/linear frequency toggle. Scroll zooms both axes;
|
(pyqtgraph ViewBox); log/linear frequency toggle. Scroll zooms both axes;
|
||||||
**Ctrl+scroll** zooms time only, **Shift+scroll** zooms the value axis only
|
**Ctrl+scroll** zooms time only, **Shift+scroll** zooms the value axis only
|
||||||
(`_AxisZoomViewBox`); scrolling over an axis also zooms just that axis
|
(`_AxisZoomViewBox`); scrolling over an axis also zooms just that axis
|
||||||
- **Time-axis mode**: Absolute (seconds) or Relative (% of each track's own
|
- **Time-axis mode**: a Relative-time toggle — off = seconds, on = 0-100% of each
|
||||||
length), so tracks of very different durations line up by song position
|
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
|
- **Custom reference lines**: side-panel list (Add/Edit/Remove/Clear) of draggable
|
||||||
horizontal markers with value/colour/style/tag; dragged via a triangle handle.
|
horizontal markers with value/colour/style/tag; dragged via a triangle handle.
|
||||||
Persist across redraws/overlay changes; cleared when the metric changes
|
Kept **per metric** (so switching metrics doesn't lose them) and expressed in
|
||||||
- **Font control**: Unified font selector with size control
|
the metric's own units — on the spectrogram they read and edit in **Hz** (the
|
||||||
- **Plot control**: Metric selector + log-frequency toggle + time-axis mode
|
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
|
+ 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
|
||||||
|
|||||||
@@ -84,27 +84,38 @@ class _AxisZoomViewBox(pg.ViewBox):
|
|||||||
class _RefLine(pg.InfiniteLine):
|
class _RefLine(pg.InfiniteLine):
|
||||||
"""A draggable horizontal reference line bound to a RefLineProps.
|
"""A draggable horizontal reference line bound to a RefLineProps.
|
||||||
|
|
||||||
Carries a triangle grab-handle at the left edge and writes its position back
|
`props.value` is in the metric's natural units (LUFS, dB, ... or Hz for the
|
||||||
into the props on drag, notifying the widget so the side-panel list refreshes.
|
spectrogram). The drawn y-position may differ from that value — the spectrogram
|
||||||
|
maps frequency to a row index — so `to_pos`/`from_pos` convert between the two.
|
||||||
|
For curve metrics these are identity. The label and the value written back on
|
||||||
|
drag are always in natural units.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, index: int, props: RefLineProps, on_moved):
|
def __init__(self, index, props: RefLineProps, to_pos, from_pos, fmt, on_moved):
|
||||||
pen = pg.mkPen(props.color, width=1.4,
|
pen = pg.mkPen(props.color, width=1.4,
|
||||||
style=_PEN_STYLE.get(props.style, Qt.DashLine))
|
style=_PEN_STYLE.get(props.style, Qt.DashLine))
|
||||||
super().__init__(
|
super().__init__(
|
||||||
pos=props.value, angle=0, movable=True, pen=pen,
|
pos=to_pos(props.value), angle=0, movable=True, pen=pen,
|
||||||
label=props.label or "{value:.2f}",
|
label="",
|
||||||
labelOpts={"position": 0.06, "color": props.color,
|
labelOpts={"position": 0.06, "color": props.color,
|
||||||
"fill": (255, 255, 255, 180)},
|
"fill": (255, 255, 255, 180)},
|
||||||
)
|
)
|
||||||
self._index = index
|
self._index = index
|
||||||
self._props = props
|
self._props = props
|
||||||
|
self._from_pos = from_pos
|
||||||
|
self._fmt = fmt
|
||||||
self._on_moved = on_moved
|
self._on_moved = on_moved
|
||||||
self.addMarker("|>", position=0.0, size=12) # triangle handle at the start
|
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)
|
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):
|
def _commit(self):
|
||||||
self._props.value = float(self.value())
|
self._props.value = float(self._from_pos(self.value()))
|
||||||
self._on_moved(self._index)
|
self._on_moved(self._index)
|
||||||
|
|
||||||
|
|
||||||
@@ -134,6 +145,11 @@ class AudioVisualizationWidget(QWidget):
|
|||||||
# passed in via set_reference_lines; the line items are rebuilt each render.
|
# passed in via set_reference_lines; the line items are rebuilt each render.
|
||||||
self._ref_props: list[RefLineProps] = []
|
self._ref_props: list[RefLineProps] = []
|
||||||
self._ref_lines: list[_RefLine] = []
|
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._show_empty()
|
||||||
|
|
||||||
# ---- public API ---------------------------------------------------------
|
# ---- public API ---------------------------------------------------------
|
||||||
@@ -187,10 +203,14 @@ class AudioVisualizationWidget(QWidget):
|
|||||||
self._ref_props = props
|
self._ref_props = props
|
||||||
self._draw_ref_lines()
|
self._draw_ref_lines()
|
||||||
|
|
||||||
def current_view_center_y(self) -> float:
|
def current_view_center_value(self) -> float:
|
||||||
"""Mid-point of the current y view — a sane default position for a new line."""
|
"""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()
|
(_, _), (y0, y1) = self.plot.viewRange()
|
||||||
return (y0 + y1) / 2.0
|
return float(self._ref_from_pos((y0 + y1) / 2.0))
|
||||||
|
|
||||||
def set_status(self, message: str):
|
def set_status(self, message: str):
|
||||||
self.status_label.setText(message)
|
self.status_label.setText(message)
|
||||||
@@ -269,6 +289,13 @@ class AudioVisualizationWidget(QWidget):
|
|||||||
img.setRect(pg.QtCore.QRectF(t0, 0.0, t1 - t0, float(n_rows)))
|
img.setRect(pg.QtCore.QRectF(t0, 0.0, t1 - t0, float(n_rows)))
|
||||||
self.plot.addItem(img)
|
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.
|
# Label the row-index y-axis with real frequencies.
|
||||||
ticks = []
|
ticks = []
|
||||||
for hz in _LOG_FREQ_TICKS:
|
for hz in _LOG_FREQ_TICKS:
|
||||||
@@ -309,7 +336,8 @@ class AudioVisualizationWidget(QWidget):
|
|||||||
"""(Re)create draggable lines from the current RefLineProps set."""
|
"""(Re)create draggable lines from the current RefLineProps set."""
|
||||||
self._remove_ref_line_items()
|
self._remove_ref_line_items()
|
||||||
for idx, props in enumerate(self._ref_props):
|
for idx, props in enumerate(self._ref_props):
|
||||||
line = _RefLine(idx, props, on_moved=self.referenceLineMoved.emit)
|
line = _RefLine(idx, props, self._ref_to_pos, self._ref_from_pos,
|
||||||
|
self._ref_fmt, on_moved=self.referenceLineMoved.emit)
|
||||||
self.plot.addItem(line)
|
self.plot.addItem(line)
|
||||||
self._ref_lines.append(line)
|
self._ref_lines.append(line)
|
||||||
|
|
||||||
@@ -338,6 +366,10 @@ class AudioVisualizationWidget(QWidget):
|
|||||||
self.legend.clear()
|
self.legend.clear()
|
||||||
self.plot.getAxis("left").setTicks(None) # drop heatmap freq ticks
|
self.plot.getAxis("left").setTicks(None) # drop heatmap freq ticks
|
||||||
self.plot.setLogMode(x=False, y=False)
|
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):
|
def _show_empty(self):
|
||||||
text = pg.TextItem("Drop an audio file to see analysis", anchor=(0.5, 0.5),
|
text = pg.TextItem("Drop an audio file to see analysis", anchor=(0.5, 0.5),
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,8 +9,7 @@ from PyQt5.QtCore import Qt
|
|||||||
from audio_visualization_widget import AudioVisualizationWidget, dataset_color
|
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 ref_line_widget import RefLineControlWidget, RefLineDialog
|
||||||
from metrics import METRICS
|
from metrics import METRICS
|
||||||
@@ -26,10 +25,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.analysis_manager = AnalysisResultsManager()
|
self.analysis_manager = AnalysisResultsManager()
|
||||||
# Guards programmatic list mutations from triggering re-render storms.
|
# Guards programmatic list mutations from triggering re-render storms.
|
||||||
self._suppress_list_signals = False
|
self._suppress_list_signals = False
|
||||||
# Custom reference lines, owned here and pushed to the plot each render.
|
# Reference lines are kept per metric (a -14 LUFS line means nothing on a
|
||||||
self.ref_lines: list[RefLineProps] = []
|
# 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."""
|
||||||
@@ -67,12 +68,6 @@ 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
|
|
||||||
self.font_control = FontControlWidget()
|
|
||||||
self.font_control.fontChanged.connect(self.on_font_changed)
|
|
||||||
self.font_control.fontSizeChanged.connect(self.on_font_size_changed)
|
|
||||||
layout.addWidget(self.font_control)
|
|
||||||
|
|
||||||
# Plot control cluster (metric selector + scale toggle + refresh)
|
# Plot control cluster (metric selector + scale toggle + refresh)
|
||||||
self.plot_control = PlotControlWidget()
|
self.plot_control = PlotControlWidget()
|
||||||
self.plot_control.metricChanged.connect(self.on_metric_changed)
|
self.plot_control.metricChanged.connect(self.on_metric_changed)
|
||||||
@@ -219,28 +214,17 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
self._refresh_view()
|
self._refresh_view()
|
||||||
|
|
||||||
def on_font_changed(self, font_name: str, font_type: str):
|
|
||||||
"""Called when font selection changes."""
|
|
||||||
self.logger.info(f"Font changed via GUI: {font_name} ({font_type})")
|
|
||||||
self._refresh_view()
|
|
||||||
|
|
||||||
def on_font_size_changed(self, font_size: int):
|
|
||||||
"""Called when Qt font size changes."""
|
|
||||||
self.logger.info(f"Qt font size changed via GUI: {font_size}pt")
|
|
||||||
# Qt font size doesn't affect the plot axes fonts directly; no redraw needed.
|
|
||||||
|
|
||||||
def on_metric_changed(self, metric_id: str):
|
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}")
|
||||||
# The value axis units change with the metric, so custom reference lines
|
# Reference lines are kept per metric, so swap in this metric's set rather
|
||||||
# placed against the old metric no longer mean anything — drop them.
|
# than discarding — switch away and back and your lines are still there.
|
||||||
self.ref_lines.clear()
|
self._activate_ref_lines()
|
||||||
self._sync_ref_lines()
|
|
||||||
self._refresh_view()
|
self._refresh_view()
|
||||||
|
|
||||||
def on_add_reference_line(self):
|
def on_add_reference_line(self):
|
||||||
"""Add a reference line at the current view centre, then edit it."""
|
"""Add a reference line at the current view centre, then edit it."""
|
||||||
value = self.visualization_widget.current_view_center_y()
|
value = self.visualization_widget.current_view_center_value()
|
||||||
props = RefLineProps(value=round(value, 2))
|
props = RefLineProps(value=round(value, 2))
|
||||||
self.ref_lines.append(props)
|
self.ref_lines.append(props)
|
||||||
self._sync_ref_lines()
|
self._sync_ref_lines()
|
||||||
@@ -251,7 +235,7 @@ class MainWindow(QMainWindow):
|
|||||||
"""Open the properties dialog for a reference line."""
|
"""Open the properties dialog for a reference line."""
|
||||||
if not (0 <= index < len(self.ref_lines)):
|
if not (0 <= index < len(self.ref_lines)):
|
||||||
return
|
return
|
||||||
dialog = RefLineDialog(self, self.ref_lines[index])
|
dialog = RefLineDialog(self, self.ref_lines[index], value_units=self._ref_value_units())
|
||||||
if dialog.exec_():
|
if dialog.exec_():
|
||||||
self.ref_lines[index] = dialog.result_props()
|
self.ref_lines[index] = dialog.result_props()
|
||||||
self._sync_ref_lines()
|
self._sync_ref_lines()
|
||||||
@@ -263,7 +247,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._sync_ref_lines()
|
self._sync_ref_lines()
|
||||||
|
|
||||||
def on_clear_reference_lines(self):
|
def on_clear_reference_lines(self):
|
||||||
"""Remove all custom reference lines."""
|
"""Remove all custom reference lines for the current metric."""
|
||||||
self.ref_lines.clear()
|
self.ref_lines.clear()
|
||||||
self._sync_ref_lines()
|
self._sync_ref_lines()
|
||||||
|
|
||||||
@@ -271,6 +255,16 @@ class MainWindow(QMainWindow):
|
|||||||
"""A line was dragged on the plot — its value is already updated; refresh list."""
|
"""A line was dragged on the plot — its value is already updated; refresh list."""
|
||||||
self.ref_line_control.set_lines(self.ref_lines)
|
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):
|
def _sync_ref_lines(self):
|
||||||
"""Push the current reference-line set to both the list view and the plot."""
|
"""Push the current reference-line set to both the list view and the plot."""
|
||||||
self.ref_line_control.set_lines(self.ref_lines)
|
self.ref_line_control.set_lines(self.ref_lines)
|
||||||
@@ -384,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
|
||||||
|
|||||||
+11
-12
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Plot control widget: pick which metric to display and refresh the current plot.
|
Plot control widget: pick the metric, set axis scale/mode, refresh the plot.
|
||||||
|
|
||||||
Mirrors FontControlWidget's clustered-groupbox style so the two sit naturally
|
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
|
||||||
@@ -57,14 +57,13 @@ class PlotControlWidget(QWidget):
|
|||||||
self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit())
|
self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit())
|
||||||
group_layout.addWidget(self.log_freq_check)
|
group_layout.addWidget(self.log_freq_check)
|
||||||
|
|
||||||
# Time axis: absolute seconds vs relative % of each track's own length, so
|
# Time axis: off = absolute seconds, on = relative % of each track's own
|
||||||
# tracks of very different durations line up by song position when overlaid.
|
# length, so tracks of very different durations line up by song position.
|
||||||
group_layout.addWidget(QLabel("Time axis:"))
|
self.relative_time_check = QCheckBox("Relative time axis (%)")
|
||||||
self.x_mode_combo = QComboBox()
|
self.relative_time_check.setToolTip(
|
||||||
self.x_mode_combo.addItem("Absolute (seconds)", X_ABSOLUTE)
|
"Off: time in seconds. On: 0-100% of each track's own length")
|
||||||
self.x_mode_combo.addItem("Relative (%)", X_RELATIVE)
|
self.relative_time_check.toggled.connect(lambda _: self.viewChanged.emit())
|
||||||
self.x_mode_combo.currentIndexChanged.connect(lambda _: self.viewChanged.emit())
|
group_layout.addWidget(self.relative_time_check)
|
||||||
group_layout.addWidget(self.x_mode_combo)
|
|
||||||
|
|
||||||
button_row = QHBoxLayout()
|
button_row = QHBoxLayout()
|
||||||
self.refresh_button = QPushButton("Refresh Plot")
|
self.refresh_button = QPushButton("Refresh Plot")
|
||||||
@@ -87,5 +86,5 @@ class PlotControlWidget(QWidget):
|
|||||||
def current_view_state(self) -> ViewState:
|
def current_view_state(self) -> ViewState:
|
||||||
return ViewState(
|
return ViewState(
|
||||||
y_log=self.log_freq_check.isChecked(),
|
y_log=self.log_freq_check.isChecked(),
|
||||||
x_mode=self.x_mode_combo.currentData(),
|
x_mode=X_RELATIVE if self.relative_time_check.isChecked() else X_ABSOLUTE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ py-modules = [
|
|||||||
"metrics",
|
"metrics",
|
||||||
"plotspec",
|
"plotspec",
|
||||||
"font_manager",
|
"font_manager",
|
||||||
"font_control_widget",
|
|
||||||
"plot_control_widget",
|
"plot_control_widget",
|
||||||
"ref_line_widget",
|
"ref_line_widget",
|
||||||
"logger_setup",
|
"logger_setup",
|
||||||
|
|||||||
+3
-1
@@ -26,7 +26,7 @@ _STYLE_CHOICES = [("Solid", "solid"), ("Dashed", "dash"), ("Dotted", "dot")]
|
|||||||
class RefLineDialog(QDialog):
|
class RefLineDialog(QDialog):
|
||||||
"""Edit one reference line's properties. Read the result via `result_props`."""
|
"""Edit one reference line's properties. Read the result via `result_props`."""
|
||||||
|
|
||||||
def __init__(self, parent, props: RefLineProps):
|
def __init__(self, parent, props: RefLineProps, value_units: str = ""):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("Reference line")
|
self.setWindowTitle("Reference line")
|
||||||
self._color = props.color
|
self._color = props.color
|
||||||
@@ -37,6 +37,8 @@ class RefLineDialog(QDialog):
|
|||||||
self.value_spin.setRange(-1e6, 1e6)
|
self.value_spin.setRange(-1e6, 1e6)
|
||||||
self.value_spin.setDecimals(2)
|
self.value_spin.setDecimals(2)
|
||||||
self.value_spin.setValue(props.value)
|
self.value_spin.setValue(props.value)
|
||||||
|
if value_units:
|
||||||
|
self.value_spin.setSuffix(f" {value_units}")
|
||||||
form.addRow("Value:", self.value_spin)
|
form.addRow("Value:", self.value_spin)
|
||||||
|
|
||||||
self.color_button = QPushButton()
|
self.color_button = QPushButton()
|
||||||
|
|||||||
Reference in New Issue
Block a user