Refactor/interactive plotting #1

Merged
mikkeli merged 3 commits from refactor/interactive-plotting into main 2026-06-13 16:17:21 +00:00
8 changed files with 132 additions and 387 deletions
Showing only changes of commit 22ecb67296 - Show all commits
+15 -11
View File
@@ -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
+42 -10
View File
@@ -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),
-311
View File
@@ -1,311 +0,0 @@
"""
Unified font control widget clustering all font-related manipulators.
Self-contained widget for easy layout management.
"""
import logging
from typing import List, Dict, Optional
from PyQt5.QtWidgets import (QWidget, QComboBox, QVBoxLayout, QHBoxLayout,
QLabel, QSlider, QGroupBox)
from PyQt5.QtCore import pyqtSignal, Qt
from font_manager import get_font_manager
class FontControlWidget(QWidget):
"""
Unified font control widget containing all font-related manipulators.
Provides clustered interface for:
- Font selection (unified for Qt and matplotlib)
- Qt font size adjustment
"""
# Signals
fontChanged = pyqtSignal(str, str) # (font_name, font_type)
fontSizeChanged = pyqtSignal(int) # font_size
def __init__(self, parent=None):
"""Initialize the font control widget."""
super().__init__(parent)
self.logger = logging.getLogger(__name__)
self.font_manager = get_font_manager()
self.available_fonts: Dict[str, str] = {} # display_name -> actual_font_name
# Font size bounds (reasonable range for GUI fonts)
self.min_font_size = 9
self.max_font_size = 12
self.default_font_size = 10
self.initUI()
self.refresh_font_list()
def initUI(self):
"""Initialize the user interface."""
# Main layout
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5) # Minimal margins
# Group box for visual clustering
group_box = QGroupBox("Font Settings")
group_layout = QVBoxLayout(group_box)
# Font selector section
font_section = self._create_font_selector_section()
group_layout.addWidget(font_section)
# Font size section
size_section = self._create_font_size_section()
group_layout.addWidget(size_section)
layout.addWidget(group_box)
def _create_font_selector_section(self) -> QWidget:
"""Create the font selector section."""
section = QWidget()
layout = QVBoxLayout(section)
layout.setContentsMargins(0, 0, 0, 0)
# Font label and dropdown
font_label = QLabel("Font:")
layout.addWidget(font_label)
self.font_combo = QComboBox()
self.font_combo.currentTextChanged.connect(self.on_font_changed)
layout.addWidget(self.font_combo)
return section
def _create_font_size_section(self) -> QWidget:
"""Create the font size control section."""
section = QWidget()
layout = QVBoxLayout(section)
layout.setContentsMargins(0, 0, 0, 0)
# Size label
self.size_label = QLabel(f"Qt Font Size: {self.default_font_size}pt")
layout.addWidget(self.size_label)
# Size slider with value labels
slider_layout = QHBoxLayout()
# Min label
min_label = QLabel(str(self.min_font_size))
min_label.setFixedWidth(20)
slider_layout.addWidget(min_label)
# Slider
self.size_slider = QSlider(Qt.Horizontal)
self.size_slider.setMinimum(self.min_font_size)
self.size_slider.setMaximum(self.max_font_size)
self.size_slider.setValue(self.default_font_size)
self.size_slider.setTickPosition(QSlider.TicksBelow)
self.size_slider.setTickInterval(2)
self.size_slider.valueChanged.connect(self.on_font_size_changed)
slider_layout.addWidget(self.size_slider)
# Max label
max_label = QLabel(str(self.max_font_size))
max_label.setFixedWidth(20)
slider_layout.addWidget(max_label)
layout.addLayout(slider_layout)
return section
def refresh_font_list(self):
"""Refresh the list of available fonts."""
self.logger.debug("Refreshing font list...")
self.available_fonts.clear()
try:
# Get custom fonts from font manager
custom_fonts = self.font_manager.loaded_fonts
# Get system font candidates
system_fonts = self.font_manager.get_available_system_fonts()[:10]
# Clear combo box
self.font_combo.clear()
# Add custom fonts first (highest priority)
if custom_fonts:
for font_family, font_path in custom_fonts.items():
display_name = f"{font_family} (Custom)"
self.available_fonts[display_name] = font_family
self.font_combo.addItem(display_name)
self.logger.debug(f"Added custom font: {display_name}")
# Add system fonts
for font_name in system_fonts:
display_name = f"{font_name} (System)"
self.available_fonts[display_name] = font_name
self.font_combo.addItem(display_name)
self.logger.debug(f"Added system font: {display_name}")
# Add default option with actual system font name
system_font_name = self.font_manager.get_default_system_font_name()
default_name = f"Default ({system_font_name})"
self.available_fonts[default_name] = "default"
self.font_combo.addItem(default_name)
# Select startup font
self._select_startup_font()
self.logger.info(f"Font list refreshed: {len(self.available_fonts)} fonts available")
except Exception as e:
self.logger.error(f"Error refreshing font list: {e}")
# Fallback: add default option only
self.font_combo.clear()
self.font_combo.addItem("Default (System)")
self.available_fonts = {"Default (System)": "default"}
def _select_startup_font(self):
"""Select the appropriate font on startup."""
# Use font manager's startup font selection logic
startup_font_name, startup_font_type = self.font_manager.select_startup_font()
# Find the corresponding display name in our combo box
target_display_name = None
for display_name, actual_name in self.available_fonts.items():
if actual_name == startup_font_name:
target_display_name = display_name
break
# If we found the font, select it
if target_display_name:
index = self.font_combo.findText(target_display_name)
if index >= 0:
self.font_combo.setCurrentIndex(index)
self.logger.info(f"Startup font selected: {target_display_name}")
return
# Fallback to default if we couldn't find the startup font
for i in range(self.font_combo.count()):
item_text = self.font_combo.itemText(i)
if item_text.startswith("Default ("):
self.font_combo.setCurrentIndex(i)
self.logger.info(f"Startup font fallback: {item_text}")
return
def on_font_changed(self, display_name: str):
"""Handle font selection change."""
if not display_name or display_name not in self.available_fonts:
return
actual_font_name = self.available_fonts[display_name]
# Determine font type
if "(Custom)" in display_name:
font_type = "custom"
elif "(System)" in display_name:
font_type = "system"
else:
font_type = "default"
self.logger.info(f"Font changed: {display_name} -> {actual_font_name} ({font_type})")
# Apply the font change
self._apply_font_change(actual_font_name, font_type)
# Emit signal for external listeners (including auto-regeneration)
self.fontChanged.emit(actual_font_name, font_type)
def on_font_size_changed(self, size: int):
"""Handle font size change."""
self.size_label.setText(f"Qt Font Size: {size}pt")
self.logger.info(f"Qt font size changed: {size}pt")
# Apply Qt font size change
self._apply_font_size_change(size)
# Emit signal for external listeners
self.fontSizeChanged.emit(size)
def _apply_font_change(self, font_name: str, font_type: str):
"""Apply the font change to both Qt and matplotlib."""
try:
# Use font manager's centralized font application
success = self.font_manager.apply_font_selection(font_name, font_type)
if not success:
self.logger.warning(f"Font application may have failed: {font_name}")
except Exception as e:
self.logger.error(f"Error applying font change: {e}")
def _apply_font_size_change(self, size: int):
"""Apply Qt font size change."""
try:
from PyQt5.QtCore import QCoreApplication
from PyQt5.QtGui import QFont
app = QCoreApplication.instance()
if app:
current_font = app.font()
current_font.setPointSize(size)
app.setFont(current_font)
self.logger.debug(f"Qt font size set to: {size}pt")
except Exception as e:
self.logger.error(f"Error applying font size change: {e}")
def get_current_font(self) -> tuple[str, str]:
"""
Get currently selected font.
Returns:
tuple: (font_name, font_type)
"""
display_name = self.font_combo.currentText()
if display_name in self.available_fonts:
actual_font_name = self.available_fonts[display_name]
if "(Custom)" in display_name:
font_type = "custom"
elif "(System)" in display_name:
font_type = "system"
else:
font_type = "default"
return actual_font_name, font_type
return "default", "default"
def get_current_font_size(self) -> int:
"""
Get current Qt font size.
Returns:
int: Current font size in points
"""
return self.size_slider.value()
def set_font(self, font_name: str):
"""
Programmatically set the font selection.
Args:
font_name: Name of font to select
"""
# Find matching display name
for display_name, actual_name in self.available_fonts.items():
if actual_name == font_name:
index = self.font_combo.findText(display_name)
if index >= 0:
self.font_combo.setCurrentIndex(index)
return
self.logger.warning(f"Font not found in selector: {font_name}")
def set_font_size(self, size: int):
"""
Programmatically set the font size.
Args:
size: Font size in points (will be clamped to valid range)
"""
clamped_size = max(self.min_font_size, min(self.max_font_size, size))
self.size_slider.setValue(clamped_size)
if clamped_size != size:
self.logger.warning(f"Font size clamped: {size} -> {clamped_size}")
+34 -3
View File
@@ -493,11 +493,42 @@ def initialize_fonts() -> bool:
def safe_title(title: str) -> str: def safe_title(title: str) -> str:
""" """
Convenience function to get CJK-safe title. Convenience function to get CJK-safe title.
Args: Args:
title: Original title title: Original title
Returns: Returns:
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
+27 -38
View File
@@ -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."""
@@ -66,12 +67,6 @@ class MainWindow(QMainWindow):
self.open_file_button = QPushButton("Open Audio File...") self.open_file_button = QPushButton("Open Audio File...")
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()
@@ -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
View File
@@ -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,
) )
-1
View File
@@ -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
View File
@@ -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()