Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f82c9197b | |||
| 507af2f676 | |||
| d7782bb9d9 | |||
| 3707917d9e | |||
| 22ecb67296 | |||
| a45bd4ba9b | |||
| b400551321 | |||
| a322f08d0c | |||
| 11182472e3 | |||
| 7bdf465799 | |||
| cf901a5686 | |||
| c466de62b2 | |||
| 3b689e0c4c | |||
| fa844dfde1 | |||
| 9e65e721d4 | |||
| 265e8254cd | |||
| 4337a31b80 |
+26
-1
@@ -1 +1,26 @@
|
||||
files.txt
|
||||
# Font directory - avoid licensing issues by not committing font files
|
||||
# Keep the directory structure but ignore actual font files
|
||||
fonts/*.ttf
|
||||
fonts/*.otf
|
||||
fonts/*.ttc
|
||||
# But preserve the placeholder file
|
||||
!fonts/PLACE_YOUR_FONT_FILES_HERE
|
||||
|
||||
# Python cache and build artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
# CJK Font Support Implementation
|
||||
|
||||
This document describes the CJK (Chinese, Japanese, Korean) font fallback system implemented for the Audio Analysis Toolkit.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The application displays song titles and metadata that may contain CJK characters from Japanese, Chinese, or Korean music files. The default matplotlib font (DejaVu Sans) lacks CJK glyphs, causing:
|
||||
- Matplotlib warnings about missing glyphs
|
||||
- Incorrect character rendering (squares, question marks, etc.)
|
||||
- Poor user experience for CJK music collections
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
### 1. Font Manager System (`font_manager.py`)
|
||||
|
||||
A centralized font management system that handles both matplotlib and Qt font configuration:
|
||||
|
||||
**Key Features:**
|
||||
- **Licensing-safe**: Uses local `fonts/` directory (gitignored) for custom fonts
|
||||
- **Graceful fallback**: System CJK fonts → matplotlib defaults
|
||||
- **Cross-platform**: Windows, macOS, Linux font detection
|
||||
- **Modular design**: Single responsibility for font configuration
|
||||
|
||||
**Font Priority Order:**
|
||||
1. Custom fonts from `fonts/` directory (highest priority)
|
||||
2. System CJK fonts (platform-specific)
|
||||
3. Default fonts (fallback)
|
||||
|
||||
### 2. Safe Title Processing
|
||||
|
||||
All text that might contain CJK characters is processed through `safe_title()` function:
|
||||
- Ensures proper encoding handling
|
||||
- Provides fallback for problematic characters
|
||||
- Maintains original text when possible
|
||||
|
||||
### 3. Integration Points
|
||||
|
||||
The font system is integrated at these key locations:
|
||||
|
||||
#### Application Startup (`main.py`)
|
||||
```python
|
||||
# Initialize font system before creating any widgets
|
||||
font_success = initialize_fonts()
|
||||
```
|
||||
|
||||
#### Plot Titles (`plotting_engine.py`)
|
||||
```python
|
||||
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||
```
|
||||
|
||||
#### Metadata Display
|
||||
```python
|
||||
song_name = safe_title(f"{audio['artist'][0]} - {audio['title'][0]}")
|
||||
```
|
||||
|
||||
## Usage Instructions
|
||||
|
||||
### Quick Setup
|
||||
|
||||
1. **Run the setup utility:**
|
||||
```bash
|
||||
uv run python setup_fonts.py
|
||||
```
|
||||
|
||||
2. **For enhanced CJK support, add fonts to the `fonts/` directory:**
|
||||
- Download free CJK fonts (Noto Sans CJK, Source Han Sans, etc.)
|
||||
- Place .ttf/.otf/.ttc files in `fonts/` directory
|
||||
- Restart the application
|
||||
|
||||
### Font Directory Structure
|
||||
```
|
||||
uj-mastering-master/
|
||||
├── fonts/ # Gitignored
|
||||
│ ├── NotoSansCJK-Regular.ttc
|
||||
│ ├── SourceHanSans-Regular.otf
|
||||
│ └── [other CJK fonts]
|
||||
└── [application files]
|
||||
```
|
||||
|
||||
### Supported Font Formats
|
||||
- `.ttf` (TrueType Font)
|
||||
- `.otf` (OpenType Font)
|
||||
- `.ttc` (TrueType Collection)
|
||||
|
||||
## Platform-Specific Behavior
|
||||
|
||||
### Windows
|
||||
**System Fonts Used:**
|
||||
- Yu Gothic UI, Meiryo, MS Gothic (sans-serif)
|
||||
- Yu Mincho, MS Mincho (serif)
|
||||
|
||||
### macOS
|
||||
**System Fonts Used:**
|
||||
- Hiragino Sans, Yu Gothic (sans-serif)
|
||||
- Hiragino Mincho ProN, Yu Mincho (serif)
|
||||
|
||||
### Linux
|
||||
**System Fonts Used:**
|
||||
- Noto Sans CJK JP, Source Han Sans (sans-serif)
|
||||
- Noto Serif CJK JP, Source Han Serif (serif)
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Font Detection Algorithm
|
||||
|
||||
1. **Custom Font Loading:**
|
||||
```python
|
||||
# Load for matplotlib
|
||||
fm.fontManager.addfont(str(font_file))
|
||||
|
||||
# Load for Qt
|
||||
font_id = QFontDatabase.addApplicationFont(str(font_file))
|
||||
```
|
||||
|
||||
2. **System Font Fallback:**
|
||||
```python
|
||||
available_fonts = set(fm.get_font_names())
|
||||
for font_name in system_fonts['sans-serif']:
|
||||
if font_name in available_fonts:
|
||||
return font_name
|
||||
```
|
||||
|
||||
3. **Matplotlib Configuration:**
|
||||
```python
|
||||
plt.rcParams['font.sans-serif'] = font_list
|
||||
plt.rcParams['font.family'] = 'sans-serif'
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The system is designed to be fault-tolerant:
|
||||
- Missing fonts directory → Use system fonts
|
||||
- Font loading failures → Log warnings, continue
|
||||
- Encoding errors → Apply safe character replacement
|
||||
- No CJK fonts found → Graceful degradation to defaults
|
||||
|
||||
## Licensing Considerations
|
||||
|
||||
### Safe Practices
|
||||
- **Custom fonts directory is gitignored** to avoid committing proprietary fonts
|
||||
- **System fonts are detected, not redistributed**
|
||||
- **Open source font recommendations** (Noto, Source Han families)
|
||||
- **No font files included in repository**
|
||||
|
||||
### Recommended Free CJK Fonts
|
||||
1. **Google Noto Fonts** (SIL Open Font License)
|
||||
- Noto Sans CJK JP/SC/TC/KR
|
||||
- Comprehensive CJK coverage
|
||||
|
||||
2. **Adobe Source Han Fonts** (SIL Open Font License)
|
||||
- Source Han Sans
|
||||
- Source Han Serif
|
||||
|
||||
## Testing and Debugging
|
||||
|
||||
### Font Status Report
|
||||
```python
|
||||
from font_manager import get_font_manager
|
||||
status = get_font_manager().get_status_report()
|
||||
print(status)
|
||||
```
|
||||
|
||||
### Test CJK Characters
|
||||
```bash
|
||||
uv run python setup_fonts.py
|
||||
```
|
||||
|
||||
### Logging
|
||||
Font system operations are logged at appropriate levels:
|
||||
- INFO: Successful initialization
|
||||
- DEBUG: Font loading details
|
||||
- WARNING: Missing fonts, fallbacks used
|
||||
- ERROR: Critical font system failures
|
||||
|
||||
## Future Improvements
|
||||
|
||||
### Potential Enhancements
|
||||
1. **Dynamic Font Switching:** Per-language font selection
|
||||
2. **Font Caching:** Faster startup with font cache
|
||||
3. **User Preferences:** GUI for font selection
|
||||
4. **Font Metrics:** Analyze font quality for CJK rendering
|
||||
|
||||
### Performance Considerations
|
||||
- Font loading is done once at startup
|
||||
- Font cache clearing only when necessary
|
||||
- Minimal performance impact on audio processing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue:** CJK characters still show as squares
|
||||
- **Solution:** Install CJK fonts in `fonts/` directory or check system font availability
|
||||
|
||||
**Issue:** Font warnings in console
|
||||
- **Solution:** Run `python setup_fonts.py` to check font configuration
|
||||
|
||||
**Issue:** Application startup slower after font system
|
||||
- **Solution:** This is normal on first run; subsequent starts should be faster
|
||||
|
||||
### Debug Commands
|
||||
```bash
|
||||
# Check font system status
|
||||
uv run python setup_fonts.py
|
||||
|
||||
# Test with specific log level
|
||||
uv run ujm --log-level DEBUG
|
||||
|
||||
# Test matplotlib font configuration
|
||||
uv run python -c "import matplotlib.pyplot as plt; print(plt.rcParams['font.sans-serif'])"
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
This CJK font support implementation provides:
|
||||
- **Robust fallback system** ensuring CJK characters display properly
|
||||
- **Licensing compliance** by avoiding font redistribution
|
||||
- **Cross-platform compatibility** with platform-specific font preferences
|
||||
- **Clean architecture** with separation of font management concerns
|
||||
- **User-friendly setup** with clear instructions and status reporting
|
||||
|
||||
The system gracefully handles missing fonts and provides clear guidance for optimal CJK character rendering while maintaining the existing application functionality.
|
||||
@@ -2,117 +2,239 @@
|
||||
|
||||
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**: CJK-capable, fixed UI font (M PLUS 1 Code @ 10pt) with system fallback
|
||||
- **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**: pyqtgraph — persistent, interactive (mouse zoom/pan, lin/log
|
||||
toggle, multi-dataset overlay). matplotlib remains only for its colormaps
|
||||
(consumed by pyqtgraph) and as a librosa dependency
|
||||
- **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 (`AudioAnalysisWorker` = load + first
|
||||
metric; `MetricComputeWorker` = one metric on an already-loaded file)
|
||||
- Caches both the loaded `AudioFile` and per-metric `compute()` output, so
|
||||
metric/font switches re-render from cache without reloading librosa
|
||||
- **Prefetch** (`PrefetchWorker`): after a file loads, the remaining metrics are
|
||||
computed in the background (one at a time, cooperatively cancellable) so the
|
||||
first switch to any metric is instant too. Superseded when a new file loads
|
||||
- Timing: workers measure compute time; `metricTiming` + phase/duration progress
|
||||
messages drive the status slip ("X computed in Ys", "Loaded in Ns — computing…")
|
||||
- `shutdown()` stops all threads on window close (`MainWindow.closeEvent`)
|
||||
|
||||
### Current Analysis Features
|
||||
- **RMS Power Analysis**: 10-second rolling window with 2-second hops
|
||||
- **Adaptive Color Mapping**: Automatically adjusts scale based on detected headroom
|
||||
- High dynamic range: 0-0.6 scale for loud masters
|
||||
#### `audio_visualization_widget.py`
|
||||
- Persistent pyqtgraph plot — the PlotItem is reused across renders, never torn
|
||||
down, so mouse zoom/pan and scale toggles survive every redraw
|
||||
- `show_specs([(label, PlotSpec), ...], view)` draws one or more datasets onto
|
||||
the shared axes, assigning a distinct colour per dataset for overlay/compare
|
||||
- Spectrogram log-frequency is realised by resampling STFT rows onto a log grid
|
||||
(`ImageItem` is affine-only and won't follow a log axis) — see `_render_heatmap`
|
||||
|
||||
#### `plotspec.py`
|
||||
- Backend-agnostic drawing descriptors: `Curve`, `Band`, `HLine`, `Heatmap`,
|
||||
`AxisSpec`, `PlotSpec`, plus the `ViewState` (recompute-free lin/log options)
|
||||
- The seam that decouples metrics from the plotting library: metrics emit
|
||||
*intent*, the renderer owns colour/layout/library specifics
|
||||
|
||||
#### `font_manager.py`
|
||||
- Auto-detection of custom fonts from `fonts/` directory; CJK fallbacks
|
||||
- `apply_fixed_font(family, size)` locks the Qt app font (used at startup to pin
|
||||
the UI to **M PLUS 1 Code @ 10pt**, falling back to the system default if the
|
||||
family isn't found). There is no runtime font picker — the old
|
||||
`font_control_widget.py` was removed as wasted panel space
|
||||
- pyqtgraph and the Qt widgets both read the app font, so this covers the plot
|
||||
too (M PLUS 1 Code has full Japanese coverage, so titles stay CJK-safe)
|
||||
|
||||
#### `plot_control_widget.py`
|
||||
- Metric selector dropdown driven by the `metrics.METRICS` registry
|
||||
- Log-frequency toggle and a time-axis mode selector — Absolute (seconds) vs
|
||||
Relative (% of each track's own length) — both view-state, recompute-free
|
||||
- `Refresh Plot` button. Compare/overlay membership is the file-list checkboxes;
|
||||
reference lines have their own cluster
|
||||
|
||||
#### `ref_line_widget.py`
|
||||
- `RefLineControlWidget`: side-panel list of custom reference lines with
|
||||
Add / Edit… / Remove / Clear; a pure view over the `RefLineProps` list the
|
||||
main window owns, emitting intents
|
||||
- `RefLineDialog`: edits one line's value, colour, line style, and tag
|
||||
- The plot draws each line with a triangle drag-handle; dragging writes the new
|
||||
value back into the shared `RefLineProps` and refreshes the list
|
||||
|
||||
#### `metrics.py`
|
||||
- Pluggable `Metric` ABC: `compute(audio_file) -> data` (heavy, worker thread,
|
||||
backend-neutral numpy/scalars) and `build_spec(data, view) -> PlotSpec` (cheap,
|
||||
GUI thread, view-aware). Metrics no longer touch the plotting library
|
||||
- Compute-time vs view-time split: scale (lin/log) is a `ViewState` argument to
|
||||
`build_spec`, so toggling it never recomputes
|
||||
- Current registry:
|
||||
- `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale
|
||||
- `WaveformMetric` — min/max envelope, fixed ±1.1 y-range
|
||||
- `LUFSMetric` — true (ungated) EBU R128 short-term (3 s) via a single
|
||||
K-weighting pass (`_kweight`, cached) + a vectorised sliding mean-square.
|
||||
Integrated (`_integrated_lufs`) and LRA (`_loudness_range`) are reimplemented
|
||||
from the same cached K-weighted signal — validated **bit-equal** to
|
||||
pyloudnorm — so nothing re-filters the signal. ~3.8 s → ~0.6 s. pyloudnorm is
|
||||
now used only to source the BS.1770 filter coefficients
|
||||
- `CrestFactorMetric` — 20·log10(peak/RMS) per 1 s window; peaks via O(N) running max
|
||||
- `PSRMetric` — sample-peak minus short-term LUFS (3 s window); reuses
|
||||
`LUFSMetric`'s short-term series (memoised on the `AudioFile`), so PSR is
|
||||
near-free once LUFS is computed
|
||||
- `TruePeakMetric` — 4× oversampled dBTP; the whole signal is oversampled once
|
||||
(`scipy.signal.resample_poly`) then an O(N) running max over windows
|
||||
- `SpectrogramMetric` — log-frequency STFT heatmap; adaptive hop caps time
|
||||
bins at ~4000, `N_FFT=4096`. Log/linear frequency is a view toggle
|
||||
- Drop in new ones (DR, spectral balance) by appending an instance to `METRICS`;
|
||||
return a `PlotSpec` from `build_spec` (curves overlay automatically; heatmaps
|
||||
show one dataset at a time)
|
||||
- Note: the old matplotlib `_show_axis_extents` exact-endpoint tick labelling is
|
||||
gone with the matplotlib render path. If wanted back, it belongs in the
|
||||
renderer, applied uniformly to every metric — not per-metric
|
||||
|
||||
#### `master_core.py`
|
||||
- Defines the `AudioFile` class: librosa loading, rolling RMS power. BPM detection
|
||||
was **removed** — `librosa.beat.beat_track` cost ~3.7 s on every load for a
|
||||
number no better than tapping by hand
|
||||
- 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 (ungated short-term + gated integrated + LRA), PSR, Crest Factor
|
||||
- **Peak analysis**: True Peak (4× oversampled dBTP)
|
||||
- **Spectral view**: log-frequency spectrogram heatmap over time
|
||||
- **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
|
||||
- **Compare/overlay**: each analysed file has a checkbox; the ticked set is
|
||||
overlaid on one graph for the current metric (curve metrics overlay; the
|
||||
spectrogram shows one track at a time). Highlighting a row drives the metadata
|
||||
panel, independent of the overlay set
|
||||
- **Interactive plot**: mouse drag-zoom, scroll-wheel zoom, pan, right-click menu
|
||||
(pyqtgraph ViewBox); log/linear frequency toggle. Scroll zooms both axes;
|
||||
**Ctrl+scroll** zooms time only, **Shift+scroll** zooms the value axis only
|
||||
(`_AxisZoomViewBox`); scrolling over an axis also zooms just that axis
|
||||
- **Time-axis mode**: a Relative-time toggle — off = seconds, on = 0-100% of each
|
||||
track's own length, so tracks of very different durations line up by position
|
||||
- **Custom reference lines**: side-panel list (Add/Edit/Remove/Clear) of draggable
|
||||
horizontal markers with value/colour/style/tag; dragged via a triangle handle.
|
||||
Kept **per metric** (so switching metrics doesn't lose them) and expressed in
|
||||
the metric's own units — on the spectrogram they read and edit in **Hz** (the
|
||||
renderer converts Hz<->row index, since the heatmap y-axis is a row index)
|
||||
- **Plot control**: Metric selector + log-frequency toggle + relative-time toggle
|
||||
+ refresh-plot button
|
||||
- **Analysis display**: Real-time visualization with metadata panels
|
||||
- **Modular architecture**: Self-contained widgets for easy layout management
|
||||
|
||||
## 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** *(zoom/pan, axis-range select, lin/log done via
|
||||
pyqtgraph)*
|
||||
- GUI-controllable plotting styles (colormap, visualization type)
|
||||
- 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** *(multi-file overlay done via file-list checkboxes;
|
||||
each song has a stable palette colour keyed to its list row)*
|
||||
- Per-song colour picker: clickable swatch in the file list (overlay already
|
||||
accepts a caller-supplied colour per dataset via `show_specs`, so this is a
|
||||
UI + override-map addition, not a render change)
|
||||
- Reference vs. comparee designation (vs. flat overlay)
|
||||
- Side-by-side track comparison interface (incl. spectrogram, which can't overlay)
|
||||
- A/B testing for mastering versions
|
||||
|
||||
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
|
||||
- matplotlib: Plotting and visualization
|
||||
- scipy: Signal processing (true-peak polyphase oversampling, K-weighting
|
||||
filters, spectrogram log-frequency resample, O(N) running-max via ndimage)
|
||||
- pyloudnorm: source of the BS.1770 K-weighting filter coefficients (the LUFS
|
||||
short-term / integrated / LRA math is now computed directly, validated against it)
|
||||
- pyqtgraph: Interactive plotting (zoom/pan, overlay, lin/log)
|
||||
- matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency
|
||||
- mutagen: Audio metadata extraction
|
||||
- PyQt5: GUI framework
|
||||
|
||||
### Architecture Considerations
|
||||
- Current code mixes analysis and visualization - consider separation
|
||||
### Architecture considerations
|
||||
- Three-stage split: `metrics.compute` (heavy, worker thread, backend-neutral
|
||||
data) → `metrics.build_spec` (cheap, GUI thread, view-aware `PlotSpec`) →
|
||||
`AudioVisualizationWidget.show_specs` (pyqtgraph rendering, overlay, colours)
|
||||
- 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 +242,23 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
||||
|
||||
## Usage
|
||||
|
||||
### Current Usage
|
||||
1. Add audio file paths to `files.txt`
|
||||
2. Run `python master_core.py` for batch analysis
|
||||
3. Run `python main.py` for GUI (incomplete)
|
||||
### Running the app
|
||||
```bash
|
||||
uv sync # one-time, after cloning
|
||||
uv run ujm # launch the GUI
|
||||
```
|
||||
|
||||
### Planned Usage
|
||||
1. Drag and drop audio files into GUI
|
||||
2. Real-time analysis with interactive graphs
|
||||
3. Export reports and comparisons
|
||||
4. VST plugin for DAW integration
|
||||
Optional flags (handled by `logger_setup.parse_log_args`):
|
||||
```bash
|
||||
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
||||
uv run ujm --log-file # also write audio_analysis.log
|
||||
```
|
||||
|
||||
The only entry point is `ujm` (defined in `pyproject.toml` as
|
||||
`ujm = "main:main"`). The previous `files.txt` batch mode and the
|
||||
`python master_core.py` workflow have been removed.
|
||||
|
||||
### Planned usage enhancements
|
||||
1. Interactive plot manipulation and style customization
|
||||
2. Audio file comparison features (reference vs. comparee)
|
||||
3. Self-contained executable releases
|
||||
@@ -1,29 +1,79 @@
|
||||
# uj-mastering-master
|
||||
Utility providing metrics to evaluate masterings.
|
||||
Now boosted by Claude Code.
|
||||
|
||||
## dependencies
|
||||
librosa, numpy, matplotlib, mutagen
|
||||
Custom mastering toolkit providing visual metrics for evaluating audio masterings.
|
||||
Developed with Claude Code assistance.
|
||||
|
||||
## usage
|
||||
## Features
|
||||
|
||||
### Command Line Analysis
|
||||
1. Edit `files.txt` to include paths to your audio files (MP3/WAV supported)
|
||||
- Use `;` or `#` to comment out files
|
||||
- One file path per line
|
||||
2. Run: `python master_core.py`
|
||||
- Generates colorized power magnitude graphs for each file
|
||||
- Displays BPM and song metadata
|
||||
- Graphs show RMS power over time with adaptive scaling
|
||||
### Current
|
||||
- **PyQt5 GUI**: drag-and-drop or file-dialog ingest of `.mp3`, `.wav`, `.flac`
|
||||
- **Switchable metrics** via a dropdown, all sharing one analysis cache:
|
||||
- **RMS Power** — 10 s rolling window with adaptive colour scale
|
||||
- **Waveform** — min/max envelope, fixed ±1.1 scale
|
||||
- **LUFS** — BS.1770 short-term (3 s) + integrated + loudness range (LRA)
|
||||
- **Crest Factor** — peak-to-RMS spread over time
|
||||
- **PSR** — peak-to-short-term-loudness ratio ("is it still breathing?")
|
||||
- **True Peak** — 4× oversampled dBTP, catches inter-sample peaks
|
||||
- **Spectrogram** — log-frequency STFT power heatmap over time
|
||||
- **Always-labelled axis extremes**: every plot forces its exact min/max onto
|
||||
the ticks, so you can read the true range even on a log axis (e.g. the
|
||||
spectrogram's 22 kHz top, which otherwise falls between decade ticks)
|
||||
- **Native sample rate**: audio is loaded without resampling, so the full band
|
||||
(up to the file's own nyquist, e.g. ~22 kHz for 44.1 kHz files) is analysed
|
||||
- **BPM detection** via librosa
|
||||
- **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored), system fallbacks, and a live font selector
|
||||
- **Background analysis thread** so the UI stays responsive; metric switches
|
||||
compute off the GUI thread and cache, so re-selecting a metric is instant
|
||||
- **Embedded matplotlib canvas** with auto-regenerated plots on font change
|
||||
|
||||
### GUI Mode (Experimental)
|
||||
Run: `python main.py`
|
||||
- Opens drag-and-drop interface
|
||||
- Currently displays dropped file paths
|
||||
- Analysis integration coming soon
|
||||
### Roadmap
|
||||
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
|
||||
dynamic range (DR meter), plot-style controls, interactive axis controls.
|
||||
|
||||
### Output
|
||||
- Interactive matplotlib graphs showing power levels over time
|
||||
- Color-coded visualization (autumn colormap)
|
||||
- Automatic headroom detection and scaling
|
||||
- Console output with BPM and metadata information
|
||||
## Quick start
|
||||
|
||||
This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run ujm
|
||||
```
|
||||
|
||||
`uv run ujm` is the only supported entry point — it boots the GUI.
|
||||
|
||||
### Logging flags
|
||||
```bash
|
||||
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
||||
uv run ujm --log-file # also write audio_analysis.log
|
||||
```
|
||||
|
||||
### Fonts
|
||||
Drop `.ttf` / `.otf` / `.ttc` files into `fonts/` to get them in the font
|
||||
selector. The directory is gitignored to avoid bundling licensed font data.
|
||||
See [CJK_FONTS.md](CJK_FONTS.md) for details.
|
||||
|
||||
## Dependencies
|
||||
`librosa`, `numpy`, `matplotlib`, `mutagen`, `pyloudnorm`, `PyQt5` — all pinned
|
||||
through `uv.lock`. Python 3.10+.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `main.py` | `MainWindow` + the `ujm` entry point |
|
||||
| `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache |
|
||||
| `master_core.py` | `AudioFile`: native-rate librosa loading, RMS rolling window, BPM |
|
||||
| `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) |
|
||||
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host |
|
||||
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config |
|
||||
| `font_control_widget.py` | Font picker + size slider |
|
||||
| `plot_control_widget.py` | Metric selector + refresh-plot button |
|
||||
| `logger_setup.py` | CLI log-level parsing + custom TRACE level |
|
||||
| `setup_fonts.py` | Diagnostic utility (run standalone) |
|
||||
|
||||
### Adding a metric
|
||||
|
||||
Subclass `Metric` in `metrics.py`, implement `compute(audio_file) -> data` (the
|
||||
heavy part, runs on the worker thread) and `render(data, file_path) -> Figure`
|
||||
(cheap, runs on the GUI thread). Register the instance in the `METRICS` dict at
|
||||
the bottom of the file — it shows up in the dropdown automatically.
|
||||
|
||||
Binary file not shown.
+321
-107
@@ -3,122 +3,336 @@ 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 time
|
||||
import logging
|
||||
|
||||
from master_core import AudioFile
|
||||
from plotting_engine import PlottingEngine
|
||||
from font_manager import safe_title
|
||||
from metrics import METRICS, DEFAULT_METRIC_ID, Metric
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisResult:
|
||||
"""Container for audio analysis results."""
|
||||
file_path: str
|
||||
song_name: str
|
||||
bpm: float
|
||||
max_amplitude: float
|
||||
avg_amplitude: float
|
||||
times: list
|
||||
rms_array: list
|
||||
analysis_successful: bool = True
|
||||
error_message: str = ""
|
||||
"""Container for audio analysis results."""
|
||||
file_path: str
|
||||
audio_file: AudioFile
|
||||
song_name: str
|
||||
max_amplitude: float
|
||||
avg_amplitude: float
|
||||
metric_data: dict[str, Any] = field(default_factory=dict)
|
||||
analysis_successful: bool = True
|
||||
error_message: str = ""
|
||||
|
||||
def metadata_text(self) -> str:
|
||||
return (
|
||||
f"Track: {safe_title(self.song_name)}\n"
|
||||
f"Max Amplitude: {self.max_amplitude:.3f}\n"
|
||||
f"Avg Amplitude: {self.avg_amplitude:.3f}"
|
||||
)
|
||||
|
||||
|
||||
class AudioAnalysisWorker(QThread):
|
||||
"""Worker thread that loads audio and computes a single metric."""
|
||||
|
||||
progressUpdate = pyqtSignal(str, int) # message, percentage
|
||||
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
|
||||
analysisError = pyqtSignal(str, str) # file_path, error_message
|
||||
|
||||
def __init__(self, file_path: str, metric: Metric):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
self.metric = metric
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
base = os.path.basename(self.file_path)
|
||||
self.logger.info(f"Starting analysis of: {base}")
|
||||
|
||||
# Decode is a black box (no progress callback), so report it as a phase
|
||||
# with its measured duration rather than a fake percentage.
|
||||
self.progressUpdate.emit(f"Loading {base}…", 0)
|
||||
t0 = time.perf_counter()
|
||||
audio_file = AudioFile(self.file_path)
|
||||
load_s = time.perf_counter() - t0
|
||||
|
||||
self.progressUpdate.emit(
|
||||
f"Loaded in {load_s:.1f}s — computing {self.metric.display_name}…", 50)
|
||||
t1 = time.perf_counter()
|
||||
metric_data = {self.metric.id: self.metric.compute(audio_file)}
|
||||
metric_s = time.perf_counter() - t1
|
||||
|
||||
result = AnalysisResult(
|
||||
file_path=self.file_path,
|
||||
audio_file=audio_file,
|
||||
song_name=audio_file.song_name,
|
||||
max_amplitude=audio_file.max_amplitude,
|
||||
avg_amplitude=audio_file.avg_amplitude,
|
||||
metric_data=metric_data,
|
||||
analysis_successful=True,
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
f"Analysis completed: {base} (load {load_s:.2f}s, "
|
||||
f"{self.metric.id} {metric_s:.2f}s)")
|
||||
self.progressUpdate.emit(
|
||||
f"{self.metric.display_name} ready in {metric_s:.1f}s "
|
||||
f"(loaded in {load_s:.1f}s)", 100)
|
||||
self.analysisCompleted.emit(self.file_path, result)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Analysis failed: {str(e)}"
|
||||
self.logger.error(f"Analysis error for {self.file_path}: {error_msg}")
|
||||
self.analysisError.emit(self.file_path, error_msg)
|
||||
|
||||
|
||||
class MetricComputeWorker(QThread):
|
||||
"""Worker thread that computes a single metric against an already-loaded AudioFile."""
|
||||
|
||||
completed = pyqtSignal(str, str, object, float) # file_path, metric_id, data, seconds
|
||||
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)}"
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
data = self.metric.compute(self.audio_file)
|
||||
elapsed = time.perf_counter() - t0
|
||||
self.completed.emit(self.file_path, self.metric.id, data, elapsed)
|
||||
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 PrefetchWorker(QThread):
|
||||
"""Background worker that warms the cache by computing the remaining metrics.
|
||||
|
||||
Runs the given metrics sequentially on an already-loaded AudioFile so that
|
||||
switching to any metric is instant the first time too. Cooperative: `stop()`
|
||||
lets it bail between metrics (e.g. when a new file supersedes it). Skips any
|
||||
metric that got computed on-demand in the meantime.
|
||||
"""
|
||||
|
||||
computedOne = pyqtSignal(str, str, object) # file_path, metric_id, data
|
||||
|
||||
def __init__(self, file_path: str, result: "AnalysisResult", metrics: list):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
self.result = result
|
||||
self.metrics = metrics
|
||||
self._stop = False
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def stop(self):
|
||||
self._stop = True
|
||||
|
||||
def run(self):
|
||||
for metric in self.metrics:
|
||||
if self._stop:
|
||||
return
|
||||
if metric.id in self.result.metric_data:
|
||||
continue # already computed on-demand while we were working
|
||||
try:
|
||||
data = metric.compute(self.result.audio_file)
|
||||
if self._stop:
|
||||
return
|
||||
self.computedOne.emit(self.file_path, metric.id, data)
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Prefetch of {metric.id} failed: {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
|
||||
metricTiming = pyqtSignal(str, str, float) # file_path, metric_id, seconds
|
||||
|
||||
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.prefetch_worker: Optional[PrefetchWorker] = None
|
||||
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()
|
||||
|
||||
# A new foreground load supersedes background prefetch of the previous file.
|
||||
self._stop_prefetch()
|
||||
|
||||
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)
|
||||
# Warm the cache for the rest of the metrics so switching is instant.
|
||||
self._start_prefetch(file_path, result)
|
||||
|
||||
def _start_prefetch(self, file_path: str, result: AnalysisResult):
|
||||
"""Compute the not-yet-cached metrics in the background, one at a time."""
|
||||
self._stop_prefetch()
|
||||
pending = [m for m in METRICS.values() if m.id not in result.metric_data]
|
||||
if not pending:
|
||||
return
|
||||
self.logger.info(
|
||||
f"Prefetching {len(pending)} metric(s) for {os.path.basename(file_path)}")
|
||||
self.prefetch_worker = PrefetchWorker(file_path, result, pending)
|
||||
self.prefetch_worker.computedOne.connect(self._on_prefetch_one)
|
||||
self.prefetch_worker.start()
|
||||
|
||||
def _stop_prefetch(self):
|
||||
worker = self.prefetch_worker
|
||||
if worker is not None and worker.isRunning():
|
||||
worker.stop()
|
||||
worker.wait()
|
||||
self.prefetch_worker = None
|
||||
|
||||
def _on_prefetch_one(self, file_path: str, metric_id: str, data: object):
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is not None and metric_id not in result.metric_data:
|
||||
result.metric_data[metric_id] = data
|
||||
# metricReady (not metricTiming): warms any waiting view without spamming the
|
||||
# status bar with background completions.
|
||||
self.metricReady.emit(file_path, metric_id)
|
||||
|
||||
def request_metric(self, file_path: str, metric_id: str) -> bool:
|
||||
"""Ensure the metric's data exists for the file; emit metricReady when ready.
|
||||
|
||||
Returns True if the data was already cached (metricReady emitted synchronously)
|
||||
or successfully kicked off (will emit later). Returns False if the file hasn't
|
||||
been analysed yet or the metric id is unknown — in that case the caller
|
||||
should wait for analysisCompleted or correct the metric id.
|
||||
"""
|
||||
Manages audio file analysis and coordinates between processing and GUI.
|
||||
Threading-ready architecture for future background processing.
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is None:
|
||||
return False
|
||||
|
||||
metric = METRICS.get(metric_id)
|
||||
if metric is None:
|
||||
self.logger.warning(f"Unknown metric requested: {metric_id}")
|
||||
return False
|
||||
|
||||
if metric_id in result.metric_data:
|
||||
# Cached — emit immediately so the caller can re-render.
|
||||
self.metricReady.emit(file_path, metric_id)
|
||||
return True
|
||||
|
||||
key = (file_path, metric_id)
|
||||
existing = self.metric_workers.get(key)
|
||||
if existing is not None and existing.isRunning():
|
||||
self.logger.debug(f"Metric compute already in flight: {metric_id} for {os.path.basename(file_path)}")
|
||||
return True
|
||||
|
||||
worker = MetricComputeWorker(file_path, result.audio_file, metric)
|
||||
worker.completed.connect(self._on_metric_completed)
|
||||
worker.failed.connect(self._on_metric_failed)
|
||||
self.metric_workers[key] = worker
|
||||
self.metricComputeStarted.emit(file_path, metric_id)
|
||||
worker.start()
|
||||
return True
|
||||
|
||||
def _on_metric_completed(self, file_path: str, metric_id: str, data: object, seconds: float):
|
||||
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)
|
||||
self.metricTiming.emit(file_path, metric_id, seconds)
|
||||
|
||||
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_data(self, file_path: str, metric_id: str):
|
||||
"""Return cached metric data, or None if not computed yet.
|
||||
|
||||
Never triggers compute — call `request_metric` first and listen for
|
||||
`metricReady` if you need on-demand computation. Spec/figure building is the
|
||||
GUI layer's job (it owns the view-state), so this stays render-agnostic.
|
||||
"""
|
||||
|
||||
# Signals for GUI communication
|
||||
analysisStarted = pyqtSignal(str) # file_path
|
||||
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult
|
||||
analysisError = pyqtSignal(str, str) # file_path, error_message
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.results_cache = {} # Store analysis results
|
||||
self.plotting_engine = PlottingEngine()
|
||||
|
||||
def analyze_file(self, file_path: str, window: int = 10, hop: int = 2):
|
||||
"""
|
||||
Analyze an audio file and emit results.
|
||||
Currently synchronous - ready for threading later.
|
||||
|
||||
Args:
|
||||
file_path: Path to audio file
|
||||
window: RMS analysis window size in seconds
|
||||
hop: Analysis hop size in seconds
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
error_msg = f"File not found: {file_path}"
|
||||
self.analysisError.emit(file_path, error_msg)
|
||||
return
|
||||
|
||||
# Emit analysis started signal
|
||||
self.analysisStarted.emit(file_path)
|
||||
|
||||
try:
|
||||
# Create AudioFile and perform analysis
|
||||
audio_file = AudioFile(file_path)
|
||||
|
||||
# Get RMS analysis data
|
||||
audio_file.get_energy_levels_over_time(window=window, hop=hop)
|
||||
|
||||
# Extract analysis results
|
||||
result = AnalysisResult(
|
||||
file_path=file_path,
|
||||
song_name=audio_file.song_name,
|
||||
bpm=audio_file.get_bpm(),
|
||||
max_amplitude=audio_file.max_amplitude,
|
||||
avg_amplitude=audio_file.avg_amplitude,
|
||||
times=audio_file._get_times(), # We'll need to add this method
|
||||
rms_array=audio_file.rms_array,
|
||||
analysis_successful=True
|
||||
)
|
||||
|
||||
# Cache the result
|
||||
self.results_cache[file_path] = result
|
||||
|
||||
# Emit completion signal
|
||||
self.analysisCompleted.emit(file_path, result)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Analysis failed: {str(e)}"
|
||||
self.analysisError.emit(file_path, error_msg)
|
||||
|
||||
def get_analysis_figure(self, file_path: str):
|
||||
"""
|
||||
Get matplotlib figure for a previously analyzed file.
|
||||
|
||||
Returns:
|
||||
matplotlib.figure.Figure or None
|
||||
"""
|
||||
if file_path not in self.results_cache:
|
||||
return None
|
||||
|
||||
result = self.results_cache[file_path]
|
||||
return self.plotting_engine.create_power_analysis_figure(
|
||||
result.times, result.rms_array, result.file_path
|
||||
)
|
||||
|
||||
def get_metadata_text(self, file_path: str) -> str:
|
||||
"""Get formatted metadata text for a file."""
|
||||
if file_path not in self.results_cache:
|
||||
return "No analysis data available"
|
||||
|
||||
result = self.results_cache[file_path]
|
||||
return self.plotting_engine.create_metadata_display_text(
|
||||
result.song_name, result.bpm,
|
||||
result.max_amplitude, result.avg_amplitude
|
||||
)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear all cached analysis results."""
|
||||
self.results_cache.clear()
|
||||
|
||||
def is_file_analyzed(self, file_path: str) -> bool:
|
||||
"""Check if a file has been analyzed."""
|
||||
return file_path in self.results_cache
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is None:
|
||||
return None
|
||||
if metric_id not in METRICS:
|
||||
return None
|
||||
return result.metric_data.get(metric_id)
|
||||
|
||||
def display_label(self, file_path: str) -> str:
|
||||
"""Short human label for a file (song name if known, else basename)."""
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is not None and result.song_name:
|
||||
return result.song_name
|
||||
return os.path.basename(file_path)
|
||||
|
||||
def get_metadata_text(self, file_path: str) -> str:
|
||||
result = self.results_cache.get(file_path)
|
||||
if result is None:
|
||||
return "No analysis data available"
|
||||
return result.metadata_text()
|
||||
|
||||
def clear_cache(self):
|
||||
self.results_cache.clear()
|
||||
|
||||
def is_file_analyzed(self, file_path: str) -> bool:
|
||||
return file_path in self.results_cache
|
||||
|
||||
def shutdown(self):
|
||||
"""Stop all background threads cleanly (call on app close)."""
|
||||
self._stop_prefetch()
|
||||
if self.current_worker and self.current_worker.isRunning():
|
||||
self.current_worker.quit()
|
||||
self.current_worker.wait()
|
||||
for worker in list(self.metric_workers.values()):
|
||||
if worker.isRunning():
|
||||
worker.wait()
|
||||
self.metric_workers.clear()
|
||||
|
||||
+374
-119
@@ -1,126 +1,381 @@
|
||||
"""
|
||||
Audio visualization widget with embedded matplotlib canvas.
|
||||
Pure display responsibility - receives plotting data and shows graphs.
|
||||
Interactive visualization widget built on pyqtgraph.
|
||||
|
||||
One persistent PlotItem that is *reused* across renders — never torn down — so
|
||||
mouse zoom/pan, the view box, and scale toggles all survive redraws. Consumes a
|
||||
list of `(label, PlotSpec)` pairs and draws them onto the same axes, using a
|
||||
caller-supplied colour per dataset so a song keeps its colour regardless of which
|
||||
others are overlaid.
|
||||
|
||||
Interaction notes:
|
||||
- Plain scroll zooms both axes; Ctrl+scroll zooms time only; Shift+scroll zooms
|
||||
the value axis only (see `_AxisZoomViewBox`). Scrolling directly over an axis
|
||||
also zooms just that axis (pyqtgraph default).
|
||||
- Reference lines (`set_reference_lines`) are draggable via a triangle handle,
|
||||
survive redraws, and write their position back into the GUI-owned RefLineProps;
|
||||
the GUI clears them when the metric changes (units change).
|
||||
|
||||
Why the spectrogram is special: pyqtgraph's ImageItem is affine-only, so it does
|
||||
not follow a log-scaled axis. Log frequency is therefore realised by resampling
|
||||
the STFT rows onto a log-spaced grid and labelling the axis by row index — see
|
||||
`_render_heatmap`.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from scipy.interpolate import interp1d
|
||||
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
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
|
||||
from plotspec import PlotSpec, ViewState, DEFAULT_VIEW, RefLineProps
|
||||
|
||||
# White canvas / black ink to match the previous matplotlib aesthetic.
|
||||
pg.setConfigOption("background", "w")
|
||||
pg.setConfigOption("foreground", "k")
|
||||
pg.setConfigOptions(antialias=True)
|
||||
|
||||
# Dataset colour cycle for overlay. First colour is the single-dataset default.
|
||||
_PALETTE = [
|
||||
"#3a7ad6", "#e76f51", "#2a9d8f", "#e09f3e",
|
||||
"#7251b5", "#c1121f", "#588157", "#9d4edd",
|
||||
]
|
||||
|
||||
# Pen styles for reference lines.
|
||||
_PEN_STYLE = {"solid": Qt.SolidLine, "dash": Qt.DashLine, "dot": Qt.DotLine}
|
||||
|
||||
# "Nice" frequencies to label on a log frequency axis, in Hz.
|
||||
_LOG_FREQ_TICKS = [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000]
|
||||
|
||||
|
||||
def dataset_color(index: int) -> str:
|
||||
"""Stable dataset colour for a given index (e.g. a file's row in the list)."""
|
||||
return _PALETTE[index % len(_PALETTE)]
|
||||
|
||||
|
||||
def _colormap(name: str):
|
||||
"""Fetch a colormap, preferring matplotlib's so 'magma' etc. resolve."""
|
||||
try:
|
||||
return pg.colormap.getFromMatplotlib(name)
|
||||
except Exception:
|
||||
return pg.colormap.get(name)
|
||||
|
||||
|
||||
def _fmt_hz(hz: float) -> str:
|
||||
return f"{hz / 1000:.0f}k" if hz >= 1000 else f"{hz:.0f}"
|
||||
|
||||
|
||||
class _AxisZoomViewBox(pg.ViewBox):
|
||||
"""ViewBox whose wheel zoom can be constrained to one axis via a modifier.
|
||||
|
||||
Plain scroll keeps pyqtgraph's both-axes zoom; Ctrl constrains to x (time),
|
||||
Shift constrains to y (the metric's value axis). This answers the "scroll
|
||||
zooms both axes, I want one" problem without taking away the default.
|
||||
"""
|
||||
|
||||
def wheelEvent(self, ev, axis=None):
|
||||
mods = ev.modifiers()
|
||||
if mods & Qt.ControlModifier:
|
||||
axis = 0 # x only
|
||||
elif mods & Qt.ShiftModifier:
|
||||
axis = 1 # y only
|
||||
super().wheelEvent(ev, axis=axis)
|
||||
|
||||
|
||||
class _RefLine(pg.InfiniteLine):
|
||||
"""A draggable horizontal reference line bound to a RefLineProps.
|
||||
|
||||
`props.value` is in the metric's natural units (LUFS, dB, ... or Hz for the
|
||||
spectrogram). The drawn y-position may differ from that value — the spectrogram
|
||||
maps frequency to a row index — so `to_pos`/`from_pos` convert between the two.
|
||||
For curve metrics these are identity. The label and the value written back on
|
||||
drag are always in natural units.
|
||||
"""
|
||||
|
||||
def __init__(self, index, props: RefLineProps, to_pos, from_pos, fmt, on_moved):
|
||||
pen = pg.mkPen(props.color, width=1.4,
|
||||
style=_PEN_STYLE.get(props.style, Qt.DashLine))
|
||||
super().__init__(
|
||||
pos=to_pos(props.value), angle=0, movable=True, pen=pen,
|
||||
label="",
|
||||
labelOpts={"position": 0.06, "color": props.color,
|
||||
"fill": (255, 255, 255, 180)},
|
||||
)
|
||||
self._index = index
|
||||
self._props = props
|
||||
self._from_pos = from_pos
|
||||
self._fmt = fmt
|
||||
self._on_moved = on_moved
|
||||
self.addMarker("|>", position=0.0, size=12) # triangle handle at the start
|
||||
self._update_label()
|
||||
self.sigPositionChanged.connect(self._update_label)
|
||||
self.sigPositionChangeFinished.connect(self._commit)
|
||||
|
||||
def _update_label(self):
|
||||
val = self._from_pos(self.value())
|
||||
self.label.setFormat(self._props.label or self._fmt(val))
|
||||
|
||||
def _commit(self):
|
||||
self._props.value = float(self._from_pos(self.value()))
|
||||
self._on_moved(self._index)
|
||||
|
||||
|
||||
class AudioVisualizationWidget(QWidget):
|
||||
"""Widget for displaying audio analysis graphs with embedded matplotlib."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.initUI()
|
||||
|
||||
def initUI(self):
|
||||
"""Initialize the UI components."""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Create matplotlib canvas
|
||||
self.figure = Figure(figsize=(10, 4), facecolor='white')
|
||||
self.canvas = FigureCanvas(self.figure)
|
||||
|
||||
# Add canvas to layout
|
||||
layout.addWidget(self.canvas)
|
||||
|
||||
# Status label for feedback
|
||||
self.status_label = QLabel("Ready for audio analysis...")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# Initialize with empty plot
|
||||
self._create_empty_plot()
|
||||
|
||||
def _create_empty_plot(self):
|
||||
"""Creates an empty placeholder plot."""
|
||||
self.figure.clear()
|
||||
ax = self.figure.add_subplot(111)
|
||||
ax.text(0.5, 0.5, 'Drop an audio file to see analysis',
|
||||
ha='center', va='center', transform=ax.transAxes,
|
||||
fontsize=14, alpha=0.7)
|
||||
ax.set_xlim(0, 1)
|
||||
ax.set_ylim(0, 1)
|
||||
ax.set_xticks([])
|
||||
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.
|
||||
More reliable than copying elements.
|
||||
|
||||
Args:
|
||||
figure: matplotlib.figure.Figure to display
|
||||
"""
|
||||
# Remove old canvas
|
||||
layout = self.layout()
|
||||
layout.removeWidget(self.canvas)
|
||||
self.canvas.deleteLater()
|
||||
|
||||
# Create new canvas with the provided figure
|
||||
self.figure = figure
|
||||
self.canvas = FigureCanvas(self.figure)
|
||||
layout.insertWidget(0, self.canvas) # Insert at position 0 (before status label)
|
||||
|
||||
self.canvas.draw()
|
||||
self.status_label.setText("Analysis complete - displaying power graph")
|
||||
|
||||
def set_status(self, message):
|
||||
"""Update the status label."""
|
||||
self.status_label.setText(message)
|
||||
"""Persistent interactive plot. Call `show_specs` to (re)draw."""
|
||||
|
||||
# Emitted (with the line's index) when a reference line is dragged, so the
|
||||
# side-panel list can refresh its displayed value.
|
||||
referenceLineMoved = pyqtSignal(int)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.glw = pg.GraphicsLayoutWidget()
|
||||
self.plot = self.glw.addPlot(row=0, col=0, viewBox=_AxisZoomViewBox())
|
||||
self.plot.showGrid(x=True, y=True, alpha=0.3)
|
||||
self.plot.setMenuEnabled(True)
|
||||
self.legend = self.plot.addLegend(offset=(-10, 10))
|
||||
layout.addWidget(self.glw)
|
||||
|
||||
self.status_label = QLabel("Ready for audio analysis...")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
self._colorbar = None
|
||||
# Reference lines are owned by the GUI controller (RefLineProps objects) and
|
||||
# passed in via set_reference_lines; the line items are rebuilt each render.
|
||||
self._ref_props: list[RefLineProps] = []
|
||||
self._ref_lines: list[_RefLine] = []
|
||||
# value<->drawn-position transforms for ref lines (identity for curve metrics;
|
||||
# frequency<->row-index for the spectrogram). Reset each render.
|
||||
self._ref_to_pos = lambda v: v
|
||||
self._ref_from_pos = lambda p: p
|
||||
self._ref_fmt = lambda v: f"{v:.2f}"
|
||||
self._show_empty()
|
||||
|
||||
# ---- public API ---------------------------------------------------------
|
||||
|
||||
def show_specs(self, specs, view: ViewState = DEFAULT_VIEW):
|
||||
"""Render datasets onto the shared axes.
|
||||
|
||||
`specs` is a list of `(label, PlotSpec)` or `(label, PlotSpec, color)`. When
|
||||
no colour is given, the dataset's palette colour by position is used. All
|
||||
specs are assumed to be the same metric (compare overlays one metric across
|
||||
files), so axis labels/ranges come from the first spec.
|
||||
"""
|
||||
self._reset_plot()
|
||||
if not specs:
|
||||
self._show_empty()
|
||||
return
|
||||
|
||||
specs = [self._normalise(s, i) for i, s in enumerate(specs)]
|
||||
base_axes = specs[0][1].axes
|
||||
|
||||
# Heatmaps do not overlay: render only the first dataset's heatmap.
|
||||
if specs[0][1].is_heatmap:
|
||||
label, spec, _ = specs[0]
|
||||
self._render_heatmap(spec, view)
|
||||
if len(specs) > 1:
|
||||
self.set_status(f"{spec.title or label}: spectrogram shows one track at a time")
|
||||
self._apply_axes(base_axes, single=True, log_y_image_handled=True)
|
||||
self._draw_ref_lines()
|
||||
return
|
||||
|
||||
single = len(specs) == 1
|
||||
for label, spec, color in specs:
|
||||
prefix = "" if single else f"{label}: "
|
||||
self._render_curves_and_bands(spec, color, prefix, single=single)
|
||||
|
||||
# Reference lines from the first spec only (identical across same-metric specs).
|
||||
for hl in specs[0][1].hlines:
|
||||
self._render_hline(hl)
|
||||
|
||||
# Scalar readouts → legend-only proxy entries.
|
||||
for label, spec, _ in specs:
|
||||
prefix = "" if single else f"{label}: "
|
||||
for note in spec.annotations:
|
||||
self._legend_note(prefix + note)
|
||||
|
||||
self._apply_axes(base_axes, single=single)
|
||||
self._draw_ref_lines()
|
||||
|
||||
def set_reference_lines(self, props: list[RefLineProps]):
|
||||
"""Set the reference-line set (RefLineProps owned by the GUI) and redraw them."""
|
||||
self._ref_props = props
|
||||
self._draw_ref_lines()
|
||||
|
||||
def current_view_center_value(self) -> float:
|
||||
"""Natural-unit value at the current y-view centre — default for a new line.
|
||||
|
||||
Runs through `from_pos`, so on the spectrogram this returns a frequency, not
|
||||
a row index.
|
||||
"""
|
||||
(_, _), (y0, y1) = self.plot.viewRange()
|
||||
return float(self._ref_from_pos((y0 + y1) / 2.0))
|
||||
|
||||
def set_status(self, message: str):
|
||||
self.status_label.setText(message)
|
||||
|
||||
# ---- rendering helpers --------------------------------------------------
|
||||
|
||||
def _normalise(self, spec_tuple, index: int):
|
||||
"""Coerce a spec tuple to (label, PlotSpec, color), filling colour by index."""
|
||||
if len(spec_tuple) == 3:
|
||||
return spec_tuple
|
||||
label, spec = spec_tuple
|
||||
return label, spec, dataset_color(index)
|
||||
|
||||
def _render_curves_and_bands(self, spec: PlotSpec, color: str, prefix: str, single: bool):
|
||||
for band in spec.bands:
|
||||
lo = np.ascontiguousarray(np.broadcast_to(band.lo, band.x.shape), dtype=float)
|
||||
hi = np.ascontiguousarray(np.broadcast_to(band.hi, band.x.shape), dtype=float)
|
||||
# FillBetweenItem fills nothing if its child curves have no pen — give them
|
||||
# a thin outline in the dataset colour (this is the RMS/Waveform fix).
|
||||
edge = pg.mkPen(color, width=1.0)
|
||||
c_lo = pg.PlotDataItem(band.x, lo, pen=edge)
|
||||
c_hi = pg.PlotDataItem(band.x, hi, pen=edge)
|
||||
self.plot.addItem(c_lo)
|
||||
self.plot.addItem(c_hi)
|
||||
# Build the colour with alpha up front: QBrush.color() returns a copy, so
|
||||
# mutating its alpha after mkBrush would be a no-op (opaque overlay bug).
|
||||
fill_color = pg.mkColor(color)
|
||||
fill_color.setAlpha(200 if single else 90)
|
||||
fill = pg.FillBetweenItem(c_lo, c_hi, brush=pg.mkBrush(fill_color))
|
||||
self.plot.addItem(fill)
|
||||
if band.label:
|
||||
self._legend_swatch(prefix + band.label, color)
|
||||
|
||||
for curve in spec.curves:
|
||||
pen = pg.mkPen(curve.color or color, width=curve.width)
|
||||
item = self.plot.plot(curve.x, curve.y, pen=pen,
|
||||
name=(prefix + curve.label) if curve.label else None,
|
||||
connect="finite") # gaps at NaN (gated PSR)
|
||||
item.setDownsampling(auto=True) # keep big series smooth under zoom
|
||||
item.setClipToView(True)
|
||||
|
||||
def _render_hline(self, hl):
|
||||
pen = pg.mkPen(hl.color, width=hl.width, style=_PEN_STYLE.get(hl.style, Qt.DotLine))
|
||||
line = pg.InfiniteLine(
|
||||
pos=hl.y, angle=0, pen=pen, movable=False,
|
||||
label=hl.label or None,
|
||||
labelOpts={"position": 0.95, "color": hl.color, "fill": (255, 255, 255, 150)},
|
||||
)
|
||||
self.plot.addItem(line)
|
||||
|
||||
def _render_heatmap(self, spec: PlotSpec, view: ViewState):
|
||||
hm = spec.heatmap
|
||||
t0, t1 = float(hm.x[0]), float(hm.x[-1])
|
||||
f_lo = max(spec.axes.y_range[0] if spec.axes.y_range else hm.y[0], hm.y[0])
|
||||
f_hi = spec.axes.y_range[1] if spec.axes.y_range else hm.y[-1]
|
||||
y_log = view.resolve_y_log(default=spec.axes.y_log)
|
||||
|
||||
n_rows = len(hm.y)
|
||||
if y_log:
|
||||
f_grid = np.logspace(np.log10(max(f_lo, 1e-6)), np.log10(f_hi), n_rows)
|
||||
else:
|
||||
f_grid = np.linspace(f_lo, f_hi, n_rows)
|
||||
|
||||
# Resample every time column from native linear freq bins onto f_grid in one
|
||||
# vectorised pass — this runs on each redraw and lin/log toggle, so the loop
|
||||
# version would make the toggle feel laggy on long files.
|
||||
interp = interp1d(hm.y, hm.z, axis=0, bounds_error=False,
|
||||
fill_value=(hm.z[0], hm.z[-1]), assume_sorted=True)
|
||||
z_grid = interp(f_grid).astype(np.float32)
|
||||
|
||||
img = pg.ImageItem()
|
||||
img.setImage(z_grid.T, autoLevels=False) # ImageItem wants (x, y) -> transpose
|
||||
img.setLevels((hm.z_min, hm.z_max))
|
||||
img.setColorMap(_colormap(hm.cmap))
|
||||
# Map image pixel space (time cols, freq rows) to data coords: x=time, y=row index.
|
||||
img.setRect(pg.QtCore.QRectF(t0, 0.0, t1 - t0, float(n_rows)))
|
||||
self.plot.addItem(img)
|
||||
|
||||
# Reference lines on the spectrogram are entered/shown in Hz but drawn at a
|
||||
# row index — install the frequency<->row transforms for this f_grid.
|
||||
rows = np.arange(n_rows)
|
||||
self._ref_to_pos = lambda hz, fg=f_grid, r=rows: float(np.interp(hz, fg, r))
|
||||
self._ref_from_pos = lambda pos, fg=f_grid, r=rows: float(np.interp(pos, r, fg))
|
||||
self._ref_fmt = lambda v: f"{v:.0f} Hz"
|
||||
|
||||
# Label the row-index y-axis with real frequencies.
|
||||
ticks = []
|
||||
for hz in _LOG_FREQ_TICKS:
|
||||
if f_lo <= hz <= f_hi:
|
||||
row = float(np.searchsorted(f_grid, hz))
|
||||
ticks.append((row, _fmt_hz(hz)))
|
||||
self.plot.getAxis("left").setTicks([ticks])
|
||||
self.plot.setYRange(0, n_rows, padding=0)
|
||||
self.plot.setXRange(t0, t1, padding=0)
|
||||
|
||||
# Place the colourbar at a fixed layout cell and link it to the image. We
|
||||
# add/remove it ourselves (rather than insert_in=) so it can't stack across
|
||||
# repeated spectrogram renders.
|
||||
self._colorbar = pg.ColorBarItem(values=(hm.z_min, hm.z_max),
|
||||
colorMap=_colormap(hm.cmap), label=hm.label)
|
||||
self._colorbar.setImageItem(img)
|
||||
self.glw.addItem(self._colorbar, row=0, col=1)
|
||||
|
||||
def _apply_axes(self, axes, single: bool, log_y_image_handled: bool = False):
|
||||
self.plot.setLabel("bottom", axes.x_label)
|
||||
self.plot.setLabel("left", axes.y_label)
|
||||
# Frame x exactly only for a single dataset; overlaid tracks of different
|
||||
# lengths (absolute mode) should autorange to their union rather than clip to
|
||||
# the first one's span. In relative mode every spec is 0-100, so either works.
|
||||
if axes.x_range and single:
|
||||
self.plot.setXRange(*axes.x_range, padding=0)
|
||||
elif not single:
|
||||
self.plot.enableAutoRange(axis=pg.ViewBox.XAxis)
|
||||
if axes.y_range and not log_y_image_handled:
|
||||
self.plot.setYRange(*axes.y_range, padding=0)
|
||||
if not log_y_image_handled:
|
||||
# Curve metrics: honour log mode if a spec ever opts in (none do today).
|
||||
self.plot.setLogMode(x=axes.x_log, y=axes.y_log)
|
||||
|
||||
# ---- user reference lines -----------------------------------------------
|
||||
|
||||
def _draw_ref_lines(self):
|
||||
"""(Re)create draggable lines from the current RefLineProps set."""
|
||||
self._remove_ref_line_items()
|
||||
for idx, props in enumerate(self._ref_props):
|
||||
line = _RefLine(idx, props, self._ref_to_pos, self._ref_from_pos,
|
||||
self._ref_fmt, on_moved=self.referenceLineMoved.emit)
|
||||
self.plot.addItem(line)
|
||||
self._ref_lines.append(line)
|
||||
|
||||
def _remove_ref_line_items(self):
|
||||
for line in self._ref_lines:
|
||||
self.plot.removeItem(line)
|
||||
self._ref_lines.clear()
|
||||
|
||||
# ---- legend / lifecycle -------------------------------------------------
|
||||
|
||||
def _legend_swatch(self, name: str, color: str):
|
||||
self.legend.addItem(pg.PlotDataItem(pen=pg.mkPen(color, width=3)), name)
|
||||
|
||||
def _legend_note(self, text: str):
|
||||
self.legend.addItem(pg.PlotDataItem(pen=None), text)
|
||||
|
||||
def _reset_plot(self):
|
||||
self._remove_ref_line_items() # cleared from scene; props persist for redraw
|
||||
self.plot.clear()
|
||||
if self._colorbar is not None:
|
||||
try:
|
||||
self.glw.removeItem(self._colorbar)
|
||||
except Exception:
|
||||
pass
|
||||
self._colorbar = None
|
||||
self.legend.clear()
|
||||
self.plot.getAxis("left").setTicks(None) # drop heatmap freq ticks
|
||||
self.plot.setLogMode(x=False, y=False)
|
||||
# Back to identity; the heatmap path reinstalls Hz<->row if needed.
|
||||
self._ref_to_pos = lambda v: v
|
||||
self._ref_from_pos = lambda p: p
|
||||
self._ref_fmt = lambda v: f"{v:.2f}"
|
||||
|
||||
def _show_empty(self):
|
||||
text = pg.TextItem("Drop an audio file to see analysis", anchor=(0.5, 0.5),
|
||||
color=(120, 120, 120))
|
||||
self.plot.addItem(text)
|
||||
self.plot.setXRange(0, 1)
|
||||
self.plot.setYRange(0, 1)
|
||||
text.setPos(0.5, 0.5)
|
||||
self.set_status("Ready for audio analysis...")
|
||||
|
||||
+534
@@ -0,0 +1,534 @@
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
def apply_fixed_font(family: str = "M PLUS 1 Code", size: int = 10) -> str:
|
||||
"""Lock the Qt application font to `family` at `size`pt.
|
||||
|
||||
Falls back to the system default family if `family` isn't available (loaded
|
||||
from fonts/ or installed). pyqtgraph and the Qt widgets both read the app
|
||||
font, so this is all the plot/UI need. Returns the family actually used.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
app = QCoreApplication.instance()
|
||||
if app is None:
|
||||
logger.warning("apply_fixed_font called before QApplication exists")
|
||||
return family
|
||||
|
||||
try:
|
||||
available = family in set(QFontDatabase().families())
|
||||
except Exception:
|
||||
available = False
|
||||
|
||||
if available:
|
||||
font = QFont(family)
|
||||
chosen = family
|
||||
else:
|
||||
font = QFont() # system default family
|
||||
chosen = font.defaultFamily()
|
||||
logger.info(f"Font '{family}' not found; using system default '{chosen}'")
|
||||
font.setPointSize(size)
|
||||
app.setFont(font)
|
||||
logger.info(f"Application font locked to '{chosen}' at {size}pt")
|
||||
return chosen
|
||||
@@ -0,0 +1,12 @@
|
||||
This directory is for custom CJK fonts to improve character rendering.
|
||||
|
||||
Supported formats: .ttf, .otf, .ttc
|
||||
|
||||
Recommended free fonts for CJK support:
|
||||
- Noto Sans CJK (Google Fonts)
|
||||
- Source Han Sans (Adobe)
|
||||
|
||||
The application will automatically detect and use fonts placed here.
|
||||
For setup instructions, run: python setup_fonts.py
|
||||
|
||||
Note: Only add fonts you have proper licensing rights to distribute.
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Logging configuration for the Audio Mastering Toolkit.
|
||||
Provides CLI configurable logging with different verbosity levels.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO", log_to_file: bool = False) -> logging.Logger:
|
||||
"""
|
||||
Setup logging configuration for the application.
|
||||
|
||||
Args:
|
||||
level: Logging level (ERROR, WARN, INFO, DEBUG, TRACE)
|
||||
log_to_file: Whether to also log to file
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
"""
|
||||
# Convert level string to logging constant
|
||||
level_map = {
|
||||
'ERROR': logging.ERROR,
|
||||
'WARN': logging.WARNING,
|
||||
'WARNING': logging.WARNING,
|
||||
'INFO': logging.INFO,
|
||||
'DEBUG': logging.DEBUG,
|
||||
'TRACE': 5 # Custom level below DEBUG
|
||||
}
|
||||
|
||||
# Add custom TRACE level
|
||||
logging.addLevelName(5, 'TRACE')
|
||||
|
||||
numeric_level = level_map.get(level.upper(), logging.INFO)
|
||||
|
||||
# Create formatter
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s [%(levelname)s] %(name)s: %(message)s',
|
||||
datefmt='%H:%M:%S'
|
||||
)
|
||||
|
||||
# Setup console handler
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setLevel(numeric_level)
|
||||
|
||||
# Setup root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(numeric_level)
|
||||
root_logger.handlers.clear() # Clear any existing handlers
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
# Optional file logging
|
||||
if log_to_file:
|
||||
file_handler = logging.FileHandler('audio_analysis.log')
|
||||
file_handler.setFormatter(formatter)
|
||||
file_handler.setLevel(numeric_level)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Add trace method to all loggers
|
||||
def trace(self, message, *args, **kwargs):
|
||||
if self.isEnabledFor(5):
|
||||
self._log(5, message, args, **kwargs)
|
||||
|
||||
logging.Logger.trace = trace
|
||||
|
||||
# Create main application logger
|
||||
app_logger = logging.getLogger('audio_mastering')
|
||||
app_logger.info(f"Logging initialized at {level.upper()} level")
|
||||
|
||||
return app_logger
|
||||
|
||||
|
||||
def parse_log_args() -> tuple[str, bool]:
|
||||
"""
|
||||
Parse command line arguments for logging configuration.
|
||||
|
||||
Returns:
|
||||
Tuple of (log_level, log_to_file)
|
||||
"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(add_help=False) # Don't interfere with main arg parsing
|
||||
parser.add_argument('--log-level', '-l',
|
||||
choices=['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'],
|
||||
default='INFO',
|
||||
help='Set logging verbosity level')
|
||||
parser.add_argument('--log-file', action='store_true',
|
||||
help='Also log to audio_analysis.log file')
|
||||
|
||||
# Parse known args only (ignore others for main app)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
return args.log_level, args.log_file
|
||||
@@ -1,12 +1,19 @@
|
||||
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 audio_visualization_widget import AudioVisualizationWidget, dataset_color
|
||||
from analysis_results_manager import AnalysisResultsManager
|
||||
from logger_setup import setup_logging, parse_log_args
|
||||
from font_manager import initialize_fonts, apply_fixed_font
|
||||
from plot_control_widget import PlotControlWidget
|
||||
from ref_line_widget import RefLineControlWidget, RefLineDialog
|
||||
from metrics import METRICS
|
||||
from plotspec import RefLineProps, apply_x_mode
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
@@ -14,9 +21,16 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.analysis_manager = AnalysisResultsManager()
|
||||
# Guards programmatic list mutations from triggering re-render storms.
|
||||
self._suppress_list_signals = False
|
||||
# Reference lines are kept per metric (a -14 LUFS line means nothing on a
|
||||
# spectrogram), so they persist when you switch metrics and come back.
|
||||
self.ref_lines_by_metric: dict[str, list[RefLineProps]] = {}
|
||||
self.initUI()
|
||||
self.connect_signals()
|
||||
self._activate_ref_lines()
|
||||
|
||||
def initUI(self):
|
||||
"""Initialize the user interface."""
|
||||
@@ -49,12 +63,34 @@ class MainWindow(QMainWindow):
|
||||
panel = QWidget()
|
||||
layout = QVBoxLayout(panel)
|
||||
|
||||
# File list
|
||||
self.file_list_label = QLabel("Analyzed Files:")
|
||||
# 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)
|
||||
|
||||
# Plot control cluster (metric selector + scale toggle + refresh)
|
||||
self.plot_control = PlotControlWidget()
|
||||
self.plot_control.metricChanged.connect(self.on_metric_changed)
|
||||
self.plot_control.viewChanged.connect(self.on_view_changed)
|
||||
self.plot_control.plotRefreshRequested.connect(self.on_plot_refresh_requested)
|
||||
layout.addWidget(self.plot_control)
|
||||
|
||||
# Reference-line management cluster (list + add/edit/remove/clear).
|
||||
self.ref_line_control = RefLineControlWidget()
|
||||
self.ref_line_control.addRequested.connect(self.on_add_reference_line)
|
||||
self.ref_line_control.editRequested.connect(self.on_edit_reference_line)
|
||||
self.ref_line_control.removeRequested.connect(self.on_remove_reference_line)
|
||||
self.ref_line_control.clearRequested.connect(self.on_clear_reference_lines)
|
||||
layout.addWidget(self.ref_line_control)
|
||||
|
||||
# File list. Each item carries a checkbox: the checked set is the overlay
|
||||
# set drawn on the graph; the highlighted item drives the metadata panel.
|
||||
self.file_list_label = QLabel("Analyzed Files (tick to overlay):")
|
||||
layout.addWidget(self.file_list_label)
|
||||
|
||||
|
||||
self.file_list = QListWidget()
|
||||
self.file_list.itemClicked.connect(self.on_file_selected)
|
||||
self.file_list.itemChanged.connect(self.on_file_check_changed)
|
||||
layout.addWidget(self.file_list)
|
||||
|
||||
# Metadata display
|
||||
@@ -81,7 +117,18 @@ 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)
|
||||
self.analysis_manager.metricTiming.connect(self.on_metric_timing)
|
||||
self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Stop background analysis/prefetch threads before the window closes."""
|
||||
self.analysis_manager.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
"""Handle drag enter event for file drops."""
|
||||
if event.mimeData().hasUrls():
|
||||
@@ -99,13 +146,30 @@ class MainWindow(QMainWindow):
|
||||
files = [u.toLocalFile() for u in event.mimeData().urls()]
|
||||
audio_files = [f for f in files if f.lower().endswith(('.mp3', '.wav', '.flac'))]
|
||||
|
||||
self.logger.info(f"Files dropped: {len(files)} total, {len(audio_files)} audio files")
|
||||
|
||||
if audio_files:
|
||||
# Analyze the first audio file
|
||||
# TODO: Add support for multiple file queue
|
||||
file_path = audio_files[0]
|
||||
self.analysis_manager.analyze_file(file_path)
|
||||
self.logger.info(f"Starting analysis of dropped file: {os.path.basename(file_path)}")
|
||||
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||
else:
|
||||
self.visualization_widget.set_status("No audio files detected in drop")
|
||||
self.logger.warning("No supported audio files found in drop")
|
||||
|
||||
def open_file_dialog(self):
|
||||
"""Open file dialog to select audio files for analysis."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Select Audio File",
|
||||
"", # Default directory (empty = current directory)
|
||||
"Audio Files (*.mp3 *.wav *.flac);;All Files (*)"
|
||||
)
|
||||
|
||||
if file_path: # User selected a file (didn't cancel)
|
||||
self.logger.info(f"File selected via dialog: {os.path.basename(file_path)}")
|
||||
self.analysis_manager.analyze_file(file_path, self.plot_control.current_metric_id())
|
||||
|
||||
def on_analysis_started(self, file_path):
|
||||
"""Called when analysis starts."""
|
||||
@@ -115,57 +179,242 @@ class MainWindow(QMainWindow):
|
||||
def on_analysis_completed(self, file_path, result):
|
||||
"""Called when analysis completes successfully."""
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
# Add to file list if not already there
|
||||
existing_items = [self.file_list.item(i).text()
|
||||
for i in range(self.file_list.count())]
|
||||
if filename not in existing_items:
|
||||
|
||||
# Add to file list (checked, so it joins the overlay set) if not present.
|
||||
item = self._item_for_path(file_path)
|
||||
if item is None:
|
||||
self._suppress_list_signals = True
|
||||
item = QListWidgetItem(filename)
|
||||
item.setData(Qt.UserRole, file_path) # Store full path
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||
item.setCheckState(Qt.Checked)
|
||||
self.file_list.addItem(item)
|
||||
|
||||
# Get and display the analysis figure
|
||||
figure = self.analysis_manager.get_analysis_figure(file_path)
|
||||
if figure:
|
||||
self.visualization_widget.display_figure_direct(figure)
|
||||
|
||||
# Update metadata display
|
||||
metadata_text = self.analysis_manager.get_metadata_text(file_path)
|
||||
self.metadata_display.setText(metadata_text)
|
||||
|
||||
# Select the analyzed file in the list
|
||||
for i in range(self.file_list.count()):
|
||||
item = self.file_list.item(i)
|
||||
if item.data(Qt.UserRole) == file_path:
|
||||
self.file_list.setCurrentItem(item)
|
||||
break
|
||||
self._suppress_list_signals = False
|
||||
|
||||
# Update metadata display and highlight the analyzed file.
|
||||
self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
|
||||
self.file_list.setCurrentItem(item)
|
||||
|
||||
# Redraw the overlay set for the current metric.
|
||||
self._refresh_view()
|
||||
|
||||
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.
|
||||
|
||||
The messages already carry phase + timing; the percentage was a coarse
|
||||
fake (load jumped 10->done), so it's logged but not shown in the slip.
|
||||
"""
|
||||
self.logger.debug(f"Progress: {message} ({percentage}%)")
|
||||
self.visualization_widget.set_status(message)
|
||||
|
||||
def on_file_selected(self, item):
|
||||
"""Called when a file is selected from the list."""
|
||||
"""Called when a file is highlighted (drives the metadata panel only)."""
|
||||
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)
|
||||
self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
|
||||
|
||||
def on_file_check_changed(self, _item):
|
||||
"""A checkbox toggled — the overlay set changed; redraw."""
|
||||
if self._suppress_list_signals:
|
||||
return
|
||||
self._refresh_view()
|
||||
|
||||
def on_metric_changed(self, metric_id: str):
|
||||
"""Called when the metric selector changes."""
|
||||
self.logger.info(f"Metric changed via GUI: {metric_id}")
|
||||
# Reference lines are kept per metric, so swap in this metric's set rather
|
||||
# than discarding — switch away and back and your lines are still there.
|
||||
self._activate_ref_lines()
|
||||
self._refresh_view()
|
||||
|
||||
def on_add_reference_line(self):
|
||||
"""Add a reference line at the current view centre, then edit it."""
|
||||
value = self.visualization_widget.current_view_center_value()
|
||||
props = RefLineProps(value=round(value, 2))
|
||||
self.ref_lines.append(props)
|
||||
self._sync_ref_lines()
|
||||
# Open the editor immediately so colour/tag/value can be set right away.
|
||||
self.on_edit_reference_line(len(self.ref_lines) - 1)
|
||||
|
||||
def on_edit_reference_line(self, index: int):
|
||||
"""Open the properties dialog for a reference line."""
|
||||
if not (0 <= index < len(self.ref_lines)):
|
||||
return
|
||||
dialog = RefLineDialog(self, self.ref_lines[index], value_units=self._ref_value_units())
|
||||
if dialog.exec_():
|
||||
self.ref_lines[index] = dialog.result_props()
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_remove_reference_line(self, index: int):
|
||||
"""Delete a reference line."""
|
||||
if 0 <= index < len(self.ref_lines):
|
||||
del self.ref_lines[index]
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_clear_reference_lines(self):
|
||||
"""Remove all custom reference lines for the current metric."""
|
||||
self.ref_lines.clear()
|
||||
self._sync_ref_lines()
|
||||
|
||||
def on_reference_line_moved(self, index: int):
|
||||
"""A line was dragged on the plot — its value is already updated; refresh list."""
|
||||
self.ref_line_control.set_lines(self.ref_lines)
|
||||
|
||||
def _activate_ref_lines(self):
|
||||
"""Point `self.ref_lines` at the current metric's set and sync the UI."""
|
||||
metric_id = self.plot_control.current_metric_id()
|
||||
self.ref_lines = self.ref_lines_by_metric.setdefault(metric_id, [])
|
||||
self._sync_ref_lines()
|
||||
|
||||
def _ref_value_units(self) -> str:
|
||||
"""Units a reference line's value is expressed in for the current metric."""
|
||||
return "Hz" if self.plot_control.current_metric_id() == "spectrogram" else ""
|
||||
|
||||
def _sync_ref_lines(self):
|
||||
"""Push the current reference-line set to both the list view and the plot."""
|
||||
self.ref_line_control.set_lines(self.ref_lines)
|
||||
self.visualization_widget.set_reference_lines(self.ref_lines)
|
||||
|
||||
def on_view_changed(self):
|
||||
"""Called when a view-scale toggle (lin/log) changes. Recompute-free redraw."""
|
||||
self.logger.info("View scale changed via GUI")
|
||||
self._refresh_view()
|
||||
|
||||
def on_plot_refresh_requested(self):
|
||||
"""Called when manual plot refresh is requested."""
|
||||
self.logger.info("Manual plot refresh requested via GUI")
|
||||
self._refresh_view()
|
||||
|
||||
def on_metric_compute_started(self, file_path: str, metric_id: str):
|
||||
"""Called when an off-thread metric compute starts."""
|
||||
if file_path not in self._overlay_paths():
|
||||
return # not in the drawn set; status bar shouldn't lie
|
||||
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 metric_id != self.plot_control.current_metric_id():
|
||||
return # user already switched to a different metric
|
||||
if file_path not in self._overlay_paths():
|
||||
return # no longer part of the overlay set
|
||||
self._refresh_view()
|
||||
|
||||
def on_metric_timing(self, file_path: str, metric_id: str, seconds: float):
|
||||
"""An on-demand metric compute finished — report how long it took."""
|
||||
if file_path not in self._overlay_paths():
|
||||
return
|
||||
if metric_id != self.plot_control.current_metric_id():
|
||||
return
|
||||
metric = METRICS.get(metric_id)
|
||||
display = metric.display_name if metric else metric_id
|
||||
self.visualization_widget.set_status(f"{display} computed in {seconds:.1f}s")
|
||||
|
||||
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 in self._overlay_paths():
|
||||
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 _item_for_path(self, file_path):
|
||||
for i in range(self.file_list.count()):
|
||||
item = self.file_list.item(i)
|
||||
if item.data(Qt.UserRole) == file_path:
|
||||
return item
|
||||
return None
|
||||
|
||||
def _row_index(self, file_path) -> int:
|
||||
for i in range(self.file_list.count()):
|
||||
if self.file_list.item(i).data(Qt.UserRole) == file_path:
|
||||
return i
|
||||
return 0
|
||||
|
||||
def _overlay_paths(self):
|
||||
"""File paths whose checkbox is ticked — the set drawn on the graph."""
|
||||
return [
|
||||
self.file_list.item(i).data(Qt.UserRole)
|
||||
for i in range(self.file_list.count())
|
||||
if self.file_list.item(i).checkState() == Qt.Checked
|
||||
]
|
||||
|
||||
def _refresh_view(self):
|
||||
"""Redraw the checked overlay set for the current metric and view-state.
|
||||
|
||||
Renders every dataset whose data is cached; for any that isn't, kicks off
|
||||
an async compute (or a full load if the file was never analysed) and
|
||||
leaves a status note. `on_metric_ready` calls back here when each lands.
|
||||
"""
|
||||
paths = self._overlay_paths()
|
||||
metric_id = self.plot_control.current_metric_id()
|
||||
view = self.plot_control.current_view_state()
|
||||
metric = METRICS.get(metric_id)
|
||||
if not paths or metric is None:
|
||||
self.visualization_widget.show_specs([])
|
||||
return
|
||||
|
||||
specs = []
|
||||
pending = 0
|
||||
for path in paths:
|
||||
data = self.analysis_manager.get_metric_data(path, metric_id)
|
||||
if data is None:
|
||||
if self.analysis_manager.is_file_analyzed(path):
|
||||
self.analysis_manager.request_metric(path, metric_id)
|
||||
else:
|
||||
self.analysis_manager.analyze_file(path, metric_id)
|
||||
pending += 1
|
||||
continue
|
||||
label = self.analysis_manager.display_label(path)
|
||||
# Colour is keyed to the file's row, not its position in the overlay
|
||||
# subset, so a song keeps its colour as others are ticked/unticked.
|
||||
color = dataset_color(self._row_index(path))
|
||||
spec = apply_x_mode(metric.build_spec(data, view), view.x_mode)
|
||||
specs.append((label, spec, color))
|
||||
|
||||
if specs:
|
||||
self.visualization_widget.show_specs(specs, view)
|
||||
if pending:
|
||||
self.visualization_widget.set_status(
|
||||
f"Computing {metric.display_name} for {pending} file(s)..."
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
# Parse logging arguments before creating QApplication
|
||||
log_level, log_to_file = parse_log_args()
|
||||
|
||||
# Initialize logging
|
||||
logger = setup_logging(log_level, log_to_file)
|
||||
logger.info("Starting Audio Mastering Analysis Toolkit")
|
||||
logger.info(f"Command line args: log-level={log_level}, log-file={log_to_file}")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Initialize font system (loads any fonts/ files, configures fallbacks) then
|
||||
# lock the UI font. M PLUS 1 Code has full Japanese coverage, so this stays
|
||||
# CJK-safe; falls back to the system default if the family isn't present.
|
||||
initialize_fonts()
|
||||
chosen = apply_fixed_font("M PLUS 1 Code", 10)
|
||||
logger.info(f"UI font locked to '{chosen}' at 10pt")
|
||||
|
||||
# Set application style
|
||||
app.setStyle('Fusion') # Modern cross-platform style
|
||||
logger.debug("Application style set to Fusion")
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
logger.info("GUI window displayed")
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Set application style
|
||||
app.setStyle('Fusion') # Modern cross-platform style
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec_())
|
||||
main()
|
||||
+62
-239
@@ -1,239 +1,62 @@
|
||||
import librosa
|
||||
import numpy as np
|
||||
import os
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.colors as mcolors
|
||||
import matplotlib.cm as cm
|
||||
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.easyid3 import EasyID3
|
||||
|
||||
def try_mp3_tags(file_path):
|
||||
try:
|
||||
# if there is metadata
|
||||
audio = MP3(file_path, ID3=EasyID3)
|
||||
return audio
|
||||
except Exception as e:
|
||||
print(f"Error reading ID3 tags: {e}")
|
||||
return None
|
||||
|
||||
def read_mp3_tags(file_path):
|
||||
if (audio := try_mp3_tags(file_path)) is not None:
|
||||
print(f"File name: {os.path.basename(file_path)}")
|
||||
print(f"{audio['artist'][0]} - {audio['title'][0]}")
|
||||
else:
|
||||
print(f"File name: {os.path.basename(file_path)}")
|
||||
|
||||
class AudioFile:
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
# file name / song name
|
||||
if (audio := try_mp3_tags(self.file_path)) is not None:
|
||||
self.song_name = f"{audio['artist'][0]} - {audio['title'][0]}"
|
||||
else:
|
||||
self.song_name = os.path.basename(self.file_path)
|
||||
|
||||
self.y, self.sr = librosa.load(file_path)
|
||||
# load automatically normalises everything to [-1.0, 1.0]
|
||||
# and that's alright
|
||||
self.y_mono = librosa.to_mono(self.y)
|
||||
self.max_amplitude = np.max(np.abs(self.y_mono))
|
||||
self.avg_amplitude = np.mean(np.abs(self.y_mono))
|
||||
self.bpm, _ = librosa.beat.beat_track(y=self.y_mono, sr=self.sr)
|
||||
|
||||
def display_song_name(self):
|
||||
print(self.song_name)
|
||||
|
||||
def get_amplitudes(self):
|
||||
return self.max_amplitude, self.avg_amplitude
|
||||
|
||||
def get_bpm(self):
|
||||
return self.bpm
|
||||
|
||||
def get_energy_levels_over_time(self, window = 10, hop = 2):
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
window (int, optional): Length of rolling RMS window in seconds. Defaults to 10.
|
||||
hop (int, optional): Length of window hop in seconds. Defaults to 2.
|
||||
"""
|
||||
# check if the window and hop are the same as before
|
||||
if (not hasattr(self, 'window')) or ((self.window != window) or (self.hop != hop)):
|
||||
self.window, self.hop = window, hop
|
||||
# only calculate if not already calculated
|
||||
if not hasattr(self, 'rms_array'):
|
||||
# window and hop are in seconds
|
||||
window_samples = window * self.sr
|
||||
hop_samples = hop * self.sr
|
||||
|
||||
# Calculate RMS over the rolling windows
|
||||
self.rms_array = librosa.feature.rms(y=self.y, frame_length=window_samples, hop_length=hop_samples)
|
||||
|
||||
def _get_times(self):
|
||||
"""Get time array for RMS data. Internal method for GUI integration."""
|
||||
if not hasattr(self, 'rms_array'):
|
||||
self.get_energy_levels_over_time()
|
||||
return librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
|
||||
|
||||
def plot_energy_levels_over_time(self, display='window'):
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
display (str, optional): Option for where to display the plot. Defaults to 'window'.
|
||||
'window' - display in a pyplot window
|
||||
'gui' - for directing to the GUI (TBD)
|
||||
"""
|
||||
if not hasattr(self, 'rms_array'):
|
||||
self.get_energy_levels_over_time()
|
||||
|
||||
# Convert frame indices to time
|
||||
times = librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
|
||||
|
||||
|
||||
# Normalize RMS for color mapping
|
||||
# check maximum power to determine mastering headspace:
|
||||
# a -6 dBFS headroom should yield a max power of around 0.25
|
||||
# otherwise could go anywhere, but we take 0.6
|
||||
local_max_power = np.max(self.rms_array)
|
||||
if local_max_power > 0.3:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.6)
|
||||
maxpower = 0.6
|
||||
else:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.3)
|
||||
maxpower = 0.3
|
||||
|
||||
# colour map
|
||||
cmap = cm.autumn
|
||||
|
||||
# Plot
|
||||
if display == 'window':
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
ax.set_ylim(0., maxpower)
|
||||
for i in range(len(times)-1):
|
||||
ax.fill_between(times[i:i+2], 0, self.rms_array[0][i], color=cmap(norm(self.rms_array[0][i])), edgecolor='none')
|
||||
|
||||
# Adding a colorbar to indicate the scale of RMS values
|
||||
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
||||
sm.set_array([])
|
||||
cbar = plt.colorbar(sm, ax=ax, label='RMS Power')
|
||||
# cbar.ax.set_yticklabels([f"{x-60.0:.0f} dBFS" for x in cbar.get_ticks()]) # Adjust labels to show true dBFS values
|
||||
|
||||
ax.set_ylabel('Power')
|
||||
ax.set_xlabel('Time')
|
||||
ax.set_title(f'{os.path.basename(self.file_path)}')
|
||||
|
||||
plt.show(block=False)
|
||||
plt.pause(0.001)
|
||||
|
||||
|
||||
|
||||
|
||||
def analyze_track_librosa(file_path):
|
||||
# Load the audio file
|
||||
# y is the audio time series and sr is the sampling rate
|
||||
y, sr = librosa.load(file_path)
|
||||
|
||||
# Calculate the maximum amplitude
|
||||
# Librosa's load function normalizes the audio to [-1, 1], so we scale it back
|
||||
max_amplitude = np.max(np.abs(y))
|
||||
# Average amplitude
|
||||
avg_amplitude = np.mean(np.abs(y))
|
||||
|
||||
# Convert max amplitude to dBFS
|
||||
max_amplitude_dBFS = librosa.amplitude_to_db([max_amplitude], ref=1.0)
|
||||
avg_amplitude_dBFS = librosa.amplitude_to_db([avg_amplitude], ref=1.0)
|
||||
|
||||
# Calculate RMS in dB
|
||||
S, phase = librosa.magphase(librosa.stft(y))
|
||||
rms_stft = librosa.feature.rms(S=S)
|
||||
rms = librosa.feature.rms(y=y)
|
||||
avg_power_dBFS_stft = 20 * np.log10(np.mean(rms_stft))
|
||||
avg_power_dBFS = 20 * np.log10(np.mean(rms))
|
||||
|
||||
return max_amplitude_dBFS[0], avg_amplitude_dBFS[0], avg_power_dBFS, avg_power_dBFS_stft
|
||||
|
||||
def plot_macro_time_power_graph(file_path):
|
||||
# Load the audio file
|
||||
y, sr = librosa.load(file_path, mono=True)
|
||||
|
||||
# Define the window and hop length
|
||||
# 10 seconds window and 1 second hop
|
||||
window_length = int(sr * 10) # 10 seconds in samples
|
||||
hop_length = int(sr * 1) # 1 second in samples
|
||||
|
||||
# Calculate RMS over the rolling windows
|
||||
rms = librosa.feature.rms(y=y, frame_length=window_length, hop_length=hop_length)
|
||||
|
||||
# Convert frame indices to time
|
||||
times = librosa.frames_to_time(np.arange(rms.shape[1]), sr=sr, hop_length=hop_length)
|
||||
|
||||
# Normalize RMS for color mapping
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.4)
|
||||
|
||||
# Choose a colormap
|
||||
cmap = cm.autumn
|
||||
|
||||
# Plot
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
ax.set_ylim(0., 0.4)
|
||||
for i in range(len(times)-1):
|
||||
ax.fill_between(times[i:i+2], 0, rms[0][i], color=cmap(norm(rms[0][i])), edgecolor='none')
|
||||
|
||||
# Adding a colorbar to indicate the scale of RMS values
|
||||
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
||||
sm.set_array([])
|
||||
cbar = plt.colorbar(sm, ax=ax, label='RMS Power')
|
||||
# cbar.ax.set_yticklabels([f"{x-60.0:.0f} dBFS" for x in cbar.get_ticks()]) # Adjust labels to show true dBFS values
|
||||
|
||||
ax.set_ylabel('Power')
|
||||
ax.set_xlabel('Time')
|
||||
# ax.set_title(f'{os.path.basename(file_path)}')
|
||||
# plt.ylabel('Power')
|
||||
# plt.xlabel('Time (s)')
|
||||
# plt.title(f'{os.path.basename(file_path)}')
|
||||
plt.show(block=False)
|
||||
plt.pause(0.001)
|
||||
|
||||
|
||||
|
||||
def find_mp3_files(directory):
|
||||
mp3_files = []
|
||||
# Walk through the directory
|
||||
for root, dirs, files in os.walk(directory):
|
||||
# Filter and append .mp3 files
|
||||
for file in files:
|
||||
if file.endswith(".mp3"):
|
||||
mp3_files.append(os.path.join(root, file))
|
||||
return mp3_files
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Legacy batch processing mode - runs when master_core.py is executed directly
|
||||
# For GUI usage, run main.py instead
|
||||
|
||||
print("Running legacy batch analysis mode...")
|
||||
print("For the new GUI interface, please run: python main.py")
|
||||
print()
|
||||
|
||||
# Replace 'path/to/your/audiofile.mp3' with the path to your audio file
|
||||
file_path = []
|
||||
with open('./files.txt', 'r') as f:
|
||||
for line in f:
|
||||
if line[0] != '#' and line[0] != ';':
|
||||
file_path.append(line.strip())
|
||||
|
||||
for file in file_path:
|
||||
# max_amplitude, avg_amplitude, avg_power, avg_power_stft = analyze_track_librosa(file)
|
||||
# # read_mp3_tags(file)
|
||||
# print(f"Maximum Amplitude: {max_amplitude:.2f} dBFS")
|
||||
# print(f"Average Amplitude: {avg_amplitude:.2f} dBFS")
|
||||
# print(f"Average Power: {avg_power:.2f} dBFS")
|
||||
# print(f"Average Power (STFT): {avg_power_stft:.2f} dBFS")
|
||||
currentsong = AudioFile(file)
|
||||
currentsong.display_song_name()
|
||||
print(f"BPM: {currentsong.get_bpm()}")
|
||||
currentsong.plot_energy_levels_over_time()
|
||||
# plot_macro_time_power_graph(file)
|
||||
|
||||
plt.show()
|
||||
import os
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.easyid3 import EasyID3
|
||||
|
||||
from font_manager import safe_title
|
||||
|
||||
|
||||
def _try_mp3_tags(file_path):
|
||||
try:
|
||||
return MP3(file_path, ID3=EasyID3)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class AudioFile:
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
audio = _try_mp3_tags(self.file_path)
|
||||
artist = audio.get('artist', [None])[0] if audio is not None else None
|
||||
title = audio.get('title', [None])[0] if audio is not None else None
|
||||
if artist and title:
|
||||
self.song_name = safe_title(f"{artist} - {title}")
|
||||
else:
|
||||
self.song_name = safe_title(os.path.basename(self.file_path))
|
||||
|
||||
# librosa.load normalises to [-1.0, 1.0]. sr=None preserves the file's
|
||||
# native sample rate; without it librosa resamples to 22050 Hz, which would
|
||||
# discard everything above ~11 kHz (the entire top octave) before analysis.
|
||||
self.y, self.sr = librosa.load(file_path, sr=None)
|
||||
self.y_mono = librosa.to_mono(self.y)
|
||||
self.max_amplitude = np.max(np.abs(self.y_mono))
|
||||
self.avg_amplitude = np.mean(np.abs(self.y_mono))
|
||||
# BPM intentionally not computed: librosa.beat.beat_track cost ~3.7s on a
|
||||
# 4-min track for a number that's no better than tapping it by hand.
|
||||
|
||||
def get_energy_levels_over_time(self, window=10, hop=2):
|
||||
"""Compute rolling RMS power.
|
||||
|
||||
Args:
|
||||
window: Rolling window length in seconds.
|
||||
hop: Hop length in seconds.
|
||||
"""
|
||||
if (not hasattr(self, 'window')) or (self.window != window) or (self.hop != hop):
|
||||
self.window, self.hop = window, hop
|
||||
if not hasattr(self, 'rms_array'):
|
||||
window_samples = window * self.sr
|
||||
hop_samples = hop * self.sr
|
||||
self.rms_array = librosa.feature.rms(
|
||||
y=self.y, frame_length=window_samples, hop_length=hop_samples
|
||||
)
|
||||
|
||||
def get_times(self):
|
||||
"""Time-axis values matching the RMS frames."""
|
||||
if not hasattr(self, 'rms_array'):
|
||||
self.get_energy_levels_over_time()
|
||||
return librosa.frames_to_time(
|
||||
np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop * self.sr
|
||||
)
|
||||
|
||||
+556
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
Pluggable analysis metrics.
|
||||
|
||||
A `Metric` computes a backend-neutral data object from an `AudioFile` and then
|
||||
turns that data into a `PlotSpec` (declarative drawing intent). Compute is the
|
||||
heavy step and runs on the worker thread; `build_spec` is cheap, view-aware, and
|
||||
reruns on every scale toggle / overlay change without recomputation.
|
||||
|
||||
To add a metric: subclass `Metric`, implement `compute` and `build_spec`, and
|
||||
register the instance in `METRICS` at the bottom of this file.
|
||||
|
||||
Note: metrics no longer touch matplotlib or know which library draws them. The
|
||||
old `_show_axis_extents` endpoint-labelling lived in the matplotlib render path
|
||||
and is gone for now; if exact-extent tick labels are wanted back, they belong in
|
||||
the renderer, applied uniformly to every metric.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import librosa
|
||||
import pyloudnorm as pyln
|
||||
from scipy import signal as scipy_signal
|
||||
from scipy.ndimage import maximum_filter1d
|
||||
|
||||
from master_core import AudioFile
|
||||
from plotspec import (
|
||||
AxisSpec, Band, Curve, Heatmap, HLine, PlotSpec, ViewState, DEFAULT_VIEW,
|
||||
)
|
||||
|
||||
|
||||
# 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 _window_starts(n: int, window_n: int, hop_n: int) -> np.ndarray:
|
||||
"""Start indices of every full sliding window of length `window_n` over `n`."""
|
||||
n_windows = 1 + (n - window_n) // hop_n
|
||||
return np.arange(n_windows) * hop_n
|
||||
|
||||
|
||||
def _window_peaks(abs_signal: np.ndarray, starts: np.ndarray, window_n: int) -> np.ndarray:
|
||||
"""Max of `abs_signal` over each window [start, start+window_n), vectorised.
|
||||
|
||||
Uses an O(N) running-max (scipy maximum_filter1d) sampled at window centres,
|
||||
replacing the per-window Python `np.max` loops. `maximum_filter1d` centres a
|
||||
size-`window_n` window on each index, so the centre of [start, start+window_n)
|
||||
is `start + window_n//2` — the two line up exactly for even windows.
|
||||
"""
|
||||
running = maximum_filter1d(abs_signal, size=window_n)
|
||||
centers = np.minimum(starts + window_n // 2, len(abs_signal) - 1)
|
||||
return running[centers]
|
||||
|
||||
|
||||
# BS.1770 loudness offset and absolute gate, shared by the routines below.
|
||||
_LUFS_OFFSET = -0.691
|
||||
_ABS_GATE = -70.0
|
||||
|
||||
|
||||
def _kweight(audio_file: AudioFile) -> np.ndarray:
|
||||
"""K-weighted mono signal (float64), filtered once and cached on the AudioFile.
|
||||
|
||||
Uses pyloudnorm's own BS.1770 biquad coefficients and filtering (passband_gain
|
||||
* lfilter, exactly as `IIRfilter.apply_filter`), so every loudness quantity
|
||||
derived from it matches pyloudnorm. Depends on `Meter._filters` internals; the
|
||||
dev-time validation guards against a coefficient change.
|
||||
"""
|
||||
cached = getattr(audio_file, "_yk", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
yk = audio_file.y_mono.astype(np.float64, copy=False)
|
||||
for filt in pyln.Meter(audio_file.sr)._filters.values():
|
||||
yk = filt.passband_gain * scipy_signal.lfilter(filt.b, filt.a, yk)
|
||||
audio_file._yk = yk
|
||||
return yk
|
||||
|
||||
|
||||
def _block_loudness(yk: np.ndarray, sr: int, block_s: float, step_pct: float):
|
||||
"""Per-block mean-square energy `z` and block loudness `l`, matching pyloudnorm.
|
||||
|
||||
Blocks are `block_s` long, stepped by `block_s * step_pct`; energy is divided
|
||||
by the *nominal* block length (not the rounded sample count), exactly as
|
||||
BS.1770 / pyloudnorm define it.
|
||||
"""
|
||||
T = len(yk) / sr
|
||||
n_blocks = int(np.round((T - block_s) / (block_s * step_pct)) + 1)
|
||||
if n_blocks < 1:
|
||||
return np.array([]), np.array([])
|
||||
j = np.arange(n_blocks)
|
||||
lo = (block_s * (j * step_pct) * sr).astype(int)
|
||||
up = np.minimum((block_s * (j * step_pct + 1) * sr).astype(int), len(yk))
|
||||
csq = np.concatenate(([0.0], np.cumsum(yk * yk)))
|
||||
z = (csq[up] - csq[lo]) / (block_s * sr)
|
||||
with np.errstate(divide="ignore"):
|
||||
l = _LUFS_OFFSET + 10.0 * np.log10(z)
|
||||
return z, l
|
||||
|
||||
|
||||
def _integrated_lufs(yk: np.ndarray, sr: int) -> float:
|
||||
"""ITU-R BS.1770 integrated (two-stage gated) loudness from the K-weighted signal.
|
||||
|
||||
Reimplements pyloudnorm's gating on 400 ms / 75%-overlap blocks — validated
|
||||
bit-equal to `Meter.integrated_loudness` — so the whole-signal re-filter that
|
||||
pyloudnorm would do is avoided (the K-weighting is already cached).
|
||||
"""
|
||||
z, l = _block_loudness(yk, sr, block_s=0.4, step_pct=0.25)
|
||||
abs_gated = l >= _ABS_GATE
|
||||
if not abs_gated.any():
|
||||
return float("-inf")
|
||||
gamma_r = _LUFS_OFFSET + 10.0 * np.log10(np.mean(z[abs_gated])) - 10.0
|
||||
gated = (l > gamma_r) & (l > _ABS_GATE)
|
||||
if not gated.any():
|
||||
return float("-inf")
|
||||
return float(_LUFS_OFFSET + 10.0 * np.log10(np.mean(z[gated])))
|
||||
|
||||
|
||||
def _loudness_range(yk: np.ndarray, sr: int) -> float:
|
||||
"""EBU Tech 3342 loudness range (LU) from the K-weighted signal.
|
||||
|
||||
3 s blocks at ~10 Hz with 1.5 s of trailing silence, absolute + relative
|
||||
gating, then the 95th-minus-10th percentile spread — matching pyloudnorm's
|
||||
`loudness_range` (validated bit-equal).
|
||||
"""
|
||||
yk_padded = np.concatenate((yk, np.zeros(int(1.5 * sr))))
|
||||
_, l = _block_loudness(yk_padded, sr, block_s=3.0, step_pct=0.03)
|
||||
abs_gated = l[l >= _ABS_GATE]
|
||||
if len(abs_gated) == 0:
|
||||
return float("nan")
|
||||
stl_integrated = 10.0 * np.log10(np.mean(np.power(10.0, abs_gated / 10.0)))
|
||||
rel_gated = abs_gated[abs_gated >= stl_integrated - 20.0]
|
||||
if len(rel_gated) == 0:
|
||||
return float("nan")
|
||||
return float(np.percentile(rel_gated, 95) - np.percentile(rel_gated, 10))
|
||||
|
||||
|
||||
def _short_term_lufs(audio_file: AudioFile, window_s: float, hop_s: float):
|
||||
"""True (ungated) EBU R128 short-term loudness series + window-centre times.
|
||||
|
||||
A vectorised sliding mean-square over the cached K-weighted signal — ~8x faster
|
||||
than the old loop of per-window `integrated_loudness` calls, which also wrongly
|
||||
gated each 3 s window (short-term loudness is ungated by definition).
|
||||
|
||||
Memoised on the AudioFile so LUFS and PSR (same 3 s / 0.5 s window) share it.
|
||||
"""
|
||||
key = (round(window_s, 6), round(hop_s, 6))
|
||||
cache = getattr(audio_file, "_st_lufs_cache", None)
|
||||
if cache is None:
|
||||
cache = audio_file._st_lufs_cache = {}
|
||||
if key in cache:
|
||||
return cache[key]
|
||||
|
||||
yk = _kweight(audio_file)
|
||||
sr = audio_file.sr
|
||||
n = len(yk)
|
||||
window_n = max(int(window_s * sr), 1)
|
||||
hop_n = max(int(hop_s * sr), 1)
|
||||
if n < window_n:
|
||||
ms = float(np.mean(yk * yk)) if n else 0.0
|
||||
times = np.array([n / (2.0 * sr)])
|
||||
lufs = np.array([_LUFS_OFFSET + 10.0 * np.log10(max(ms, _EPS))])
|
||||
else:
|
||||
csq = np.concatenate(([0.0], np.cumsum(yk * yk)))
|
||||
starts = _window_starts(n, window_n, hop_n)
|
||||
ms = (csq[starts + window_n] - csq[starts]) / window_n
|
||||
lufs = _LUFS_OFFSET + 10.0 * np.log10(np.maximum(ms, _EPS))
|
||||
times = (starts + window_n / 2.0) / sr
|
||||
|
||||
cache[key] = (times, lufs)
|
||||
return cache[key]
|
||||
|
||||
|
||||
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 must be backend-neutral (numpy arrays + scalars). It is
|
||||
cached and later passed to `build_spec`. Heavy; runs on the worker thread.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def build_spec(self, data: Any, view: ViewState = DEFAULT_VIEW) -> PlotSpec:
|
||||
"""Turn precomputed data into a PlotSpec. Cheap; runs on the GUI thread.
|
||||
|
||||
`view` carries recompute-free options (lin/log). Titles are set by the
|
||||
renderer per dataset, not here, so specs compose under overlay.
|
||||
"""
|
||||
|
||||
|
||||
class RMSPowerMetric(Metric):
|
||||
"""Rolling RMS power as a filled area over time."""
|
||||
|
||||
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": np.asarray(audio_file.rms_array).reshape(-1),
|
||||
}
|
||||
|
||||
def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec:
|
||||
times = data["times"]
|
||||
rms = data["rms"]
|
||||
# Adaptive headroom: loud masters get a taller scale.
|
||||
ymax = 0.6 if (rms.size and np.max(rms) > 0.3) else 0.3
|
||||
return PlotSpec(
|
||||
axes=AxisSpec(
|
||||
x_label="Time (seconds)", y_label="Power",
|
||||
y_range=(0.0, ymax),
|
||||
x_range=(float(times[0]), float(times[-1])) if times.size else None,
|
||||
),
|
||||
bands=[Band(x=times, lo=np.zeros_like(rms), hi=rms, label="RMS power")],
|
||||
)
|
||||
|
||||
|
||||
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 build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec:
|
||||
times = data["times"]
|
||||
return PlotSpec(
|
||||
axes=AxisSpec(
|
||||
x_label="Time (seconds)", y_label="Amplitude",
|
||||
y_range=(-1.1, 1.1),
|
||||
x_range=(float(times[0]), float(times[-1])) if times.size else None,
|
||||
),
|
||||
bands=[Band(x=times, lo=data["lo"], hi=data["hi"], label="Waveform")],
|
||||
)
|
||||
|
||||
|
||||
class LUFSMetric(Metric):
|
||||
"""ITU-R BS.1770 loudness: short-term (3 s) time series + integrated + LRA."""
|
||||
|
||||
id = "lufs"
|
||||
display_name = "LUFS"
|
||||
|
||||
WINDOW_S = 3.0
|
||||
HOP_S = 0.5
|
||||
SILENCE_FLOOR = -70.0 # BS.1770 absolute gate
|
||||
|
||||
def compute(self, audio_file: AudioFile):
|
||||
sr = audio_file.sr
|
||||
|
||||
# Short-term series: fast, ungated, shared with PSR.
|
||||
times, lufs = _short_term_lufs(audio_file, self.WINDOW_S, self.HOP_S)
|
||||
lufs = np.clip(np.where(np.isfinite(lufs), lufs, self.SILENCE_FLOOR),
|
||||
self.SILENCE_FLOOR, 0.0)
|
||||
|
||||
# Integrated + LRA from the same cached K-weighting (gating matches pyloudnorm).
|
||||
yk = _kweight(audio_file)
|
||||
integrated = _integrated_lufs(yk, sr)
|
||||
lra = _loudness_range(yk, sr) if len(yk) >= int(self.WINDOW_S * sr) else float("nan")
|
||||
|
||||
return {
|
||||
"times": times,
|
||||
"lufs": lufs,
|
||||
"integrated": float(integrated),
|
||||
"lra": lra,
|
||||
}
|
||||
|
||||
def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec:
|
||||
times = data["times"]
|
||||
lufs = data["lufs"]
|
||||
integrated = data["integrated"]
|
||||
lra = data.get("lra", float("nan"))
|
||||
|
||||
hlines = [
|
||||
HLine(y=-14.0, label="-14 LUFS (streaming target)", style="dot"),
|
||||
]
|
||||
annotations = []
|
||||
if np.isfinite(integrated):
|
||||
hlines.append(HLine(y=integrated, label=f"Integrated: {integrated:.1f} LUFS",
|
||||
color="#e76f51", style="dash", width=1.5))
|
||||
if np.isfinite(lra):
|
||||
annotations.append(f"LRA: {lra:.1f} LU")
|
||||
|
||||
return PlotSpec(
|
||||
axes=AxisSpec(
|
||||
x_label="Time (seconds)", y_label="LUFS",
|
||||
y_range=(-50.0, 0.0),
|
||||
x_range=(float(times[0]), float(times[-1])) if times.size else None,
|
||||
),
|
||||
curves=[Curve(x=times, y=lufs, label="Short-term (3 s)")],
|
||||
hlines=hlines,
|
||||
annotations=annotations,
|
||||
)
|
||||
|
||||
|
||||
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 O(N) running max.
|
||||
y2 = y * y
|
||||
cumsum = np.concatenate(([0.0], np.cumsum(y2)))
|
||||
starts = _window_starts(len(y), window_n, hop_n)
|
||||
mean_sq = (cumsum[starts + window_n] - cumsum[starts]) / window_n
|
||||
rms = np.sqrt(np.maximum(mean_sq, _EPS))
|
||||
|
||||
peaks = _window_peaks(np.abs(y), starts, window_n)
|
||||
|
||||
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 build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec:
|
||||
times = data["times"]
|
||||
return PlotSpec(
|
||||
axes=AxisSpec(
|
||||
x_label="Time (seconds)", y_label="Crest factor (dB)",
|
||||
y_range=(0.0, 25.0),
|
||||
x_range=(float(times[0]), float(times[-1])) if times.size else None,
|
||||
),
|
||||
curves=[Curve(x=times, y=data["crest_db"], label="Crest factor (1 s)")],
|
||||
hlines=[
|
||||
HLine(y=12.0, label="12 dB", style="dot"),
|
||||
HLine(y=6.0, label="6 dB (squashed)", style="dot"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class PSRMetric(Metric):
|
||||
"""Peak-to-Short-term LUFS Ratio (sample-peak variant), in LU."""
|
||||
|
||||
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
|
||||
window_n = max(int(self.WINDOW_S * sr), 1)
|
||||
hop_n = max(int(self.HOP_S * sr), 1)
|
||||
|
||||
# Short-term loudness series, shared (cache hit) with LUFSMetric.
|
||||
times, lufs_series = _short_term_lufs(audio_file, self.WINDOW_S, self.HOP_S)
|
||||
abs_y = np.abs(y)
|
||||
|
||||
if len(y) < window_n:
|
||||
peaks_db = np.array([_to_dbfs(np.max(abs_y)) if len(y) else self.SILENCE_FLOOR])
|
||||
else:
|
||||
starts = _window_starts(len(y), window_n, hop_n)
|
||||
peaks_db = _to_dbfs(_window_peaks(abs_y, starts, window_n))
|
||||
|
||||
# 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 build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec:
|
||||
times = data["times"]
|
||||
return PlotSpec(
|
||||
axes=AxisSpec(
|
||||
x_label="Time (seconds)", y_label="PSR (LU)",
|
||||
y_range=(0.0, 25.0),
|
||||
x_range=(float(times[0]), float(times[-1])) if times.size else None,
|
||||
),
|
||||
curves=[Curve(x=times, y=data["psr"], label="PSR (3 s)")],
|
||||
hlines=[
|
||||
HLine(y=10.0, label="10 LU (good punch)", style="dot"),
|
||||
HLine(y=4.0, label="4 LU (squashed)", style="dot"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class TruePeakMetric(Metric):
|
||||
"""ITU-R BS.1770 true peak via 4x polyphase oversampling, in dBTP."""
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
# Oversample the whole signal once (not per window), then take an O(N)
|
||||
# running max over the oversampled windows — replaces thousands of tiny
|
||||
# resample_poly calls with one big one.
|
||||
os_factor = self.OVERSAMPLE
|
||||
abs_up = np.abs(scipy_signal.resample_poly(y, os_factor, 1).astype(np.float32))
|
||||
win_up = window_n * os_factor
|
||||
running = maximum_filter1d(abs_up, size=win_up)
|
||||
|
||||
starts = _window_starts(len(y), window_n, hop_n)
|
||||
centers_up = np.minimum(starts * os_factor + win_up // 2, len(abs_up) - 1)
|
||||
tp_db = _to_dbfs(running[centers_up])
|
||||
times = (starts + 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 build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec:
|
||||
times = data["times"]
|
||||
integrated = data.get("integrated_tp_db", float("nan"))
|
||||
annotations = []
|
||||
if np.isfinite(integrated):
|
||||
annotations.append(f"Max: {integrated:.2f} dBTP")
|
||||
return PlotSpec(
|
||||
axes=AxisSpec(
|
||||
x_label="Time (seconds)", y_label="dBTP",
|
||||
y_range=(-30.0, 6.0),
|
||||
x_range=(float(times[0]), float(times[-1])) if times.size else None,
|
||||
),
|
||||
curves=[Curve(x=times, y=data["tp_db"], label="True Peak (250 ms)", width=1.0)],
|
||||
hlines=[
|
||||
HLine(y=0.0, label="0 dBTP (clip)", color="#000000", style="dash", width=1.0),
|
||||
HLine(y=-1.0, label="-1 dBTP (typical ceiling)", style="dot"),
|
||||
],
|
||||
annotations=annotations,
|
||||
)
|
||||
|
||||
|
||||
class SpectrogramMetric(Metric):
|
||||
"""Log-frequency STFT spectrogram: frequency power distribution over time."""
|
||||
|
||||
id = "spectrogram"
|
||||
display_name = "Spectrogram"
|
||||
|
||||
N_FFT = 4096
|
||||
TARGET_COLUMNS = 4000
|
||||
DB_FLOOR = -80.0
|
||||
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
|
||||
|
||||
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 a log frequency axis has no non-positive coord.
|
||||
return {
|
||||
"freqs": freqs[1:],
|
||||
"times": times,
|
||||
"s_db": s_db[1:, :],
|
||||
"nyquist": sr / 2.0,
|
||||
}
|
||||
|
||||
def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec:
|
||||
freqs = data["freqs"]
|
||||
times = data["times"]
|
||||
nyquist = data["nyquist"]
|
||||
y_log = view.resolve_y_log(default=True) # log frequency by default
|
||||
|
||||
return PlotSpec(
|
||||
axes=AxisSpec(
|
||||
x_label="Time (seconds)", y_label="Frequency (Hz)",
|
||||
y_log=y_log, y_log_allowed=True,
|
||||
y_range=(self.F_MIN, float(nyquist)),
|
||||
x_range=(float(times[0]), float(times[-1])) if times.size else None,
|
||||
),
|
||||
heatmap=Heatmap(
|
||||
x=times, y=freqs, z=data["s_db"],
|
||||
z_min=self.DB_FLOOR, z_max=0.0, cmap="magma", label="Power (dB)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
METRICS: dict[str, Metric] = {
|
||||
m.id: m for m in (
|
||||
RMSPowerMetric(),
|
||||
WaveformMetric(),
|
||||
LUFSMetric(),
|
||||
CrestFactorMetric(),
|
||||
PSRMetric(),
|
||||
TruePeakMetric(),
|
||||
SpectrogramMetric(),
|
||||
)
|
||||
}
|
||||
DEFAULT_METRIC_ID = "rms_power"
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Plot control widget: pick the metric, set axis scale/mode, refresh the plot.
|
||||
|
||||
A clustered groupbox for the left panel: metric selector, log-frequency toggle,
|
||||
relative-time toggle, and a manual refresh button.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QGroupBox,
|
||||
QCheckBox,
|
||||
)
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
|
||||
from metrics import METRICS, DEFAULT_METRIC_ID
|
||||
from plotspec import ViewState, X_ABSOLUTE, X_RELATIVE
|
||||
|
||||
|
||||
class PlotControlWidget(QWidget):
|
||||
"""Metric selector, view-scale/x-mode toggles, and manual plot refresh.
|
||||
|
||||
Overlay/compare membership is driven by the file-list checkboxes and reference
|
||||
lines by their own cluster; this one governs *what* metric and *how* its axes
|
||||
are scaled (lin/log frequency) and laid out (absolute vs relative time).
|
||||
"""
|
||||
|
||||
metricChanged = pyqtSignal(str) # metric_id
|
||||
viewChanged = pyqtSignal() # view-state (scale / x-mode) changed
|
||||
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)
|
||||
|
||||
# Frequency-axis scale. Only the spectrogram honours it today; harmless
|
||||
# elsewhere (build_spec ignores unsupported toggles).
|
||||
self.log_freq_check = QCheckBox("Log frequency (spectrogram)")
|
||||
self.log_freq_check.setChecked(True)
|
||||
self.log_freq_check.toggled.connect(lambda _: self.viewChanged.emit())
|
||||
group_layout.addWidget(self.log_freq_check)
|
||||
|
||||
# Time axis: off = absolute seconds, on = relative % of each track's own
|
||||
# length, so tracks of very different durations line up by song position.
|
||||
self.relative_time_check = QCheckBox("Relative time axis (%)")
|
||||
self.relative_time_check.setToolTip(
|
||||
"Off: time in seconds. On: 0-100% of each track's own length")
|
||||
self.relative_time_check.toggled.connect(lambda _: self.viewChanged.emit())
|
||||
group_layout.addWidget(self.relative_time_check)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self.refresh_button = QPushButton("Refresh Plot")
|
||||
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
|
||||
|
||||
def current_view_state(self) -> ViewState:
|
||||
return ViewState(
|
||||
y_log=self.log_freq_check.isChecked(),
|
||||
x_mode=X_RELATIVE if self.relative_time_check.isChecked() else X_ABSOLUTE,
|
||||
)
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Backend-agnostic plot descriptors.
|
||||
|
||||
A metric's `build_spec` turns precomputed data into a `PlotSpec`: a declarative
|
||||
description of *what* to draw (curves, reference lines, an optional heatmap) and
|
||||
*how the axes should behave* (labels, default scale, which lin/log toggles are
|
||||
legal). It says nothing about the plotting library, colours, or widget layout —
|
||||
that is the renderer's job.
|
||||
|
||||
This seam is what makes overlay/compare cheap: drawing N datasets on one axis is
|
||||
"render N specs," and the renderer owns the colour cycle so overlaid curves stay
|
||||
distinct. It is also what makes lin/log a pure view toggle — `build_spec` takes a
|
||||
`ViewState`, so switching scale never touches `compute`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class Curve:
|
||||
"""A single x/y line. Colour is assigned by the renderer for overlay distinctness."""
|
||||
x: np.ndarray
|
||||
y: np.ndarray
|
||||
label: str = ""
|
||||
width: float = 1.4
|
||||
# Explicit colour overrides the dataset colour cycle. Leave None for overlay.
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HLine:
|
||||
"""A horizontal reference line with an attached label.
|
||||
|
||||
The label rides on the line itself (renderer places it), so reference markers
|
||||
no longer need anchoring at `times[-1]` — overlaid tracks of different lengths
|
||||
stop fighting over label position.
|
||||
"""
|
||||
y: float
|
||||
label: str = ""
|
||||
color: str = "#888888"
|
||||
style: str = "dot" # 'solid' | 'dash' | 'dot'
|
||||
width: float = 0.8
|
||||
|
||||
|
||||
@dataclass
|
||||
class Band:
|
||||
"""A filled envelope between `lo` and `hi` over `x` (RMS area, waveform min/max).
|
||||
|
||||
One drawn primitive instead of thousands of per-segment fills, and overlay-safe:
|
||||
the renderer gives each dataset's band a translucent dataset colour.
|
||||
"""
|
||||
x: np.ndarray
|
||||
lo: np.ndarray # scalar-broadcast or per-x lower edge
|
||||
hi: np.ndarray # per-x upper edge
|
||||
label: str = ""
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Heatmap:
|
||||
"""A 2-D field (e.g. a spectrogram). Heatmaps do not overlay — at most one."""
|
||||
x: np.ndarray # column axis (time)
|
||||
y: np.ndarray # row axis (frequency), linear; renderer handles log
|
||||
z: np.ndarray # shape (len(y), len(x))
|
||||
z_min: float
|
||||
z_max: float
|
||||
cmap: str = "magma"
|
||||
label: str = "" # colourbar label
|
||||
|
||||
|
||||
@dataclass
|
||||
class AxisSpec:
|
||||
x_label: str = ""
|
||||
y_label: str = ""
|
||||
y_log: bool = False # this metric's natural default scale
|
||||
x_log: bool = False
|
||||
y_range: Optional[tuple[float, float]] = None
|
||||
x_range: Optional[tuple[float, float]] = None
|
||||
y_log_allowed: bool = False # is a lin/log toggle meaningful on this axis?
|
||||
x_log_allowed: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlotSpec:
|
||||
"""Everything the renderer needs to draw one dataset of one metric."""
|
||||
title: str = ""
|
||||
axes: AxisSpec = field(default_factory=AxisSpec)
|
||||
curves: list[Curve] = field(default_factory=list)
|
||||
bands: list[Band] = field(default_factory=list)
|
||||
hlines: list[HLine] = field(default_factory=list)
|
||||
heatmap: Optional[Heatmap] = None
|
||||
# Scalar readouts (integrated LUFS, LRA, max dBTP) surfaced in the legend.
|
||||
annotations: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_heatmap(self) -> bool:
|
||||
return self.heatmap is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefLineProps:
|
||||
"""A user-defined horizontal reference line.
|
||||
|
||||
Owned by the GUI controller and passed to the renderer, which draws it as a
|
||||
draggable line and writes `value` back on drag. Persists across redraws; the
|
||||
GUI clears the set when the metric changes (the value axis units change).
|
||||
"""
|
||||
value: float
|
||||
color: str = "#444444"
|
||||
style: str = "dash" # 'solid' | 'dash' | 'dot'
|
||||
label: str = "" # tag shown on the line; falls back to the value
|
||||
|
||||
|
||||
# X-axis modes for comparison.
|
||||
X_ABSOLUTE = "absolute" # time in seconds (native)
|
||||
X_RELATIVE = "relative" # 0-100% of each track's own length
|
||||
|
||||
|
||||
@dataclass
|
||||
class ViewState:
|
||||
"""User-controlled, recompute-free view options.
|
||||
|
||||
`None` means "use the metric's default for this axis." `build_spec` resolves
|
||||
the concrete scale via `resolve_*`, so a metric never has to special-case the
|
||||
unset state.
|
||||
"""
|
||||
y_log: Optional[bool] = None
|
||||
x_log: Optional[bool] = None
|
||||
x_mode: str = X_ABSOLUTE
|
||||
|
||||
def resolve_y_log(self, default: bool) -> bool:
|
||||
return self.y_log if self.y_log is not None else default
|
||||
|
||||
def resolve_x_log(self, default: bool) -> bool:
|
||||
return self.x_log if self.x_log is not None else default
|
||||
|
||||
|
||||
def apply_x_mode(spec: PlotSpec, mode: str) -> PlotSpec:
|
||||
"""Rewrite a spec's x-axis to relative position (0-100%) in place, if asked.
|
||||
|
||||
Each dataset is normalised to *its own* span, so tracks of different lengths
|
||||
line up by song position — the point of relative mode. A pure view transform:
|
||||
it reassigns the x arrays (cached data is left untouched) and relabels the
|
||||
axis. No-op for absolute mode.
|
||||
"""
|
||||
if mode != X_RELATIVE:
|
||||
return spec
|
||||
|
||||
xs = [c.x for c in spec.curves] + [b.x for b in spec.bands]
|
||||
if spec.heatmap is not None:
|
||||
xs.append(spec.heatmap.x)
|
||||
xs = [x for x in xs if len(x)]
|
||||
if not xs:
|
||||
return spec
|
||||
|
||||
lo = min(float(x[0]) for x in xs)
|
||||
hi = max(float(x[-1]) for x in xs)
|
||||
span = (hi - lo) or 1.0
|
||||
|
||||
def rel(x):
|
||||
return (x - lo) / span * 100.0
|
||||
|
||||
for c in spec.curves:
|
||||
c.x = rel(c.x)
|
||||
for b in spec.bands:
|
||||
b.x = rel(b.x)
|
||||
if spec.heatmap is not None:
|
||||
spec.heatmap.x = rel(spec.heatmap.x)
|
||||
spec.axes.x_label = "Position (%)"
|
||||
spec.axes.x_range = (0.0, 100.0)
|
||||
return spec
|
||||
|
||||
|
||||
# A neutral default reused wherever a caller hasn't supplied view options.
|
||||
DEFAULT_VIEW = ViewState()
|
||||
@@ -1,79 +0,0 @@
|
||||
"""
|
||||
Audio visualization plotting engine.
|
||||
Separates plotting logic from audio processing for clean GUI integration.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.colors as mcolors
|
||||
import matplotlib.cm as cm
|
||||
from matplotlib.figure import Figure
|
||||
import os
|
||||
|
||||
|
||||
class PlottingEngine:
|
||||
"""Handles all matplotlib visualization logic for audio analysis."""
|
||||
|
||||
@staticmethod
|
||||
def create_power_analysis_figure(times, rms_array, file_path, figsize=(10, 4)):
|
||||
"""
|
||||
Creates a matplotlib Figure for power analysis visualization.
|
||||
|
||||
Args:
|
||||
times: Array of time points
|
||||
rms_array: RMS power values over time
|
||||
file_path: Path to the audio file for title
|
||||
figsize: Figure size tuple
|
||||
|
||||
Returns:
|
||||
matplotlib.figure.Figure: Ready-to-embed figure
|
||||
"""
|
||||
# Determine color scale based on headroom detection
|
||||
local_max_power = np.max(rms_array)
|
||||
if local_max_power > 0.3:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.6)
|
||||
maxpower = 0.6
|
||||
else:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.3)
|
||||
maxpower = 0.3
|
||||
|
||||
# Create figure and axis
|
||||
fig = Figure(figsize=figsize, facecolor='white')
|
||||
ax = fig.add_subplot(111)
|
||||
|
||||
# Color map
|
||||
cmap = cm.autumn
|
||||
|
||||
# Plot power levels as colored bars
|
||||
ax.set_ylim(0., maxpower)
|
||||
for i in range(len(times)-1):
|
||||
ax.fill_between(times[i:i+2], 0, rms_array[0][i],
|
||||
color=cmap(norm(rms_array[0][i])), edgecolor='none')
|
||||
|
||||
# Add colorbar
|
||||
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
||||
sm.set_array([])
|
||||
cbar = fig.colorbar(sm, ax=ax, label='RMS Power')
|
||||
|
||||
# Labels and title
|
||||
ax.set_ylabel('Power')
|
||||
ax.set_xlabel('Time (seconds)')
|
||||
ax.set_title(f'{os.path.basename(file_path)}')
|
||||
|
||||
# Tight layout for better appearance in GUI
|
||||
fig.tight_layout()
|
||||
|
||||
return fig
|
||||
|
||||
@staticmethod
|
||||
def create_metadata_display_text(song_name, bpm, max_amplitude, avg_amplitude):
|
||||
"""
|
||||
Creates formatted text for metadata display.
|
||||
|
||||
Returns:
|
||||
str: Formatted metadata text
|
||||
"""
|
||||
return f"""Track: {song_name}
|
||||
BPM: {bpm:.1f}
|
||||
Max Amplitude: {max_amplitude:.3f}
|
||||
Avg Amplitude: {avg_amplitude:.3f}"""
|
||||
@@ -0,0 +1,38 @@
|
||||
[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'",
|
||||
"pyqtgraph>=0.14.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
ujm = "main:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = [
|
||||
"main",
|
||||
"analysis_results_manager",
|
||||
"audio_visualization_widget",
|
||||
"master_core",
|
||||
"metrics",
|
||||
"plotspec",
|
||||
"font_manager",
|
||||
"plot_control_widget",
|
||||
"ref_line_widget",
|
||||
"logger_setup",
|
||||
"setup_fonts",
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Reference-line management: a side-panel list of custom horizontal markers plus a
|
||||
properties dialog.
|
||||
|
||||
`RefLineControlWidget` is a pure view over a list of `RefLineProps` owned by the
|
||||
main window: it renders the list and emits intents (add / edit / remove / clear).
|
||||
`RefLineDialog` edits one line's value, colour, line style, and tag.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QListWidget, QPushButton,
|
||||
QDialog, QFormLayout, QDoubleSpinBox, QComboBox, QLineEdit, QColorDialog,
|
||||
QDialogButtonBox,
|
||||
)
|
||||
from PyQt5.QtGui import QColor
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
|
||||
from plotspec import RefLineProps
|
||||
|
||||
|
||||
_STYLE_CHOICES = [("Solid", "solid"), ("Dashed", "dash"), ("Dotted", "dot")]
|
||||
|
||||
|
||||
class RefLineDialog(QDialog):
|
||||
"""Edit one reference line's properties. Read the result via `result_props`."""
|
||||
|
||||
def __init__(self, parent, props: RefLineProps, value_units: str = ""):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Reference line")
|
||||
self._color = props.color
|
||||
|
||||
form = QFormLayout(self)
|
||||
|
||||
self.value_spin = QDoubleSpinBox()
|
||||
self.value_spin.setRange(-1e6, 1e6)
|
||||
self.value_spin.setDecimals(2)
|
||||
self.value_spin.setValue(props.value)
|
||||
if value_units:
|
||||
self.value_spin.setSuffix(f" {value_units}")
|
||||
form.addRow("Value:", self.value_spin)
|
||||
|
||||
self.color_button = QPushButton()
|
||||
self.color_button.clicked.connect(self._pick_color)
|
||||
self._refresh_color_button()
|
||||
form.addRow("Colour:", self.color_button)
|
||||
|
||||
self.style_combo = QComboBox()
|
||||
for label, key in _STYLE_CHOICES:
|
||||
self.style_combo.addItem(label, key)
|
||||
idx = self.style_combo.findData(props.style)
|
||||
if idx >= 0:
|
||||
self.style_combo.setCurrentIndex(idx)
|
||||
form.addRow("Line style:", self.style_combo)
|
||||
|
||||
self.label_edit = QLineEdit(props.label)
|
||||
self.label_edit.setPlaceholderText("(optional tag)")
|
||||
form.addRow("Tag:", self.label_edit)
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
form.addRow(buttons)
|
||||
|
||||
def _pick_color(self):
|
||||
chosen = QColorDialog.getColor(QColor(self._color), self, "Reference line colour")
|
||||
if chosen.isValid():
|
||||
self._color = chosen.name()
|
||||
self._refresh_color_button()
|
||||
|
||||
def _refresh_color_button(self):
|
||||
self.color_button.setText(self._color)
|
||||
# Show the colour as the button's background for a quick read.
|
||||
self.color_button.setStyleSheet(f"background-color: {self._color};")
|
||||
|
||||
def result_props(self) -> RefLineProps:
|
||||
return RefLineProps(
|
||||
value=float(self.value_spin.value()),
|
||||
color=self._color,
|
||||
style=self.style_combo.currentData(),
|
||||
label=self.label_edit.text().strip(),
|
||||
)
|
||||
|
||||
|
||||
class RefLineControlWidget(QWidget):
|
||||
"""List of reference lines with Add / Edit / Remove / Clear controls."""
|
||||
|
||||
addRequested = pyqtSignal()
|
||||
editRequested = pyqtSignal(int)
|
||||
removeRequested = pyqtSignal(int)
|
||||
clearRequested = 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("Reference lines")
|
||||
group_layout = QVBoxLayout(group_box)
|
||||
|
||||
self.line_list = QListWidget()
|
||||
self.line_list.setMaximumHeight(110)
|
||||
self.line_list.itemDoubleClicked.connect(self._on_double_click)
|
||||
group_layout.addWidget(self.line_list)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.add_button = QPushButton("Add")
|
||||
self.add_button.clicked.connect(self.addRequested.emit)
|
||||
row.addWidget(self.add_button)
|
||||
|
||||
self.edit_button = QPushButton("Edit…")
|
||||
self.edit_button.clicked.connect(self._emit_edit)
|
||||
row.addWidget(self.edit_button)
|
||||
|
||||
self.remove_button = QPushButton("Remove")
|
||||
self.remove_button.clicked.connect(self._emit_remove)
|
||||
row.addWidget(self.remove_button)
|
||||
|
||||
self.clear_button = QPushButton("Clear")
|
||||
self.clear_button.clicked.connect(self.clearRequested.emit)
|
||||
row.addWidget(self.clear_button)
|
||||
|
||||
group_layout.addLayout(row)
|
||||
layout.addWidget(group_box)
|
||||
|
||||
def set_lines(self, lines: list[RefLineProps]):
|
||||
"""Repopulate the list display from the current props (preserving selection)."""
|
||||
current = self.line_list.currentRow()
|
||||
self.line_list.clear()
|
||||
for p in lines:
|
||||
tag = f" {p.label}" if p.label else ""
|
||||
self.line_list.addItem(f"{p.value:.2f}{tag}")
|
||||
if 0 <= current < self.line_list.count():
|
||||
self.line_list.setCurrentRow(current)
|
||||
|
||||
def _selected_row(self) -> int:
|
||||
return self.line_list.currentRow()
|
||||
|
||||
def _emit_edit(self):
|
||||
row = self._selected_row()
|
||||
if row >= 0:
|
||||
self.editRequested.emit(row)
|
||||
|
||||
def _emit_remove(self):
|
||||
row = self._selected_row()
|
||||
if row >= 0:
|
||||
self.removeRequested.emit(row)
|
||||
|
||||
def _on_double_click(self, _item):
|
||||
self._emit_edit()
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Font setup utility for CJK character support.
|
||||
Provides installation instructions and system font detection.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
# Add current directory to path to import our modules
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from font_manager import get_font_manager
|
||||
|
||||
|
||||
def main():
|
||||
"""Main font setup utility."""
|
||||
print("=== Audio Analysis Toolkit - CJK Font Setup ===\n")
|
||||
|
||||
# Get font manager and show current status
|
||||
font_manager = get_font_manager()
|
||||
|
||||
print("Current System Information:")
|
||||
print(f"Platform: {platform.system()} {platform.release()}")
|
||||
print(f"Python: {platform.python_version()}\n")
|
||||
|
||||
# Try to initialize fonts
|
||||
print("Initializing font system...")
|
||||
success = font_manager.initialize()
|
||||
|
||||
# Show detailed status
|
||||
status = font_manager.get_status_report()
|
||||
print(f"Font system status: {'✓ OK' if success else '⚠ Issues detected'}")
|
||||
print(f"Matplotlib configured: {'✓' if status['matplotlib_configured'] else '✗'}")
|
||||
print(f"Qt configured: {'✓' if status['qt_configured'] else '✗'}")
|
||||
print(f"Custom fonts loaded: {status['custom_fonts_loaded']}")
|
||||
|
||||
if status['custom_font_families']:
|
||||
print(f"Custom font families: {', '.join(status['custom_font_families'])}")
|
||||
|
||||
print(f"Fonts directory: {status['fonts_directory_path']}")
|
||||
print(f"Directory exists: {'✓' if status['fonts_directory_exists'] else '✗'}")
|
||||
print()
|
||||
|
||||
# Show current matplotlib font configuration
|
||||
print("Current matplotlib font stack:")
|
||||
for i, font in enumerate(status['current_matplotlib_fonts'][:8], 1):
|
||||
print(f" {i}. {font}")
|
||||
print()
|
||||
|
||||
# Show installation instructions
|
||||
print(font_manager.get_font_installation_instructions())
|
||||
|
||||
# Test CJK character handling
|
||||
print("\n=== Testing CJK Character Handling ===")
|
||||
test_strings = [
|
||||
"English Title",
|
||||
"日本語のタイトル", # Japanese
|
||||
"中文标题", # Chinese
|
||||
"한국어 제목", # Korean
|
||||
"Test - テスト", # Mixed
|
||||
]
|
||||
|
||||
print("Testing font-safe title conversion:")
|
||||
for test_str in test_strings:
|
||||
safe_str = font_manager.get_cjk_safe_title(test_str)
|
||||
status_indicator = "✓" if test_str == safe_str else "⚠"
|
||||
print(f" {status_indicator} '{test_str}' -> '{safe_str}'")
|
||||
|
||||
print("\n=== Setup Complete ===")
|
||||
if success:
|
||||
print("Font system is ready for use!")
|
||||
if status['custom_fonts_loaded'] == 0:
|
||||
print("Consider adding CJK fonts to improve character display.")
|
||||
else:
|
||||
print("There were issues with font setup. Check the logs for details.")
|
||||
print("The application will still work but CJK characters may not display correctly.")
|
||||
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user