From 22ecb672963832e5398408410c558063dad599e7 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Sun, 14 Jun 2026 01:05:23 +0900 Subject: [PATCH] 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 --- CLAUDE.md | 26 +-- audio_visualization_widget.py | 52 ++++-- font_control_widget.py | 311 ---------------------------------- font_manager.py | 37 +++- main.py | 65 +++---- plot_control_widget.py | 23 ++- pyproject.toml | 1 - ref_line_widget.py | 4 +- 8 files changed, 132 insertions(+), 387 deletions(-) delete mode 100644 font_control_widget.py diff --git a/CLAUDE.md b/CLAUDE.md index 9092a7a..a2923c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 - **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 -- **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 ### 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 *intent*, the renderer owns colour/layout/library specifics -#### `font_control_widget.py` & `font_manager.py` -- Unified font control system with clustered interface -- Auto-detection of custom fonts from `fonts/` directory -- System font discovery and CJK compatibility -- Font changes trigger a cheap re-render of the cached metric data +#### `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` - 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; **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**: Absolute (seconds) or Relative (% of each track's own - length), so tracks of very different durations line up by song position +- **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. - Persist across redraws/overlay changes; cleared when the metric changes -- **Font control**: Unified font selector with size control -- **Plot control**: Metric selector + log-frequency toggle + time-axis mode + 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 - **Modular architecture**: Self-contained widgets for easy layout management diff --git a/audio_visualization_widget.py b/audio_visualization_widget.py index 6148b37..0454003 100644 --- a/audio_visualization_widget.py +++ b/audio_visualization_widget.py @@ -84,27 +84,38 @@ class _AxisZoomViewBox(pg.ViewBox): class _RefLine(pg.InfiniteLine): """A draggable horizontal reference line bound to a RefLineProps. - Carries a triangle grab-handle at the left edge and writes its position back - into the props on drag, notifying the widget so the side-panel list refreshes. + `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: 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, style=_PEN_STYLE.get(props.style, Qt.DashLine)) super().__init__( - pos=props.value, angle=0, movable=True, pen=pen, - label=props.label or "{value:.2f}", + 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.value()) + self._props.value = float(self._from_pos(self.value())) 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. 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() # ---- public API --------------------------------------------------------- @@ -187,10 +203,14 @@ class AudioVisualizationWidget(QWidget): self._ref_props = props self._draw_ref_lines() - def current_view_center_y(self) -> float: - """Mid-point of the current y view — a sane default position for a new line.""" + 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 (y0 + y1) / 2.0 + return float(self._ref_from_pos((y0 + y1) / 2.0)) def set_status(self, message: str): 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))) 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: @@ -309,7 +336,8 @@ class AudioVisualizationWidget(QWidget): """(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, 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._ref_lines.append(line) @@ -338,6 +366,10 @@ class AudioVisualizationWidget(QWidget): 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), diff --git a/font_control_widget.py b/font_control_widget.py deleted file mode 100644 index d0a1eb7..0000000 --- a/font_control_widget.py +++ /dev/null @@ -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}") \ No newline at end of file diff --git a/font_manager.py b/font_manager.py index dd43a56..633c939 100644 --- a/font_manager.py +++ b/font_manager.py @@ -493,11 +493,42 @@ def initialize_fonts() -> bool: def safe_title(title: str) -> str: """ Convenience function to get CJK-safe title. - + Args: title: Original title - + Returns: str: Safe title for display """ - return get_font_manager().get_cjk_safe_title(title) \ No newline at end of file + 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 \ No newline at end of file diff --git a/main.py b/main.py index 6582e5b..08d1afb 100644 --- a/main.py +++ b/main.py @@ -9,8 +9,7 @@ from PyQt5.QtCore import Qt from audio_visualization_widget import AudioVisualizationWidget, dataset_color from analysis_results_manager import AnalysisResultsManager from logger_setup import setup_logging, parse_log_args -from font_manager import initialize_fonts, get_font_manager -from font_control_widget import FontControlWidget +from font_manager import initialize_fonts, apply_fixed_font from plot_control_widget import PlotControlWidget from ref_line_widget import RefLineControlWidget, RefLineDialog from metrics import METRICS @@ -26,10 +25,12 @@ class MainWindow(QMainWindow): self.analysis_manager = AnalysisResultsManager() # Guards programmatic list mutations from triggering re-render storms. self._suppress_list_signals = False - # Custom reference lines, owned here and pushed to the plot each render. - self.ref_lines: list[RefLineProps] = [] + # 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.connect_signals() + self._activate_ref_lines() def initUI(self): """Initialize the user interface.""" @@ -66,12 +67,6 @@ class MainWindow(QMainWindow): self.open_file_button = QPushButton("Open Audio File...") self.open_file_button.clicked.connect(self.open_file_dialog) 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) self.plot_control = PlotControlWidget() @@ -219,28 +214,17 @@ class MainWindow(QMainWindow): return 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): """Called when the metric selector changes.""" self.logger.info(f"Metric changed via GUI: {metric_id}") - # The value axis units change with the metric, so custom reference lines - # placed against the old metric no longer mean anything — drop them. - self.ref_lines.clear() - self._sync_ref_lines() + # 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_y() + value = self.visualization_widget.current_view_center_value() props = RefLineProps(value=round(value, 2)) self.ref_lines.append(props) self._sync_ref_lines() @@ -251,7 +235,7 @@ class MainWindow(QMainWindow): """Open the properties dialog for a reference line.""" if not (0 <= index < len(self.ref_lines)): return - dialog = RefLineDialog(self, self.ref_lines[index]) + 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() @@ -263,7 +247,7 @@ class MainWindow(QMainWindow): self._sync_ref_lines() 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._sync_ref_lines() @@ -271,6 +255,16 @@ class MainWindow(QMainWindow): """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) @@ -384,17 +378,12 @@ def main(): app = QApplication(sys.argv) - # Initialize font system before creating any widgets - font_success = initialize_fonts() - if font_success: - logger.info("Font system initialized successfully") - # Log font status for debugging - font_status = get_font_manager().get_status_report() - 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") + # Initialize font system (loads any fonts/ files, configures fallbacks) then + # lock the UI font. M PLUS 1 Code has full Japanese coverage, so this stays + # CJK-safe; falls back to the system default if the family isn't present. + initialize_fonts() + chosen = apply_fixed_font("M PLUS 1 Code", 10) + logger.info(f"UI font locked to '{chosen}' at 10pt") # Set application style app.setStyle('Fusion') # Modern cross-platform style diff --git a/plot_control_widget.py b/plot_control_widget.py index 2c86c50..30ab5d2 100644 --- a/plot_control_widget.py +++ b/plot_control_widget.py @@ -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 -next to each other in the left panel. +A clustered groupbox for the left panel: metric selector, log-frequency toggle, +relative-time toggle, and a manual refresh button. """ import logging @@ -57,14 +57,13 @@ class PlotControlWidget(QWidget): self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit()) group_layout.addWidget(self.log_freq_check) - # Time axis: absolute seconds vs relative % of each track's own length, so - # tracks of very different durations line up by song position when overlaid. - group_layout.addWidget(QLabel("Time axis:")) - self.x_mode_combo = QComboBox() - self.x_mode_combo.addItem("Absolute (seconds)", X_ABSOLUTE) - self.x_mode_combo.addItem("Relative (%)", X_RELATIVE) - self.x_mode_combo.currentIndexChanged.connect(lambda _: self.viewChanged.emit()) - group_layout.addWidget(self.x_mode_combo) + # 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() self.refresh_button = QPushButton("Refresh Plot") @@ -87,5 +86,5 @@ class PlotControlWidget(QWidget): def current_view_state(self) -> ViewState: return ViewState( 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, ) diff --git a/pyproject.toml b/pyproject.toml index ebb8690..21bc906 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ py-modules = [ "metrics", "plotspec", "font_manager", - "font_control_widget", "plot_control_widget", "ref_line_widget", "logger_setup", diff --git a/ref_line_widget.py b/ref_line_widget.py index d9dc5a9..6a54b5b 100644 --- a/ref_line_widget.py +++ b/ref_line_widget.py @@ -26,7 +26,7 @@ _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): + def __init__(self, parent, props: RefLineProps, value_units: str = ""): super().__init__(parent) self.setWindowTitle("Reference line") self._color = props.color @@ -37,6 +37,8 @@ class RefLineDialog(QDialog): 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()