Implement threading, logging, and CJK font support

Major improvements to GUI stability and internationalization:

- Fix GUI freezing by implementing threaded audio analysis
  - Add AudioAnalysisWorker thread for background processing
  - Progress signals with percentage updates
  - Thread-safe communication via Qt signals

- Add comprehensive CLI logging system
  - 5 log levels: ERROR, WARN, INFO, DEBUG, TRACE
  - Command line control: --log-level, --log-file
  - Real-time feedback during analysis operations

- Implement CJK font fallback system
  - FontManager with 3-tier fallback (custom → system → default)
  - Cross-platform CJK font detection (Windows/macOS/Linux)
  - Licensing-safe fonts/ directory with gitignored font files
  - Setup utility and comprehensive documentation

- Fix numpy array formatting issue with BPM detection
- Add progress indicators for long-running operations
- Preserve fonts directory structure with placeholder file

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2025-08-21 23:39:53 +09:00
parent b07b363454
commit 4337a31b80
13 changed files with 1007 additions and 42 deletions
+21
View File
@@ -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"
}
+23
View File
@@ -1 +1,24 @@
files.txt 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
+223
View File
@@ -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.
+84 -25
View File
@@ -3,10 +3,11 @@ Analysis Results Manager - Bridge between audio processing and GUI.
Manages analysis queue and coordinates between components. 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 dataclasses import dataclass
from typing import Optional from typing import Optional
import os import os
import logging
from master_core import AudioFile from master_core import AudioFile
from plotting_engine import PlottingEngine from plotting_engine import PlottingEngine
@@ -26,26 +27,86 @@ class AnalysisResult:
error_message: str = "" 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): class AnalysisResultsManager(QObject):
""" """
Manages audio file analysis and coordinates between processing and GUI. 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 # Signals for GUI communication
analysisStarted = pyqtSignal(str) # file_path analysisStarted = pyqtSignal(str) # file_path
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
analysisError = pyqtSignal(str, str) # file_path, error_message analysisError = pyqtSignal(str, str) # file_path, error_message
progressUpdate = pyqtSignal(str, int) # message, percentage
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.results_cache = {} # Store analysis results self.results_cache = {} # Store analysis results
self.plotting_engine = PlottingEngine() 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): def analyze_file(self, file_path: str, window: int = 10, hop: int = 2):
""" """
Analyze an audio file and emit results. Analyze an audio file using background thread to prevent GUI freezing.
Currently synchronous - ready for threading later.
Args: Args:
file_path: Path to audio file file_path: Path to audio file
@@ -54,41 +115,39 @@ class AnalysisResultsManager(QObject):
""" """
if not os.path.exists(file_path): if not os.path.exists(file_path):
error_msg = f"File not found: {file_path}" error_msg = f"File not found: {file_path}"
self.logger.error(error_msg)
self.analysisError.emit(file_path, error_msg) self.analysisError.emit(file_path, error_msg)
return 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 # Emit analysis started signal
self.analysisStarted.emit(file_path) self.analysisStarted.emit(file_path)
self.logger.info(f"Queuing analysis: {os.path.basename(file_path)}")
try: # Create and start worker thread
# Create AudioFile and perform analysis self.current_worker = AudioAnalysisWorker(file_path, window, hop)
audio_file = AudioFile(file_path)
# Get RMS analysis data # Connect worker signals
audio_file.get_energy_levels_over_time(window=window, hop=hop) 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)
# Extract analysis results # Start the background analysis
result = AnalysisResult( self.current_worker.start()
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
)
def _on_worker_completed(self, file_path: str, result: AnalysisResult):
"""Handle completion of worker thread analysis."""
# Cache the result # Cache the result
self.results_cache[file_path] = result self.results_cache[file_path] = result
# Emit completion signal # Forward the signal to GUI
self.analysisCompleted.emit(file_path, result) self.analysisCompleted.emit(file_path, result)
except Exception as e:
error_msg = f"Analysis failed: {str(e)}"
self.analysisError.emit(file_path, error_msg)
def get_analysis_figure(self, file_path: str): def get_analysis_figure(self, file_path: str):
""" """
Get matplotlib figure for a previously analyzed file. Get matplotlib figure for a previously analyzed file.
+40
View File
@@ -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()
+357
View File
@@ -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)
+12
View File
@@ -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.
+95
View File
@@ -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
+37
View File
@@ -1,5 +1,6 @@
import sys import sys
import os import os
import logging
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QSplitter, QLabel, QListWidget, QHBoxLayout, QSplitter, QLabel, QListWidget,
QTextEdit, QListWidgetItem) QTextEdit, QListWidgetItem)
@@ -7,6 +8,8 @@ from PyQt5.QtCore import Qt
from audio_visualization_widget import AudioVisualizationWidget from audio_visualization_widget import AudioVisualizationWidget
from analysis_results_manager import AnalysisResultsManager 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): class MainWindow(QMainWindow):
@@ -14,6 +17,7 @@ class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.logger = logging.getLogger(__name__)
self.analysis_manager = AnalysisResultsManager() self.analysis_manager = AnalysisResultsManager()
self.initUI() self.initUI()
self.connect_signals() self.connect_signals()
@@ -81,6 +85,7 @@ class MainWindow(QMainWindow):
self.analysis_manager.analysisStarted.connect(self.on_analysis_started) self.analysis_manager.analysisStarted.connect(self.on_analysis_started)
self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed) self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed)
self.analysis_manager.analysisError.connect(self.on_analysis_error) self.analysis_manager.analysisError.connect(self.on_analysis_error)
self.analysis_manager.progressUpdate.connect(self.on_progress_update)
def dragEnterEvent(self, event): def dragEnterEvent(self, event):
"""Handle drag enter event for file drops.""" """Handle drag enter event for file drops."""
@@ -99,13 +104,17 @@ class MainWindow(QMainWindow):
files = [u.toLocalFile() for u in event.mimeData().urls()] files = [u.toLocalFile() for u in event.mimeData().urls()]
audio_files = [f for f in files if f.lower().endswith(('.mp3', '.wav', '.flac'))] 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: if audio_files:
# Analyze the first audio file # Analyze the first audio file
# TODO: Add support for multiple file queue # TODO: Add support for multiple file queue
file_path = audio_files[0] 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) self.analysis_manager.analyze_file(file_path)
else: else:
self.visualization_widget.set_status("No audio files detected in drop") 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): def on_analysis_started(self, file_path):
"""Called when analysis starts.""" """Called when analysis starts."""
@@ -143,8 +152,14 @@ class MainWindow(QMainWindow):
def on_analysis_error(self, file_path, error_message): def on_analysis_error(self, file_path, error_message):
"""Called when analysis fails.""" """Called when analysis fails."""
filename = os.path.basename(file_path) 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}") 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): def on_file_selected(self, item):
"""Called when a file is selected from the list.""" """Called when a file is selected from the list."""
file_path = item.data(Qt.UserRole) file_path = item.data(Qt.UserRole)
@@ -160,12 +175,34 @@ class MainWindow(QMainWindow):
if __name__ == '__main__': 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) 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 # Set application style
app.setStyle('Fusion') # Modern cross-platform style app.setStyle('Fusion') # Modern cross-platform style
logger.debug("Application style set to Fusion")
window = MainWindow() window = MainWindow()
window.show() window.show()
logger.info("GUI window displayed")
sys.exit(app.exec_()) sys.exit(app.exec_())
+14 -7
View File
@@ -8,6 +8,7 @@ import matplotlib.cm as cm
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
from font_manager import safe_title, initialize_fonts
def try_mp3_tags(file_path): def try_mp3_tags(file_path):
try: try:
@@ -20,19 +21,19 @@ def try_mp3_tags(file_path):
def read_mp3_tags(file_path): def read_mp3_tags(file_path):
if (audio := try_mp3_tags(file_path)) is not None: if (audio := try_mp3_tags(file_path)) is not None:
print(f"File name: {os.path.basename(file_path)}") print(f"File name: {safe_title(os.path.basename(file_path))}")
print(f"{audio['artist'][0]} - {audio['title'][0]}") print(f"{safe_title(audio['artist'][0])} - {safe_title(audio['title'][0])}")
else: else:
print(f"File name: {os.path.basename(file_path)}") print(f"File name: {safe_title(os.path.basename(file_path))}")
class AudioFile: class AudioFile:
def __init__(self, file_path): def __init__(self, file_path):
self.file_path = file_path self.file_path = file_path
# file name / song name # file name / song name
if (audio := try_mp3_tags(self.file_path)) is not None: 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: 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) self.y, self.sr = librosa.load(file_path)
# load automatically normalises everything to [-1.0, 1.0] # load automatically normalises everything to [-1.0, 1.0]
@@ -49,7 +50,10 @@ class AudioFile:
return self.max_amplitude, self.avg_amplitude return self.max_amplitude, self.avg_amplitude
def get_bpm(self): 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): def get_energy_levels_over_time(self, window = 10, hop = 2):
"""_summary_ """_summary_
@@ -121,7 +125,7 @@ class AudioFile:
ax.set_ylabel('Power') ax.set_ylabel('Power')
ax.set_xlabel('Time') 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.show(block=False)
plt.pause(0.001) plt.pause(0.001)
@@ -216,6 +220,9 @@ if __name__ == '__main__':
print("For the new GUI interface, please run: python main.py") print("For the new GUI interface, please run: python main.py")
print() print()
# Initialize fonts for matplotlib
initialize_fonts()
# Replace 'path/to/your/audiofile.mp3' with the path to your audio file # Replace 'path/to/your/audiofile.mp3' with the path to your audio file
file_path = [] file_path = []
with open('./files.txt', 'r') as f: with open('./files.txt', 'r') as f:
+6
View File
@@ -0,0 +1,6 @@
{
"name": "uj-mastering-master",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+3 -2
View File
@@ -9,6 +9,7 @@ import matplotlib.colors as mcolors
import matplotlib.cm as cm import matplotlib.cm as cm
from matplotlib.figure import Figure from matplotlib.figure import Figure
import os import os
from font_manager import safe_title
class PlottingEngine: class PlottingEngine:
@@ -58,7 +59,7 @@ class PlottingEngine:
# Labels and title # Labels and title
ax.set_ylabel('Power') ax.set_ylabel('Power')
ax.set_xlabel('Time (seconds)') 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 # Tight layout for better appearance in GUI
fig.tight_layout() fig.tight_layout()
@@ -73,7 +74,7 @@ class PlottingEngine:
Returns: Returns:
str: Formatted metadata text str: Formatted metadata text
""" """
return f"""Track: {song_name} return f"""Track: {safe_title(song_name)}
BPM: {bpm:.1f} BPM: {bpm:.1f}
Max Amplitude: {max_amplitude:.3f} Max Amplitude: {max_amplitude:.3f}
Avg Amplitude: {avg_amplitude:.3f}""" Avg Amplitude: {avg_amplitude:.3f}"""
+84
View File
@@ -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())