Compare commits

...

17 Commits

Author SHA1 Message Date
mikkeli 4f82c9197b Merge pull request 'Perf/faster analysis' (#2) from perf/faster-analysis into main
Reviewed-on: http://novoyuuparosk.org:1551/mikkeli/uj-mastering-master/pulls/2
2026-06-13 16:57:21 +00:00
Mikkeli Matlock 507af2f676 Native integrated/LRA, background prefetch, timing-rich status
LUFS integrated + LRA:
- Reimplement BS.1770 two-stage gating (integrated) and EBU 3342 (LRA) directly
  from the cached K-weighted signal, dropping pyloudnorm's two whole-signal
  re-filters. Validated bit-equal to pyloudnorm across steady/dynamic/quiet
  signals (0.0000 diff). LUFS compute ~1.9s -> ~0.6s (orig 3.8s). pyloudnorm now
  only supplies the filter coefficients.

Prefetch on load:
- After a file loads, PrefetchWorker computes the remaining metrics in the
  background (sequential, cooperatively cancellable, skips on-demand hits), so
  the first switch to any metric is instant. Superseded when a new file loads.

Status slip:
- Workers measure compute time; metricTiming + phase/duration progress messages
  drive the slip ("Loaded in Ns - computing X...", "X computed in Ys"). The old
  fake percentage (10% then done) is logged but no longer shown.
- shutdown() stops background threads on window close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:49:50 +09:00
Mikkeli Matlock d7782bb9d9 Speed up analysis: drop BPM, vectorise loudness/true-peak, share short-term
Profiled hot spots on a 4-min track and cut the worst offenders:

- Remove BPM: librosa.beat.beat_track ran on every load (~3.7s) for a number no
  better than tapping by hand. Dropped from AudioFile + the metadata panel.
- LUFS short-term: replace 474 per-window pyloudnorm.integrated_loudness calls
  with one K-weighting pass (reusing pyloudnorm's own filter coefficients) + a
  vectorised sliding mean-square. This is true *ungated* EBU R128 short-term
  (the old loop wrongly gated each 3s window). Integrated + LRA still use
  pyloudnorm's gated calls. ~3.8s -> ~1.9s.
- PSR: reuse LUFS's short-term series (memoised on the AudioFile) + vectorised
  sample-peak. ~3.0s -> ~0.2s.
- True Peak: oversample the whole signal once, then an O(N) running max over
  windows instead of per-window resample_poly. Bit-identical to the old loop
  (max|diff| 0.0000 dB). ~2.1s -> ~1.1s.
- Crest Factor: peaks via the same O(N) running max (last per-window loop gone).

lufs+psr+true_peak: ~9.2s -> ~3.2s, plus ~3.7s of BPM removed from every load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:36:08 +09:00
mikkeli 3707917d9e Merge pull request 'Refactor/interactive plotting' (#1) from refactor/interactive-plotting into main
Reviewed-on: http://novoyuuparosk.org:1551/mikkeli/uj-mastering-master/pulls/1
2026-06-13 16:17:21 +00:00
Mikkeli Matlock 22ecb67296 Per-metric ref lines with Hz spectrogram units; relative-time toggle; fixed font
Reference lines:
- Kept per metric (dict keyed by metric_id) so switching metrics and coming back
  preserves them, instead of clearing on every switch
- Spectrogram lines read/edit/drag in Hz: the renderer installs Hz<->row-index
  transforms (the heatmap y-axis is a row index), so value, label, the edit
  dialog, and the new-line default all speak frequency. Curve metrics stay
  identity. Round-trip verified (1000 Hz -> row -> 1000 Hz)
- Line label and drag-readback always in the metric's natural units

Controls:
- Relative-time abs/rel is now a checkbox toggle, not a dropdown
- Removed the font control panel; UI font is locked to M PLUS 1 Code @ 10pt via
  font_manager.apply_fixed_font (system-default fallback). Deleted
  font_control_widget.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 01:05:23 +09:00
Mikkeli Matlock a45bd4ba9b Reference-line manager, relative time axis, axis-aware ref-line UX
Reference lines:
- Side-panel cluster (ref_line_widget.py): list of lines with Add/Edit/Remove/
  Clear and a properties dialog (value, colour, line style, tag)
- Lines own their state as RefLineProps (held by MainWindow), drawn with a
  triangle drag-handle; dragging writes the value back and refreshes the list
- Replaces the old centre-only add-and-drag-the-whole-line approach

Comparison:
- Time-axis mode selector: Absolute (seconds) vs Relative (% of each track's own
  length), so a long track and a short one line up by song position. Pure view
  transform (plotspec.apply_x_mode), no recompute
- Overlaid different-length tracks now autorange their x union in absolute mode
  instead of clipping to the first track's span

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:51:33 +09:00
Mikkeli Matlock b400551321 Move plotting to pyqtgraph: interactive, overlay-capable render layer
Replace the fire-and-forget matplotlib pipeline (render() -> throwaway Figure ->
canvas teardown) with a three-stage architecture that supports zoom/pan, lin/log
toggling, and multi-file overlay:

  compute(audio_file) -> data        # heavy, worker thread, backend-neutral
  build_spec(data, view) -> PlotSpec # cheap, GUI thread, view-aware
  show_specs([(label, spec, color)]) # pyqtgraph, persistent PlotItem, overlay

- plotspec.py: backend-agnostic descriptors (Curve, Band, HLine, Heatmap,
  AxisSpec, PlotSpec) + ViewState (recompute-free lin/log)
- audio_visualization_widget.py: persistent pyqtgraph plot, never torn down;
  per-dataset colours for overlay; spectrogram log-freq via row resample
  (ImageItem is affine-only); ColorBarItem at a fixed cell
- Compare/overlay driven by file-list checkboxes; stable per-song colour by row
- Custom draggable reference lines (add/clear), persist across redraws
- Axis-constrained scroll zoom: Ctrl=time, Shift=value (_AxisZoomViewBox)
- RMS render no longer per-segment fill_between (was the slow path)

Fixes found in review/testing:
- FillBetweenItem needs penned child curves or it fills nothing (RMS/Waveform
  were blank); band fill verified by pixel count
- band overlay alpha was a no-op (QBrush.color() returns a copy)
- colorbar could stack across renders; now added/removed at a fixed layout cell

Deferred (per scope): stereo retention, deep perf rewrites (eager beat_track,
true-peak/crest loops, shared LUFS), per-song colour picker UI.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-21 23:39:53 +09:00
20 changed files with 5038 additions and 695 deletions
+26 -1
View File
@@ -1 +1,26 @@
files.txt # Font directory - avoid licensing issues by not committing font files
# Keep the directory structure but ignore actual font files
fonts/*.ttf
fonts/*.otf
fonts/*.ttc
# But preserve the placeholder file
!fonts/PLACE_YOUR_FONT_FILES_HERE
# Python cache and build artifacts
__pycache__/
*.pyc
*.pyo
*.egg-info/
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# claude
.claude/
+223
View File
@@ -0,0 +1,223 @@
# CJK Font Support Implementation
This document describes the CJK (Chinese, Japanese, Korean) font fallback system implemented for the Audio Analysis Toolkit.
## Problem Statement
The application displays song titles and metadata that may contain CJK characters from Japanese, Chinese, or Korean music files. The default matplotlib font (DejaVu Sans) lacks CJK glyphs, causing:
- Matplotlib warnings about missing glyphs
- Incorrect character rendering (squares, question marks, etc.)
- Poor user experience for CJK music collections
## Solution Architecture
### 1. Font Manager System (`font_manager.py`)
A centralized font management system that handles both matplotlib and Qt font configuration:
**Key Features:**
- **Licensing-safe**: Uses local `fonts/` directory (gitignored) for custom fonts
- **Graceful fallback**: System CJK fonts → matplotlib defaults
- **Cross-platform**: Windows, macOS, Linux font detection
- **Modular design**: Single responsibility for font configuration
**Font Priority Order:**
1. Custom fonts from `fonts/` directory (highest priority)
2. System CJK fonts (platform-specific)
3. Default fonts (fallback)
### 2. Safe Title Processing
All text that might contain CJK characters is processed through `safe_title()` function:
- Ensures proper encoding handling
- Provides fallback for problematic characters
- Maintains original text when possible
### 3. Integration Points
The font system is integrated at these key locations:
#### Application Startup (`main.py`)
```python
# Initialize font system before creating any widgets
font_success = initialize_fonts()
```
#### Plot Titles (`plotting_engine.py`)
```python
ax.set_title(safe_title(os.path.basename(file_path)))
```
#### Metadata Display
```python
song_name = safe_title(f"{audio['artist'][0]} - {audio['title'][0]}")
```
## Usage Instructions
### Quick Setup
1. **Run the setup utility:**
```bash
uv run python setup_fonts.py
```
2. **For enhanced CJK support, add fonts to the `fonts/` directory:**
- Download free CJK fonts (Noto Sans CJK, Source Han Sans, etc.)
- Place .ttf/.otf/.ttc files in `fonts/` directory
- Restart the application
### Font Directory Structure
```
uj-mastering-master/
├── fonts/ # Gitignored
│ ├── NotoSansCJK-Regular.ttc
│ ├── SourceHanSans-Regular.otf
│ └── [other CJK fonts]
└── [application files]
```
### Supported Font Formats
- `.ttf` (TrueType Font)
- `.otf` (OpenType Font)
- `.ttc` (TrueType Collection)
## Platform-Specific Behavior
### Windows
**System Fonts Used:**
- Yu Gothic UI, Meiryo, MS Gothic (sans-serif)
- Yu Mincho, MS Mincho (serif)
### macOS
**System Fonts Used:**
- Hiragino Sans, Yu Gothic (sans-serif)
- Hiragino Mincho ProN, Yu Mincho (serif)
### Linux
**System Fonts Used:**
- Noto Sans CJK JP, Source Han Sans (sans-serif)
- Noto Serif CJK JP, Source Han Serif (serif)
## Technical Implementation Details
### Font Detection Algorithm
1. **Custom Font Loading:**
```python
# Load for matplotlib
fm.fontManager.addfont(str(font_file))
# Load for Qt
font_id = QFontDatabase.addApplicationFont(str(font_file))
```
2. **System Font Fallback:**
```python
available_fonts = set(fm.get_font_names())
for font_name in system_fonts['sans-serif']:
if font_name in available_fonts:
return font_name
```
3. **Matplotlib Configuration:**
```python
plt.rcParams['font.sans-serif'] = font_list
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['axes.unicode_minus'] = False
```
### Error Handling
The system is designed to be fault-tolerant:
- Missing fonts directory → Use system fonts
- Font loading failures → Log warnings, continue
- Encoding errors → Apply safe character replacement
- No CJK fonts found → Graceful degradation to defaults
## Licensing Considerations
### Safe Practices
- **Custom fonts directory is gitignored** to avoid committing proprietary fonts
- **System fonts are detected, not redistributed**
- **Open source font recommendations** (Noto, Source Han families)
- **No font files included in repository**
### Recommended Free CJK Fonts
1. **Google Noto Fonts** (SIL Open Font License)
- Noto Sans CJK JP/SC/TC/KR
- Comprehensive CJK coverage
2. **Adobe Source Han Fonts** (SIL Open Font License)
- Source Han Sans
- Source Han Serif
## Testing and Debugging
### Font Status Report
```python
from font_manager import get_font_manager
status = get_font_manager().get_status_report()
print(status)
```
### Test CJK Characters
```bash
uv run python setup_fonts.py
```
### Logging
Font system operations are logged at appropriate levels:
- INFO: Successful initialization
- DEBUG: Font loading details
- WARNING: Missing fonts, fallbacks used
- ERROR: Critical font system failures
## Future Improvements
### Potential Enhancements
1. **Dynamic Font Switching:** Per-language font selection
2. **Font Caching:** Faster startup with font cache
3. **User Preferences:** GUI for font selection
4. **Font Metrics:** Analyze font quality for CJK rendering
### Performance Considerations
- Font loading is done once at startup
- Font cache clearing only when necessary
- Minimal performance impact on audio processing
## Troubleshooting
### Common Issues
**Issue:** CJK characters still show as squares
- **Solution:** Install CJK fonts in `fonts/` directory or check system font availability
**Issue:** Font warnings in console
- **Solution:** Run `python setup_fonts.py` to check font configuration
**Issue:** Application startup slower after font system
- **Solution:** This is normal on first run; subsequent starts should be faster
### Debug Commands
```bash
# Check font system status
uv run python setup_fonts.py
# Test with specific log level
uv run ujm --log-level DEBUG
# Test matplotlib font configuration
uv run python -c "import matplotlib.pyplot as plt; print(plt.rcParams['font.sans-serif'])"
```
## Conclusion
This CJK font support implementation provides:
- **Robust fallback system** ensuring CJK characters display properly
- **Licensing compliance** by avoiding font redistribution
- **Cross-platform compatibility** with platform-specific font preferences
- **Clean architecture** with separation of font management concerns
- **User-friendly setup** with clear instructions and status reporting
The system gracefully handles missing fonts and provides clear guidance for optimal CJK character rendering while maintaining the existing application functionality.
+212 -80
View File
@@ -2,117 +2,239 @@
A custom mastering toolkit that provides metrics to evaluate audio masterings through visual analysis. A custom mastering toolkit that provides metrics to evaluate audio masterings through visual analysis.
## Current Implementation ## Current implementation
### Core Features ### Core features
- **Audio Analysis**: Uses librosa to analyze audio files (MP3/WAV support) - **Audio Analysis**: Uses librosa to analyze audio files (MP3/WAV/FLAC support) at native sample rate (no resampling)
- **Power Visualization**: Generates colorized power magnitude graphs over time - **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 - **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 - **Audio Processing**: librosa, numpy
- **Visualization**: matplotlib with custom colormaps - **Visualization**: pyqtgraph — persistent, interactive (mouse zoom/pan, lin/log
- **GUI Framework**: PyQt5 (drag-and-drop functionality) toggle, multi-dataset overlay). matplotlib remains only for its colormaps
- **Metadata**: mutagen for MP3 tag reading (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 ### 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
#### `main.py` #### `main.py`
- PyQt5 drag-and-drop interface - Complete GUI application with modular architecture
- Currently displays file paths but doesn't integrate with analysis functions - Drag-and-drop and file dialog support for audio files
- Placeholder for GUI integration - Integrated font control system
- Real-time analysis display and file management
#### `files.txt` #### `analysis_results_manager.py`
- Configuration file listing audio files to analyze - Background threading for audio analysis (`AudioAnalysisWorker` = load + first
- Supports comments (`;` and `#` prefixed lines) metric; `MetricComputeWorker` = one metric on an already-loaded file)
- Currently contains various music file paths - 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 #### `audio_visualization_widget.py`
- **RMS Power Analysis**: 10-second rolling window with 2-second hops - Persistent pyqtgraph plot — the PlotItem is reused across renders, never torn
- **Adaptive Color Mapping**: Automatically adjusts scale based on detected headroom 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 - High dynamic range: 0-0.6 scale for loud masters
- Conservative mastering: 0-0.3 scale for quiet masters - Conservative mastering: 0-0.3 scale for quiet masters
- **BPM Detection**: Automatic tempo analysis - **Loudness metrics**: LUFS (ungated short-term + gated integrated + LRA), PSR, Crest Factor
- **Metadata Display**: Artist and title from ID3 tags - **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 features
- GUI integration incomplete (drag-drop doesn't trigger analysis) - **File management**: Drag-and-drop and file dialog for audio selection
- MP3 tag reading temporarily disabled in some parts - **Compare/overlay**: each analysed file has a checkbox; the ticked set is
- No interactive features yet implemented 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 ### Short-term (urgent)
1. **Complete GUI Integration** 1. **Plot control widget cluster** *(metric selector + Refresh Plot done; still TODO)*
- Connect drag-drop functionality to analysis pipeline - Plot style controller (colormap, line vs bar, etc.)
- Real-time graph display in GUI window - Foundation for mastering comparison features
- File browser for batch processing
2. **Enhanced Metrics** ### Short-term (not urgent)
1. **Enhanced metrics** *(plug new ones into `metrics.METRICS`)*
- Dynamic range measurement (DR meter) - Dynamic range measurement (DR meter)
- Peak-to-average ratio analysis - Long-term average spectrum (LTAS) / tonal-balance curve
- Frequency spectrum analysis - Stereo metrics (correlation, mid/side) — needs `AudioFile` to retain stereo
- Loudness standards compliance (LUFS)
3. **Interactive Features** 2. **Interactive plot features** *(zoom/pan, axis-range select, lin/log done via
- Zoom/pan on power graphs pyqtgraph)*
- Playback controls with visual cursor - GUI-controllable plotting styles (colormap, visualization type)
- Export analysis results to CSV/JSON - Export analysis results to CSV/JSON
### Medium-term Goals 3. **Advanced GUI controls**
1. **Advanced Analysis Tools** - 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 - Spectral centroid and bandwidth analysis
- Stereo width measurements - Stereo width measurements
- Transient detection and analysis - Transient detection and analysis
- Harmonic distortion detection - Harmonic distortion detection
2. **Comparison Features** 2. **Professional 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**
- EBU R128 compliance checking - EBU R128 compliance checking
- Custom target curves - Custom target curves
- Professional reporting formats - Professional reporting formats
- Multi-format export capabilities - 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 ### Dependencies
- librosa: Audio analysis and feature extraction - librosa: Audio analysis and feature extraction
- numpy: Numerical computations - 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 - mutagen: Audio metadata extraction
- PyQt5: GUI framework - PyQt5: GUI framework
### Architecture Considerations ### Architecture considerations
- Current code mixes analysis and visualization - consider separation - 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 - File path handling needs improvement for cross-platform compatibility
- Error handling should be enhanced for production use - Error handling should be enhanced for production use
- Consider moving from PyQt5 to PyQt6 or PySide for better licensing - Consider moving from PyQt5 to PyQt6 or PySide for better licensing
### Testing Requirements ### Testing requirements
- Unit tests for audio analysis functions - Unit tests for audio analysis functions
- GUI component testing - GUI component testing
- File format compatibility testing - File format compatibility testing
@@ -120,13 +242,23 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
## Usage ## Usage
### Current Usage ### Running the app
1. Add audio file paths to `files.txt` ```bash
2. Run `python master_core.py` for batch analysis uv sync # one-time, after cloning
3. Run `python main.py` for GUI (incomplete) uv run ujm # launch the GUI
```
### Planned Usage Optional flags (handled by `logger_setup.parse_log_args`):
1. Drag and drop audio files into GUI ```bash
2. Real-time analysis with interactive graphs uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
3. Export reports and comparisons uv run ujm --log-file # also write audio_analysis.log
4. VST plugin for DAW integration ```
The only entry point is `ujm` (defined in `pyproject.toml` as
`ujm = "main:main"`). The previous `files.txt` batch mode and the
`python master_core.py` workflow have been removed.
### Planned usage enhancements
1. Interactive plot manipulation and style customization
2. Audio file comparison features (reference vs. comparee)
3. Self-contained executable releases
+73 -23
View File
@@ -1,29 +1,79 @@
# uj-mastering-master # uj-mastering-master
Utility providing metrics to evaluate masterings.
Now boosted by Claude Code.
## dependencies Custom mastering toolkit providing visual metrics for evaluating audio masterings.
librosa, numpy, matplotlib, mutagen Developed with Claude Code assistance.
## usage ## Features
### Command Line Analysis ### Current
1. Edit `files.txt` to include paths to your audio files (MP3/WAV supported) - **PyQt5 GUI**: drag-and-drop or file-dialog ingest of `.mp3`, `.wav`, `.flac`
- Use `;` or `#` to comment out files - **Switchable metrics** via a dropdown, all sharing one analysis cache:
- One file path per line - **RMS Power** — 10 s rolling window with adaptive colour scale
2. Run: `python master_core.py` - **Waveform** — min/max envelope, fixed ±1.1 scale
- Generates colorized power magnitude graphs for each file - **LUFS** — BS.1770 short-term (3 s) + integrated + loudness range (LRA)
- Displays BPM and song metadata - **Crest Factor** — peak-to-RMS spread over time
- Graphs show RMS power over time with adaptive scaling - **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) ### Roadmap
Run: `python main.py` See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
- Opens drag-and-drop interface dynamic range (DR meter), plot-style controls, interactive axis controls.
- Currently displays dropped file paths
- Analysis integration coming soon
### Output ## Quick start
- Interactive matplotlib graphs showing power levels over time
- Color-coded visualization (autumn colormap) This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
- Automatic headroom detection and scaling
- Console output with BPM and metadata information ```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.
+316 -102
View File
@@ -3,122 +3,336 @@ Analysis Results Manager - Bridge between audio processing and GUI.
Manages analysis queue and coordinates between components. Manages analysis queue and coordinates between components.
""" """
from PyQt5.QtCore import QObject, pyqtSignal from PyQt5.QtCore import QObject, pyqtSignal, QThread
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Optional from typing import Any, Optional
import os import os
import time
import logging
from master_core import AudioFile 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 @dataclass
class AnalysisResult: class AnalysisResult:
"""Container for audio analysis results.""" """Container for audio analysis results."""
file_path: str file_path: str
song_name: str audio_file: AudioFile
bpm: float song_name: str
max_amplitude: float max_amplitude: float
avg_amplitude: float avg_amplitude: float
times: list metric_data: dict[str, Any] = field(default_factory=dict)
rms_array: list analysis_successful: bool = True
analysis_successful: bool = True error_message: str = ""
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): 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. result = self.results_cache.get(file_path)
Threading-ready architecture for future background processing. 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.
""" """
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)
# Signals for GUI communication def display_label(self, file_path: str) -> str:
analysisStarted = pyqtSignal(str) # file_path """Short human label for a file (song name if known, else basename)."""
analysisCompleted = pyqtSignal(str, object) # file_path, AnalysisResult result = self.results_cache.get(file_path)
analysisError = pyqtSignal(str, str) # file_path, error_message if result is not None and result.song_name:
return result.song_name
return os.path.basename(file_path)
def __init__(self): def get_metadata_text(self, file_path: str) -> str:
super().__init__() result = self.results_cache.get(file_path)
self.results_cache = {} # Store analysis results if result is None:
self.plotting_engine = PlottingEngine() return "No analysis data available"
return result.metadata_text()
def analyze_file(self, file_path: str, window: int = 10, hop: int = 2): def clear_cache(self):
""" self.results_cache.clear()
Analyze an audio file and emit results.
Currently synchronous - ready for threading later.
Args: def is_file_analyzed(self, file_path: str) -> bool:
file_path: Path to audio file return file_path in self.results_cache
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 def shutdown(self):
self.analysisStarted.emit(file_path) """Stop all background threads cleanly (call on app close)."""
self._stop_prefetch()
try: if self.current_worker and self.current_worker.isRunning():
# Create AudioFile and perform analysis self.current_worker.quit()
audio_file = AudioFile(file_path) self.current_worker.wait()
for worker in list(self.metric_workers.values()):
# Get RMS analysis data if worker.isRunning():
audio_file.get_energy_levels_over_time(window=window, hop=hop) worker.wait()
self.metric_workers.clear()
# 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
+352 -97
View File
@@ -1,126 +1,381 @@
""" """
Audio visualization widget with embedded matplotlib canvas. Interactive visualization widget built on pyqtgraph.
Pure display responsibility - receives plotting data and shows graphs.
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 PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from PyQt5.QtCore import Qt, pyqtSignal
from matplotlib.figure import Figure
import matplotlib.pyplot as plt 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): class AudioVisualizationWidget(QWidget):
"""Widget for displaying audio analysis graphs with embedded matplotlib.""" """Persistent interactive plot. Call `show_specs` to (re)draw."""
def __init__(self, parent=None): # Emitted (with the line's index) when a reference line is dragged, so the
super().__init__(parent) # side-panel list can refresh its displayed value.
self.initUI() referenceLineMoved = pyqtSignal(int)
def initUI(self): def __init__(self, parent=None):
"""Initialize the UI components.""" super().__init__(parent)
layout = QVBoxLayout() layout = QVBoxLayout(self)
# Create matplotlib canvas self.glw = pg.GraphicsLayoutWidget()
self.figure = Figure(figsize=(10, 4), facecolor='white') self.plot = self.glw.addPlot(row=0, col=0, viewBox=_AxisZoomViewBox())
self.canvas = FigureCanvas(self.figure) 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)
# Add canvas to layout self.status_label = QLabel("Ready for audio analysis...")
layout.addWidget(self.canvas) layout.addWidget(self.status_label)
# Status label for feedback self._colorbar = None
self.status_label = QLabel("Ready for audio analysis...") # Reference lines are owned by the GUI controller (RefLineProps objects) and
layout.addWidget(self.status_label) # 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()
self.setLayout(layout) # ---- public API ---------------------------------------------------------
# Initialize with empty plot def show_specs(self, specs, view: ViewState = DEFAULT_VIEW):
self._create_empty_plot() """Render datasets onto the shared axes.
def _create_empty_plot(self): `specs` is a list of `(label, PlotSpec)` or `(label, PlotSpec, color)`. When
"""Creates an empty placeholder plot.""" no colour is given, the dataset's palette colour by position is used. All
self.figure.clear() specs are assumed to be the same metric (compare overlays one metric across
ax = self.figure.add_subplot(111) files), so axis labels/ranges come from the first spec.
ax.text(0.5, 0.5, 'Drop an audio file to see analysis', """
ha='center', va='center', transform=ax.transAxes, self._reset_plot()
fontsize=14, alpha=0.7) if not specs:
ax.set_xlim(0, 1) self._show_empty()
ax.set_ylim(0, 1) return
ax.set_xticks([])
ax.set_yticks([])
self.canvas.draw()
def display_analysis_figure(self, figure): specs = [self._normalise(s, i) for i, s in enumerate(specs)]
""" base_axes = specs[0][1].axes
Display a matplotlib figure in the widget.
Args: # Heatmaps do not overlay: render only the first dataset's heatmap.
figure: matplotlib.figure.Figure to display if specs[0][1].is_heatmap:
""" label, spec, _ = specs[0]
# Clear current figure self._render_heatmap(spec, view)
self.figure.clear() 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
# Copy the provided figure to our canvas single = len(specs) == 1
# Get the subplot from the provided figure for label, spec, color in specs:
source_ax = figure.get_axes()[0] prefix = "" if single else f"{label}: "
self._render_curves_and_bands(spec, color, prefix, single=single)
# Create new subplot in our figure # Reference lines from the first spec only (identical across same-metric specs).
ax = self.figure.add_subplot(111) for hl in specs[0][1].hlines:
self._render_hline(hl)
# Copy all the plot elements # Scalar readouts → legend-only proxy entries.
for child in source_ax.get_children(): for label, spec, _ in specs:
if hasattr(child, 'get_data'): prefix = "" if single else f"{label}: "
# Copy line plots for note in spec.annotations:
try: self._legend_note(prefix + note)
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) self._apply_axes(base_axes, single=single)
for collection in source_ax.collections: self._draw_ref_lines()
ax.add_collection(collection)
# Copy axis properties def set_reference_lines(self, props: list[RefLineProps]):
ax.set_xlim(source_ax.get_xlim()) """Set the reference-line set (RefLineProps owned by the GUI) and redraw them."""
ax.set_ylim(source_ax.get_ylim()) self._ref_props = props
ax.set_xlabel(source_ax.get_xlabel()) self._draw_ref_lines()
ax.set_ylabel(source_ax.get_ylabel())
ax.set_title(source_ax.get_title())
# Copy colorbar if it exists def current_view_center_value(self) -> float:
if hasattr(figure, '_colorbar') or len(figure.get_axes()) > 1: """Natural-unit value at the current y-view centre — default for a new line.
# Try to copy colorbar
try:
cbar = figure.colorbar(source_ax.collections[-1], ax=ax, label='RMS Power')
except:
pass
self.figure.tight_layout() Runs through `from_pos`, so on the spectrogram this returns a frequency, not
self.canvas.draw() a row index.
self.status_label.setText("Analysis complete - displaying power graph") """
(_, _), (y0, y1) = self.plot.viewRange()
return float(self._ref_from_pos((y0 + y1) / 2.0))
def display_figure_direct(self, figure): def set_status(self, message: str):
""" self.status_label.setText(message)
Display a figure by replacing our canvas figure entirely.
More reliable than copying elements.
Args: # ---- rendering helpers --------------------------------------------------
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 def _normalise(self, spec_tuple, index: int):
self.figure = figure """Coerce a spec tuple to (label, PlotSpec, color), filling colour by index."""
self.canvas = FigureCanvas(self.figure) if len(spec_tuple) == 3:
layout.insertWidget(0, self.canvas) # Insert at position 0 (before status label) return spec_tuple
label, spec = spec_tuple
return label, spec, dataset_color(index)
self.canvas.draw() def _render_curves_and_bands(self, spec: PlotSpec, color: str, prefix: str, single: bool):
self.status_label.setText("Analysis complete - displaying power graph") 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)
def set_status(self, message): for curve in spec.curves:
"""Update the status label.""" pen = pg.mkPen(curve.color or color, width=curve.width)
self.status_label.setText(message) 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
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
This directory is for custom CJK fonts to improve character rendering.
Supported formats: .ttf, .otf, .ttc
Recommended free fonts for CJK support:
- Noto Sans CJK (Google Fonts)
- Source Han Sans (Adobe)
The application will automatically detect and use fonts placed here.
For setup instructions, run: python setup_fonts.py
Note: Only add fonts you have proper licensing rights to distribute.
+95
View File
@@ -0,0 +1,95 @@
"""
Logging configuration for the Audio Mastering Toolkit.
Provides CLI configurable logging with different verbosity levels.
"""
import logging
import sys
from typing import Optional
def setup_logging(level: str = "INFO", log_to_file: bool = False) -> logging.Logger:
"""
Setup logging configuration for the application.
Args:
level: Logging level (ERROR, WARN, INFO, DEBUG, TRACE)
log_to_file: Whether to also log to file
Returns:
Configured logger instance
"""
# Convert level string to logging constant
level_map = {
'ERROR': logging.ERROR,
'WARN': logging.WARNING,
'WARNING': logging.WARNING,
'INFO': logging.INFO,
'DEBUG': logging.DEBUG,
'TRACE': 5 # Custom level below DEBUG
}
# Add custom TRACE level
logging.addLevelName(5, 'TRACE')
numeric_level = level_map.get(level.upper(), logging.INFO)
# Create formatter
formatter = logging.Formatter(
'%(asctime)s [%(levelname)s] %(name)s: %(message)s',
datefmt='%H:%M:%S'
)
# Setup console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
console_handler.setLevel(numeric_level)
# Setup root logger
root_logger = logging.getLogger()
root_logger.setLevel(numeric_level)
root_logger.handlers.clear() # Clear any existing handlers
root_logger.addHandler(console_handler)
# Optional file logging
if log_to_file:
file_handler = logging.FileHandler('audio_analysis.log')
file_handler.setFormatter(formatter)
file_handler.setLevel(numeric_level)
root_logger.addHandler(file_handler)
# Add trace method to all loggers
def trace(self, message, *args, **kwargs):
if self.isEnabledFor(5):
self._log(5, message, args, **kwargs)
logging.Logger.trace = trace
# Create main application logger
app_logger = logging.getLogger('audio_mastering')
app_logger.info(f"Logging initialized at {level.upper()} level")
return app_logger
def parse_log_args() -> tuple[str, bool]:
"""
Parse command line arguments for logging configuration.
Returns:
Tuple of (log_level, log_to_file)
"""
import argparse
parser = argparse.ArgumentParser(add_help=False) # Don't interfere with main arg parsing
parser.add_argument('--log-level', '-l',
choices=['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'],
default='INFO',
help='Set logging verbosity level')
parser.add_argument('--log-file', action='store_true',
help='Also log to audio_analysis.log file')
# Parse known args only (ignore others for main app)
args, _ = parser.parse_known_args()
return args.log_level, args.log_file
+281 -32
View File
@@ -1,12 +1,19 @@
import sys import sys
import os import os
import logging
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
QHBoxLayout, QSplitter, QLabel, QListWidget, QHBoxLayout, QSplitter, QLabel, QListWidget,
QTextEdit, QListWidgetItem) QTextEdit, QListWidgetItem, QPushButton, QFileDialog)
from PyQt5.QtCore import Qt 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 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): class MainWindow(QMainWindow):
@@ -14,9 +21,16 @@ class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.logger = logging.getLogger(__name__)
self.analysis_manager = AnalysisResultsManager() self.analysis_manager = AnalysisResultsManager()
# 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.initUI()
self.connect_signals() self.connect_signals()
self._activate_ref_lines()
def initUI(self): def initUI(self):
"""Initialize the user interface.""" """Initialize the user interface."""
@@ -49,12 +63,34 @@ class MainWindow(QMainWindow):
panel = QWidget() panel = QWidget()
layout = QVBoxLayout(panel) layout = QVBoxLayout(panel)
# File list # Open File button
self.file_list_label = QLabel("Analyzed Files:") 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) layout.addWidget(self.file_list_label)
self.file_list = QListWidget() self.file_list = QListWidget()
self.file_list.itemClicked.connect(self.on_file_selected) self.file_list.itemClicked.connect(self.on_file_selected)
self.file_list.itemChanged.connect(self.on_file_check_changed)
layout.addWidget(self.file_list) layout.addWidget(self.file_list)
# Metadata display # Metadata display
@@ -81,6 +117,17 @@ class MainWindow(QMainWindow):
self.analysis_manager.analysisStarted.connect(self.on_analysis_started) self.analysis_manager.analysisStarted.connect(self.on_analysis_started)
self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed) self.analysis_manager.analysisCompleted.connect(self.on_analysis_completed)
self.analysis_manager.analysisError.connect(self.on_analysis_error) self.analysis_manager.analysisError.connect(self.on_analysis_error)
self.analysis_manager.progressUpdate.connect(self.on_progress_update)
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): def dragEnterEvent(self, event):
"""Handle drag enter event for file drops.""" """Handle drag enter event for file drops."""
@@ -99,13 +146,30 @@ class MainWindow(QMainWindow):
files = [u.toLocalFile() for u in event.mimeData().urls()] files = [u.toLocalFile() for u in event.mimeData().urls()]
audio_files = [f for f in files if f.lower().endswith(('.mp3', '.wav', '.flac'))] audio_files = [f for f in files if f.lower().endswith(('.mp3', '.wav', '.flac'))]
self.logger.info(f"Files dropped: {len(files)} total, {len(audio_files)} audio files")
if audio_files: if audio_files:
# Analyze the first audio file # Analyze the first audio file
# TODO: Add support for multiple file queue # TODO: Add support for multiple file queue
file_path = audio_files[0] file_path = audio_files[0]
self.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: else:
self.visualization_widget.set_status("No audio files detected in drop") self.visualization_widget.set_status("No audio files detected in drop")
self.logger.warning("No supported audio files found in drop")
def 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): def on_analysis_started(self, file_path):
"""Called when analysis starts.""" """Called when analysis starts."""
@@ -116,56 +180,241 @@ class MainWindow(QMainWindow):
"""Called when analysis completes successfully.""" """Called when analysis completes successfully."""
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
# Add to file list if not already there # Add to file list (checked, so it joins the overlay set) if not present.
existing_items = [self.file_list.item(i).text() item = self._item_for_path(file_path)
for i in range(self.file_list.count())] if item is None:
if filename not in existing_items: self._suppress_list_signals = True
item = QListWidgetItem(filename) item = QListWidgetItem(filename)
item.setData(Qt.UserRole, file_path) # Store full path item.setData(Qt.UserRole, file_path) # Store full path
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
self.file_list.addItem(item) self.file_list.addItem(item)
self._suppress_list_signals = False
# Get and display the analysis figure # Update metadata display and highlight the analyzed file.
figure = self.analysis_manager.get_analysis_figure(file_path) self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
if figure: self.file_list.setCurrentItem(item)
self.visualization_widget.display_figure_direct(figure)
# Update metadata display # Redraw the overlay set for the current metric.
metadata_text = self.analysis_manager.get_metadata_text(file_path) self._refresh_view()
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
def on_analysis_error(self, file_path, error_message): def on_analysis_error(self, file_path, error_message):
"""Called when analysis fails.""" """Called when analysis fails."""
filename = os.path.basename(file_path) filename = os.path.basename(file_path)
self.logger.error(f"Analysis failed for {filename}: {error_message}")
self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}") self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}")
def on_progress_update(self, message, percentage):
"""Called when analysis progress updates.
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): 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) file_path = item.data(Qt.UserRole)
self.metadata_display.setText(self.analysis_manager.get_metadata_text(file_path))
# Display the analysis figure def on_file_check_changed(self, _item):
figure = self.analysis_manager.get_analysis_figure(file_path) """A checkbox toggled — the overlay set changed; redraw."""
if figure: if self._suppress_list_signals:
self.visualization_widget.display_figure_direct(figure) return
self._refresh_view()
# Update metadata display def on_metric_changed(self, metric_id: str):
metadata_text = self.analysis_manager.get_metadata_text(file_path) """Called when the metric selector changes."""
self.metadata_display.setText(metadata_text) 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)..."
)
if __name__ == '__main__': 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) 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 # Set application style
app.setStyle('Fusion') # Modern cross-platform style app.setStyle('Fusion') # Modern cross-platform style
logger.debug("Application style set to Fusion")
window = MainWindow() window = MainWindow()
window.show() window.show()
logger.info("GUI window displayed")
sys.exit(app.exec_()) sys.exit(app.exec_())
if __name__ == '__main__':
main()
+33 -210
View File
@@ -1,239 +1,62 @@
import librosa
import numpy as np
import os import os
import matplotlib.pyplot as plt import librosa
import matplotlib.colors as mcolors import numpy as np
import matplotlib.cm as cm
from mutagen.mp3 import MP3 from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
def try_mp3_tags(file_path): from font_manager import safe_title
def _try_mp3_tags(file_path):
try: try:
# if there is metadata return MP3(file_path, ID3=EasyID3)
audio = MP3(file_path, ID3=EasyID3) except Exception:
return audio
except Exception as e:
print(f"Error reading ID3 tags: {e}")
return None 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: class AudioFile:
def __init__(self, file_path): def __init__(self, file_path):
self.file_path = file_path self.file_path = file_path
# file name / song name audio = _try_mp3_tags(self.file_path)
if (audio := try_mp3_tags(self.file_path)) is not None: artist = audio.get('artist', [None])[0] if audio is not None else None
self.song_name = f"{audio['artist'][0]} - {audio['title'][0]}" 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: else:
self.song_name = os.path.basename(self.file_path) self.song_name = safe_title(os.path.basename(self.file_path))
self.y, self.sr = librosa.load(file_path) # librosa.load normalises to [-1.0, 1.0]. sr=None preserves the file's
# load automatically normalises everything to [-1.0, 1.0] # native sample rate; without it librosa resamples to 22050 Hz, which would
# and that's alright # 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.y_mono = librosa.to_mono(self.y)
self.max_amplitude = np.max(np.abs(self.y_mono)) self.max_amplitude = np.max(np.abs(self.y_mono))
self.avg_amplitude = np.mean(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) # 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 display_song_name(self): def get_energy_levels_over_time(self, window=10, hop=2):
print(self.song_name) """Compute rolling RMS power.
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: Args:
window (int, optional): Length of rolling RMS window in seconds. Defaults to 10. window: Rolling window length in seconds.
hop (int, optional): Length of window hop in seconds. Defaults to 2. hop: Hop length in seconds.
""" """
# check if the window and hop are the same as before if (not hasattr(self, 'window')) or (self.window != window) or (self.hop != hop):
if (not hasattr(self, 'window')) or ((self.window != window) or (self.hop != hop)):
self.window, self.hop = window, hop self.window, self.hop = window, hop
# only calculate if not already calculated
if not hasattr(self, 'rms_array'): if not hasattr(self, 'rms_array'):
# window and hop are in seconds
window_samples = window * self.sr window_samples = window * self.sr
hop_samples = hop * self.sr hop_samples = hop * self.sr
self.rms_array = librosa.feature.rms(
y=self.y, frame_length=window_samples, hop_length=hop_samples
)
# Calculate RMS over the rolling windows def get_times(self):
self.rms_array = librosa.feature.rms(y=self.y, frame_length=window_samples, hop_length=hop_samples) """Time-axis values matching the RMS frames."""
def _get_times(self):
"""Get time array for RMS data. Internal method for GUI integration."""
if not hasattr(self, 'rms_array'): if not hasattr(self, 'rms_array'):
self.get_energy_levels_over_time() 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) 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()
+556
View File
@@ -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"
+90
View File
@@ -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
View File
@@ -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()
-79
View File
@@ -1,79 +0,0 @@
"""
Audio visualization plotting engine.
Separates plotting logic from audio processing for clean GUI integration.
"""
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import matplotlib.cm as cm
from matplotlib.figure import Figure
import os
class PlottingEngine:
"""Handles all matplotlib visualization logic for audio analysis."""
@staticmethod
def create_power_analysis_figure(times, rms_array, file_path, figsize=(10, 4)):
"""
Creates a matplotlib Figure for power analysis visualization.
Args:
times: Array of time points
rms_array: RMS power values over time
file_path: Path to the audio file for title
figsize: Figure size tuple
Returns:
matplotlib.figure.Figure: Ready-to-embed figure
"""
# Determine color scale based on headroom detection
local_max_power = np.max(rms_array)
if local_max_power > 0.3:
norm = mcolors.Normalize(vmin=0, vmax=0.6)
maxpower = 0.6
else:
norm = mcolors.Normalize(vmin=0, vmax=0.3)
maxpower = 0.3
# Create figure and axis
fig = Figure(figsize=figsize, facecolor='white')
ax = fig.add_subplot(111)
# Color map
cmap = cm.autumn
# Plot power levels as colored bars
ax.set_ylim(0., maxpower)
for i in range(len(times)-1):
ax.fill_between(times[i:i+2], 0, rms_array[0][i],
color=cmap(norm(rms_array[0][i])), edgecolor='none')
# Add colorbar
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
cbar = fig.colorbar(sm, ax=ax, label='RMS Power')
# Labels and title
ax.set_ylabel('Power')
ax.set_xlabel('Time (seconds)')
ax.set_title(f'{os.path.basename(file_path)}')
# Tight layout for better appearance in GUI
fig.tight_layout()
return fig
@staticmethod
def create_metadata_display_text(song_name, bpm, max_amplitude, avg_amplitude):
"""
Creates formatted text for metadata display.
Returns:
str: Formatted metadata text
"""
return f"""Track: {song_name}
BPM: {bpm:.1f}
Max Amplitude: {max_amplitude:.3f}
Avg Amplitude: {avg_amplitude:.3f}"""
+38
View File
@@ -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",
]
+155
View File
@@ -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()
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Font setup utility for CJK character support.
Provides installation instructions and system font detection.
"""
import sys
import platform
from pathlib import Path
# Add current directory to path to import our modules
sys.path.insert(0, str(Path(__file__).parent))
from font_manager import get_font_manager
def main():
"""Main font setup utility."""
print("=== Audio Analysis Toolkit - CJK Font Setup ===\n")
# Get font manager and show current status
font_manager = get_font_manager()
print("Current System Information:")
print(f"Platform: {platform.system()} {platform.release()}")
print(f"Python: {platform.python_version()}\n")
# Try to initialize fonts
print("Initializing font system...")
success = font_manager.initialize()
# Show detailed status
status = font_manager.get_status_report()
print(f"Font system status: {'✓ OK' if success else '⚠ Issues detected'}")
print(f"Matplotlib configured: {'' if status['matplotlib_configured'] else ''}")
print(f"Qt configured: {'' if status['qt_configured'] else ''}")
print(f"Custom fonts loaded: {status['custom_fonts_loaded']}")
if status['custom_font_families']:
print(f"Custom font families: {', '.join(status['custom_font_families'])}")
print(f"Fonts directory: {status['fonts_directory_path']}")
print(f"Directory exists: {'' if status['fonts_directory_exists'] else ''}")
print()
# Show current matplotlib font configuration
print("Current matplotlib font stack:")
for i, font in enumerate(status['current_matplotlib_fonts'][:8], 1):
print(f" {i}. {font}")
print()
# Show installation instructions
print(font_manager.get_font_installation_instructions())
# Test CJK character handling
print("\n=== Testing CJK Character Handling ===")
test_strings = [
"English Title",
"日本語のタイトル", # Japanese
"中文标题", # Chinese
"한국어 제목", # Korean
"Test - テスト", # Mixed
]
print("Testing font-safe title conversion:")
for test_str in test_strings:
safe_str = font_manager.get_cjk_safe_title(test_str)
status_indicator = "" if test_str == safe_str else ""
print(f" {status_indicator} '{test_str}' -> '{safe_str}'")
print("\n=== Setup Complete ===")
if success:
print("Font system is ready for use!")
if status['custom_fonts_loaded'] == 0:
print("Consider adding CJK fonts to improve character display.")
else:
print("There were issues with font setup. Check the logs for details.")
print("The application will still work but CJK characters may not display correctly.")
return 0 if success else 1
if __name__ == '__main__':
sys.exit(main())
Generated
+1707
View File
File diff suppressed because it is too large Load Diff