Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a322f08d0c | |||
| 11182472e3 | |||
| 7bdf465799 | |||
| cf901a5686 | |||
| c466de62b2 | |||
| 3b689e0c4c | |||
| fa844dfde1 | |||
| 9e65e721d4 | |||
| 265e8254cd | |||
| 4337a31b80 |
+26
-1
@@ -1 +1,26 @@
|
||||
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 and build artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
+223
@@ -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`)
|
||||
```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
|
||||
uv run 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
|
||||
uv run 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
|
||||
uv run python setup_fonts.py
|
||||
|
||||
# Test with specific log level
|
||||
uv run ujm --log-level DEBUG
|
||||
|
||||
# Test matplotlib font configuration
|
||||
uv run 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.
|
||||
@@ -2,117 +2,173 @@
|
||||
|
||||
A custom mastering toolkit that provides metrics to evaluate audio masterings through visual analysis.
|
||||
|
||||
## Current Implementation
|
||||
## Current implementation
|
||||
|
||||
### Core Features
|
||||
- **Audio Analysis**: Uses librosa to analyze audio files (MP3/WAV support)
|
||||
- **Power Visualization**: Generates colorized power magnitude graphs over time
|
||||
### Core features
|
||||
- **Audio Analysis**: Uses librosa to analyze audio files (MP3/WAV/FLAC support) at native sample rate (no resampling)
|
||||
- **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
|
||||
- **GUI Foundation**: Basic PyQt5 drag-and-drop interface (work in progress)
|
||||
- **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
|
||||
- **Threading & Logging**: Robust background processing with detailed logging system
|
||||
|
||||
### Technical Stack
|
||||
### Technical stack
|
||||
- **Audio Processing**: librosa, numpy
|
||||
- **Visualization**: matplotlib with custom colormaps
|
||||
- **GUI Framework**: PyQt5 (drag-and-drop functionality)
|
||||
- **Metadata**: mutagen for MP3 tag reading
|
||||
- **Visualization**: matplotlib with custom colormaps and embedded Qt widgets
|
||||
- **GUI Framework**: PyQt5 with modular widget architecture
|
||||
- **Metadata**: mutagen for audio tag reading
|
||||
- **Font Support**: Custom font loading system with CJK fallback
|
||||
|
||||
### Key Components
|
||||
|
||||
#### `master_core.py`
|
||||
- `AudioFile` class: Main audio processing class
|
||||
- Loads audio files and extracts basic metrics (max/avg amplitude, BPM)
|
||||
- `get_energy_levels_over_time()`: Calculates RMS power over rolling windows
|
||||
- `plot_energy_levels_over_time()`: Creates colorized power graphs with automatic headroom detection
|
||||
- `analyze_track_librosa()`: Legacy analysis function (dBFS calculations)
|
||||
- File processing from `files.txt` configuration
|
||||
### Key components
|
||||
|
||||
#### `main.py`
|
||||
- PyQt5 drag-and-drop interface
|
||||
- Currently displays file paths but doesn't integrate with analysis functions
|
||||
- Placeholder for GUI integration
|
||||
- Complete GUI application with modular architecture
|
||||
- Drag-and-drop and file dialog support for audio files
|
||||
- Integrated font control system
|
||||
- Real-time analysis display and file management
|
||||
|
||||
#### `files.txt`
|
||||
- Configuration file listing audio files to analyze
|
||||
- Supports comments (`;` and `#` prefixed lines)
|
||||
- Currently contains various music file paths
|
||||
#### `analysis_results_manager.py`
|
||||
- Background threading for audio analysis
|
||||
- Caches both the loaded `AudioFile` and per-metric `compute()` output, so
|
||||
metric/font switches re-render from cache without reloading librosa
|
||||
- Progress tracking and error handling
|
||||
|
||||
### Current Analysis Features
|
||||
- **RMS Power Analysis**: 10-second rolling window with 2-second hops
|
||||
- **Adaptive Color Mapping**: Automatically adjusts scale based on detected headroom
|
||||
- High dynamic range: 0-0.6 scale for loud masters
|
||||
#### `audio_visualization_widget.py`
|
||||
- Embedded matplotlib visualization with Qt integration
|
||||
- Real-time plot updates and status display
|
||||
|
||||
#### `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
|
||||
|
||||
#### `plot_control_widget.py`
|
||||
- Metric selector dropdown driven by the `metrics.METRICS` registry
|
||||
- Houses the `Refresh Plot` button (foundation for upcoming style controls)
|
||||
|
||||
#### `metrics.py`
|
||||
- Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread)
|
||||
and `render(data, file_path) -> Figure` (cheap, GUI thread)
|
||||
- Current registry:
|
||||
- `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale
|
||||
- `WaveformMetric` — min/max envelope, fixed ±1.1 y-range
|
||||
- `LUFSMetric` — BS.1770 short-term (3 s) + integrated + LRA, via pyloudnorm
|
||||
- `CrestFactorMetric` — 20·log10(peak/RMS) per 1 s window
|
||||
- `PSRMetric` — sample-peak minus short-term LUFS (3 s window)
|
||||
- `TruePeakMetric` — 4× oversampled dBTP via `scipy.signal.resample_poly`
|
||||
- `SpectrogramMetric` — log-frequency STFT heatmap; adaptive hop caps time
|
||||
bins at ~4000, `N_FFT=4096`
|
||||
- Shared render helpers: `_show_axis_extents(ax)` forces each axis's exact
|
||||
min/max onto the ticks (so log-axis extremes like 22 kHz are always
|
||||
labelled); `_fmt_tick` keeps those labels compact
|
||||
- Drop in new ones (DR, spectral balance) by appending an instance to `METRICS`
|
||||
|
||||
#### `master_core.py`
|
||||
- Defines the `AudioFile` class: librosa loading, rolling RMS power, BPM detection
|
||||
- Loads at **native sample rate** (`librosa.load(..., sr=None)`) so the full
|
||||
band is preserved — analysis runs ~2× heavier on 44.1/48 kHz files than the
|
||||
old 22050 Hz default, by design
|
||||
- No batch / CLI mode — all analysis is driven from `main.py` via `AnalysisResultsManager`
|
||||
|
||||
### Current analysis features
|
||||
- **Native-rate loading**: full-band analysis up to the file's own nyquist
|
||||
- **RMS power analysis**: 10-second rolling window with 2-second hops
|
||||
- **Adaptive colour mapping**: Automatically adjusts scale based on detected headroom
|
||||
- High dynamic range: 0-0.6 scale for loud masters
|
||||
- Conservative mastering: 0-0.3 scale for quiet masters
|
||||
- **BPM Detection**: Automatic tempo analysis
|
||||
- **Metadata Display**: Artist and title from ID3 tags
|
||||
- **Loudness metrics**: LUFS (short-term + integrated + LRA), PSR, Crest Factor
|
||||
- **Peak analysis**: True Peak (4× oversampled dBTP)
|
||||
- **Spectral view**: log-frequency spectrogram heatmap over time
|
||||
- **Readable axes**: exact min/max of every axis is always labelled, even on log scale
|
||||
- **BPM detection**: Automatic tempo analysis
|
||||
- **Metadata display**: Artist and title from audio tags
|
||||
- **Real-time visualization**: Embedded matplotlib plots with font-aware rendering
|
||||
|
||||
### Known Issues
|
||||
- GUI integration incomplete (drag-drop doesn't trigger analysis)
|
||||
- MP3 tag reading temporarily disabled in some parts
|
||||
- No interactive features yet implemented
|
||||
### GUI features
|
||||
- **File management**: Drag-and-drop and file dialog for audio selection
|
||||
- **Font control**: Unified font selector with size control
|
||||
- **Plot control**: Metric selector + refresh-plot button
|
||||
- **Analysis display**: Real-time visualization with metadata panels
|
||||
- **Modular architecture**: Self-contained widgets for easy layout management
|
||||
|
||||
## Future Development Plans
|
||||
## Future development plans
|
||||
|
||||
### Short-term Goals
|
||||
1. **Complete GUI Integration**
|
||||
- Connect drag-drop functionality to analysis pipeline
|
||||
- Real-time graph display in GUI window
|
||||
- File browser for batch processing
|
||||
### Short-term (urgent)
|
||||
1. **Plot control widget cluster** *(metric selector + Refresh Plot done; still TODO)*
|
||||
- Plot style controller (colormap, line vs bar, etc.)
|
||||
- Foundation for mastering comparison features
|
||||
|
||||
2. **Enhanced Metrics**
|
||||
### Short-term (not urgent)
|
||||
1. **Enhanced metrics** *(plug new ones into `metrics.METRICS`)*
|
||||
- Dynamic range measurement (DR meter)
|
||||
- Peak-to-average ratio analysis
|
||||
- Frequency spectrum analysis
|
||||
- Loudness standards compliance (LUFS)
|
||||
- Long-term average spectrum (LTAS) / tonal-balance curve
|
||||
- Stereo metrics (correlation, mid/side) — needs `AudioFile` to retain stereo
|
||||
|
||||
3. **Interactive Features**
|
||||
- Zoom/pan on power graphs
|
||||
- Playback controls with visual cursor
|
||||
2. **Interactive plot features**
|
||||
- GUI-controllable plotting styles (colormap, visualization type)
|
||||
- Select axis ranges on the fly with automatic graph updates
|
||||
- Zoom/pan controls for detailed analysis
|
||||
- Export analysis results to CSV/JSON
|
||||
|
||||
### Medium-term Goals
|
||||
1. **Advanced Analysis Tools**
|
||||
3. **Advanced GUI controls**
|
||||
- Plot style customization interface
|
||||
- Real-time axis range selection (zooming in/out)
|
||||
- Interactive plot manipulation tools
|
||||
|
||||
4. **Better looking UI**
|
||||
- Graphical loading bar
|
||||
- Graphical logging text box
|
||||
|
||||
### Mid-to-long-term (very not urgent)
|
||||
1. **Audio comparison system**
|
||||
- Reference vs. comparee audio file analysis
|
||||
- Side-by-side track comparison interface
|
||||
- A/B testing for mastering versions
|
||||
- Overlay visualization for comparative analysis
|
||||
|
||||
2. **Distribution & deployment**
|
||||
- Self-contained executable releases
|
||||
- Cross-platform packaging
|
||||
- Installer creation and distribution
|
||||
|
||||
### Future vision
|
||||
1. **Advanced analysis tools**
|
||||
- Spectral centroid and bandwidth analysis
|
||||
- Stereo width measurements
|
||||
- Transient detection and analysis
|
||||
- Harmonic distortion detection
|
||||
|
||||
2. **Comparison Features**
|
||||
- Side-by-side track comparison
|
||||
- Reference track overlay
|
||||
- Mastering version A/B testing
|
||||
|
||||
3. **Batch Processing**
|
||||
- Folder-based analysis
|
||||
- Automated report generation
|
||||
- Progress tracking for large collections
|
||||
|
||||
### Long-term Vision
|
||||
1. **VST Plugin Development**
|
||||
- Real-time analysis during mixing/mastering
|
||||
- Integration with DAWs
|
||||
- Live feedback during production
|
||||
|
||||
2. **Professional Features**
|
||||
2. **Professional features**
|
||||
- EBU R128 compliance checking
|
||||
- Custom target curves
|
||||
- Professional reporting formats
|
||||
- Multi-format export capabilities
|
||||
|
||||
## Development Notes
|
||||
3. **VST plugin development**
|
||||
- Real-time analysis during mixing/mastering
|
||||
- Integration with DAWs
|
||||
- Live feedback during production
|
||||
|
||||
## Development notes
|
||||
|
||||
### Dependencies
|
||||
- librosa: Audio analysis and feature extraction
|
||||
- numpy: Numerical computations
|
||||
- scipy: Signal processing (true-peak polyphase oversampling)
|
||||
- pyloudnorm: BS.1770 loudness (LUFS, LRA)
|
||||
- matplotlib: Plotting and visualization
|
||||
- mutagen: Audio metadata extraction
|
||||
- PyQt5: GUI framework
|
||||
|
||||
### Architecture Considerations
|
||||
- Current code mixes analysis and visualization - consider separation
|
||||
### Architecture considerations
|
||||
- Analysis (`metrics.compute`) and visualization (`metrics.render`) are split
|
||||
across the `Metric` ABC; compute runs on a worker thread, render on the GUI
|
||||
- File path handling needs improvement for cross-platform compatibility
|
||||
- Error handling should be enhanced for production use
|
||||
- Consider moving from PyQt5 to PyQt6 or PySide for better licensing
|
||||
|
||||
### Testing Requirements
|
||||
### Testing requirements
|
||||
- Unit tests for audio analysis functions
|
||||
- GUI component testing
|
||||
- File format compatibility testing
|
||||
@@ -120,13 +176,23 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
||||
|
||||
## Usage
|
||||
|
||||
### Current Usage
|
||||
1. Add audio file paths to `files.txt`
|
||||
2. Run `python master_core.py` for batch analysis
|
||||
3. Run `python main.py` for GUI (incomplete)
|
||||
### Running the app
|
||||
```bash
|
||||
uv sync # one-time, after cloning
|
||||
uv run ujm # launch the GUI
|
||||
```
|
||||
|
||||
### Planned Usage
|
||||
1. Drag and drop audio files into GUI
|
||||
2. Real-time analysis with interactive graphs
|
||||
3. Export reports and comparisons
|
||||
4. VST plugin for DAW integration
|
||||
Optional flags (handled by `logger_setup.parse_log_args`):
|
||||
```bash
|
||||
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
||||
uv run ujm --log-file # also write audio_analysis.log
|
||||
```
|
||||
|
||||
The only entry point is `ujm` (defined in `pyproject.toml` as
|
||||
`ujm = "main:main"`). The previous `files.txt` batch mode and the
|
||||
`python master_core.py` workflow have been removed.
|
||||
|
||||
### Planned usage enhancements
|
||||
1. Interactive plot manipulation and style customization
|
||||
2. Audio file comparison features (reference vs. comparee)
|
||||
3. Self-contained executable releases
|
||||
@@ -1,29 +1,79 @@
|
||||
# uj-mastering-master
|
||||
Utility providing metrics to evaluate masterings.
|
||||
Now boosted by Claude Code.
|
||||
|
||||
## dependencies
|
||||
librosa, numpy, matplotlib, mutagen
|
||||
Custom mastering toolkit providing visual metrics for evaluating audio masterings.
|
||||
Developed with Claude Code assistance.
|
||||
|
||||
## usage
|
||||
## Features
|
||||
|
||||
### Command Line Analysis
|
||||
1. Edit `files.txt` to include paths to your audio files (MP3/WAV supported)
|
||||
- Use `;` or `#` to comment out files
|
||||
- One file path per line
|
||||
2. Run: `python master_core.py`
|
||||
- Generates colorized power magnitude graphs for each file
|
||||
- Displays BPM and song metadata
|
||||
- Graphs show RMS power over time with adaptive scaling
|
||||
### Current
|
||||
- **PyQt5 GUI**: drag-and-drop or file-dialog ingest of `.mp3`, `.wav`, `.flac`
|
||||
- **Switchable metrics** via a dropdown, all sharing one analysis cache:
|
||||
- **RMS Power** — 10 s rolling window with adaptive colour scale
|
||||
- **Waveform** — min/max envelope, fixed ±1.1 scale
|
||||
- **LUFS** — BS.1770 short-term (3 s) + integrated + loudness range (LRA)
|
||||
- **Crest Factor** — peak-to-RMS spread over time
|
||||
- **PSR** — peak-to-short-term-loudness ratio ("is it still breathing?")
|
||||
- **True Peak** — 4× oversampled dBTP, catches inter-sample peaks
|
||||
- **Spectrogram** — log-frequency STFT power heatmap over time
|
||||
- **Always-labelled axis extremes**: every plot forces its exact min/max onto
|
||||
the ticks, so you can read the true range even on a log axis (e.g. the
|
||||
spectrogram's 22 kHz top, which otherwise falls between decade ticks)
|
||||
- **Native sample rate**: audio is loaded without resampling, so the full band
|
||||
(up to the file's own nyquist, e.g. ~22 kHz for 44.1 kHz files) is analysed
|
||||
- **BPM detection** via librosa
|
||||
- **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored), system fallbacks, and a live font selector
|
||||
- **Background analysis thread** so the UI stays responsive; metric switches
|
||||
compute off the GUI thread and cache, so re-selecting a metric is instant
|
||||
- **Embedded matplotlib canvas** with auto-regenerated plots on font change
|
||||
|
||||
### GUI Mode (Experimental)
|
||||
Run: `python main.py`
|
||||
- Opens drag-and-drop interface
|
||||
- Currently displays dropped file paths
|
||||
- Analysis integration coming soon
|
||||
### Roadmap
|
||||
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
|
||||
dynamic range (DR meter), plot-style controls, interactive axis controls.
|
||||
|
||||
### Output
|
||||
- Interactive matplotlib graphs showing power levels over time
|
||||
- Color-coded visualization (autumn colormap)
|
||||
- Automatic headroom detection and scaling
|
||||
- Console output with BPM and metadata information
|
||||
## Quick start
|
||||
|
||||
This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run ujm
|
||||
```
|
||||
|
||||
`uv run ujm` is the only supported entry point — it boots the GUI.
|
||||
|
||||
### Logging flags
|
||||
```bash
|
||||
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
||||
uv run ujm --log-file # also write audio_analysis.log
|
||||
```
|
||||
|
||||
### Fonts
|
||||
Drop `.ttf` / `.otf` / `.ttc` files into `fonts/` to get them in the font
|
||||
selector. The directory is gitignored to avoid bundling licensed font data.
|
||||
See [CJK_FONTS.md](CJK_FONTS.md) for details.
|
||||
|
||||
## Dependencies
|
||||
`librosa`, `numpy`, `matplotlib`, `mutagen`, `pyloudnorm`, `PyQt5` — all pinned
|
||||
through `uv.lock`. Python 3.10+.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `main.py` | `MainWindow` + the `ujm` entry point |
|
||||
| `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache |
|
||||
| `master_core.py` | `AudioFile`: native-rate librosa loading, RMS rolling window, BPM |
|
||||
| `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) |
|
||||
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host |
|
||||
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config |
|
||||
| `font_control_widget.py` | Font picker + size slider |
|
||||
| `plot_control_widget.py` | Metric selector + refresh-plot button |
|
||||
| `logger_setup.py` | CLI log-level parsing + custom TRACE level |
|
||||
| `setup_fonts.py` | Diagnostic utility (run standalone) |
|
||||
|
||||
### Adding a metric
|
||||
|
||||
Subclass `Metric` in `metrics.py`, implement `compute(audio_file) -> data` (the
|
||||
heavy part, runs on the worker thread) and `render(data, file_path) -> Figure`
|
||||
(cheap, runs on the GUI thread). Register the instance in the `METRICS` dict at
|
||||
the bottom of the file — it shows up in the dropdown automatically.
|
||||
|
||||
Binary file not shown.
+227
-107
@@ -3,122 +3,242 @@ Analysis Results Manager - Bridge between audio processing and GUI.
|
||||
Manages analysis queue and coordinates between components.
|
||||
"""
|
||||
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from PyQt5.QtCore import QObject, pyqtSignal, QThread
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
import os
|
||||
import logging
|
||||
|
||||
from master_core import AudioFile
|
||||
from plotting_engine import PlottingEngine
|
||||
from font_manager import safe_title
|
||||
from metrics import METRICS, DEFAULT_METRIC_ID, Metric
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
"""Container for audio analysis results."""
|
||||
file_path: str
|
||||
song_name: str
|
||||
bpm: float
|
||||
max_amplitude: float
|
||||
avg_amplitude: float
|
||||
times: list
|
||||
rms_array: list
|
||||
analysis_successful: bool = True
|
||||
error_message: str = ""
|
||||
"""Container for audio analysis results."""
|
||||
file_path: str
|
||||
audio_file: AudioFile
|
||||
song_name: str
|
||||
bpm: float
|
||||
max_amplitude: float
|
||||
avg_amplitude: float
|
||||
metric_data: dict[str, Any] = field(default_factory=dict)
|
||||
analysis_successful: bool = True
|
||||
error_message: str = ""
|
||||
|
||||
def metadata_text(self) -> str:
|
||||
return (
|
||||
f"Track: {safe_title(self.song_name)}\n"
|
||||
f"BPM: {self.bpm:.1f}\n"
|
||||
f"Max Amplitude: {self.max_amplitude:.3f}\n"
|
||||
f"Avg Amplitude: {self.avg_amplitude:.3f}"
|
||||
)
|
||||
|
||||
|
||||
class AudioAnalysisWorker(QThread):
|
||||
"""Worker thread that loads audio and computes a single metric."""
|
||||
|
||||
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, metric: Metric):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
self.metric = metric
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}")
|
||||
self.progressUpdate.emit("Loading audio file...", 10)
|
||||
|
||||
audio_file = AudioFile(self.file_path)
|
||||
self.progressUpdate.emit("Audio loaded, detecting tempo...", 30)
|
||||
|
||||
self.progressUpdate.emit(f"Computing {self.metric.display_name}...", 60)
|
||||
metric_data = {self.metric.id: self.metric.compute(audio_file)}
|
||||
|
||||
self.progressUpdate.emit("Finalizing analysis...", 90)
|
||||
|
||||
result = AnalysisResult(
|
||||
file_path=self.file_path,
|
||||
audio_file=audio_file,
|
||||
song_name=audio_file.song_name,
|
||||
bpm=audio_file.get_bpm(),
|
||||
max_amplitude=audio_file.max_amplitude,
|
||||
avg_amplitude=audio_file.avg_amplitude,
|
||||
metric_data=metric_data,
|
||||
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})"
|
||||
)
|
||||
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 MetricComputeWorker(QThread):
|
||||
"""Worker thread that computes a single metric against an already-loaded AudioFile."""
|
||||
|
||||
completed = pyqtSignal(str, str, object) # file_path, metric_id, data
|
||||
failed = pyqtSignal(str, str, str) # file_path, metric_id, error_message
|
||||
|
||||
def __init__(self, file_path: str, audio_file: AudioFile, metric: Metric):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
self.audio_file = audio_file
|
||||
self.metric = metric
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.logger.info(
|
||||
f"Computing {self.metric.display_name} for {os.path.basename(self.file_path)}"
|
||||
)
|
||||
data = self.metric.compute(self.audio_file)
|
||||
self.completed.emit(self.file_path, self.metric.id, data)
|
||||
except Exception as e:
|
||||
msg = f"{self.metric.display_name} compute failed: {e}"
|
||||
self.logger.error(msg)
|
||||
self.failed.emit(self.file_path, self.metric.id, str(e))
|
||||
|
||||
|
||||
class AnalysisResultsManager(QObject):
|
||||
"""Manages audio file analysis and coordinates between processing and GUI."""
|
||||
|
||||
# Full-analysis (load + initial metric) signals.
|
||||
analysisStarted = pyqtSignal(str)
|
||||
analysisCompleted = pyqtSignal(str, object)
|
||||
analysisError = pyqtSignal(str, str)
|
||||
progressUpdate = pyqtSignal(str, int)
|
||||
|
||||
# Metric-only signals (used for switches after analysis has completed).
|
||||
metricComputeStarted = pyqtSignal(str, str) # file_path, metric_id
|
||||
metricReady = pyqtSignal(str, str) # file_path, metric_id
|
||||
metricComputeError = pyqtSignal(str, str, str) # file_path, metric_id, error
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.results_cache: dict[str, AnalysisResult] = {}
|
||||
self.current_worker: Optional[AudioAnalysisWorker] = None
|
||||
self.metric_workers: dict[tuple[str, str], MetricComputeWorker] = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def analyze_file(self, file_path: str, metric_id: str = DEFAULT_METRIC_ID):
|
||||
"""Kick off background analysis for the given file and metric."""
|
||||
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
|
||||
|
||||
metric = METRICS.get(metric_id)
|
||||
if metric is None:
|
||||
error_msg = f"Unknown metric: {metric_id}"
|
||||
self.logger.error(error_msg)
|
||||
self.analysisError.emit(file_path, error_msg)
|
||||
return
|
||||
|
||||
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()
|
||||
|
||||
self.analysisStarted.emit(file_path)
|
||||
self.logger.info(
|
||||
f"Queuing analysis: {os.path.basename(file_path)} ({metric.display_name})"
|
||||
)
|
||||
|
||||
self.current_worker = AudioAnalysisWorker(file_path, metric)
|
||||
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)
|
||||
self.current_worker.start()
|
||||
|
||||
def _on_worker_completed(self, file_path: str, result: AnalysisResult):
|
||||
self.results_cache[file_path] = result
|
||||
self.analysisCompleted.emit(file_path, result)
|
||||
|
||||
def request_metric(self, file_path: str, metric_id: str) -> bool:
|
||||
"""Ensure the metric's data exists for the file; emit metricReady when ready.
|
||||
|
||||
Returns True if the data was already cached (metricReady emitted synchronously)
|
||||
or successfully kicked off (will emit later). Returns False if the file hasn't
|
||||
been analysed yet or the metric id is unknown — in that case the caller
|
||||
should wait for analysisCompleted or correct the metric id.
|
||||
"""
|
||||
Manages audio file analysis and coordinates between processing and GUI.
|
||||
Threading-ready architecture for future background processing.
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is None:
|
||||
return False
|
||||
|
||||
metric = METRICS.get(metric_id)
|
||||
if metric is None:
|
||||
self.logger.warning(f"Unknown metric requested: {metric_id}")
|
||||
return False
|
||||
|
||||
if metric_id in result.metric_data:
|
||||
# Cached — emit immediately so the caller can re-render.
|
||||
self.metricReady.emit(file_path, metric_id)
|
||||
return True
|
||||
|
||||
key = (file_path, metric_id)
|
||||
existing = self.metric_workers.get(key)
|
||||
if existing is not None and existing.isRunning():
|
||||
self.logger.debug(f"Metric compute already in flight: {metric_id} for {os.path.basename(file_path)}")
|
||||
return True
|
||||
|
||||
worker = MetricComputeWorker(file_path, result.audio_file, metric)
|
||||
worker.completed.connect(self._on_metric_completed)
|
||||
worker.failed.connect(self._on_metric_failed)
|
||||
self.metric_workers[key] = worker
|
||||
self.metricComputeStarted.emit(file_path, metric_id)
|
||||
worker.start()
|
||||
return True
|
||||
|
||||
def _on_metric_completed(self, file_path: str, metric_id: str, data: object):
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is not None:
|
||||
result.metric_data[metric_id] = data
|
||||
self.metric_workers.pop((file_path, metric_id), None)
|
||||
self.metricReady.emit(file_path, metric_id)
|
||||
|
||||
def _on_metric_failed(self, file_path: str, metric_id: str, error_message: str):
|
||||
self.metric_workers.pop((file_path, metric_id), None)
|
||||
self.metricComputeError.emit(file_path, metric_id, error_message)
|
||||
|
||||
def get_metric_figure(self, file_path: str, metric_id: str):
|
||||
"""Render a Figure from cached metric data. Returns None if not cached.
|
||||
|
||||
Never triggers compute — call `request_metric` first and listen for
|
||||
`metricReady` if you need on-demand computation.
|
||||
"""
|
||||
|
||||
# Signals for GUI communication
|
||||
analysisStarted = pyqtSignal(str) # file_path
|
||||
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
|
||||
analysisError = pyqtSignal(str, str) # file_path, error_message
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.results_cache = {} # Store analysis results
|
||||
self.plotting_engine = PlottingEngine()
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
window: RMS analysis window size in seconds
|
||||
hop: Analysis hop size in seconds
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
error_msg = f"File not found: {file_path}"
|
||||
self.analysisError.emit(file_path, error_msg)
|
||||
return
|
||||
|
||||
# Emit analysis started signal
|
||||
self.analysisStarted.emit(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)
|
||||
|
||||
def get_analysis_figure(self, file_path: str):
|
||||
"""
|
||||
Get matplotlib figure for a previously analyzed file.
|
||||
|
||||
Returns:
|
||||
matplotlib.figure.Figure or None
|
||||
"""
|
||||
if file_path not in self.results_cache:
|
||||
return None
|
||||
|
||||
result = self.results_cache[file_path]
|
||||
return self.plotting_engine.create_power_analysis_figure(
|
||||
result.times, result.rms_array, result.file_path
|
||||
)
|
||||
|
||||
def get_metadata_text(self, file_path: str) -> str:
|
||||
"""Get formatted metadata text for a file."""
|
||||
if file_path not in self.results_cache:
|
||||
return "No analysis data available"
|
||||
|
||||
result = self.results_cache[file_path]
|
||||
return self.plotting_engine.create_metadata_display_text(
|
||||
result.song_name, result.bpm,
|
||||
result.max_amplitude, result.avg_amplitude
|
||||
)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear all cached analysis results."""
|
||||
self.results_cache.clear()
|
||||
|
||||
def is_file_analyzed(self, file_path: str) -> bool:
|
||||
"""Check if a file has been analyzed."""
|
||||
return file_path in self.results_cache
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is None:
|
||||
return None
|
||||
metric = METRICS.get(metric_id)
|
||||
if metric is None:
|
||||
return None
|
||||
data = result.metric_data.get(metric_id)
|
||||
if data is None:
|
||||
return None
|
||||
return metric.render(data, file_path)
|
||||
|
||||
def get_metadata_text(self, file_path: str) -> str:
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is None:
|
||||
return "No analysis data available"
|
||||
return result.metadata_text()
|
||||
|
||||
def clear_cache(self):
|
||||
self.results_cache.clear()
|
||||
|
||||
def is_file_analyzed(self, file_path: str) -> bool:
|
||||
return file_path in self.results_cache
|
||||
|
||||
@@ -6,7 +6,6 @@ Pure display responsibility - receives plotting data and shows graphs.
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
from matplotlib.figure import Figure
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
class AudioVisualizationWidget(QWidget):
|
||||
@@ -49,57 +48,6 @@ class AudioVisualizationWidget(QWidget):
|
||||
ax.set_yticks([])
|
||||
self.canvas.draw()
|
||||
|
||||
def display_analysis_figure(self, figure):
|
||||
"""
|
||||
Display a matplotlib figure in the widget.
|
||||
|
||||
Args:
|
||||
figure: matplotlib.figure.Figure to display
|
||||
"""
|
||||
# Clear current figure
|
||||
self.figure.clear()
|
||||
|
||||
# Copy the provided figure to our canvas
|
||||
# Get the subplot from the provided figure
|
||||
source_ax = figure.get_axes()[0]
|
||||
|
||||
# Create new subplot in our figure
|
||||
ax = self.figure.add_subplot(111)
|
||||
|
||||
# Copy all the plot elements
|
||||
for child in source_ax.get_children():
|
||||
if hasattr(child, 'get_data'):
|
||||
# Copy line plots
|
||||
try:
|
||||
x_data, y_data = child.get_data()
|
||||
ax.plot(x_data, y_data, color=child.get_color(),
|
||||
linewidth=child.get_linewidth())
|
||||
except:
|
||||
pass
|
||||
|
||||
# Copy collections (fill_between creates PolyCollection)
|
||||
for collection in source_ax.collections:
|
||||
ax.add_collection(collection)
|
||||
|
||||
# Copy axis properties
|
||||
ax.set_xlim(source_ax.get_xlim())
|
||||
ax.set_ylim(source_ax.get_ylim())
|
||||
ax.set_xlabel(source_ax.get_xlabel())
|
||||
ax.set_ylabel(source_ax.get_ylabel())
|
||||
ax.set_title(source_ax.get_title())
|
||||
|
||||
# Copy colorbar if it exists
|
||||
if hasattr(figure, '_colorbar') or len(figure.get_axes()) > 1:
|
||||
# Try to copy colorbar
|
||||
try:
|
||||
cbar = figure.colorbar(source_ax.collections[-1], ax=ax, label='RMS Power')
|
||||
except:
|
||||
pass
|
||||
|
||||
self.figure.tight_layout()
|
||||
self.canvas.draw()
|
||||
self.status_label.setText("Analysis complete - displaying power graph")
|
||||
|
||||
def display_figure_direct(self, figure):
|
||||
"""
|
||||
Display a figure by replacing our canvas figure entirely.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""
|
||||
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}")
|
||||
+503
@@ -0,0 +1,503 @@
|
||||
"""
|
||||
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
|
||||
try:
|
||||
# Try the common method for refreshing font cache
|
||||
if hasattr(fm.fontManager, '_load_fontmanager'):
|
||||
fm.fontManager._load_fontmanager(try_read_cache=False)
|
||||
else:
|
||||
# For newer matplotlib versions, just reinitialize
|
||||
fm.fontManager.__init__()
|
||||
except Exception as font_cache_error:
|
||||
self.logger.debug(f"Font cache refresh failed (non-critical): {font_cache_error}")
|
||||
|
||||
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_available_system_fonts(self) -> List[str]:
|
||||
"""
|
||||
Get list of available system fonts that are good candidates for selection.
|
||||
|
||||
Returns:
|
||||
List[str]: List of available system font names
|
||||
"""
|
||||
current_system = platform.system()
|
||||
fallback_fonts = self.system_font_fallbacks.get(current_system,
|
||||
self.system_font_fallbacks['Linux'])
|
||||
|
||||
# Combine all font categories
|
||||
preferred_fonts = []
|
||||
for category in ['sans-serif', 'serif', 'monospace']:
|
||||
preferred_fonts.extend(fallback_fonts.get(category, []))
|
||||
|
||||
# Get actually available fonts on the system
|
||||
available_fonts = set(fm.get_font_names())
|
||||
|
||||
# Filter to only fonts that are actually available
|
||||
system_candidates = []
|
||||
for font_name in preferred_fonts:
|
||||
if font_name in available_fonts:
|
||||
system_candidates.append(font_name)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
unique_candidates = []
|
||||
for font in system_candidates:
|
||||
if font not in seen:
|
||||
seen.add(font)
|
||||
unique_candidates.append(font)
|
||||
|
||||
return unique_candidates
|
||||
|
||||
def get_default_system_font_name(self) -> str:
|
||||
"""
|
||||
Get the name of the default system font for display purposes.
|
||||
|
||||
Returns:
|
||||
str: Human-readable name of the default system font
|
||||
"""
|
||||
from PyQt5.QtGui import QFont
|
||||
from PyQt5.QtCore import QCoreApplication
|
||||
|
||||
# Try to get the actual default font name from Qt
|
||||
app = QCoreApplication.instance()
|
||||
if app:
|
||||
default_font = QFont()
|
||||
return default_font.family()
|
||||
|
||||
# Fallback to platform-specific defaults
|
||||
current_system = platform.system()
|
||||
if current_system == 'Windows':
|
||||
return 'Segoe UI'
|
||||
elif current_system == 'Darwin':
|
||||
return 'SF Pro Display'
|
||||
else:
|
||||
return 'DejaVu Sans'
|
||||
|
||||
def select_startup_font(self) -> tuple[str, str]:
|
||||
"""
|
||||
Select the appropriate font for application startup.
|
||||
Priority: First custom font alphabetically, then first system font, then default.
|
||||
|
||||
Returns:
|
||||
tuple: (font_name, font_type) where font_type is 'custom', 'system', or 'default'
|
||||
"""
|
||||
# Check for custom fonts first
|
||||
if self.loaded_fonts:
|
||||
custom_font_names = sorted(self.loaded_fonts.keys())
|
||||
selected_font = custom_font_names[0]
|
||||
self.logger.info(f"Startup font selected (custom): {selected_font}")
|
||||
return selected_font, 'custom'
|
||||
|
||||
# Check for system fonts
|
||||
system_fonts = self.get_available_system_fonts()
|
||||
if system_fonts:
|
||||
selected_font = system_fonts[0]
|
||||
self.logger.info(f"Startup font selected (system): {selected_font}")
|
||||
return selected_font, 'system'
|
||||
|
||||
# Default fallback
|
||||
default_font = self.get_default_system_font_name()
|
||||
self.logger.info(f"Startup font selected (default): {default_font}")
|
||||
return default_font, 'default'
|
||||
|
||||
def apply_font_selection(self, font_name: str, font_type: str) -> bool:
|
||||
"""
|
||||
Apply a font selection to both matplotlib and Qt.
|
||||
|
||||
Args:
|
||||
font_name: Name of the font to apply
|
||||
font_type: Type of font ('custom', 'system', or 'default')
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
if font_type == 'default':
|
||||
# Reset to default configuration
|
||||
self._configure_matplotlib()
|
||||
self._configure_qt()
|
||||
else:
|
||||
# Apply specific font
|
||||
self._set_specific_font(font_name)
|
||||
|
||||
self.logger.info(f"Font applied successfully: {font_name} ({font_type})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error applying font selection: {e}")
|
||||
return False
|
||||
|
||||
def _set_specific_font(self, font_name: str):
|
||||
"""Set a specific font for both matplotlib and Qt."""
|
||||
from PyQt5.QtCore import QCoreApplication
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
# Update Qt font
|
||||
app = QCoreApplication.instance()
|
||||
if app:
|
||||
font = QFont(font_name)
|
||||
app.setFont(font)
|
||||
|
||||
# Update matplotlib font (insert at front of font list)
|
||||
current_fonts = plt.rcParams['font.sans-serif'].copy()
|
||||
if font_name in current_fonts:
|
||||
current_fonts.remove(font_name)
|
||||
current_fonts.insert(0, font_name)
|
||||
plt.rcParams['font.sans-serif'] = current_fonts
|
||||
|
||||
def get_status_report(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a status report of the font system.
|
||||
|
||||
Returns:
|
||||
dict: Status information
|
||||
"""
|
||||
startup_font, startup_type = self.select_startup_font()
|
||||
|
||||
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()),
|
||||
'available_system_fonts': self.get_available_system_fonts(),
|
||||
'default_system_font': self.get_default_system_font_name(),
|
||||
'startup_font': startup_font,
|
||||
'startup_font_type': startup_type,
|
||||
'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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,12 +1,17 @@
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||
QHBoxLayout, QSplitter, QLabel, QListWidget,
|
||||
QTextEdit, QListWidgetItem)
|
||||
QTextEdit, QListWidgetItem, QPushButton, QFileDialog)
|
||||
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
|
||||
from font_control_widget import FontControlWidget
|
||||
from plot_control_widget import PlotControlWidget
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
@@ -14,6 +19,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.analysis_manager = AnalysisResultsManager()
|
||||
self.initUI()
|
||||
self.connect_signals()
|
||||
@@ -49,6 +55,23 @@ class MainWindow(QMainWindow):
|
||||
panel = QWidget()
|
||||
layout = QVBoxLayout(panel)
|
||||
|
||||
# Open File button
|
||||
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 + refresh)
|
||||
self.plot_control = PlotControlWidget()
|
||||
self.plot_control.metricChanged.connect(self.on_metric_changed)
|
||||
self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested)
|
||||
layout.addWidget(self.plot_control)
|
||||
|
||||
# File list
|
||||
self.file_list_label = QLabel("Analyzed Files:")
|
||||
layout.addWidget(self.file_list_label)
|
||||
@@ -81,6 +104,10 @@ 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)
|
||||
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
|
||||
self.analysis_manager.metricReady.connect(self.on_metric_ready)
|
||||
self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
"""Handle drag enter event for file drops."""
|
||||
@@ -99,13 +126,30 @@ 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.analysis_manager.analyze_file(file_path)
|
||||
self.logger.info(f"Starting analysis of dropped file: {os.path.basename(file_path)}")
|
||||
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||
else:
|
||||
self.visualization_widget.set_status("No audio files detected in drop")
|
||||
self.logger.warning("No supported audio files found in drop")
|
||||
|
||||
def open_file_dialog(self):
|
||||
"""Open file dialog to select audio files for analysis."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Select Audio File",
|
||||
"", # Default directory (empty = current directory)
|
||||
"Audio Files (*.mp3 *.wav *.flac);;All Files (*)"
|
||||
)
|
||||
|
||||
if file_path: # User selected a file (didn't cancel)
|
||||
self.logger.info(f"File selected via dialog: {os.path.basename(file_path)}")
|
||||
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||
|
||||
def on_analysis_started(self, file_path):
|
||||
"""Called when analysis starts."""
|
||||
@@ -115,57 +159,154 @@ class MainWindow(QMainWindow):
|
||||
def on_analysis_completed(self, file_path, result):
|
||||
"""Called when analysis completes successfully."""
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
|
||||
# Add to file list if not already there
|
||||
existing_items = [self.file_list.item(i).text()
|
||||
existing_items = [self.file_list.item(i).text()
|
||||
for i in range(self.file_list.count())]
|
||||
if filename not in existing_items:
|
||||
item = QListWidgetItem(filename)
|
||||
item.setData(Qt.UserRole, file_path) # Store full path
|
||||
self.file_list.addItem(item)
|
||||
|
||||
# Get and display the analysis figure
|
||||
figure = self.analysis_manager.get_analysis_figure(file_path)
|
||||
if figure:
|
||||
self.visualization_widget.display_figure_direct(figure)
|
||||
|
||||
|
||||
# Update metadata display
|
||||
metadata_text = self.analysis_manager.get_metadata_text(file_path)
|
||||
self.metadata_display.setText(metadata_text)
|
||||
|
||||
|
||||
# Select the analyzed file in the list
|
||||
for i in range(self.file_list.count()):
|
||||
item = self.file_list.item(i)
|
||||
if item.data(Qt.UserRole) == file_path:
|
||||
self.file_list.setCurrentItem(item)
|
||||
break
|
||||
|
||||
# Render the currently-selected metric (cached, or async-compute it)
|
||||
self._render_or_request(file_path)
|
||||
|
||||
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)
|
||||
|
||||
# Display the analysis figure
|
||||
figure = self.analysis_manager.get_analysis_figure(file_path)
|
||||
if figure:
|
||||
self.visualization_widget.display_figure_direct(figure)
|
||||
|
||||
|
||||
# Update metadata display
|
||||
metadata_text = self.analysis_manager.get_metadata_text(file_path)
|
||||
self.metadata_display.setText(metadata_text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = QApplication(sys.argv)
|
||||
# Render the currently-selected metric (cached, or async-compute it)
|
||||
self._render_or_request(file_path)
|
||||
|
||||
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})")
|
||||
# Cheap re-render — cached metric data, redraws under the new font.
|
||||
self._render_or_request(self._current_file_path())
|
||||
|
||||
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 matplotlib plots, so no regeneration 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}")
|
||||
self._render_or_request(self._current_file_path())
|
||||
|
||||
def on_plot_refresh_requested(self):
|
||||
"""Called when manual plot refresh is requested."""
|
||||
self.logger.info("Manual plot refresh requested via GUI")
|
||||
self._render_or_request(self._current_file_path())
|
||||
|
||||
def on_metric_compute_started(self, file_path: str, metric_id: str):
|
||||
"""Called when an off-thread metric compute starts."""
|
||||
if file_path != self._current_file_path():
|
||||
return # selection moved on; status bar shouldn't lie
|
||||
from metrics import METRICS
|
||||
metric = METRICS.get(metric_id)
|
||||
display = metric.display_name if metric else metric_id
|
||||
self.visualization_widget.set_status(f"Computing {display}...")
|
||||
|
||||
def on_metric_ready(self, file_path: str, metric_id: str):
|
||||
"""Called when metric data is available (cached hit or async finish)."""
|
||||
if file_path != self._current_file_path():
|
||||
return # stale — user moved on
|
||||
if metric_id != self.plot_control.current_metric_id():
|
||||
return # user already switched to a different metric
|
||||
figure = self.analysis_manager.get_metric_figure(file_path, metric_id)
|
||||
if figure:
|
||||
self.visualization_widget.display_figure_direct(figure)
|
||||
|
||||
def on_metric_compute_error(self, file_path: str, metric_id: str, error_message: str):
|
||||
self.logger.error(f"Metric compute failed ({metric_id} / {os.path.basename(file_path)}): {error_message}")
|
||||
if file_path == self._current_file_path():
|
||||
self.visualization_widget.set_status(f"Error computing {metric_id}: {error_message}")
|
||||
|
||||
def _current_file_path(self):
|
||||
item = self.file_list.currentItem()
|
||||
return item.data(Qt.UserRole) if item else None
|
||||
|
||||
def _render_or_request(self, file_path):
|
||||
"""Render the current metric from cache, or kick off async compute if missing.
|
||||
|
||||
Falls back to a full analyse_file if the file hasn't been processed yet
|
||||
(e.g. font change on an empty session — defensive).
|
||||
"""
|
||||
if not file_path:
|
||||
return
|
||||
metric_id = self.plot_control.current_metric_id()
|
||||
figure = self.analysis_manager.get_metric_figure(file_path, metric_id)
|
||||
if figure:
|
||||
self.visualization_widget.display_figure_direct(figure)
|
||||
return
|
||||
# Not cached yet — try async compute if the file has been loaded.
|
||||
if self.analysis_manager.is_file_analyzed(file_path):
|
||||
self.analysis_manager.request_metric(file_path, metric_id)
|
||||
else:
|
||||
# No AudioFile yet either; kick off a full analysis with this metric.
|
||||
self.analysis_manager.analyze_file(file_path, metric_id)
|
||||
|
||||
|
||||
def 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()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
logger.info("GUI window displayed")
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+67
-239
@@ -1,239 +1,67 @@
|
||||
import librosa
|
||||
import numpy as np
|
||||
import os
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.colors as mcolors
|
||||
import matplotlib.cm as cm
|
||||
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.easyid3 import EasyID3
|
||||
|
||||
def try_mp3_tags(file_path):
|
||||
try:
|
||||
# if there is metadata
|
||||
audio = MP3(file_path, ID3=EasyID3)
|
||||
return audio
|
||||
except Exception as e:
|
||||
print(f"Error reading ID3 tags: {e}")
|
||||
return None
|
||||
|
||||
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]}")
|
||||
else:
|
||||
print(f"File name: {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]}"
|
||||
else:
|
||||
self.song_name = os.path.basename(self.file_path)
|
||||
|
||||
self.y, self.sr = librosa.load(file_path)
|
||||
# load automatically normalises everything to [-1.0, 1.0]
|
||||
# and that's alright
|
||||
self.y_mono = librosa.to_mono(self.y)
|
||||
self.max_amplitude = np.max(np.abs(self.y_mono))
|
||||
self.avg_amplitude = np.mean(np.abs(self.y_mono))
|
||||
self.bpm, _ = librosa.beat.beat_track(y=self.y_mono, sr=self.sr)
|
||||
|
||||
def display_song_name(self):
|
||||
print(self.song_name)
|
||||
|
||||
def get_amplitudes(self):
|
||||
return self.max_amplitude, self.avg_amplitude
|
||||
|
||||
def get_bpm(self):
|
||||
return self.bpm
|
||||
|
||||
def get_energy_levels_over_time(self, window = 10, hop = 2):
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
window (int, optional): Length of rolling RMS window in seconds. Defaults to 10.
|
||||
hop (int, optional): Length of window hop in seconds. Defaults to 2.
|
||||
"""
|
||||
# check if the window and hop are the same as before
|
||||
if (not hasattr(self, 'window')) or ((self.window != window) or (self.hop != hop)):
|
||||
self.window, self.hop = window, hop
|
||||
# only calculate if not already calculated
|
||||
if not hasattr(self, 'rms_array'):
|
||||
# window and hop are in seconds
|
||||
window_samples = window * self.sr
|
||||
hop_samples = hop * self.sr
|
||||
|
||||
# Calculate RMS over the rolling windows
|
||||
self.rms_array = librosa.feature.rms(y=self.y, frame_length=window_samples, hop_length=hop_samples)
|
||||
|
||||
def _get_times(self):
|
||||
"""Get time array for RMS data. Internal method for GUI integration."""
|
||||
if not hasattr(self, 'rms_array'):
|
||||
self.get_energy_levels_over_time()
|
||||
return librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
|
||||
|
||||
def plot_energy_levels_over_time(self, display='window'):
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
display (str, optional): Option for where to display the plot. Defaults to 'window'.
|
||||
'window' - display in a pyplot window
|
||||
'gui' - for directing to the GUI (TBD)
|
||||
"""
|
||||
if not hasattr(self, 'rms_array'):
|
||||
self.get_energy_levels_over_time()
|
||||
|
||||
# Convert frame indices to time
|
||||
times = librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
|
||||
|
||||
|
||||
# Normalize RMS for color mapping
|
||||
# check maximum power to determine mastering headspace:
|
||||
# a -6 dBFS headroom should yield a max power of around 0.25
|
||||
# otherwise could go anywhere, but we take 0.6
|
||||
local_max_power = np.max(self.rms_array)
|
||||
if local_max_power > 0.3:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.6)
|
||||
maxpower = 0.6
|
||||
else:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.3)
|
||||
maxpower = 0.3
|
||||
|
||||
# colour map
|
||||
cmap = cm.autumn
|
||||
|
||||
# Plot
|
||||
if display == 'window':
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
ax.set_ylim(0., maxpower)
|
||||
for i in range(len(times)-1):
|
||||
ax.fill_between(times[i:i+2], 0, self.rms_array[0][i], color=cmap(norm(self.rms_array[0][i])), edgecolor='none')
|
||||
|
||||
# Adding a colorbar to indicate the scale of RMS values
|
||||
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
||||
sm.set_array([])
|
||||
cbar = plt.colorbar(sm, ax=ax, label='RMS Power')
|
||||
# cbar.ax.set_yticklabels([f"{x-60.0:.0f} dBFS" for x in cbar.get_ticks()]) # Adjust labels to show true dBFS values
|
||||
|
||||
ax.set_ylabel('Power')
|
||||
ax.set_xlabel('Time')
|
||||
ax.set_title(f'{os.path.basename(self.file_path)}')
|
||||
|
||||
plt.show(block=False)
|
||||
plt.pause(0.001)
|
||||
|
||||
|
||||
|
||||
|
||||
def analyze_track_librosa(file_path):
|
||||
# Load the audio file
|
||||
# y is the audio time series and sr is the sampling rate
|
||||
y, sr = librosa.load(file_path)
|
||||
|
||||
# Calculate the maximum amplitude
|
||||
# Librosa's load function normalizes the audio to [-1, 1], so we scale it back
|
||||
max_amplitude = np.max(np.abs(y))
|
||||
# Average amplitude
|
||||
avg_amplitude = np.mean(np.abs(y))
|
||||
|
||||
# Convert max amplitude to dBFS
|
||||
max_amplitude_dBFS = librosa.amplitude_to_db([max_amplitude], ref=1.0)
|
||||
avg_amplitude_dBFS = librosa.amplitude_to_db([avg_amplitude], ref=1.0)
|
||||
|
||||
# Calculate RMS in dB
|
||||
S, phase = librosa.magphase(librosa.stft(y))
|
||||
rms_stft = librosa.feature.rms(S=S)
|
||||
rms = librosa.feature.rms(y=y)
|
||||
avg_power_dBFS_stft = 20 * np.log10(np.mean(rms_stft))
|
||||
avg_power_dBFS = 20 * np.log10(np.mean(rms))
|
||||
|
||||
return max_amplitude_dBFS[0], avg_amplitude_dBFS[0], avg_power_dBFS, avg_power_dBFS_stft
|
||||
|
||||
def plot_macro_time_power_graph(file_path):
|
||||
# Load the audio file
|
||||
y, sr = librosa.load(file_path, mono=True)
|
||||
|
||||
# Define the window and hop length
|
||||
# 10 seconds window and 1 second hop
|
||||
window_length = int(sr * 10) # 10 seconds in samples
|
||||
hop_length = int(sr * 1) # 1 second in samples
|
||||
|
||||
# Calculate RMS over the rolling windows
|
||||
rms = librosa.feature.rms(y=y, frame_length=window_length, hop_length=hop_length)
|
||||
|
||||
# Convert frame indices to time
|
||||
times = librosa.frames_to_time(np.arange(rms.shape[1]), sr=sr, hop_length=hop_length)
|
||||
|
||||
# Normalize RMS for color mapping
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.4)
|
||||
|
||||
# Choose a colormap
|
||||
cmap = cm.autumn
|
||||
|
||||
# Plot
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
ax.set_ylim(0., 0.4)
|
||||
for i in range(len(times)-1):
|
||||
ax.fill_between(times[i:i+2], 0, rms[0][i], color=cmap(norm(rms[0][i])), edgecolor='none')
|
||||
|
||||
# Adding a colorbar to indicate the scale of RMS values
|
||||
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
||||
sm.set_array([])
|
||||
cbar = plt.colorbar(sm, ax=ax, label='RMS Power')
|
||||
# cbar.ax.set_yticklabels([f"{x-60.0:.0f} dBFS" for x in cbar.get_ticks()]) # Adjust labels to show true dBFS values
|
||||
|
||||
ax.set_ylabel('Power')
|
||||
ax.set_xlabel('Time')
|
||||
# ax.set_title(f'{os.path.basename(file_path)}')
|
||||
# plt.ylabel('Power')
|
||||
# plt.xlabel('Time (s)')
|
||||
# plt.title(f'{os.path.basename(file_path)}')
|
||||
plt.show(block=False)
|
||||
plt.pause(0.001)
|
||||
|
||||
|
||||
|
||||
def find_mp3_files(directory):
|
||||
mp3_files = []
|
||||
# Walk through the directory
|
||||
for root, dirs, files in os.walk(directory):
|
||||
# Filter and append .mp3 files
|
||||
for file in files:
|
||||
if file.endswith(".mp3"):
|
||||
mp3_files.append(os.path.join(root, file))
|
||||
return mp3_files
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Legacy batch processing mode - runs when master_core.py is executed directly
|
||||
# For GUI usage, run main.py instead
|
||||
|
||||
print("Running legacy batch analysis mode...")
|
||||
print("For the new GUI interface, please run: python main.py")
|
||||
print()
|
||||
|
||||
# Replace 'path/to/your/audiofile.mp3' with the path to your audio file
|
||||
file_path = []
|
||||
with open('./files.txt', 'r') as f:
|
||||
for line in f:
|
||||
if line[0] != '#' and line[0] != ';':
|
||||
file_path.append(line.strip())
|
||||
|
||||
for file in file_path:
|
||||
# max_amplitude, avg_amplitude, avg_power, avg_power_stft = analyze_track_librosa(file)
|
||||
# # read_mp3_tags(file)
|
||||
# print(f"Maximum Amplitude: {max_amplitude:.2f} dBFS")
|
||||
# print(f"Average Amplitude: {avg_amplitude:.2f} dBFS")
|
||||
# print(f"Average Power: {avg_power:.2f} dBFS")
|
||||
# print(f"Average Power (STFT): {avg_power_stft:.2f} dBFS")
|
||||
currentsong = AudioFile(file)
|
||||
currentsong.display_song_name()
|
||||
print(f"BPM: {currentsong.get_bpm()}")
|
||||
currentsong.plot_energy_levels_over_time()
|
||||
# plot_macro_time_power_graph(file)
|
||||
|
||||
plt.show()
|
||||
import os
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.easyid3 import EasyID3
|
||||
|
||||
from font_manager import safe_title
|
||||
|
||||
|
||||
def _try_mp3_tags(file_path):
|
||||
try:
|
||||
return MP3(file_path, ID3=EasyID3)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class AudioFile:
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
audio = _try_mp3_tags(self.file_path)
|
||||
artist = audio.get('artist', [None])[0] if audio is not None else None
|
||||
title = audio.get('title', [None])[0] if audio is not None else None
|
||||
if artist and title:
|
||||
self.song_name = safe_title(f"{artist} - {title}")
|
||||
else:
|
||||
self.song_name = safe_title(os.path.basename(self.file_path))
|
||||
|
||||
# librosa.load normalises to [-1.0, 1.0]. sr=None preserves the file's
|
||||
# native sample rate; without it librosa resamples to 22050 Hz, which would
|
||||
# discard everything above ~11 kHz (the entire top octave) before analysis.
|
||||
self.y, self.sr = librosa.load(file_path, sr=None)
|
||||
self.y_mono = librosa.to_mono(self.y)
|
||||
self.max_amplitude = np.max(np.abs(self.y_mono))
|
||||
self.avg_amplitude = np.mean(np.abs(self.y_mono))
|
||||
self.bpm, _ = librosa.beat.beat_track(y=self.y_mono, sr=self.sr)
|
||||
|
||||
def get_bpm(self):
|
||||
# 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):
|
||||
"""Compute rolling RMS power.
|
||||
|
||||
Args:
|
||||
window: Rolling window length in seconds.
|
||||
hop: Hop length in seconds.
|
||||
"""
|
||||
if (not hasattr(self, 'window')) or (self.window != window) or (self.hop != hop):
|
||||
self.window, self.hop = window, hop
|
||||
if not hasattr(self, 'rms_array'):
|
||||
window_samples = window * self.sr
|
||||
hop_samples = hop * self.sr
|
||||
self.rms_array = librosa.feature.rms(
|
||||
y=self.y, frame_length=window_samples, hop_length=hop_samples
|
||||
)
|
||||
|
||||
def get_times(self):
|
||||
"""Time-axis values matching the RMS frames."""
|
||||
if not hasattr(self, 'rms_array'):
|
||||
self.get_energy_levels_over_time()
|
||||
return librosa.frames_to_time(
|
||||
np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop * self.sr
|
||||
)
|
||||
|
||||
+572
@@ -0,0 +1,572 @@
|
||||
"""
|
||||
Pluggable analysis metrics.
|
||||
|
||||
A `Metric` knows how to compute a series from an `AudioFile` and how to render
|
||||
that series into a matplotlib `Figure`. Compute is the heavy step (runs on the
|
||||
worker thread); render is cheap and reruns on font / refresh.
|
||||
|
||||
To add a metric: subclass `Metric`, implement `compute` and `render`, and
|
||||
register the instance in `METRICS` at the bottom of this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import matplotlib.colors as mcolors
|
||||
import matplotlib.cm as cm
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.ticker import FuncFormatter, NullFormatter
|
||||
import librosa
|
||||
import pyloudnorm as pyln
|
||||
from scipy import signal as scipy_signal
|
||||
|
||||
from font_manager import safe_title
|
||||
from master_core import AudioFile
|
||||
|
||||
|
||||
# Small constant to keep 20*log10(...) from blowing up on perfect silence.
|
||||
_EPS = 1e-12
|
||||
|
||||
|
||||
def _to_dbfs(linear: np.ndarray | float) -> np.ndarray | float:
|
||||
"""Convert a linear magnitude to dBFS, floored at _EPS."""
|
||||
return 20.0 * np.log10(np.maximum(linear, _EPS))
|
||||
|
||||
|
||||
def _fmt_tick(v, _pos=None) -> str:
|
||||
"""Compact tick label: integer for big/whole values, trimmed decimals else."""
|
||||
av = abs(v)
|
||||
if v == 0 or av >= 100:
|
||||
return f"{v:.0f}"
|
||||
if av >= 1:
|
||||
return f"{v:.1f}".rstrip("0").rstrip(".")
|
||||
return f"{v:.3f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _show_axis_extents(ax) -> None:
|
||||
"""Force the exact min/max of each axis onto the tick list.
|
||||
|
||||
Matplotlib's locators often omit the extreme values — most visibly on a log
|
||||
frequency axis, where the top (e.g. 22050 Hz) falls between decade ticks and
|
||||
goes unlabelled. Union the endpoints into the existing in-range ticks so you
|
||||
can always read where a plot actually starts and stops.
|
||||
"""
|
||||
fmt = FuncFormatter(_fmt_tick)
|
||||
for is_log, get_lim, set_lim, get_ticks, set_ticks, mpl_axis in (
|
||||
(ax.get_xscale() == "log", ax.get_xlim, ax.set_xlim, ax.get_xticks, ax.set_xticks, ax.xaxis),
|
||||
(ax.get_yscale() == "log", ax.get_ylim, ax.set_ylim, ax.get_yticks, ax.set_yticks, ax.yaxis),
|
||||
):
|
||||
lo, hi = get_lim()
|
||||
inside = [t for t in get_ticks() if lo <= t <= hi]
|
||||
mpl_axis.set_major_formatter(fmt)
|
||||
if is_log:
|
||||
mpl_axis.set_minor_formatter(NullFormatter()) # keep minor marks unlabelled
|
||||
set_ticks(sorted(set(inside) | {lo, hi}))
|
||||
set_lim(lo, hi) # set_ticks can nudge the view; restore exact limits
|
||||
|
||||
|
||||
class Metric(ABC):
|
||||
"""A pluggable analysis metric."""
|
||||
|
||||
id: str
|
||||
display_name: str
|
||||
|
||||
@abstractmethod
|
||||
def compute(self, audio_file: AudioFile) -> Any:
|
||||
"""Compute and return the metric's data from a loaded AudioFile.
|
||||
|
||||
The returned object is cached and later passed to `render`. This is the
|
||||
heavy step and runs on the worker thread.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def render(self, data: Any, file_path: str, figsize=(10, 4)) -> Figure:
|
||||
"""Render a Figure from precomputed data. Cheap; runs on the GUI thread."""
|
||||
|
||||
|
||||
class RMSPowerMetric(Metric):
|
||||
id = "rms_power"
|
||||
display_name = "RMS Power"
|
||||
|
||||
def __init__(self, window: int = 10, hop: int = 2):
|
||||
self.window = window
|
||||
self.hop = hop
|
||||
|
||||
def compute(self, audio_file: AudioFile):
|
||||
audio_file.get_energy_levels_over_time(window=self.window, hop=self.hop)
|
||||
return {
|
||||
"times": audio_file.get_times(),
|
||||
"rms_array": audio_file.rms_array,
|
||||
}
|
||||
|
||||
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||
times = data["times"]
|
||||
rms_array = data["rms_array"]
|
||||
|
||||
# Adaptive colour scale: bump headroom for loud masters.
|
||||
maxpower = 0.6 if np.max(rms_array) > 0.3 else 0.3
|
||||
norm = mcolors.Normalize(vmin=0, vmax=maxpower)
|
||||
cmap = cm.autumn
|
||||
|
||||
fig = Figure(figsize=figsize, facecolor="white")
|
||||
ax = fig.add_subplot(111)
|
||||
ax.set_ylim(0., maxpower)
|
||||
for i in range(len(times) - 1):
|
||||
ax.fill_between(
|
||||
times[i:i + 2], 0, rms_array[0][i],
|
||||
color=cmap(norm(rms_array[0][i])), edgecolor="none",
|
||||
)
|
||||
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
||||
sm.set_array([])
|
||||
fig.colorbar(sm, ax=ax, label="RMS Power")
|
||||
ax.set_ylabel("Power")
|
||||
ax.set_xlabel("Time (seconds)")
|
||||
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||
_show_axis_extents(ax)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
class WaveformMetric(Metric):
|
||||
"""Raw mono waveform with a min/max envelope downsample for plotting speed."""
|
||||
|
||||
id = "waveform"
|
||||
display_name = "Waveform"
|
||||
|
||||
def __init__(self, target_columns: int = 4000):
|
||||
self.target_columns = target_columns
|
||||
|
||||
def compute(self, audio_file: AudioFile):
|
||||
y = audio_file.y_mono
|
||||
sr = audio_file.sr
|
||||
n = len(y)
|
||||
if n <= self.target_columns:
|
||||
times = np.arange(n) / sr
|
||||
return {"times": times, "lo": y, "hi": y}
|
||||
|
||||
chunk = n // self.target_columns
|
||||
trimmed = y[: chunk * self.target_columns]
|
||||
reshaped = trimmed.reshape(self.target_columns, chunk)
|
||||
lo = reshaped.min(axis=1)
|
||||
hi = reshaped.max(axis=1)
|
||||
times = (np.arange(self.target_columns) * chunk + chunk / 2) / sr
|
||||
return {"times": times, "lo": lo, "hi": hi}
|
||||
|
||||
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||
times = data["times"]
|
||||
lo = data["lo"]
|
||||
hi = data["hi"]
|
||||
|
||||
fig = Figure(figsize=figsize, facecolor="white")
|
||||
ax = fig.add_subplot(111)
|
||||
ax.fill_between(times, lo, hi, color="#3a7ad6", linewidth=0)
|
||||
ax.axhline(0, color="black", linewidth=0.5, alpha=0.3)
|
||||
# Fixed full-scale range with a touch of headroom for float-wav signals.
|
||||
ax.set_ylim(-1.1, 1.1)
|
||||
ax.set_xlim(times[0], times[-1])
|
||||
ax.set_ylabel("Amplitude")
|
||||
ax.set_xlabel("Time (seconds)")
|
||||
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||
_show_axis_extents(ax)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
class LUFSMetric(Metric):
|
||||
"""ITU-R BS.1770 loudness: short-term (3 s) time series + integrated + LRA.
|
||||
|
||||
Powered by pyloudnorm. The time series slides `meter.integrated_loudness`
|
||||
across the track because pyloudnorm doesn't expose a per-block series.
|
||||
Slightly redundant work, but the per-call cost is small.
|
||||
"""
|
||||
|
||||
id = "lufs"
|
||||
display_name = "LUFS"
|
||||
|
||||
# Short-term as defined by EBU R128 / BS.1770: 3-second window.
|
||||
WINDOW_S = 3.0
|
||||
HOP_S = 0.5
|
||||
SILENCE_FLOOR = -70.0 # BS.1770 absolute gate
|
||||
|
||||
def compute(self, audio_file: AudioFile):
|
||||
y = audio_file.y_mono.astype(np.float64, copy=False)
|
||||
sr = audio_file.sr
|
||||
meter = pyln.Meter(sr)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
integrated = self._safe_integrated(meter, y)
|
||||
|
||||
window_n = int(self.WINDOW_S * sr)
|
||||
hop_n = int(self.HOP_S * sr)
|
||||
|
||||
if len(y) < window_n:
|
||||
times = np.array([len(y) / (2.0 * sr)])
|
||||
lufs = np.array([integrated if np.isfinite(integrated) else self.SILENCE_FLOOR])
|
||||
lra = float("nan")
|
||||
else:
|
||||
n_windows = 1 + (len(y) - window_n) // hop_n
|
||||
lufs = np.empty(n_windows)
|
||||
for i in range(n_windows):
|
||||
start = i * hop_n
|
||||
lufs[i] = self._safe_integrated(meter, y[start:start + window_n])
|
||||
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
|
||||
try:
|
||||
lra = float(meter.loudness_range(y))
|
||||
except (ValueError, FloatingPointError):
|
||||
lra = float("nan")
|
||||
|
||||
lufs = np.where(np.isfinite(lufs), lufs, self.SILENCE_FLOOR)
|
||||
lufs = np.clip(lufs, self.SILENCE_FLOOR, 0.0)
|
||||
|
||||
return {
|
||||
"times": times,
|
||||
"lufs": lufs,
|
||||
"integrated": float(integrated),
|
||||
"lra": lra,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _safe_integrated(meter: "pyln.Meter", segment: np.ndarray) -> float:
|
||||
try:
|
||||
return float(meter.integrated_loudness(segment))
|
||||
except (ValueError, FloatingPointError):
|
||||
return float("-inf")
|
||||
|
||||
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||
times = data["times"]
|
||||
lufs = data["lufs"]
|
||||
integrated = data["integrated"]
|
||||
lra = data.get("lra", float("nan"))
|
||||
|
||||
fig = Figure(figsize=figsize, facecolor="white")
|
||||
ax = fig.add_subplot(111)
|
||||
ax.plot(times, lufs, color="#2a9d8f", linewidth=1.4, label="Short-term (3 s)")
|
||||
|
||||
if np.isfinite(integrated):
|
||||
ax.axhline(
|
||||
integrated, color="#e76f51", linestyle="--", linewidth=1.5,
|
||||
label=f"Integrated: {integrated:.1f} LUFS",
|
||||
)
|
||||
|
||||
if np.isfinite(lra):
|
||||
# Invisible plot entry to surface LRA in the legend without adding a line.
|
||||
ax.plot([], [], " ", label=f"LRA: {lra:.1f} LU")
|
||||
|
||||
# Streaming target reference (Spotify normalises to -14 LUFS).
|
||||
ax.axhline(-14.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
|
||||
ax.text(
|
||||
times[-1], -14.0, " -14 LUFS (streaming target)",
|
||||
va="center", ha="left", fontsize=8, alpha=0.6,
|
||||
)
|
||||
|
||||
ax.set_ylim(-50.0, 0.0)
|
||||
ax.set_xlim(times[0], times[-1])
|
||||
ax.set_ylabel("LUFS")
|
||||
ax.set_xlabel("Time (seconds)")
|
||||
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(loc="lower right", fontsize=8)
|
||||
_show_axis_extents(ax)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
class CrestFactorMetric(Metric):
|
||||
"""Crest factor = 20*log10(peak / RMS) per sliding window, in dB."""
|
||||
|
||||
id = "crest_factor"
|
||||
display_name = "Crest Factor"
|
||||
|
||||
WINDOW_S = 1.0
|
||||
HOP_S = 0.25
|
||||
|
||||
def compute(self, audio_file: AudioFile):
|
||||
y = audio_file.y_mono.astype(np.float64, copy=False)
|
||||
sr = audio_file.sr
|
||||
window_n = int(self.WINDOW_S * sr)
|
||||
hop_n = int(self.HOP_S * sr)
|
||||
|
||||
if len(y) < window_n:
|
||||
times = np.array([len(y) / (2.0 * sr)])
|
||||
peak = float(np.max(np.abs(y))) if len(y) else 0.0
|
||||
rms = float(np.sqrt(np.mean(y * y))) if len(y) else 0.0
|
||||
crest = 20.0 * np.log10(max(peak, _EPS) / max(rms, _EPS))
|
||||
return {"times": times, "crest_db": np.array([crest])}
|
||||
|
||||
# RMS via cumulative-sum-of-squares (O(N)); peaks via sliding window view.
|
||||
y2 = y * y
|
||||
cumsum = np.concatenate(([0.0], np.cumsum(y2)))
|
||||
n_windows = 1 + (len(y) - window_n) // hop_n
|
||||
starts = np.arange(n_windows) * hop_n
|
||||
ends = starts + window_n
|
||||
mean_sq = (cumsum[ends] - cumsum[starts]) / window_n
|
||||
rms = np.sqrt(np.maximum(mean_sq, _EPS))
|
||||
|
||||
abs_y = np.abs(y)
|
||||
peaks = np.empty(n_windows)
|
||||
for i in range(n_windows):
|
||||
peaks[i] = np.max(abs_y[starts[i]:ends[i]])
|
||||
|
||||
crest_db = 20.0 * np.log10(np.maximum(peaks, _EPS) / rms)
|
||||
times = (starts + window_n / 2.0) / sr
|
||||
return {"times": times, "crest_db": crest_db}
|
||||
|
||||
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||
times = data["times"]
|
||||
crest_db = data["crest_db"]
|
||||
|
||||
fig = Figure(figsize=figsize, facecolor="white")
|
||||
ax = fig.add_subplot(111)
|
||||
ax.plot(times, crest_db, color="#e09f3e", linewidth=1.4, label=f"Crest factor (1 s)")
|
||||
|
||||
# Rules of thumb: ~12 dB = roomy, ~6 dB = heavily limited.
|
||||
ax.axhline(12.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
|
||||
ax.text(times[-1], 12.0, " 12 dB", va="center", ha="left", fontsize=8, alpha=0.6)
|
||||
ax.axhline(6.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
|
||||
ax.text(times[-1], 6.0, " 6 dB (squashed)", va="center", ha="left", fontsize=8, alpha=0.6)
|
||||
|
||||
ax.set_ylim(0.0, 25.0)
|
||||
ax.set_xlim(times[0], times[-1])
|
||||
ax.set_ylabel("Crest factor (dB)")
|
||||
ax.set_xlabel("Time (seconds)")
|
||||
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(loc="lower right", fontsize=8)
|
||||
_show_axis_extents(ax)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
class PSRMetric(Metric):
|
||||
"""Peak-to-Short-term LUFS Ratio (sample-peak variant), in LU.
|
||||
|
||||
PSR = sample_peak_dBFS - short_term_LUFS over the same 3 s windows used by
|
||||
LUFSMetric. High PSR = punchy transients; low PSR = heavily limited.
|
||||
"""
|
||||
|
||||
id = "psr"
|
||||
display_name = "PSR"
|
||||
|
||||
WINDOW_S = 3.0
|
||||
HOP_S = 0.5
|
||||
SILENCE_FLOOR = -70.0
|
||||
|
||||
def compute(self, audio_file: AudioFile):
|
||||
y = audio_file.y_mono.astype(np.float64, copy=False)
|
||||
sr = audio_file.sr
|
||||
meter = pyln.Meter(sr)
|
||||
|
||||
window_n = int(self.WINDOW_S * sr)
|
||||
hop_n = int(self.HOP_S * sr)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
if len(y) < window_n:
|
||||
times = np.array([len(y) / (2.0 * sr)])
|
||||
peak_db = _to_dbfs(np.max(np.abs(y))) if len(y) else self.SILENCE_FLOOR
|
||||
lufs = LUFSMetric._safe_integrated(meter, y)
|
||||
psr = peak_db - lufs if np.isfinite(lufs) else 0.0
|
||||
return {"times": times, "psr": np.array([psr])}
|
||||
|
||||
n_windows = 1 + (len(y) - window_n) // hop_n
|
||||
abs_y = np.abs(y)
|
||||
lufs_series = np.empty(n_windows)
|
||||
peaks_db = np.empty(n_windows)
|
||||
for i in range(n_windows):
|
||||
start = i * hop_n
|
||||
end = start + window_n
|
||||
peaks_db[i] = _to_dbfs(np.max(abs_y[start:end]))
|
||||
lufs_series[i] = LUFSMetric._safe_integrated(meter, y[start:end])
|
||||
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
|
||||
|
||||
# PSR is meaningless where the loudness reading is below the absolute gate.
|
||||
valid = np.isfinite(lufs_series) & (lufs_series > self.SILENCE_FLOOR)
|
||||
psr = np.where(valid, peaks_db - lufs_series, np.nan)
|
||||
return {"times": times, "psr": psr}
|
||||
|
||||
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||
times = data["times"]
|
||||
psr = data["psr"]
|
||||
|
||||
fig = Figure(figsize=figsize, facecolor="white")
|
||||
ax = fig.add_subplot(111)
|
||||
ax.plot(times, psr, color="#7251b5", linewidth=1.4, label="PSR (3 s)")
|
||||
|
||||
# Ian Shepherd's rough thresholds.
|
||||
ax.axhline(10.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
|
||||
ax.text(times[-1], 10.0, " 10 LU (good punch)", va="center", ha="left", fontsize=8, alpha=0.6)
|
||||
ax.axhline(4.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
|
||||
ax.text(times[-1], 4.0, " 4 LU (squashed)", va="center", ha="left", fontsize=8, alpha=0.6)
|
||||
|
||||
ax.set_ylim(0.0, 25.0)
|
||||
ax.set_xlim(times[0], times[-1])
|
||||
ax.set_ylabel("PSR (LU)")
|
||||
ax.set_xlabel("Time (seconds)")
|
||||
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(loc="lower right", fontsize=8)
|
||||
_show_axis_extents(ax)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
class TruePeakMetric(Metric):
|
||||
"""ITU-R BS.1770 true peak via 4x polyphase oversampling, in dBTP.
|
||||
|
||||
Per-window true peak with a moderate hop so it renders quickly. Windows are
|
||||
oversampled independently — slight edge under-detection at window boundaries
|
||||
is masked by the 60% overlap.
|
||||
"""
|
||||
|
||||
id = "true_peak"
|
||||
display_name = "True Peak"
|
||||
|
||||
WINDOW_S = 0.25
|
||||
HOP_S = 0.1
|
||||
OVERSAMPLE = 4
|
||||
|
||||
def compute(self, audio_file: AudioFile):
|
||||
y = audio_file.y_mono.astype(np.float32, copy=False)
|
||||
sr = audio_file.sr
|
||||
window_n = int(self.WINDOW_S * sr)
|
||||
hop_n = int(self.HOP_S * sr)
|
||||
|
||||
if len(y) < window_n:
|
||||
y_up = scipy_signal.resample_poly(y, self.OVERSAMPLE, 1) if len(y) else np.zeros(1, dtype=np.float32)
|
||||
peak_db = _to_dbfs(np.max(np.abs(y_up))) if len(y_up) else -70.0
|
||||
return {
|
||||
"times": np.array([len(y) / (2.0 * sr)]),
|
||||
"tp_db": np.array([peak_db]),
|
||||
"integrated_tp_db": float(peak_db),
|
||||
}
|
||||
|
||||
n_windows = 1 + (len(y) - window_n) // hop_n
|
||||
tp_db = np.empty(n_windows)
|
||||
for i in range(n_windows):
|
||||
start = i * hop_n
|
||||
w = y[start:start + window_n]
|
||||
w_up = scipy_signal.resample_poly(w, self.OVERSAMPLE, 1)
|
||||
tp_db[i] = _to_dbfs(np.max(np.abs(w_up)))
|
||||
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
|
||||
|
||||
integrated_tp_db = float(np.max(tp_db))
|
||||
return {"times": times, "tp_db": tp_db, "integrated_tp_db": integrated_tp_db}
|
||||
|
||||
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||
times = data["times"]
|
||||
tp_db = data["tp_db"]
|
||||
integrated = data.get("integrated_tp_db", float("nan"))
|
||||
|
||||
fig = Figure(figsize=figsize, facecolor="white")
|
||||
ax = fig.add_subplot(111)
|
||||
ax.plot(times, tp_db, color="#c1121f", linewidth=1.0, label="True Peak (250 ms)")
|
||||
|
||||
# 0 dBTP = sample-level clip; -1 dBTP a common mastering ceiling.
|
||||
ax.axhline(0.0, color="black", linestyle="--", linewidth=1.0, alpha=0.8)
|
||||
ax.text(times[-1], 0.0, " 0 dBTP (clip)", va="center", ha="left", fontsize=8, alpha=0.7)
|
||||
ax.axhline(-1.0, color="gray", linestyle=":", linewidth=0.8, alpha=0.6)
|
||||
ax.text(times[-1], -1.0, " -1 dBTP (typical ceiling)", va="center", ha="left", fontsize=8, alpha=0.6)
|
||||
|
||||
if np.isfinite(integrated):
|
||||
ax.plot([], [], " ", label=f"Max: {integrated:.2f} dBTP")
|
||||
|
||||
ax.set_ylim(-30.0, 6.0)
|
||||
ax.set_xlim(times[0], times[-1])
|
||||
ax.set_ylabel("dBTP")
|
||||
ax.set_xlabel("Time (seconds)")
|
||||
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.legend(loc="lower right", fontsize=8)
|
||||
_show_axis_extents(ax)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
class SpectrogramMetric(Metric):
|
||||
"""Log-frequency STFT spectrogram: frequency power distribution over time.
|
||||
|
||||
Each column is the magnitude spectrum of a short window, plotted in serial
|
||||
as a colour-coded heatmap. The hop is chosen adaptively so long tracks don't
|
||||
produce tens of thousands of columns (which would stall the GUI redraw): for
|
||||
typical song lengths the hop lands around 50 ms, coarsening gracefully on
|
||||
very long files.
|
||||
"""
|
||||
|
||||
id = "spectrogram"
|
||||
display_name = "Spectrogram"
|
||||
|
||||
N_FFT = 4096 # ~11 Hz bins at 44.1 kHz; keeps low-freq detail now
|
||||
# that sr is native (nyquist ~22 kHz, not 11 kHz)
|
||||
TARGET_COLUMNS = 4000 # cap on time bins, for render speed
|
||||
DB_FLOOR = -80.0 # dynamic range shown, relative to peak
|
||||
F_MIN = 20.0 # log axis can't show DC; clip the low edge here
|
||||
|
||||
def compute(self, audio_file: AudioFile):
|
||||
y = audio_file.y_mono.astype(np.float32, copy=False)
|
||||
sr = audio_file.sr
|
||||
|
||||
# Pick a hop that keeps the column count near TARGET_COLUMNS, but never
|
||||
# finer than n_fft//4 (the usual 75%-overlap floor).
|
||||
min_hop = self.N_FFT // 4
|
||||
hop = max(min_hop, len(y) // self.TARGET_COLUMNS)
|
||||
|
||||
stft = librosa.stft(y, n_fft=self.N_FFT, hop_length=hop)
|
||||
mag = np.abs(stft)
|
||||
s_db = librosa.amplitude_to_db(mag, ref=np.max)
|
||||
|
||||
freqs = librosa.fft_frequencies(sr=sr, n_fft=self.N_FFT)
|
||||
times = librosa.frames_to_time(
|
||||
np.arange(s_db.shape[1]), sr=sr, hop_length=hop, n_fft=self.N_FFT
|
||||
)
|
||||
|
||||
# Drop the DC bin (0 Hz) so the log frequency axis has no non-positive coord.
|
||||
return {
|
||||
"freqs": freqs[1:],
|
||||
"times": times,
|
||||
"s_db": s_db[1:, :],
|
||||
"nyquist": sr / 2.0,
|
||||
}
|
||||
|
||||
def render(self, data, file_path, figsize=(10, 4)) -> Figure:
|
||||
freqs = data["freqs"]
|
||||
times = data["times"]
|
||||
s_db = data["s_db"]
|
||||
nyquist = data["nyquist"]
|
||||
|
||||
fig = Figure(figsize=figsize, facecolor="white")
|
||||
ax = fig.add_subplot(111)
|
||||
mesh = ax.pcolormesh(
|
||||
times, freqs, s_db,
|
||||
cmap="magma", vmin=self.DB_FLOOR, vmax=0.0, shading="auto",
|
||||
)
|
||||
fig.colorbar(mesh, ax=ax, label="Power (dB)")
|
||||
|
||||
ax.set_yscale("log")
|
||||
ax.set_ylim(self.F_MIN, nyquist)
|
||||
ax.set_xlim(times[0], times[-1])
|
||||
ax.set_ylabel("Frequency (Hz)")
|
||||
ax.set_xlabel("Time (seconds)")
|
||||
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||
_show_axis_extents(ax)
|
||||
fig.tight_layout()
|
||||
return fig
|
||||
|
||||
|
||||
METRICS: dict[str, Metric] = {
|
||||
m.id: m for m in (
|
||||
RMSPowerMetric(),
|
||||
WaveformMetric(),
|
||||
LUFSMetric(),
|
||||
CrestFactorMetric(),
|
||||
PSRMetric(),
|
||||
TruePeakMetric(),
|
||||
SpectrogramMetric(),
|
||||
)
|
||||
}
|
||||
DEFAULT_METRIC_ID = "rms_power"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Plot control widget: pick which metric to display and refresh the current plot.
|
||||
|
||||
Mirrors FontControlWidget's clustered-groupbox style so the two sit naturally
|
||||
next to each other in the left panel.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QGroupBox,
|
||||
)
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
|
||||
from metrics import METRICS, DEFAULT_METRIC_ID
|
||||
|
||||
|
||||
class PlotControlWidget(QWidget):
|
||||
"""Metric selector + manual plot refresh."""
|
||||
|
||||
metricChanged = pyqtSignal(str) # metric_id
|
||||
plotRefreshRequested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.initUI()
|
||||
|
||||
def initUI(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
group_box = QGroupBox("Plot")
|
||||
group_layout = QVBoxLayout(group_box)
|
||||
|
||||
group_layout.addWidget(QLabel("Metric:"))
|
||||
self.metric_combo = QComboBox()
|
||||
for metric_id, metric in METRICS.items():
|
||||
self.metric_combo.addItem(metric.display_name, metric_id)
|
||||
default_idx = self.metric_combo.findData(DEFAULT_METRIC_ID)
|
||||
if default_idx >= 0:
|
||||
self.metric_combo.setCurrentIndex(default_idx)
|
||||
self.metric_combo.currentIndexChanged.connect(self._on_metric_changed)
|
||||
group_layout.addWidget(self.metric_combo)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self.refresh_button = QPushButton("Refresh Plot")
|
||||
self.refresh_button.setToolTip("Re-render the current plot with current settings")
|
||||
self.refresh_button.clicked.connect(self.plotRefreshRequested.emit)
|
||||
button_row.addWidget(self.refresh_button)
|
||||
group_layout.addLayout(button_row)
|
||||
|
||||
layout.addWidget(group_box)
|
||||
|
||||
def _on_metric_changed(self, _index: int):
|
||||
metric_id = self.metric_combo.currentData()
|
||||
if metric_id:
|
||||
self.logger.info(f"Metric changed: {metric_id}")
|
||||
self.metricChanged.emit(metric_id)
|
||||
|
||||
def current_metric_id(self) -> str:
|
||||
return self.metric_combo.currentData() or DEFAULT_METRIC_ID
|
||||
@@ -1,79 +0,0 @@
|
||||
"""
|
||||
Audio visualization plotting engine.
|
||||
Separates plotting logic from audio processing for clean GUI integration.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.colors as mcolors
|
||||
import matplotlib.cm as cm
|
||||
from matplotlib.figure import Figure
|
||||
import os
|
||||
|
||||
|
||||
class PlottingEngine:
|
||||
"""Handles all matplotlib visualization logic for audio analysis."""
|
||||
|
||||
@staticmethod
|
||||
def create_power_analysis_figure(times, rms_array, file_path, figsize=(10, 4)):
|
||||
"""
|
||||
Creates a matplotlib Figure for power analysis visualization.
|
||||
|
||||
Args:
|
||||
times: Array of time points
|
||||
rms_array: RMS power values over time
|
||||
file_path: Path to the audio file for title
|
||||
figsize: Figure size tuple
|
||||
|
||||
Returns:
|
||||
matplotlib.figure.Figure: Ready-to-embed figure
|
||||
"""
|
||||
# Determine color scale based on headroom detection
|
||||
local_max_power = np.max(rms_array)
|
||||
if local_max_power > 0.3:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.6)
|
||||
maxpower = 0.6
|
||||
else:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.3)
|
||||
maxpower = 0.3
|
||||
|
||||
# Create figure and axis
|
||||
fig = Figure(figsize=figsize, facecolor='white')
|
||||
ax = fig.add_subplot(111)
|
||||
|
||||
# Color map
|
||||
cmap = cm.autumn
|
||||
|
||||
# Plot power levels as colored bars
|
||||
ax.set_ylim(0., maxpower)
|
||||
for i in range(len(times)-1):
|
||||
ax.fill_between(times[i:i+2], 0, rms_array[0][i],
|
||||
color=cmap(norm(rms_array[0][i])), edgecolor='none')
|
||||
|
||||
# Add colorbar
|
||||
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
||||
sm.set_array([])
|
||||
cbar = fig.colorbar(sm, ax=ax, label='RMS Power')
|
||||
|
||||
# Labels and title
|
||||
ax.set_ylabel('Power')
|
||||
ax.set_xlabel('Time (seconds)')
|
||||
ax.set_title(f'{os.path.basename(file_path)}')
|
||||
|
||||
# Tight layout for better appearance in GUI
|
||||
fig.tight_layout()
|
||||
|
||||
return fig
|
||||
|
||||
@staticmethod
|
||||
def create_metadata_display_text(song_name, bpm, max_amplitude, avg_amplitude):
|
||||
"""
|
||||
Creates formatted text for metadata display.
|
||||
|
||||
Returns:
|
||||
str: Formatted metadata text
|
||||
"""
|
||||
return f"""Track: {song_name}
|
||||
BPM: {bpm:.1f}
|
||||
Max Amplitude: {max_amplitude:.3f}
|
||||
Avg Amplitude: {avg_amplitude:.3f}"""
|
||||
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "uj-mastering-master"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"librosa",
|
||||
"numpy",
|
||||
"matplotlib",
|
||||
"mutagen",
|
||||
"pyloudnorm",
|
||||
"PyQt5>=5.15.10",
|
||||
# 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
|
||||
# versions are Linux/macOS only.
|
||||
"PyQt5-Qt5==5.15.2 ; sys_platform == 'win32'",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ujm = "main:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = [
|
||||
"main",
|
||||
"analysis_results_manager",
|
||||
"audio_visualization_widget",
|
||||
"master_core",
|
||||
"metrics",
|
||||
"font_manager",
|
||||
"font_control_widget",
|
||||
"plot_control_widget",
|
||||
"logger_setup",
|
||||
"setup_fonts",
|
||||
]
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user