diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1ac9877 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,21 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:docs.anthropic.com)", + "Bash(cat:*)", + "Bash(lsb_release:*)", + "Bash(find:*)", + "Bash(python:*)", + "Bash(git add:*)", + "Bash(git push:*)", + "WebFetch(domain:docs.github.com)", + "WebFetch(domain:docs.github.com)", + "Bash(ssh:*)", + "Bash(rm:*)" + ], + "deny": [], + "ask": [], + "defaultMode": "acceptEdits" + }, + "outputStyle": "haruki" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index e79eb32..f1ff9d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,24 @@ -files.txt \ No newline at end of file +files.txt + +# Font directory - avoid licensing issues by not committing font files +# Keep the directory structure but ignore actual font files +fonts/*.ttf +fonts/*.otf +fonts/*.ttc +# But preserve the placeholder file +!fonts/PLACE_YOUR_FONT_FILES_HERE + +# Python cache +__pycache__/ +*.pyc +*.pyo + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/CJK_FONTS.md b/CJK_FONTS.md new file mode 100644 index 0000000..e49630d --- /dev/null +++ b/CJK_FONTS.md @@ -0,0 +1,223 @@ +# CJK Font Support Implementation + +This document describes the CJK (Chinese, Japanese, Korean) font fallback system implemented for the Audio Analysis Toolkit. + +## Problem Statement + +The application displays song titles and metadata that may contain CJK characters from Japanese, Chinese, or Korean music files. The default matplotlib font (DejaVu Sans) lacks CJK glyphs, causing: +- Matplotlib warnings about missing glyphs +- Incorrect character rendering (squares, question marks, etc.) +- Poor user experience for CJK music collections + +## Solution Architecture + +### 1. Font Manager System (`font_manager.py`) + +A centralized font management system that handles both matplotlib and Qt font configuration: + +**Key Features:** +- **Licensing-safe**: Uses local `fonts/` directory (gitignored) for custom fonts +- **Graceful fallback**: System CJK fonts → matplotlib defaults +- **Cross-platform**: Windows, macOS, Linux font detection +- **Modular design**: Single responsibility for font configuration + +**Font Priority Order:** +1. Custom fonts from `fonts/` directory (highest priority) +2. System CJK fonts (platform-specific) +3. Default fonts (fallback) + +### 2. Safe Title Processing + +All text that might contain CJK characters is processed through `safe_title()` function: +- Ensures proper encoding handling +- Provides fallback for problematic characters +- Maintains original text when possible + +### 3. Integration Points + +The font system is integrated at these key locations: + +#### Application Startup (`main.py`) +```python +# Initialize font system before creating any widgets +font_success = initialize_fonts() +``` + +#### Plot Titles (`plotting_engine.py`, `master_core.py`) +```python +ax.set_title(safe_title(os.path.basename(file_path))) +``` + +#### Metadata Display +```python +song_name = safe_title(f"{audio['artist'][0]} - {audio['title'][0]}") +``` + +## Usage Instructions + +### Quick Setup + +1. **Run the setup utility:** +```bash +python setup_fonts.py +``` + +2. **For enhanced CJK support, add fonts to the `fonts/` directory:** + - Download free CJK fonts (Noto Sans CJK, Source Han Sans, etc.) + - Place .ttf/.otf/.ttc files in `fonts/` directory + - Restart the application + +### Font Directory Structure +``` +uj-mastering-master/ +├── fonts/ # Gitignored +│ ├── NotoSansCJK-Regular.ttc +│ ├── SourceHanSans-Regular.otf +│ └── [other CJK fonts] +└── [application files] +``` + +### Supported Font Formats +- `.ttf` (TrueType Font) +- `.otf` (OpenType Font) +- `.ttc` (TrueType Collection) + +## Platform-Specific Behavior + +### Windows +**System Fonts Used:** +- Yu Gothic UI, Meiryo, MS Gothic (sans-serif) +- Yu Mincho, MS Mincho (serif) + +### macOS +**System Fonts Used:** +- Hiragino Sans, Yu Gothic (sans-serif) +- Hiragino Mincho ProN, Yu Mincho (serif) + +### Linux +**System Fonts Used:** +- Noto Sans CJK JP, Source Han Sans (sans-serif) +- Noto Serif CJK JP, Source Han Serif (serif) + +## Technical Implementation Details + +### Font Detection Algorithm + +1. **Custom Font Loading:** + ```python + # Load for matplotlib + fm.fontManager.addfont(str(font_file)) + + # Load for Qt + font_id = QFontDatabase.addApplicationFont(str(font_file)) + ``` + +2. **System Font Fallback:** + ```python + available_fonts = set(fm.get_font_names()) + for font_name in system_fonts['sans-serif']: + if font_name in available_fonts: + return font_name + ``` + +3. **Matplotlib Configuration:** + ```python + plt.rcParams['font.sans-serif'] = font_list + plt.rcParams['font.family'] = 'sans-serif' + plt.rcParams['axes.unicode_minus'] = False + ``` + +### Error Handling + +The system is designed to be fault-tolerant: +- Missing fonts directory → Use system fonts +- Font loading failures → Log warnings, continue +- Encoding errors → Apply safe character replacement +- No CJK fonts found → Graceful degradation to defaults + +## Licensing Considerations + +### Safe Practices +- **Custom fonts directory is gitignored** to avoid committing proprietary fonts +- **System fonts are detected, not redistributed** +- **Open source font recommendations** (Noto, Source Han families) +- **No font files included in repository** + +### Recommended Free CJK Fonts +1. **Google Noto Fonts** (SIL Open Font License) + - Noto Sans CJK JP/SC/TC/KR + - Comprehensive CJK coverage + +2. **Adobe Source Han Fonts** (SIL Open Font License) + - Source Han Sans + - Source Han Serif + +## Testing and Debugging + +### Font Status Report +```python +from font_manager import get_font_manager +status = get_font_manager().get_status_report() +print(status) +``` + +### Test CJK Characters +```bash +python setup_fonts.py +``` + +### Logging +Font system operations are logged at appropriate levels: +- INFO: Successful initialization +- DEBUG: Font loading details +- WARNING: Missing fonts, fallbacks used +- ERROR: Critical font system failures + +## Future Improvements + +### Potential Enhancements +1. **Dynamic Font Switching:** Per-language font selection +2. **Font Caching:** Faster startup with font cache +3. **User Preferences:** GUI for font selection +4. **Font Metrics:** Analyze font quality for CJK rendering + +### Performance Considerations +- Font loading is done once at startup +- Font cache clearing only when necessary +- Minimal performance impact on audio processing + +## Troubleshooting + +### Common Issues + +**Issue:** CJK characters still show as squares +- **Solution:** Install CJK fonts in `fonts/` directory or check system font availability + +**Issue:** Font warnings in console +- **Solution:** Run `python setup_fonts.py` to check font configuration + +**Issue:** Application startup slower after font system +- **Solution:** This is normal on first run; subsequent starts should be faster + +### Debug Commands +```bash +# Check font system status +python setup_fonts.py + +# Test with specific log level +python main.py --log-level DEBUG + +# Test matplotlib font configuration +python -c "import matplotlib.pyplot as plt; print(plt.rcParams['font.sans-serif'])" +``` + +## Conclusion + +This CJK font support implementation provides: +- **Robust fallback system** ensuring CJK characters display properly +- **Licensing compliance** by avoiding font redistribution +- **Cross-platform compatibility** with platform-specific font preferences +- **Clean architecture** with separation of font management concerns +- **User-friendly setup** with clear instructions and status reporting + +The system gracefully handles missing fonts and provides clear guidance for optimal CJK character rendering while maintaining the existing application functionality. \ No newline at end of file diff --git a/analysis_results_manager.py b/analysis_results_manager.py index 0ec23e4..8c78ad7 100644 --- a/analysis_results_manager.py +++ b/analysis_results_manager.py @@ -3,10 +3,11 @@ Analysis Results Manager - Bridge between audio processing and GUI. Manages analysis queue and coordinates between components. """ -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt5.QtCore import QObject, pyqtSignal, QThread from dataclasses import dataclass from typing import Optional import os +import logging from master_core import AudioFile from plotting_engine import PlottingEngine @@ -26,26 +27,86 @@ class AnalysisResult: error_message: str = "" +class AudioAnalysisWorker(QThread): + """ + Worker thread for audio analysis to prevent GUI freezing. + Performs heavy librosa operations in background. + """ + + # Signals for communicating with main thread + progressUpdate = pyqtSignal(str, int) # message, percentage + analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult + analysisError = pyqtSignal(str, str) # file_path, error_message + + def __init__(self, file_path: str, window: int = 10, hop: int = 2): + super().__init__() + self.file_path = file_path + self.window = window + self.hop = hop + self.logger = logging.getLogger(__name__) + + def run(self): + """Main thread execution - performs audio analysis.""" + try: + self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}") + self.progressUpdate.emit("Loading audio file...", 10) + + # Create AudioFile and load audio data + audio_file = AudioFile(self.file_path) + self.progressUpdate.emit("Audio loaded, detecting tempo...", 30) + + # BPM is already calculated in __init__, now do RMS analysis + self.progressUpdate.emit("Computing RMS power levels...", 60) + audio_file.get_energy_levels_over_time(window=self.window, hop=self.hop) + + self.progressUpdate.emit("Finalizing analysis...", 90) + + # Extract analysis results + result = AnalysisResult( + file_path=self.file_path, + song_name=audio_file.song_name, + bpm=audio_file.get_bpm(), + max_amplitude=audio_file.max_amplitude, + avg_amplitude=audio_file.avg_amplitude, + times=audio_file._get_times(), + rms_array=audio_file.rms_array, + analysis_successful=True + ) + + self.progressUpdate.emit("Analysis complete!", 100) + self.logger.info(f"Analysis completed: {os.path.basename(self.file_path)} (BPM: {result.bpm:.1f})") + + # Emit success signal + self.analysisCompleted.emit(self.file_path, result) + + except Exception as e: + error_msg = f"Analysis failed: {str(e)}" + self.logger.error(f"Analysis error for {self.file_path}: {error_msg}") + self.analysisError.emit(self.file_path, error_msg) + + class AnalysisResultsManager(QObject): """ Manages audio file analysis and coordinates between processing and GUI. - Threading-ready architecture for future background processing. + Now uses background threads to prevent GUI freezing. """ # Signals for GUI communication analysisStarted = pyqtSignal(str) # file_path analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult analysisError = pyqtSignal(str, str) # file_path, error_message + progressUpdate = pyqtSignal(str, int) # message, percentage def __init__(self): super().__init__() self.results_cache = {} # Store analysis results self.plotting_engine = PlottingEngine() + self.current_worker = None # Track active worker thread + self.logger = logging.getLogger(__name__) def analyze_file(self, file_path: str, window: int = 10, hop: int = 2): """ - Analyze an audio file and emit results. - Currently synchronous - ready for threading later. + Analyze an audio file using background thread to prevent GUI freezing. Args: file_path: Path to audio file @@ -54,40 +115,38 @@ class AnalysisResultsManager(QObject): """ if not os.path.exists(file_path): error_msg = f"File not found: {file_path}" + self.logger.error(error_msg) self.analysisError.emit(file_path, error_msg) return + + # Stop any existing worker + if self.current_worker and self.current_worker.isRunning(): + self.logger.info("Stopping previous analysis to start new one") + self.current_worker.quit() + self.current_worker.wait() # Emit analysis started signal self.analysisStarted.emit(file_path) + self.logger.info(f"Queuing analysis: {os.path.basename(file_path)}") - try: - # Create AudioFile and perform analysis - audio_file = AudioFile(file_path) - - # Get RMS analysis data - audio_file.get_energy_levels_over_time(window=window, hop=hop) - - # Extract analysis results - result = AnalysisResult( - file_path=file_path, - song_name=audio_file.song_name, - bpm=audio_file.get_bpm(), - max_amplitude=audio_file.max_amplitude, - avg_amplitude=audio_file.avg_amplitude, - times=audio_file._get_times(), # We'll need to add this method - rms_array=audio_file.rms_array, - analysis_successful=True - ) - - # Cache the result - self.results_cache[file_path] = result - - # Emit completion signal - self.analysisCompleted.emit(file_path, result) - - except Exception as e: - error_msg = f"Analysis failed: {str(e)}" - self.analysisError.emit(file_path, error_msg) + # Create and start worker thread + self.current_worker = AudioAnalysisWorker(file_path, window, hop) + + # Connect worker signals + self.current_worker.progressUpdate.connect(self.progressUpdate.emit) + self.current_worker.analysisCompleted.connect(self._on_worker_completed) + self.current_worker.analysisError.connect(self.analysisError.emit) + + # Start the background analysis + self.current_worker.start() + + def _on_worker_completed(self, file_path: str, result: AnalysisResult): + """Handle completion of worker thread analysis.""" + # Cache the result + self.results_cache[file_path] = result + + # Forward the signal to GUI + self.analysisCompleted.emit(file_path, result) def get_analysis_figure(self, file_path: str): """ diff --git a/demo_logging.py b/demo_logging.py new file mode 100644 index 0000000..aad69c2 --- /dev/null +++ b/demo_logging.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +Demo script showing the different logging levels available. +Usage examples: + +python demo_logging.py --log-level=ERROR +python demo_logging.py --log-level=INFO +python demo_logging.py --log-level=DEBUG +python demo_logging.py --log-level=TRACE --log-file +""" + +import sys +from logger_setup import setup_logging, parse_log_args + +def main(): + # Parse logging configuration + log_level, log_to_file = parse_log_args() + + # Initialize logging + logger = setup_logging(log_level, log_to_file) + + # Demo different log levels + print(f"\n=== Audio Mastering Toolkit Logging Demo ===") + print(f"Log Level: {log_level}") + print(f"Log to File: {log_to_file}") + print(f"============================================\n") + + # Test all logging levels + logger.error("This is an ERROR message - critical failures only") + logger.warning("This is a WARNING message - non-fatal issues") + logger.info("This is an INFO message - key operations") + logger.debug("This is a DEBUG message - detailed processing steps") + logger.trace("This is a TRACE message - granular details") + + print(f"\nDemo complete! Messages above {log_level} level are visible.") + if log_to_file: + print("Check 'audio_analysis.log' for file output.") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/font_manager.py b/font_manager.py new file mode 100644 index 0000000..8b221ab --- /dev/null +++ b/font_manager.py @@ -0,0 +1,357 @@ +""" +Font management system with CJK fallback support. +Handles matplotlib and Qt font configuration with licensing-safe approach. +""" + +import os +import logging +import platform +from pathlib import Path +from typing import List, Optional, Dict, Any + +import matplotlib.pyplot as plt +import matplotlib.font_manager as fm +from PyQt5.QtCore import QCoreApplication +from PyQt5.QtGui import QFontDatabase, QFont + + +class FontManager: + """ + Centralized font management for CJK character support. + + Provides licensing-safe font fallback by: + 1. Loading fonts from local fonts/ directory (gitignored) + 2. Falling back to system CJK fonts + 3. Gracefully degrading to default fonts + """ + + def __init__(self, fonts_dir: str = "fonts"): + """ + Initialize font manager. + + Args: + fonts_dir: Directory name for custom fonts (relative to project root) + """ + self.logger = logging.getLogger(__name__) + self.fonts_dir = Path(__file__).parent / fonts_dir + self.loaded_fonts: Dict[str, str] = {} + self._matplotlib_configured = False + self._qt_configured = False + + # CJK font preferences by platform and type + self.system_font_fallbacks = { + 'Windows': { + 'serif': ['Yu Mincho', 'MS Mincho', '游明朝', 'MS 明朝'], + 'sans-serif': ['Yu Gothic UI', 'Meiryo', 'MS Gothic', '游ゴシック', 'メイリオ', 'MS ゴシック'], + 'monospace': ['MS Gothic', 'MS ゴシック'] + }, + 'Darwin': { # macOS + 'serif': ['Hiragino Mincho ProN', 'Yu Mincho', 'Times New Roman'], + 'sans-serif': ['Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS'], + 'monospace': ['Menlo', 'Monaco', 'Courier New'] + }, + 'Linux': { + 'serif': ['Noto Serif CJK JP', 'Source Han Serif', 'DejaVu Serif'], + 'sans-serif': ['Noto Sans CJK JP', 'Source Han Sans', 'DejaVu Sans'], + 'monospace': ['Noto Sans Mono CJK JP', 'Source Code Pro', 'DejaVu Sans Mono'] + } + } + + def initialize(self) -> bool: + """ + Initialize font system for both matplotlib and Qt. + + Returns: + bool: True if initialization was successful + """ + try: + self.logger.info("Initializing font management system...") + + # Load custom fonts if available + custom_fonts_loaded = self._load_custom_fonts() + + # Configure matplotlib + matplotlib_success = self._configure_matplotlib() + + # Configure Qt + qt_success = self._configure_qt() + + success = matplotlib_success and qt_success + + if success: + self.logger.info(f"Font system initialized successfully. Custom fonts: {custom_fonts_loaded}") + else: + self.logger.warning("Font system initialized with some issues") + + return success + + except Exception as e: + self.logger.error(f"Font system initialization failed: {e}") + return False + + def _load_custom_fonts(self) -> int: + """ + Load fonts from the fonts/ directory if it exists. + + Returns: + int: Number of custom fonts loaded + """ + if not self.fonts_dir.exists(): + self.logger.info(f"Custom fonts directory {self.fonts_dir} not found - using system fonts") + return 0 + + font_extensions = {'.ttf', '.otf', '.ttc'} + fonts_loaded = 0 + + try: + for font_file in self.fonts_dir.iterdir(): + if font_file.suffix.lower() in font_extensions: + try: + # Load for matplotlib + fm.fontManager.addfont(str(font_file)) + + # Load for Qt + font_id = QFontDatabase.addApplicationFont(str(font_file)) + if font_id != -1: + font_families = QFontDatabase.applicationFontFamilies(font_id) + for family in font_families: + self.loaded_fonts[family] = str(font_file) + + fonts_loaded += 1 + self.logger.debug(f"Loaded custom font: {font_file.name} -> {font_families}") + else: + self.logger.warning(f"Failed to load font for Qt: {font_file.name}") + + except Exception as e: + self.logger.warning(f"Failed to load custom font {font_file.name}: {e}") + + if fonts_loaded > 0: + # Clear matplotlib's font cache to recognize new fonts + fm.fontManager._load_fontmanager(try_read_cache=False) + + except Exception as e: + self.logger.error(f"Error loading custom fonts: {e}") + + return fonts_loaded + + def _configure_matplotlib(self) -> bool: + """Configure matplotlib with appropriate CJK font fallbacks.""" + try: + current_system = platform.system() + fallback_fonts = self.system_font_fallbacks.get(current_system, + self.system_font_fallbacks['Linux']) + + # Build font list: custom fonts + system fallbacks + matplotlib defaults + font_list = [] + + # Add custom fonts first (highest priority) + font_list.extend(self.loaded_fonts.keys()) + + # Add system CJK fonts + font_list.extend(fallback_fonts['sans-serif']) + + # Add matplotlib defaults as final fallback + font_list.extend(['DejaVu Sans', 'Arial', 'sans-serif']) + + # Update matplotlib configuration + plt.rcParams['font.sans-serif'] = font_list + plt.rcParams['font.family'] = 'sans-serif' + + # Ensure matplotlib can handle Unicode + plt.rcParams['axes.unicode_minus'] = False + + self.logger.info(f"Matplotlib configured with font list: {font_list[:3]}...") + self._matplotlib_configured = True + return True + + except Exception as e: + self.logger.error(f"Matplotlib font configuration failed: {e}") + return False + + def _configure_qt(self) -> bool: + """Configure Qt application with appropriate CJK fonts.""" + try: + app = QCoreApplication.instance() + if not app: + self.logger.warning("No Qt application instance found - Qt font configuration skipped") + return True + + # Get best available CJK font + cjk_font = self._get_best_cjk_font() + + if cjk_font: + # Set application-wide font + font = QFont(cjk_font) + app.setFont(font) + self.logger.info(f"Qt configured with CJK font: {cjk_font}") + else: + self.logger.info("Qt using default system font (no specific CJK font found)") + + self._qt_configured = True + return True + + except Exception as e: + self.logger.error(f"Qt font configuration failed: {e}") + return False + + def _get_best_cjk_font(self) -> Optional[str]: + """ + Find the best available CJK font for the current system. + + Returns: + str or None: Name of best available CJK font + """ + # Check custom fonts first + for font_name in self.loaded_fonts.keys(): + if self._is_cjk_capable(font_name): + return font_name + + # Check system fonts + current_system = platform.system() + system_fonts = self.system_font_fallbacks.get(current_system, + self.system_font_fallbacks['Linux']) + + available_fonts = set(fm.get_font_names()) + for font_name in system_fonts['sans-serif']: + if font_name in available_fonts: + return font_name + + return None + + def _is_cjk_capable(self, font_name: str) -> bool: + """ + Check if a font supports CJK characters. + Simple heuristic based on font name. + """ + cjk_indicators = [ + 'cjk', 'japanese', 'chinese', 'korean', 'han', 'noto', 'yu', + 'hiragino', 'meiryo', 'gothic', 'mincho', '游', 'メイリオ', + 'ゴシック', '明朝' + ] + font_lower = font_name.lower() + return any(indicator in font_lower for indicator in cjk_indicators) + + def get_cjk_safe_title(self, title: str, fallback_encoding: str = 'utf-8') -> str: + """ + Ensure title string is safe for display with current font configuration. + + Args: + title: Original title string + fallback_encoding: Encoding to use for problematic characters + + Returns: + str: Safe title string for display + """ + if not title: + return title + + try: + # Test if the string can be encoded/decoded properly + title.encode(fallback_encoding).decode(fallback_encoding) + return title + except UnicodeError: + # If there are encoding issues, create a safe fallback + safe_title = title.encode(fallback_encoding, errors='replace').decode(fallback_encoding) + self.logger.debug(f"Title encoding adjusted: {title[:50]}... -> {safe_title[:50]}...") + return safe_title + + def create_fonts_directory_if_needed(self) -> Path: + """ + Create the fonts directory and return its path. + Useful for setup instructions. + + Returns: + Path: Path to the fonts directory + """ + self.fonts_dir.mkdir(exist_ok=True) + return self.fonts_dir + + def get_font_installation_instructions(self) -> str: + """ + Generate user instructions for installing CJK fonts. + + Returns: + str: Multi-line instruction string + """ + fonts_path = self.create_fonts_directory_if_needed() + + instructions = f"""CJK Font Installation Instructions: + +1. Create or use the fonts directory: {fonts_path.absolute()} + +2. Download CJK fonts (legally) from sources like: + - Google Fonts (Noto Sans CJK, free & open source) + - Adobe Source Han fonts (free & open source) + - System fonts from your OS (if redistribution is allowed) + +3. Place .ttf, .otf, or .ttc font files in the fonts/ directory + +4. Restart the application to load the new fonts + +Note: The fonts/ directory is gitignored to avoid licensing issues. +System CJK fonts will be used as fallback if available. + +Current system: {platform.system()} +Recommended fonts: {', '.join(self.system_font_fallbacks.get(platform.system(), {}).get('sans-serif', ['System default'])[:3])} +""" + return instructions + + def get_status_report(self) -> Dict[str, Any]: + """ + Generate a status report of the font system. + + Returns: + dict: Status information + """ + return { + 'matplotlib_configured': self._matplotlib_configured, + 'qt_configured': self._qt_configured, + 'custom_fonts_loaded': len(self.loaded_fonts), + 'custom_font_families': list(self.loaded_fonts.keys()), + 'fonts_directory_exists': self.fonts_dir.exists(), + 'fonts_directory_path': str(self.fonts_dir.absolute()), + 'current_matplotlib_fonts': plt.rcParams.get('font.sans-serif', [])[:5], + 'current_system': platform.system() + } + + +# Global font manager instance +_font_manager: Optional[FontManager] = None + + +def get_font_manager() -> FontManager: + """ + Get the global font manager instance. + Creates one if it doesn't exist. + + Returns: + FontManager: Global font manager instance + """ + global _font_manager + if _font_manager is None: + _font_manager = FontManager() + return _font_manager + + +def initialize_fonts() -> bool: + """ + Initialize the global font system. + Call this early in application startup. + + Returns: + bool: True if successful + """ + return get_font_manager().initialize() + + +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 diff --git a/fonts/PLACE_YOUR_FONT_FILES_HERE b/fonts/PLACE_YOUR_FONT_FILES_HERE new file mode 100644 index 0000000..e676128 --- /dev/null +++ b/fonts/PLACE_YOUR_FONT_FILES_HERE @@ -0,0 +1,12 @@ +This directory is for custom CJK fonts to improve character rendering. + +Supported formats: .ttf, .otf, .ttc + +Recommended free fonts for CJK support: +- Noto Sans CJK (Google Fonts) +- Source Han Sans (Adobe) + +The application will automatically detect and use fonts placed here. +For setup instructions, run: python setup_fonts.py + +Note: Only add fonts you have proper licensing rights to distribute. \ No newline at end of file diff --git a/logger_setup.py b/logger_setup.py new file mode 100644 index 0000000..6671c24 --- /dev/null +++ b/logger_setup.py @@ -0,0 +1,95 @@ +""" +Logging configuration for the Audio Mastering Toolkit. +Provides CLI configurable logging with different verbosity levels. +""" + +import logging +import sys +from typing import Optional + + +def setup_logging(level: str = "INFO", log_to_file: bool = False) -> logging.Logger: + """ + Setup logging configuration for the application. + + Args: + level: Logging level (ERROR, WARN, INFO, DEBUG, TRACE) + log_to_file: Whether to also log to file + + Returns: + Configured logger instance + """ + # Convert level string to logging constant + level_map = { + 'ERROR': logging.ERROR, + 'WARN': logging.WARNING, + 'WARNING': logging.WARNING, + 'INFO': logging.INFO, + 'DEBUG': logging.DEBUG, + 'TRACE': 5 # Custom level below DEBUG + } + + # Add custom TRACE level + logging.addLevelName(5, 'TRACE') + + numeric_level = level_map.get(level.upper(), logging.INFO) + + # Create formatter + formatter = logging.Formatter( + '%(asctime)s [%(levelname)s] %(name)s: %(message)s', + datefmt='%H:%M:%S' + ) + + # Setup console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + console_handler.setLevel(numeric_level) + + # Setup root logger + root_logger = logging.getLogger() + root_logger.setLevel(numeric_level) + root_logger.handlers.clear() # Clear any existing handlers + root_logger.addHandler(console_handler) + + # Optional file logging + if log_to_file: + file_handler = logging.FileHandler('audio_analysis.log') + file_handler.setFormatter(formatter) + file_handler.setLevel(numeric_level) + root_logger.addHandler(file_handler) + + # Add trace method to all loggers + def trace(self, message, *args, **kwargs): + if self.isEnabledFor(5): + self._log(5, message, args, **kwargs) + + logging.Logger.trace = trace + + # Create main application logger + app_logger = logging.getLogger('audio_mastering') + app_logger.info(f"Logging initialized at {level.upper()} level") + + return app_logger + + +def parse_log_args() -> tuple[str, bool]: + """ + Parse command line arguments for logging configuration. + + Returns: + Tuple of (log_level, log_to_file) + """ + import argparse + + parser = argparse.ArgumentParser(add_help=False) # Don't interfere with main arg parsing + parser.add_argument('--log-level', '-l', + choices=['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'], + default='INFO', + help='Set logging verbosity level') + parser.add_argument('--log-file', action='store_true', + help='Also log to audio_analysis.log file') + + # Parse known args only (ignore others for main app) + args, _ = parser.parse_known_args() + + return args.log_level, args.log_file \ No newline at end of file diff --git a/main.py b/main.py index c9044e7..8347da1 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ import sys import os +import logging from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QLabel, QListWidget, QTextEdit, QListWidgetItem) @@ -7,6 +8,8 @@ from PyQt5.QtCore import Qt from audio_visualization_widget import AudioVisualizationWidget from analysis_results_manager import AnalysisResultsManager +from logger_setup import setup_logging, parse_log_args +from font_manager import initialize_fonts, get_font_manager class MainWindow(QMainWindow): @@ -14,6 +17,7 @@ class MainWindow(QMainWindow): def __init__(self): super().__init__() + self.logger = logging.getLogger(__name__) self.analysis_manager = AnalysisResultsManager() self.initUI() self.connect_signals() @@ -81,6 +85,7 @@ class MainWindow(QMainWindow): self.analysis_manager.analysisStarted.connect(self.on_analysis_started) self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed) self.analysis_manager.analysisError.connect(self.on_analysis_error) + self.analysis_manager.progressUpdate.connect(self.on_progress_update) def dragEnterEvent(self, event): """Handle drag enter event for file drops.""" @@ -99,13 +104,17 @@ class MainWindow(QMainWindow): files = [u.toLocalFile() for u in event.mimeData().urls()] audio_files = [f for f in files if f.lower().endswith(('.mp3', '.wav', '.flac'))] + self.logger.info(f"Files dropped: {len(files)} total, {len(audio_files)} audio files") + if audio_files: # Analyze the first audio file # TODO: Add support for multiple file queue file_path = audio_files[0] + self.logger.info(f"Starting analysis of dropped file: {os.path.basename(file_path)}") self.analysis_manager.analyze_file(file_path) else: self.visualization_widget.set_status("No audio files detected in drop") + self.logger.warning("No supported audio files found in drop") def on_analysis_started(self, file_path): """Called when analysis starts.""" @@ -143,8 +152,14 @@ class MainWindow(QMainWindow): def on_analysis_error(self, file_path, error_message): """Called when analysis fails.""" filename = os.path.basename(file_path) + self.logger.error(f"Analysis failed for {filename}: {error_message}") self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}") + def on_progress_update(self, message, percentage): + """Called when analysis progress updates.""" + self.logger.debug(f"Progress: {message} ({percentage}%)") + self.visualization_widget.set_status(f"{message} ({percentage}%)") + def on_file_selected(self, item): """Called when a file is selected from the list.""" file_path = item.data(Qt.UserRole) @@ -160,12 +175,34 @@ class MainWindow(QMainWindow): if __name__ == '__main__': + # Parse logging arguments before creating QApplication + log_level, log_to_file = parse_log_args() + + # Initialize logging + logger = setup_logging(log_level, log_to_file) + logger.info("Starting Audio Mastering Analysis Toolkit") + logger.info(f"Command line args: log-level={log_level}, log-file={log_to_file}") + 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") + # Set application style app.setStyle('Fusion') # Modern cross-platform style + logger.debug("Application style set to Fusion") window = MainWindow() window.show() + logger.info("GUI window displayed") sys.exit(app.exec_()) \ No newline at end of file diff --git a/master_core.py b/master_core.py index ed290ad..1dd7d3a 100644 --- a/master_core.py +++ b/master_core.py @@ -8,6 +8,7 @@ import matplotlib.cm as cm from mutagen.mp3 import MP3 from mutagen.easyid3 import EasyID3 +from font_manager import safe_title, initialize_fonts def try_mp3_tags(file_path): try: @@ -20,19 +21,19 @@ def try_mp3_tags(file_path): def read_mp3_tags(file_path): if (audio := try_mp3_tags(file_path)) is not None: - print(f"File name: {os.path.basename(file_path)}") - print(f"{audio['artist'][0]} - {audio['title'][0]}") + print(f"File name: {safe_title(os.path.basename(file_path))}") + print(f"{safe_title(audio['artist'][0])} - {safe_title(audio['title'][0])}") else: - print(f"File name: {os.path.basename(file_path)}") + print(f"File name: {safe_title(os.path.basename(file_path))}") class AudioFile: def __init__(self, file_path): self.file_path = file_path # file name / song name if (audio := try_mp3_tags(self.file_path)) is not None: - self.song_name = f"{audio['artist'][0]} - {audio['title'][0]}" + self.song_name = safe_title(f"{audio['artist'][0]} - {audio['title'][0]}") else: - self.song_name = os.path.basename(self.file_path) + self.song_name = safe_title(os.path.basename(self.file_path)) self.y, self.sr = librosa.load(file_path) # load automatically normalises everything to [-1.0, 1.0] @@ -49,7 +50,10 @@ class AudioFile: return self.max_amplitude, self.avg_amplitude def get_bpm(self): - return self.bpm + # librosa.beat.beat_track returns numpy array - extract scalar value + if isinstance(self.bpm, np.ndarray): + return float(self.bpm[0]) if len(self.bpm) > 0 else 0.0 + return float(self.bpm) def get_energy_levels_over_time(self, window = 10, hop = 2): """_summary_ @@ -121,7 +125,7 @@ class AudioFile: ax.set_ylabel('Power') ax.set_xlabel('Time') - ax.set_title(f'{os.path.basename(self.file_path)}') + ax.set_title(safe_title(os.path.basename(self.file_path))) plt.show(block=False) plt.pause(0.001) @@ -216,6 +220,9 @@ if __name__ == '__main__': print("For the new GUI interface, please run: python main.py") print() + # Initialize fonts for matplotlib + initialize_fonts() + # Replace 'path/to/your/audiofile.mp3' with the path to your audio file file_path = [] with open('./files.txt', 'r') as f: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e2dd47f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "uj-mastering-master", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/plotting_engine.py b/plotting_engine.py index d3538bc..dd85e37 100644 --- a/plotting_engine.py +++ b/plotting_engine.py @@ -9,6 +9,7 @@ import matplotlib.colors as mcolors import matplotlib.cm as cm from matplotlib.figure import Figure import os +from font_manager import safe_title class PlottingEngine: @@ -58,7 +59,7 @@ class PlottingEngine: # Labels and title ax.set_ylabel('Power') ax.set_xlabel('Time (seconds)') - ax.set_title(f'{os.path.basename(file_path)}') + ax.set_title(safe_title(os.path.basename(file_path))) # Tight layout for better appearance in GUI fig.tight_layout() @@ -73,7 +74,7 @@ class PlottingEngine: Returns: str: Formatted metadata text """ - return f"""Track: {song_name} + return f"""Track: {safe_title(song_name)} BPM: {bpm:.1f} Max Amplitude: {max_amplitude:.3f} Avg Amplitude: {avg_amplitude:.3f}""" \ No newline at end of file diff --git a/setup_fonts.py b/setup_fonts.py new file mode 100644 index 0000000..0b65a77 --- /dev/null +++ b/setup_fonts.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Font setup utility for CJK character support. +Provides installation instructions and system font detection. +""" + +import sys +import platform +from pathlib import Path + +# Add current directory to path to import our modules +sys.path.insert(0, str(Path(__file__).parent)) + +from font_manager import get_font_manager + + +def main(): + """Main font setup utility.""" + print("=== Audio Analysis Toolkit - CJK Font Setup ===\n") + + # Get font manager and show current status + font_manager = get_font_manager() + + print("Current System Information:") + print(f"Platform: {platform.system()} {platform.release()}") + print(f"Python: {platform.python_version()}\n") + + # Try to initialize fonts + print("Initializing font system...") + success = font_manager.initialize() + + # Show detailed status + status = font_manager.get_status_report() + print(f"Font system status: {'✓ OK' if success else '⚠ Issues detected'}") + print(f"Matplotlib configured: {'✓' if status['matplotlib_configured'] else '✗'}") + print(f"Qt configured: {'✓' if status['qt_configured'] else '✗'}") + print(f"Custom fonts loaded: {status['custom_fonts_loaded']}") + + if status['custom_font_families']: + print(f"Custom font families: {', '.join(status['custom_font_families'])}") + + print(f"Fonts directory: {status['fonts_directory_path']}") + print(f"Directory exists: {'✓' if status['fonts_directory_exists'] else '✗'}") + print() + + # Show current matplotlib font configuration + print("Current matplotlib font stack:") + for i, font in enumerate(status['current_matplotlib_fonts'][:8], 1): + print(f" {i}. {font}") + print() + + # Show installation instructions + print(font_manager.get_font_installation_instructions()) + + # Test CJK character handling + print("\n=== Testing CJK Character Handling ===") + test_strings = [ + "English Title", + "日本語のタイトル", # Japanese + "中文标题", # Chinese + "한국어 제목", # Korean + "Test - テスト", # Mixed + ] + + print("Testing font-safe title conversion:") + for test_str in test_strings: + safe_str = font_manager.get_cjk_safe_title(test_str) + status_indicator = "✓" if test_str == safe_str else "⚠" + print(f" {status_indicator} '{test_str}' -> '{safe_str}'") + + print("\n=== Setup Complete ===") + if success: + print("Font system is ready for use!") + if status['custom_fonts_loaded'] == 0: + print("Consider adding CJK fonts to improve character display.") + else: + print("There were issues with font setup. Check the logs for details.") + print("The application will still work but CJK characters may not display correctly.") + + return 0 if success else 1 + + +if __name__ == '__main__': + sys.exit(main()) \ No newline at end of file