Compare commits

...

10 Commits

Author SHA1 Message Date
Mikkeli Matlock a322f08d0c Add spectrogram, native-rate loading, and always-labelled axis extremes
- SpectrogramMetric: log-frequency STFT power heatmap over time, magma
  colormap, -80 dB floor. Adaptive hop caps time bins at ~4000 so long
  tracks stay responsive on redraw; N_FFT=4096 keeps low-freq resolution.
- master_core: load audio at native sample rate (librosa.load sr=None)
  instead of librosa's 22050 Hz default, so the full band up to the
  file's own nyquist (~22 kHz at 44.1 kHz) is analysed. ~2x heavier on
  44.1/48 kHz files, by design.
- metrics: shared _show_axis_extents helper forces each axis's exact
  min/max onto the tick list with compact labels (_fmt_tick), so the
  true range is always readable -- notably the spectrogram's 22 kHz top,
  which otherwise sits unlabelled between log-scale decade ticks. Applied
  to all metric renders.
- Docs: README + CLAUDE updated for the new metric, native-rate loading,
  and axis-readability behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 00:06:50 +09:00
Mikkeli Matlock 11182472e3 Add Tier-1 mastering metrics: Crest Factor, PSR, True Peak; LRA on LUFS
Three new metrics plug into the existing Metric registry. All inherit
the async compute path, so first-use is non-blocking and subsequent
switches hit the per-(file, metric) cache.

- CrestFactorMetric: 20*log10(peak/RMS) per 1 s window, 0.25 s hop.
  Uses cumsum-of-squares for O(N) RMS. Reference lines at 12 dB
  (roomy) and 6 dB (heavily limited).
- PSRMetric: sample-peak (dBFS) minus short-term LUFS over the same
  3 s window as LUFSMetric, so the two plots line up. Reference lines
  at 10 LU (good punch) and 4 LU (squashed).
- TruePeakMetric: ITU-R BS.1770 oversampled true peak via
  scipy.signal.resample_poly at 4x over 250 ms / 100 ms windows.
  data["integrated_tp_db"] carries the track-wide max.
- LUFSMetric now also computes Meter.loudness_range and surfaces it
  in the plot legend alongside the integrated value.
- Shared _to_dbfs helper floors 20*log10 at _EPS so silent windows
  don't explode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 21:22:36 +09:00
Mikkeli Matlock 7bdf465799 Add pluggable metrics architecture with LUFS
Introduce a Metric ABC (compute on worker thread, render on GUI thread)
with a METRICS registry, and refactor the analysis pipeline around it.
RMS power (ported), raw waveform, and BS.1770 LUFS (via pyloudnorm) ship
as the initial three; new metrics drop in by appending to METRICS.

- metrics.py: Metric ABC + RMSPowerMetric, WaveformMetric (locked to
  +/-1.1 y-range for float headroom), LUFSMetric (short-term 3 s window
  + integrated value, with streaming-target reference line).
- plot_control_widget.py: metric selector dropdown + Refresh Plot button
  (moved out of FontControlWidget).
- analysis_results_manager.py: AnalysisResult caches the AudioFile and a
  per-metric data dict; new MetricComputeWorker runs metric switches off
  the GUI thread via metricComputeStarted/metricReady/metricComputeError
  signals, so LUFS on a 12-minute track no longer stalls the UI.
- main.py: all redraw paths funnel through one _render_or_request helper;
  stale-result guards keep slow computes from overwriting fresh selections.
- plotting_engine.py removed (metadata text moved onto AnalysisResult;
  figure construction lives in each Metric).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 00:42:45 +09:00
Mikkeli Matlock cf901a5686 cleanup 2026-05-28 14:05:34 +09:00
Mikkeli Matlock c466de62b2 updated gitignore 2026-03-15 15:16:32 +09:00
Mikkeli Matlock 3b689e0c4c Changed font size adjustable range 2025-08-22 00:52:58 +09:00
Mikkeli Matlock fa844dfde1 Updating CLAUDE settings to match initial rules 2025-08-22 00:36:33 +09:00
Mikkeli Matlock 9e65e721d4 Update project roadmap and documentation
- Reflect current implementation status with complete GUI and font system
- Update roadmap with prioritized development plan focusing on plot control widgets
- Reorganize future goals into urgent/short-term/mid-long term categories
- Add comprehensive feature overview and usage instructions
- Clarify next priority: plot control system clustering

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 00:26:27 +09:00
Mikkeli Matlock 265e8254cd Add unified font control system with auto-regeneration
- Add open file button for direct audio file selection
- Implement clustered FontControlWidget with font selector, size slider, and refresh controls
- Add auto-regeneration of matplotlib plots when fonts change
- Enhance FontManager with system font discovery and startup selection logic
- Replace individual font selector with comprehensive font control interface
- Support both custom fonts and system font candidates with proper prioritization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 00:18:31 +09:00
Mikkeli Matlock 4337a31b80 Implement threading, logging, and CJK font support
Major improvements to GUI stability and internationalization:

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-21 23:39:53 +09:00
19 changed files with 4283 additions and 604 deletions
+26 -1
View File
@@ -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
View File
@@ -0,0 +1,223 @@
# CJK Font Support Implementation
This document describes the CJK (Chinese, Japanese, Korean) font fallback system implemented for the Audio Analysis Toolkit.
## Problem Statement
The application displays song titles and metadata that may contain CJK characters from Japanese, Chinese, or Korean music files. The default matplotlib font (DejaVu Sans) lacks CJK glyphs, causing:
- Matplotlib warnings about missing glyphs
- Incorrect character rendering (squares, question marks, etc.)
- Poor user experience for CJK music collections
## Solution Architecture
### 1. Font Manager System (`font_manager.py`)
A centralized font management system that handles both matplotlib and Qt font configuration:
**Key Features:**
- **Licensing-safe**: Uses local `fonts/` directory (gitignored) for custom fonts
- **Graceful fallback**: System CJK fonts → matplotlib defaults
- **Cross-platform**: Windows, macOS, Linux font detection
- **Modular design**: Single responsibility for font configuration
**Font Priority Order:**
1. Custom fonts from `fonts/` directory (highest priority)
2. System CJK fonts (platform-specific)
3. Default fonts (fallback)
### 2. Safe Title Processing
All text that might contain CJK characters is processed through `safe_title()` function:
- Ensures proper encoding handling
- Provides fallback for problematic characters
- Maintains original text when possible
### 3. Integration Points
The font system is integrated at these key locations:
#### Application Startup (`main.py`)
```python
# Initialize font system before creating any widgets
font_success = initialize_fonts()
```
#### Plot Titles (`plotting_engine.py`)
```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.
+145 -79
View File
@@ -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
#### `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
+73 -23
View File
@@ -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.
+190 -70
View File
@@ -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
audio_file: AudioFile
song_name: str
bpm: float
max_amplitude: float
avg_amplitude: float
times: list
rms_array: list
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 AnalysisResultsManager(QObject):
"""
Manages audio file analysis and coordinates between processing and GUI.
Threading-ready architecture for future background processing.
"""
# Signals for GUI communication
analysisStarted = pyqtSignal(str) # file_path
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):
def __init__(self, file_path: str, metric: Metric):
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)
self.file_path = file_path
self.metric = metric
self.logger = logging.getLogger(__name__)
def run(self):
try:
# Create AudioFile and perform analysis
audio_file = AudioFile(file_path)
self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}")
self.progressUpdate.emit("Loading audio file...", 10)
# Get RMS analysis data
audio_file.get_energy_levels_over_time(window=window, hop=hop)
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)
# Extract analysis results
result = AnalysisResult(
file_path=file_path,
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,
times=audio_file._get_times(), # We'll need to add this method
rms_array=audio_file.rms_array,
analysis_successful=True
metric_data=metric_data,
analysis_successful=True,
)
# Cache the result
self.results_cache[file_path] = result
# Emit completion signal
self.analysisCompleted.emit(file_path, result)
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.analysisError.emit(file_path, error_msg)
self.logger.error(f"Analysis error for {self.file_path}: {error_msg}")
self.analysisError.emit(self.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
class MetricComputeWorker(QThread):
"""Worker thread that computes a single metric against an already-loaded AudioFile."""
result = self.results_cache[file_path]
return self.plotting_engine.create_power_analysis_figure(
result.times, result.rms_array, result.file_path
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.
"""
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.
"""
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:
"""Get formatted metadata text for a file."""
if file_path not in self.results_cache:
result = self.results_cache.get(file_path)
if result is None:
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
)
return result.metadata_text()
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
-52
View File
@@ -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.
+311
View File
@@ -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
View File
@@ -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)
+12
View File
@@ -0,0 +1,12 @@
This directory is for custom CJK fonts to improve character rendering.
Supported formats: .ttf, .otf, .ttc
Recommended free fonts for CJK support:
- Noto Sans CJK (Google Fonts)
- Source Han Sans (Adobe)
The application will automatically detect and use fonts placed here.
For setup instructions, run: python setup_fonts.py
Note: Only add fonts you have proper licensing rights to distribute.
+95
View File
@@ -0,0 +1,95 @@
"""
Logging configuration for the Audio Mastering Toolkit.
Provides CLI configurable logging with different verbosity levels.
"""
import logging
import sys
from typing import Optional
def setup_logging(level: str = "INFO", log_to_file: bool = False) -> logging.Logger:
"""
Setup logging configuration for the application.
Args:
level: Logging level (ERROR, WARN, INFO, DEBUG, TRACE)
log_to_file: Whether to also log to file
Returns:
Configured logger instance
"""
# Convert level string to logging constant
level_map = {
'ERROR': logging.ERROR,
'WARN': logging.WARNING,
'WARNING': logging.WARNING,
'INFO': logging.INFO,
'DEBUG': logging.DEBUG,
'TRACE': 5 # Custom level below DEBUG
}
# Add custom TRACE level
logging.addLevelName(5, 'TRACE')
numeric_level = level_map.get(level.upper(), logging.INFO)
# Create formatter
formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s: %(message)s',
datefmt='%H:%M:%S'
)
# Setup console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
console_handler.setLevel(numeric_level)
# Setup root logger
root_logger = logging.getLogger()
root_logger.setLevel(numeric_level)
root_logger.handlers.clear() # Clear any existing handlers
root_logger.addHandler(console_handler)
# Optional file logging
if log_to_file:
file_handler = logging.FileHandler('audio_analysis.log')
file_handler.setFormatter(formatter)
file_handler.setLevel(numeric_level)
root_logger.addHandler(file_handler)
# Add trace method to all loggers
def trace(self, message, *args, **kwargs):
if self.isEnabledFor(5):
self._log(5, message, args, **kwargs)
logging.Logger.trace = trace
# Create main application logger
app_logger = logging.getLogger('audio_mastering')
app_logger.info(f"Logging initialized at {level.upper()} level")
return app_logger
def parse_log_args() -> tuple[str, bool]:
"""
Parse command line arguments for logging configuration.
Returns:
Tuple of (log_level, log_to_file)
"""
import argparse
parser = argparse.ArgumentParser(add_help=False) # Don't interfere with main arg parsing
parser.add_argument('--log-level', '-l',
choices=['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'],
default='INFO',
help='Set logging verbosity level')
parser.add_argument('--log-file', action='store_true',
help='Also log to audio_analysis.log file')
# Parse known args only (ignore others for main app)
args, _ = parser.parse_known_args()
return args.log_level, args.log_file
+154 -13
View 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."""
@@ -124,11 +168,6 @@ class MainWindow(QMainWindow):
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)
@@ -140,32 +179,134 @@ class MainWindow(QMainWindow):
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)
# 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}")
if __name__ == '__main__':
app = QApplication(sys.argv)
# Initialize font system before creating any widgets
font_success = initialize_fonts()
if font_success:
logger.info("Font system initialized successfully")
# Log font status for debugging
font_status = get_font_manager().get_status_report()
logger.debug(f"Font status: matplotlib={font_status['matplotlib_configured']}, "
f"qt={font_status['qt_configured']}, "
f"custom_fonts={font_status['custom_fonts_loaded']}")
else:
logger.warning("Font system initialization failed - CJK characters may not display properly")
# Set application style
app.setStyle('Fusion') # Modern cross-platform style
logger.debug("Application style set to Fusion")
window = MainWindow()
window.show()
logger.info("GUI window displayed")
sys.exit(app.exec_())
if __name__ == '__main__':
main()
+34 -206
View File
@@ -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
import librosa
import numpy as np
from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3
def try_mp3_tags(file_path):
from font_manager import safe_title
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 MP3(file_path, ID3=EasyID3)
except Exception:
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]}"
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 = os.path.basename(self.file_path)
self.song_name = safe_title(os.path.basename(self.file_path))
self.y, self.sr = librosa.load(file_path)
# load automatically normalises everything to [-1.0, 1.0]
# and that's alright
# 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 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
# librosa.beat.beat_track returns numpy array - extract scalar value
if isinstance(self.bpm, np.ndarray):
return float(self.bpm[0]) if len(self.bpm) > 0 else 0.0
return float(self.bpm)
def get_energy_levels_over_time(self, window=10, hop=2):
"""_summary_
"""Compute rolling RMS power.
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.
window: Rolling window length in seconds.
hop: Hop length in seconds.
"""
# check if the window and hop are the same as before
if (not hasattr(self, 'window')) or ((self.window != window) or (self.hop != hop)):
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
self.rms_array = librosa.feature.rms(
y=self.y, frame_length=window_samples, hop_length=hop_samples
)
# 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."""
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)
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()
return librosa.frames_to_time(
np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop * self.sr
)
+572
View File
@@ -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"
+61
View File
@@ -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
-79
View File
@@ -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}"""
+36
View File
@@ -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",
]
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Font setup utility for CJK character support.
Provides installation instructions and system font detection.
"""
import sys
import platform
from pathlib import Path
# Add current directory to path to import our modules
sys.path.insert(0, str(Path(__file__).parent))
from font_manager import get_font_manager
def main():
"""Main font setup utility."""
print("=== Audio Analysis Toolkit - CJK Font Setup ===\n")
# Get font manager and show current status
font_manager = get_font_manager()
print("Current System Information:")
print(f"Platform: {platform.system()} {platform.release()}")
print(f"Python: {platform.python_version()}\n")
# Try to initialize fonts
print("Initializing font system...")
success = font_manager.initialize()
# Show detailed status
status = font_manager.get_status_report()
print(f"Font system status: {'✓ OK' if success else '⚠ Issues detected'}")
print(f"Matplotlib configured: {'' if status['matplotlib_configured'] else ''}")
print(f"Qt configured: {'' if status['qt_configured'] else ''}")
print(f"Custom fonts loaded: {status['custom_fonts_loaded']}")
if status['custom_font_families']:
print(f"Custom font families: {', '.join(status['custom_font_families'])}")
print(f"Fonts directory: {status['fonts_directory_path']}")
print(f"Directory exists: {'' if status['fonts_directory_exists'] else ''}")
print()
# Show current matplotlib font configuration
print("Current matplotlib font stack:")
for i, font in enumerate(status['current_matplotlib_fonts'][:8], 1):
print(f" {i}. {font}")
print()
# Show installation instructions
print(font_manager.get_font_installation_instructions())
# Test CJK character handling
print("\n=== Testing CJK Character Handling ===")
test_strings = [
"English Title",
"日本語のタイトル", # Japanese
"中文标题", # Chinese
"한국어 제목", # Korean
"Test - テスト", # Mixed
]
print("Testing font-safe title conversion:")
for test_str in test_strings:
safe_str = font_manager.get_cjk_safe_title(test_str)
status_indicator = "" if test_str == safe_str else ""
print(f" {status_indicator} '{test_str}' -> '{safe_str}'")
print("\n=== Setup Complete ===")
if success:
print("Font system is ready for use!")
if status['custom_fonts_loaded'] == 0:
print("Consider adding CJK fonts to improve character display.")
else:
print("There were issues with font setup. Check the logs for details.")
print("The application will still work but CJK characters may not display correctly.")
return 0 if success else 1
if __name__ == '__main__':
sys.exit(main())
Generated
+1683
View File
File diff suppressed because it is too large Load Diff