cleanup
This commit is contained in:
+2
-3
@@ -1,5 +1,3 @@
|
|||||||
files.txt
|
|
||||||
|
|
||||||
# Font directory - avoid licensing issues by not committing font files
|
# Font directory - avoid licensing issues by not committing font files
|
||||||
# Keep the directory structure but ignore actual font files
|
# Keep the directory structure but ignore actual font files
|
||||||
fonts/*.ttf
|
fonts/*.ttf
|
||||||
@@ -8,10 +6,11 @@ fonts/*.ttc
|
|||||||
# But preserve the placeholder file
|
# But preserve the placeholder file
|
||||||
!fonts/PLACE_YOUR_FONT_FILES_HERE
|
!fonts/PLACE_YOUR_FONT_FILES_HERE
|
||||||
|
|
||||||
# Python cache
|
# Python cache and build artifacts
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
# IDE files
|
# IDE files
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
+6
-6
@@ -43,7 +43,7 @@ The font system is integrated at these key locations:
|
|||||||
font_success = initialize_fonts()
|
font_success = initialize_fonts()
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Plot Titles (`plotting_engine.py`, `master_core.py`)
|
#### Plot Titles (`plotting_engine.py`)
|
||||||
```python
|
```python
|
||||||
ax.set_title(safe_title(os.path.basename(file_path)))
|
ax.set_title(safe_title(os.path.basename(file_path)))
|
||||||
```
|
```
|
||||||
@@ -59,7 +59,7 @@ song_name = safe_title(f"{audio['artist'][0]} - {audio['title'][0]}")
|
|||||||
|
|
||||||
1. **Run the setup utility:**
|
1. **Run the setup utility:**
|
||||||
```bash
|
```bash
|
||||||
python setup_fonts.py
|
uv run python setup_fonts.py
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **For enhanced CJK support, add fonts to the `fonts/` directory:**
|
2. **For enhanced CJK support, add fonts to the `fonts/` directory:**
|
||||||
@@ -163,7 +163,7 @@ print(status)
|
|||||||
|
|
||||||
### Test CJK Characters
|
### Test CJK Characters
|
||||||
```bash
|
```bash
|
||||||
python setup_fonts.py
|
uv run python setup_fonts.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
@@ -202,13 +202,13 @@ Font system operations are logged at appropriate levels:
|
|||||||
### Debug Commands
|
### Debug Commands
|
||||||
```bash
|
```bash
|
||||||
# Check font system status
|
# Check font system status
|
||||||
python setup_fonts.py
|
uv run python setup_fonts.py
|
||||||
|
|
||||||
# Test with specific log level
|
# Test with specific log level
|
||||||
python main.py --log-level DEBUG
|
uv run ujm --log-level DEBUG
|
||||||
|
|
||||||
# Test matplotlib font configuration
|
# Test matplotlib font configuration
|
||||||
python -c "import matplotlib.pyplot as plt; print(plt.rcParams['font.sans-serif'])"
|
uv run python -c "import matplotlib.pyplot as plt; print(plt.rcParams['font.sans-serif'])"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conclusion
|
## Conclusion
|
||||||
|
|||||||
@@ -43,9 +43,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
- Auto-regeneration of plots when fonts change
|
- Auto-regeneration of plots when fonts change
|
||||||
|
|
||||||
#### `master_core.py`
|
#### `master_core.py`
|
||||||
- Core audio analysis functionality
|
- Defines the `AudioFile` class: librosa loading, rolling RMS power, BPM detection
|
||||||
- `AudioFile` class with comprehensive metrics extraction
|
- No batch / CLI mode — all analysis is driven from `main.py` via `AnalysisResultsManager`
|
||||||
- RMS power analysis and BPM detection
|
|
||||||
|
|
||||||
### Current analysis features
|
### Current analysis features
|
||||||
- **RMS power analysis**: 10-second rolling window with 2-second hops
|
- **RMS power analysis**: 10-second rolling window with 2-second hops
|
||||||
@@ -146,16 +145,21 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Current usage
|
### Running the app
|
||||||
1. Run `python main.py` to launch the GUI application
|
```bash
|
||||||
2. Use "Open Audio File..." button or drag-and-drop audio files for analysis
|
uv sync # one-time, after cloning
|
||||||
3. Adjust font settings using the Font Settings panel
|
uv run ujm # launch the GUI
|
||||||
4. View real-time analysis results with embedded matplotlib plots
|
```
|
||||||
5. Select different analyzed files from the file list to compare results
|
|
||||||
|
|
||||||
### Legacy usage (batch Mode)
|
Optional flags (handled by `logger_setup.parse_log_args`):
|
||||||
1. Add audio file paths to `files.txt`
|
```bash
|
||||||
2. Run `python master_core.py` for batch analysis
|
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
||||||
|
uv run ujm --log-file # also write audio_analysis.log
|
||||||
|
```
|
||||||
|
|
||||||
|
The only entry point is `ujm` (defined in `pyproject.toml` as
|
||||||
|
`ujm = "main:main"`). The previous `files.txt` batch mode and the
|
||||||
|
`python master_core.py` workflow have been removed.
|
||||||
|
|
||||||
### Planned usage enhancements
|
### Planned usage enhancements
|
||||||
1. Interactive plot manipulation and style customization
|
1. Interactive plot manipulation and style customization
|
||||||
|
|||||||
@@ -1,78 +1,58 @@
|
|||||||
# uj-mastering-master
|
# uj-mastering-master
|
||||||
Custom mastering toolkit providing comprehensive metrics to evaluate audio masterings through visual analysis.
|
|
||||||
|
Custom mastering toolkit providing visual metrics for evaluating audio masterings.
|
||||||
Developed with Claude Code assistance.
|
Developed with Claude Code assistance.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### ✅ Current Implementation
|
### Current
|
||||||
- **Complete GUI Application**: Modular PyQt5 interface with drag-and-drop and file dialog support
|
- **PyQt5 GUI**: drag-and-drop or file-dialog ingest of `.mp3`, `.wav`, `.flac`
|
||||||
- **Real-time Analysis**: Background threading with embedded matplotlib visualization
|
- **RMS power analysis** on a 10 s rolling window with adaptive colour scale
|
||||||
- **Font Management**: CJK-compatible font system with custom font support from `fonts/` directory
|
- **BPM detection** via librosa
|
||||||
- **Audio Support**: MP3, WAV, and FLAC file analysis
|
- **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored), system fallbacks, and a live font selector
|
||||||
- **RMS Power Analysis**: Rolling window analysis with adaptive color mapping
|
- **Background analysis thread** so the UI stays responsive
|
||||||
- **Metadata Display**: Automatic extraction and display of audio tags and BPM
|
- **Embedded matplotlib canvas** with auto-regenerated plots on font change
|
||||||
|
|
||||||
### 🚧 In Development
|
### Roadmap
|
||||||
- **Plot Control Widgets**: Dedicated cluster for plot manipulation and style controls
|
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
|
||||||
- **LUFS Metrics**: Professional loudness measurement implementation
|
plot-control widget cluster, LUFS, dynamic range, interactive axis controls.
|
||||||
- **Interactive Plotting**: Real-time axis control and style customization
|
|
||||||
|
|
||||||
### 🔮 Planned Features
|
## Quick start
|
||||||
- **Audio Comparison**: Reference vs. comparee analysis for mastering evaluation
|
|
||||||
- **Advanced Metrics**: Dynamic range, spectral analysis, and professional standards compliance
|
This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
|
||||||
- **Standalone Releases**: Self-contained executable distribution
|
|
||||||
|
```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
|
## Dependencies
|
||||||
- **Core**: `librosa`, `numpy`, `matplotlib`, `mutagen`
|
`librosa`, `numpy`, `matplotlib`, `mutagen`, `PyQt5` — all pinned through
|
||||||
- **GUI**: `PyQt5`
|
`uv.lock`. Python 3.10+.
|
||||||
- **Audio Processing**: Advanced librosa-based analysis pipeline
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### GUI Application (Recommended)
|
|
||||||
```bash
|
|
||||||
python main.py
|
|
||||||
```
|
|
||||||
- **Load Files**: Use "Open Audio File..." button or drag-and-drop
|
|
||||||
- **Font Control**: Adjust interface fonts and regenerate plots automatically
|
|
||||||
- **Analysis Display**: View real-time RMS power analysis with metadata
|
|
||||||
- **File Management**: Switch between analyzed files using the file list
|
|
||||||
|
|
||||||
### Command Line Analysis (Legacy)
|
|
||||||
```bash
|
|
||||||
# 1. Edit files.txt with your audio file paths
|
|
||||||
# 2. Run batch analysis
|
|
||||||
python master_core.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Modular Design
|
| Module | Responsibility |
|
||||||
- **Self-contained Widgets**: Easy layout management and customization
|
| --- | --- |
|
||||||
- **Background Processing**: Non-blocking analysis with progress feedback
|
| `main.py` | `MainWindow` + the `ujm` entry point |
|
||||||
- **Signal-based Communication**: Clean separation between GUI and analysis logic
|
| `analysis_results_manager.py` | Background `QThread` worker, result cache |
|
||||||
|
| `master_core.py` | `AudioFile`: librosa loading, RMS rolling window, BPM |
|
||||||
### Key Components
|
| `plotting_engine.py` | Matplotlib `Figure` builder for the power graph |
|
||||||
- `main.py`: Complete GUI application with modular architecture
|
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host |
|
||||||
- `font_control_widget.py`: Unified font management with plot regeneration
|
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config |
|
||||||
- `analysis_results_manager.py`: Threaded analysis with caching
|
| `font_control_widget.py` | Font picker, size slider, refresh-plot button |
|
||||||
- `audio_visualization_widget.py`: Embedded matplotlib with Qt integration
|
| `logger_setup.py` | CLI log-level parsing + custom TRACE level |
|
||||||
|
| `setup_fonts.py` | Diagnostic utility (run standalone) |
|
||||||
## Development Roadmap
|
|
||||||
|
|
||||||
### 🎯 Next Priority: Plot Control System
|
|
||||||
Moving from font-focused interface to comprehensive plot manipulation:
|
|
||||||
- Cluster plot controls (refresh, style, metric selection)
|
|
||||||
- Interactive axis range selection
|
|
||||||
- Real-time plot style customization
|
|
||||||
- Foundation for comparison features
|
|
||||||
|
|
||||||
### 🎵 Short-term Goals
|
|
||||||
- **LUFS Implementation**: Professional loudness standards
|
|
||||||
- **Plot Interactivity**: GUI-controlled visualization styles
|
|
||||||
- **Metric Selection**: Choose which analysis to display
|
|
||||||
|
|
||||||
### 🎼 Long-term Vision
|
|
||||||
- **Mastering Comparison**: Side-by-side analysis tools
|
|
||||||
- **Professional Standards**: EBU R128 compliance checking
|
|
||||||
- **Standalone Distribution**: Self-contained executable releases
|
|
||||||
|
|||||||
Binary file not shown.
@@ -68,7 +68,7 @@ class AudioAnalysisWorker(QThread):
|
|||||||
bpm=audio_file.get_bpm(),
|
bpm=audio_file.get_bpm(),
|
||||||
max_amplitude=audio_file.max_amplitude,
|
max_amplitude=audio_file.max_amplitude,
|
||||||
avg_amplitude=audio_file.avg_amplitude,
|
avg_amplitude=audio_file.avg_amplitude,
|
||||||
times=audio_file._get_times(),
|
times=audio_file.get_times(),
|
||||||
rms_array=audio_file.rms_array,
|
rms_array=audio_file.rms_array,
|
||||||
analysis_successful=True
|
analysis_successful=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Pure display responsibility - receives plotting data and shows graphs.
|
|||||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||||
from matplotlib.figure import Figure
|
from matplotlib.figure import Figure
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
|
|
||||||
|
|
||||||
class AudioVisualizationWidget(QWidget):
|
class AudioVisualizationWidget(QWidget):
|
||||||
@@ -49,57 +48,6 @@ class AudioVisualizationWidget(QWidget):
|
|||||||
ax.set_yticks([])
|
ax.set_yticks([])
|
||||||
self.canvas.draw()
|
self.canvas.draw()
|
||||||
|
|
||||||
def display_analysis_figure(self, figure):
|
|
||||||
"""
|
|
||||||
Display a matplotlib figure in the widget.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
figure: matplotlib.figure.Figure to display
|
|
||||||
"""
|
|
||||||
# Clear current figure
|
|
||||||
self.figure.clear()
|
|
||||||
|
|
||||||
# Copy the provided figure to our canvas
|
|
||||||
# Get the subplot from the provided figure
|
|
||||||
source_ax = figure.get_axes()[0]
|
|
||||||
|
|
||||||
# Create new subplot in our figure
|
|
||||||
ax = self.figure.add_subplot(111)
|
|
||||||
|
|
||||||
# Copy all the plot elements
|
|
||||||
for child in source_ax.get_children():
|
|
||||||
if hasattr(child, 'get_data'):
|
|
||||||
# Copy line plots
|
|
||||||
try:
|
|
||||||
x_data, y_data = child.get_data()
|
|
||||||
ax.plot(x_data, y_data, color=child.get_color(),
|
|
||||||
linewidth=child.get_linewidth())
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Copy collections (fill_between creates PolyCollection)
|
|
||||||
for collection in source_ax.collections:
|
|
||||||
ax.add_collection(collection)
|
|
||||||
|
|
||||||
# Copy axis properties
|
|
||||||
ax.set_xlim(source_ax.get_xlim())
|
|
||||||
ax.set_ylim(source_ax.get_ylim())
|
|
||||||
ax.set_xlabel(source_ax.get_xlabel())
|
|
||||||
ax.set_ylabel(source_ax.get_ylabel())
|
|
||||||
ax.set_title(source_ax.get_title())
|
|
||||||
|
|
||||||
# Copy colorbar if it exists
|
|
||||||
if hasattr(figure, '_colorbar') or len(figure.get_axes()) > 1:
|
|
||||||
# Try to copy colorbar
|
|
||||||
try:
|
|
||||||
cbar = figure.colorbar(source_ax.collections[-1], ax=ax, label='RMS Power')
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.figure.tight_layout()
|
|
||||||
self.canvas.draw()
|
|
||||||
self.status_label.setText("Analysis complete - displaying power graph")
|
|
||||||
|
|
||||||
def display_figure_direct(self, figure):
|
def display_figure_direct(self, figure):
|
||||||
"""
|
"""
|
||||||
Display a figure by replacing our canvas figure entirely.
|
Display a figure by replacing our canvas figure entirely.
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Demo script showing the different logging levels available.
|
|
||||||
Usage examples:
|
|
||||||
|
|
||||||
python demo_logging.py --log-level=ERROR
|
|
||||||
python demo_logging.py --log-level=INFO
|
|
||||||
python demo_logging.py --log-level=DEBUG
|
|
||||||
python demo_logging.py --log-level=TRACE --log-file
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from logger_setup import setup_logging, parse_log_args
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# Parse logging configuration
|
|
||||||
log_level, log_to_file = parse_log_args()
|
|
||||||
|
|
||||||
# Initialize logging
|
|
||||||
logger = setup_logging(log_level, log_to_file)
|
|
||||||
|
|
||||||
# Demo different log levels
|
|
||||||
print(f"\n=== Audio Mastering Toolkit Logging Demo ===")
|
|
||||||
print(f"Log Level: {log_level}")
|
|
||||||
print(f"Log to File: {log_to_file}")
|
|
||||||
print(f"============================================\n")
|
|
||||||
|
|
||||||
# Test all logging levels
|
|
||||||
logger.error("This is an ERROR message - critical failures only")
|
|
||||||
logger.warning("This is a WARNING message - non-fatal issues")
|
|
||||||
logger.info("This is an INFO message - key operations")
|
|
||||||
logger.debug("This is a DEBUG message - detailed processing steps")
|
|
||||||
logger.trace("This is a TRACE message - granular details")
|
|
||||||
|
|
||||||
print(f"\nDemo complete! Messages above {log_level} level are visible.")
|
|
||||||
if log_to_file:
|
|
||||||
print("Check 'audio_analysis.log' for file output.")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
"""
|
|
||||||
Font selector widget providing GUI interface to font management system.
|
|
||||||
Self-contained widget that can be placed anywhere in the layout.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import List, Dict, Optional
|
|
||||||
from PyQt5.QtWidgets import QWidget, QComboBox, QVBoxLayout, QLabel
|
|
||||||
from PyQt5.QtCore import pyqtSignal
|
|
||||||
|
|
||||||
from font_manager import get_font_manager
|
|
||||||
|
|
||||||
|
|
||||||
class FontSelectorWidget(QWidget):
|
|
||||||
"""
|
|
||||||
Self-contained font selector widget.
|
|
||||||
|
|
||||||
Provides a dropdown interface to select fonts from available
|
|
||||||
custom fonts and system fonts. Integrates with the FontManager
|
|
||||||
backend for font discovery and application.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Signal emitted when font selection changes
|
|
||||||
fontChanged = pyqtSignal(str, str) # (font_name, font_type)
|
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
|
||||||
"""Initialize the font selector widget."""
|
|
||||||
super().__init__(parent)
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
self.font_manager = get_font_manager()
|
|
||||||
self.available_fonts: Dict[str, str] = {} # display_name -> actual_font_name
|
|
||||||
|
|
||||||
self.initUI()
|
|
||||||
self.refresh_font_list()
|
|
||||||
|
|
||||||
def initUI(self):
|
|
||||||
"""Initialize the user interface."""
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
layout.setContentsMargins(0, 0, 0, 0) # Minimal margins for embedding
|
|
||||||
|
|
||||||
# Label
|
|
||||||
self.label = QLabel("Font:")
|
|
||||||
layout.addWidget(self.label)
|
|
||||||
|
|
||||||
# Font selector dropdown
|
|
||||||
self.font_combo = QComboBox()
|
|
||||||
self.font_combo.currentTextChanged.connect(self.on_font_changed)
|
|
||||||
layout.addWidget(self.font_combo)
|
|
||||||
|
|
||||||
def refresh_font_list(self):
|
|
||||||
"""Refresh the list of available fonts."""
|
|
||||||
self.logger.debug("Refreshing font list...")
|
|
||||||
self.available_fonts.clear()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get custom fonts from font manager
|
|
||||||
custom_fonts = self.font_manager.loaded_fonts
|
|
||||||
|
|
||||||
# Get system font candidates
|
|
||||||
system_fonts = self._get_system_font_candidates()
|
|
||||||
|
|
||||||
# Clear combo box
|
|
||||||
self.font_combo.clear()
|
|
||||||
|
|
||||||
# Add custom fonts first (highest priority)
|
|
||||||
if custom_fonts:
|
|
||||||
for font_family, font_path in custom_fonts.items():
|
|
||||||
display_name = f"{font_family} (Custom)"
|
|
||||||
self.available_fonts[display_name] = font_family
|
|
||||||
self.font_combo.addItem(display_name)
|
|
||||||
self.logger.debug(f"Added custom font: {display_name}")
|
|
||||||
|
|
||||||
# Add system fonts
|
|
||||||
for font_name in system_fonts:
|
|
||||||
display_name = f"{font_name} (System)"
|
|
||||||
self.available_fonts[display_name] = font_name
|
|
||||||
self.font_combo.addItem(display_name)
|
|
||||||
self.logger.debug(f"Added system font: {display_name}")
|
|
||||||
|
|
||||||
# Add default option with actual system font name
|
|
||||||
system_font_name = self.font_manager.get_default_system_font_name()
|
|
||||||
default_name = f"Default ({system_font_name})"
|
|
||||||
self.available_fonts[default_name] = "default"
|
|
||||||
self.font_combo.addItem(default_name)
|
|
||||||
|
|
||||||
# Select startup font
|
|
||||||
self._select_startup_font()
|
|
||||||
|
|
||||||
self.logger.info(f"Font list refreshed: {len(self.available_fonts)} fonts available")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error refreshing font list: {e}")
|
|
||||||
# Fallback: add default option only
|
|
||||||
self.font_combo.clear()
|
|
||||||
self.font_combo.addItem("Default (System)")
|
|
||||||
self.available_fonts = {"Default (System)": "default"}
|
|
||||||
|
|
||||||
def _get_system_font_candidates(self) -> List[str]:
|
|
||||||
"""
|
|
||||||
Get list of available system fonts that are good candidates.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List[str]: List of available system font names
|
|
||||||
"""
|
|
||||||
# Use font manager's enhanced system font discovery
|
|
||||||
return self.font_manager.get_available_system_fonts()[:10] # Limit to reasonable number
|
|
||||||
|
|
||||||
def _select_startup_font(self):
|
|
||||||
"""Select the appropriate font on startup."""
|
|
||||||
# Use font manager's startup font selection logic
|
|
||||||
startup_font_name, startup_font_type = self.font_manager.select_startup_font()
|
|
||||||
|
|
||||||
# Find the corresponding display name in our combo box
|
|
||||||
target_display_name = None
|
|
||||||
for display_name, actual_name in self.available_fonts.items():
|
|
||||||
if actual_name == startup_font_name:
|
|
||||||
target_display_name = display_name
|
|
||||||
break
|
|
||||||
|
|
||||||
# If we found the font, select it
|
|
||||||
if target_display_name:
|
|
||||||
index = self.font_combo.findText(target_display_name)
|
|
||||||
if index >= 0:
|
|
||||||
self.font_combo.setCurrentIndex(index)
|
|
||||||
self.logger.info(f"Startup font selected: {target_display_name}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Fallback to default if we couldn't find the startup font
|
|
||||||
# Find any item that starts with "Default ("
|
|
||||||
for i in range(self.font_combo.count()):
|
|
||||||
item_text = self.font_combo.itemText(i)
|
|
||||||
if item_text.startswith("Default ("):
|
|
||||||
self.font_combo.setCurrentIndex(i)
|
|
||||||
self.logger.info(f"Startup font fallback: {item_text}")
|
|
||||||
return
|
|
||||||
|
|
||||||
def on_font_changed(self, display_name: str):
|
|
||||||
"""Handle font selection change."""
|
|
||||||
if not display_name or display_name not in self.available_fonts:
|
|
||||||
return
|
|
||||||
|
|
||||||
actual_font_name = self.available_fonts[display_name]
|
|
||||||
|
|
||||||
# Determine font type
|
|
||||||
if "(Custom)" in display_name:
|
|
||||||
font_type = "custom"
|
|
||||||
elif "(System)" in display_name:
|
|
||||||
font_type = "system"
|
|
||||||
else:
|
|
||||||
font_type = "default"
|
|
||||||
|
|
||||||
self.logger.info(f"Font changed: {display_name} -> {actual_font_name} ({font_type})")
|
|
||||||
|
|
||||||
# Apply the font change
|
|
||||||
self._apply_font_change(actual_font_name, font_type)
|
|
||||||
|
|
||||||
# Emit signal for any external listeners
|
|
||||||
self.fontChanged.emit(actual_font_name, font_type)
|
|
||||||
|
|
||||||
def _apply_font_change(self, font_name: str, font_type: str):
|
|
||||||
"""Apply the font change to the application."""
|
|
||||||
try:
|
|
||||||
# Use font manager's centralized font application
|
|
||||||
success = self.font_manager.apply_font_selection(font_name, font_type)
|
|
||||||
if not success:
|
|
||||||
self.logger.warning(f"Font application may have failed: {font_name}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error applying font change: {e}")
|
|
||||||
|
|
||||||
def get_current_font(self) -> tuple[str, str]:
|
|
||||||
"""
|
|
||||||
Get currently selected font.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple: (font_name, font_type)
|
|
||||||
"""
|
|
||||||
display_name = self.font_combo.currentText()
|
|
||||||
if display_name in self.available_fonts:
|
|
||||||
actual_font_name = self.available_fonts[display_name]
|
|
||||||
|
|
||||||
if "(Custom)" in display_name:
|
|
||||||
font_type = "custom"
|
|
||||||
elif "(System)" in display_name:
|
|
||||||
font_type = "system"
|
|
||||||
else:
|
|
||||||
font_type = "default"
|
|
||||||
|
|
||||||
return actual_font_name, font_type
|
|
||||||
|
|
||||||
return "default", "default"
|
|
||||||
|
|
||||||
def set_font(self, font_name: str):
|
|
||||||
"""
|
|
||||||
Programmatically set the font selection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
font_name: Name of font to select
|
|
||||||
"""
|
|
||||||
# Find matching display name
|
|
||||||
for display_name, actual_name in self.available_fonts.items():
|
|
||||||
if actual_name == font_name:
|
|
||||||
index = self.font_combo.findText(display_name)
|
|
||||||
if index >= 0:
|
|
||||||
self.font_combo.setCurrentIndex(index)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.logger.warning(f"Font not found in selector: {font_name}")
|
|
||||||
@@ -239,17 +239,17 @@ class MainWindow(QMainWindow):
|
|||||||
self.visualization_widget.set_status(f"Error regenerating plot: {e}")
|
self.visualization_widget.set_status(f"Error regenerating plot: {e}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
def main():
|
||||||
# Parse logging arguments before creating QApplication
|
# Parse logging arguments before creating QApplication
|
||||||
log_level, log_to_file = parse_log_args()
|
log_level, log_to_file = parse_log_args()
|
||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
logger = setup_logging(log_level, log_to_file)
|
logger = setup_logging(log_level, log_to_file)
|
||||||
logger.info("Starting Audio Mastering Analysis Toolkit")
|
logger.info("Starting Audio Mastering Analysis Toolkit")
|
||||||
logger.info(f"Command line args: log-level={log_level}, log-file={log_to_file}")
|
logger.info(f"Command line args: log-level={log_level}, log-file={log_to_file}")
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
|
|
||||||
# Initialize font system before creating any widgets
|
# Initialize font system before creating any widgets
|
||||||
font_success = initialize_fonts()
|
font_success = initialize_fonts()
|
||||||
if font_success:
|
if font_success:
|
||||||
@@ -261,13 +261,17 @@ if __name__ == '__main__':
|
|||||||
f"custom_fonts={font_status['custom_fonts_loaded']}")
|
f"custom_fonts={font_status['custom_fonts_loaded']}")
|
||||||
else:
|
else:
|
||||||
logger.warning("Font system initialization failed - CJK characters may not display properly")
|
logger.warning("Font system initialization failed - CJK characters may not display properly")
|
||||||
|
|
||||||
# Set application style
|
# Set application style
|
||||||
app.setStyle('Fusion') # Modern cross-platform style
|
app.setStyle('Fusion') # Modern cross-platform style
|
||||||
logger.debug("Application style set to Fusion")
|
logger.debug("Application style set to Fusion")
|
||||||
|
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.show()
|
window.show()
|
||||||
logger.info("GUI window displayed")
|
logger.info("GUI window displayed")
|
||||||
|
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+65
-246
@@ -1,246 +1,65 @@
|
|||||||
import librosa
|
import os
|
||||||
import numpy as np
|
|
||||||
import os
|
import librosa
|
||||||
|
import numpy as np
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import matplotlib.colors as mcolors
|
from mutagen.mp3 import MP3
|
||||||
import matplotlib.cm as cm
|
from mutagen.easyid3 import EasyID3
|
||||||
|
|
||||||
from mutagen.mp3 import MP3
|
from font_manager import safe_title
|
||||||
from mutagen.easyid3 import EasyID3
|
|
||||||
from font_manager import safe_title, initialize_fonts
|
|
||||||
|
def _try_mp3_tags(file_path):
|
||||||
def try_mp3_tags(file_path):
|
try:
|
||||||
try:
|
return MP3(file_path, ID3=EasyID3)
|
||||||
# if there is metadata
|
except Exception:
|
||||||
audio = MP3(file_path, ID3=EasyID3)
|
return None
|
||||||
return audio
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error reading ID3 tags: {e}")
|
class AudioFile:
|
||||||
return None
|
def __init__(self, file_path):
|
||||||
|
self.file_path = file_path
|
||||||
def read_mp3_tags(file_path):
|
audio = _try_mp3_tags(self.file_path)
|
||||||
if (audio := try_mp3_tags(file_path)) is not None:
|
artist = audio.get('artist', [None])[0] if audio is not None else None
|
||||||
print(f"File name: {safe_title(os.path.basename(file_path))}")
|
title = audio.get('title', [None])[0] if audio is not None else None
|
||||||
print(f"{safe_title(audio['artist'][0])} - {safe_title(audio['title'][0])}")
|
if artist and title:
|
||||||
else:
|
self.song_name = safe_title(f"{artist} - {title}")
|
||||||
print(f"File name: {safe_title(os.path.basename(file_path))}")
|
else:
|
||||||
|
self.song_name = safe_title(os.path.basename(self.file_path))
|
||||||
class AudioFile:
|
|
||||||
def __init__(self, file_path):
|
# librosa.load normalises to [-1.0, 1.0]
|
||||||
self.file_path = file_path
|
self.y, self.sr = librosa.load(file_path)
|
||||||
# file name / song name
|
self.y_mono = librosa.to_mono(self.y)
|
||||||
if (audio := try_mp3_tags(self.file_path)) is not None:
|
self.max_amplitude = np.max(np.abs(self.y_mono))
|
||||||
self.song_name = safe_title(f"{audio['artist'][0]} - {audio['title'][0]}")
|
self.avg_amplitude = np.mean(np.abs(self.y_mono))
|
||||||
else:
|
self.bpm, _ = librosa.beat.beat_track(y=self.y_mono, sr=self.sr)
|
||||||
self.song_name = safe_title(os.path.basename(self.file_path))
|
|
||||||
|
def get_bpm(self):
|
||||||
self.y, self.sr = librosa.load(file_path)
|
# librosa.beat.beat_track returns numpy array - extract scalar value
|
||||||
# load automatically normalises everything to [-1.0, 1.0]
|
if isinstance(self.bpm, np.ndarray):
|
||||||
# and that's alright
|
return float(self.bpm[0]) if len(self.bpm) > 0 else 0.0
|
||||||
self.y_mono = librosa.to_mono(self.y)
|
return float(self.bpm)
|
||||||
self.max_amplitude = np.max(np.abs(self.y_mono))
|
|
||||||
self.avg_amplitude = np.mean(np.abs(self.y_mono))
|
def get_energy_levels_over_time(self, window=10, hop=2):
|
||||||
self.bpm, _ = librosa.beat.beat_track(y=self.y_mono, sr=self.sr)
|
"""Compute rolling RMS power.
|
||||||
|
|
||||||
def display_song_name(self):
|
Args:
|
||||||
print(self.song_name)
|
window: Rolling window length in seconds.
|
||||||
|
hop: Hop length in seconds.
|
||||||
def get_amplitudes(self):
|
"""
|
||||||
return self.max_amplitude, self.avg_amplitude
|
if (not hasattr(self, 'window')) or (self.window != window) or (self.hop != hop):
|
||||||
|
self.window, self.hop = window, hop
|
||||||
def get_bpm(self):
|
if not hasattr(self, 'rms_array'):
|
||||||
# librosa.beat.beat_track returns numpy array - extract scalar value
|
window_samples = window * self.sr
|
||||||
if isinstance(self.bpm, np.ndarray):
|
hop_samples = hop * self.sr
|
||||||
return float(self.bpm[0]) if len(self.bpm) > 0 else 0.0
|
self.rms_array = librosa.feature.rms(
|
||||||
return float(self.bpm)
|
y=self.y, frame_length=window_samples, hop_length=hop_samples
|
||||||
|
)
|
||||||
def get_energy_levels_over_time(self, window = 10, hop = 2):
|
|
||||||
"""_summary_
|
def get_times(self):
|
||||||
|
"""Time-axis values matching the RMS frames."""
|
||||||
Args:
|
if not hasattr(self, 'rms_array'):
|
||||||
window (int, optional): Length of rolling RMS window in seconds. Defaults to 10.
|
self.get_energy_levels_over_time()
|
||||||
hop (int, optional): Length of window hop in seconds. Defaults to 2.
|
return librosa.frames_to_time(
|
||||||
"""
|
np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop * self.sr
|
||||||
# check if the window and hop are the same as before
|
)
|
||||||
if (not hasattr(self, 'window')) or ((self.window != window) or (self.hop != hop)):
|
|
||||||
self.window, self.hop = window, hop
|
|
||||||
# only calculate if not already calculated
|
|
||||||
if not hasattr(self, 'rms_array'):
|
|
||||||
# window and hop are in seconds
|
|
||||||
window_samples = window * self.sr
|
|
||||||
hop_samples = hop * self.sr
|
|
||||||
|
|
||||||
# Calculate RMS over the rolling windows
|
|
||||||
self.rms_array = librosa.feature.rms(y=self.y, frame_length=window_samples, hop_length=hop_samples)
|
|
||||||
|
|
||||||
def _get_times(self):
|
|
||||||
"""Get time array for RMS data. Internal method for GUI integration."""
|
|
||||||
if not hasattr(self, 'rms_array'):
|
|
||||||
self.get_energy_levels_over_time()
|
|
||||||
return librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
|
|
||||||
|
|
||||||
def plot_energy_levels_over_time(self, display='window'):
|
|
||||||
"""_summary_
|
|
||||||
|
|
||||||
Args:
|
|
||||||
display (str, optional): Option for where to display the plot. Defaults to 'window'.
|
|
||||||
'window' - display in a pyplot window
|
|
||||||
'gui' - for directing to the GUI (TBD)
|
|
||||||
"""
|
|
||||||
if not hasattr(self, 'rms_array'):
|
|
||||||
self.get_energy_levels_over_time()
|
|
||||||
|
|
||||||
# Convert frame indices to time
|
|
||||||
times = librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
|
|
||||||
|
|
||||||
|
|
||||||
# Normalize RMS for color mapping
|
|
||||||
# check maximum power to determine mastering headspace:
|
|
||||||
# a -6 dBFS headroom should yield a max power of around 0.25
|
|
||||||
# otherwise could go anywhere, but we take 0.6
|
|
||||||
local_max_power = np.max(self.rms_array)
|
|
||||||
if local_max_power > 0.3:
|
|
||||||
norm = mcolors.Normalize(vmin=0, vmax=0.6)
|
|
||||||
maxpower = 0.6
|
|
||||||
else:
|
|
||||||
norm = mcolors.Normalize(vmin=0, vmax=0.3)
|
|
||||||
maxpower = 0.3
|
|
||||||
|
|
||||||
# colour map
|
|
||||||
cmap = cm.autumn
|
|
||||||
|
|
||||||
# Plot
|
|
||||||
if display == 'window':
|
|
||||||
fig, ax = plt.subplots(figsize=(10, 4))
|
|
||||||
ax.set_ylim(0., maxpower)
|
|
||||||
for i in range(len(times)-1):
|
|
||||||
ax.fill_between(times[i:i+2], 0, self.rms_array[0][i], color=cmap(norm(self.rms_array[0][i])), edgecolor='none')
|
|
||||||
|
|
||||||
# Adding a colorbar to indicate the scale of RMS values
|
|
||||||
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
|
||||||
sm.set_array([])
|
|
||||||
cbar = plt.colorbar(sm, ax=ax, label='RMS Power')
|
|
||||||
# cbar.ax.set_yticklabels([f"{x-60.0:.0f} dBFS" for x in cbar.get_ticks()]) # Adjust labels to show true dBFS values
|
|
||||||
|
|
||||||
ax.set_ylabel('Power')
|
|
||||||
ax.set_xlabel('Time')
|
|
||||||
ax.set_title(safe_title(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()
|
|
||||||
|
|
||||||
# Initialize fonts for matplotlib
|
|
||||||
initialize_fonts()
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "uj-mastering-master",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[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",
|
||||||
|
"PyQt5>=5.15.10",
|
||||||
|
# 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
|
||||||
|
# versions are Linux/macOS only.
|
||||||
|
"PyQt5-Qt5==5.15.2 ; sys_platform == 'win32'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
ujm = "main:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = [
|
||||||
|
"main",
|
||||||
|
"analysis_results_manager",
|
||||||
|
"audio_visualization_widget",
|
||||||
|
"master_core",
|
||||||
|
"plotting_engine",
|
||||||
|
"font_manager",
|
||||||
|
"font_control_widget",
|
||||||
|
"logger_setup",
|
||||||
|
"setup_fonts",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user