cleanup
This commit is contained in:
+2
-3
@@ -1,5 +1,3 @@
|
||||
files.txt
|
||||
|
||||
# Font directory - avoid licensing issues by not committing font files
|
||||
# Keep the directory structure but ignore actual font files
|
||||
fonts/*.ttf
|
||||
@@ -8,10 +6,11 @@ fonts/*.ttc
|
||||
# But preserve the placeholder file
|
||||
!fonts/PLACE_YOUR_FONT_FILES_HERE
|
||||
|
||||
# Python cache
|
||||
# Python cache and build artifacts
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.egg-info/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
|
||||
+6
-6
@@ -43,7 +43,7 @@ The font system is integrated at these key locations:
|
||||
font_success = initialize_fonts()
|
||||
```
|
||||
|
||||
#### Plot Titles (`plotting_engine.py`, `master_core.py`)
|
||||
#### Plot Titles (`plotting_engine.py`)
|
||||
```python
|
||||
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:**
|
||||
```bash
|
||||
python setup_fonts.py
|
||||
uv run python setup_fonts.py
|
||||
```
|
||||
|
||||
2. **For enhanced CJK support, add fonts to the `fonts/` directory:**
|
||||
@@ -163,7 +163,7 @@ print(status)
|
||||
|
||||
### Test CJK Characters
|
||||
```bash
|
||||
python setup_fonts.py
|
||||
uv run python setup_fonts.py
|
||||
```
|
||||
|
||||
### Logging
|
||||
@@ -202,13 +202,13 @@ Font system operations are logged at appropriate levels:
|
||||
### Debug Commands
|
||||
```bash
|
||||
# Check font system status
|
||||
python setup_fonts.py
|
||||
uv run python setup_fonts.py
|
||||
|
||||
# Test with specific log level
|
||||
python main.py --log-level DEBUG
|
||||
uv run ujm --log-level DEBUG
|
||||
|
||||
# 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
|
||||
|
||||
@@ -43,9 +43,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
||||
- Auto-regeneration of plots when fonts change
|
||||
|
||||
#### `master_core.py`
|
||||
- Core audio analysis functionality
|
||||
- `AudioFile` class with comprehensive metrics extraction
|
||||
- RMS power analysis and BPM detection
|
||||
- Defines the `AudioFile` class: librosa loading, rolling RMS power, BPM detection
|
||||
- No batch / CLI mode — all analysis is driven from `main.py` via `AnalysisResultsManager`
|
||||
|
||||
### Current analysis features
|
||||
- **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
|
||||
|
||||
### Current usage
|
||||
1. Run `python main.py` to launch the GUI application
|
||||
2. Use "Open Audio File..." button or drag-and-drop audio files for analysis
|
||||
3. Adjust font settings using the Font Settings panel
|
||||
4. View real-time analysis results with embedded matplotlib plots
|
||||
5. Select different analyzed files from the file list to compare results
|
||||
### Running the app
|
||||
```bash
|
||||
uv sync # one-time, after cloning
|
||||
uv run ujm # launch the GUI
|
||||
```
|
||||
|
||||
### Legacy usage (batch Mode)
|
||||
1. Add audio file paths to `files.txt`
|
||||
2. Run `python master_core.py` for batch analysis
|
||||
Optional flags (handled by `logger_setup.parse_log_args`):
|
||||
```bash
|
||||
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
||||
uv run ujm --log-file # also write audio_analysis.log
|
||||
```
|
||||
|
||||
The only entry point is `ujm` (defined in `pyproject.toml` as
|
||||
`ujm = "main:main"`). The previous `files.txt` batch mode and the
|
||||
`python master_core.py` workflow have been removed.
|
||||
|
||||
### Planned usage enhancements
|
||||
1. Interactive plot manipulation and style customization
|
||||
|
||||
@@ -1,78 +1,58 @@
|
||||
# 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.
|
||||
|
||||
## Features
|
||||
|
||||
### ✅ Current Implementation
|
||||
- **Complete GUI Application**: Modular PyQt5 interface with drag-and-drop and file dialog support
|
||||
- **Real-time Analysis**: Background threading with embedded matplotlib visualization
|
||||
- **Font Management**: CJK-compatible font system with custom font support from `fonts/` directory
|
||||
- **Audio Support**: MP3, WAV, and FLAC file analysis
|
||||
- **RMS Power Analysis**: Rolling window analysis with adaptive color mapping
|
||||
- **Metadata Display**: Automatic extraction and display of audio tags and BPM
|
||||
### Current
|
||||
- **PyQt5 GUI**: drag-and-drop or file-dialog ingest of `.mp3`, `.wav`, `.flac`
|
||||
- **RMS power analysis** on a 10 s rolling window with adaptive colour scale
|
||||
- **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
|
||||
- **Embedded matplotlib canvas** with auto-regenerated plots on font change
|
||||
|
||||
### 🚧 In Development
|
||||
- **Plot Control Widgets**: Dedicated cluster for plot manipulation and style controls
|
||||
- **LUFS Metrics**: Professional loudness measurement implementation
|
||||
- **Interactive Plotting**: Real-time axis control and style customization
|
||||
### Roadmap
|
||||
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
|
||||
plot-control widget cluster, LUFS, dynamic range, interactive axis controls.
|
||||
|
||||
### 🔮 Planned Features
|
||||
- **Audio Comparison**: Reference vs. comparee analysis for mastering evaluation
|
||||
- **Advanced Metrics**: Dynamic range, spectral analysis, and professional standards compliance
|
||||
- **Standalone Releases**: Self-contained executable distribution
|
||||
## Quick start
|
||||
|
||||
This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run ujm
|
||||
```
|
||||
|
||||
`uv run ujm` is the only supported entry point — it boots the GUI.
|
||||
|
||||
### Logging flags
|
||||
```bash
|
||||
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
||||
uv run ujm --log-file # also write audio_analysis.log
|
||||
```
|
||||
|
||||
### Fonts
|
||||
Drop `.ttf` / `.otf` / `.ttc` files into `fonts/` to get them in the font
|
||||
selector. The directory is gitignored to avoid bundling licensed font data.
|
||||
See [CJK_FONTS.md](CJK_FONTS.md) for details.
|
||||
|
||||
## Dependencies
|
||||
- **Core**: `librosa`, `numpy`, `matplotlib`, `mutagen`
|
||||
- **GUI**: `PyQt5`
|
||||
- **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
|
||||
```
|
||||
`librosa`, `numpy`, `matplotlib`, `mutagen`, `PyQt5` — all pinned through
|
||||
`uv.lock`. Python 3.10+.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Modular Design
|
||||
- **Self-contained Widgets**: Easy layout management and customization
|
||||
- **Background Processing**: Non-blocking analysis with progress feedback
|
||||
- **Signal-based Communication**: Clean separation between GUI and analysis logic
|
||||
|
||||
### Key Components
|
||||
- `main.py`: Complete GUI application with modular architecture
|
||||
- `font_control_widget.py`: Unified font management with plot regeneration
|
||||
- `analysis_results_manager.py`: Threaded analysis with caching
|
||||
- `audio_visualization_widget.py`: Embedded matplotlib with Qt integration
|
||||
|
||||
## 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
|
||||
| Module | Responsibility |
|
||||
| --- | --- |
|
||||
| `main.py` | `MainWindow` + the `ujm` entry point |
|
||||
| `analysis_results_manager.py` | Background `QThread` worker, result cache |
|
||||
| `master_core.py` | `AudioFile`: librosa loading, RMS rolling window, BPM |
|
||||
| `plotting_engine.py` | Matplotlib `Figure` builder for the power graph |
|
||||
| `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, refresh-plot button |
|
||||
| `logger_setup.py` | CLI log-level parsing + custom TRACE level |
|
||||
| `setup_fonts.py` | Diagnostic utility (run standalone) |
|
||||
|
||||
Binary file not shown.
@@ -68,7 +68,7 @@ class AudioAnalysisWorker(QThread):
|
||||
bpm=audio_file.get_bpm(),
|
||||
max_amplitude=audio_file.max_amplitude,
|
||||
avg_amplitude=audio_file.avg_amplitude,
|
||||
times=audio_file._get_times(),
|
||||
times=audio_file.get_times(),
|
||||
rms_array=audio_file.rms_array,
|
||||
analysis_successful=True
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ Pure display responsibility - receives plotting data and shows graphs.
|
||||
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
from matplotlib.figure import Figure
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
class AudioVisualizationWidget(QWidget):
|
||||
@@ -49,57 +48,6 @@ class AudioVisualizationWidget(QWidget):
|
||||
ax.set_yticks([])
|
||||
self.canvas.draw()
|
||||
|
||||
def display_analysis_figure(self, figure):
|
||||
"""
|
||||
Display a matplotlib figure in the widget.
|
||||
|
||||
Args:
|
||||
figure: matplotlib.figure.Figure to display
|
||||
"""
|
||||
# Clear current figure
|
||||
self.figure.clear()
|
||||
|
||||
# Copy the provided figure to our canvas
|
||||
# Get the subplot from the provided figure
|
||||
source_ax = figure.get_axes()[0]
|
||||
|
||||
# Create new subplot in our figure
|
||||
ax = self.figure.add_subplot(111)
|
||||
|
||||
# Copy all the plot elements
|
||||
for child in source_ax.get_children():
|
||||
if hasattr(child, 'get_data'):
|
||||
# Copy line plots
|
||||
try:
|
||||
x_data, y_data = child.get_data()
|
||||
ax.plot(x_data, y_data, color=child.get_color(),
|
||||
linewidth=child.get_linewidth())
|
||||
except:
|
||||
pass
|
||||
|
||||
# Copy collections (fill_between creates PolyCollection)
|
||||
for collection in source_ax.collections:
|
||||
ax.add_collection(collection)
|
||||
|
||||
# Copy axis properties
|
||||
ax.set_xlim(source_ax.get_xlim())
|
||||
ax.set_ylim(source_ax.get_ylim())
|
||||
ax.set_xlabel(source_ax.get_xlabel())
|
||||
ax.set_ylabel(source_ax.get_ylabel())
|
||||
ax.set_title(source_ax.get_title())
|
||||
|
||||
# Copy colorbar if it exists
|
||||
if hasattr(figure, '_colorbar') or len(figure.get_axes()) > 1:
|
||||
# Try to copy colorbar
|
||||
try:
|
||||
cbar = figure.colorbar(source_ax.collections[-1], ax=ax, label='RMS Power')
|
||||
except:
|
||||
pass
|
||||
|
||||
self.figure.tight_layout()
|
||||
self.canvas.draw()
|
||||
self.status_label.setText("Analysis complete - displaying power graph")
|
||||
|
||||
def display_figure_direct(self, figure):
|
||||
"""
|
||||
Display a figure by replacing our canvas figure entirely.
|
||||
|
||||
@@ -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,7 +239,7 @@ class MainWindow(QMainWindow):
|
||||
self.visualization_widget.set_status(f"Error regenerating plot: {e}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
def main():
|
||||
# Parse logging arguments before creating QApplication
|
||||
log_level, log_to_file = parse_log_args()
|
||||
|
||||
@@ -271,3 +271,7 @@ if __name__ == '__main__':
|
||||
logger.info("GUI window displayed")
|
||||
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+26
-207
@@ -1,54 +1,39 @@
|
||||
import librosa
|
||||
import numpy as np
|
||||
import os
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.colors as mcolors
|
||||
import matplotlib.cm as cm
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
from mutagen.mp3 import MP3
|
||||
from mutagen.easyid3 import EasyID3
|
||||
from font_manager import safe_title, initialize_fonts
|
||||
|
||||
def try_mp3_tags(file_path):
|
||||
from font_manager import safe_title
|
||||
|
||||
|
||||
def _try_mp3_tags(file_path):
|
||||
try:
|
||||
# if there is metadata
|
||||
audio = MP3(file_path, ID3=EasyID3)
|
||||
return audio
|
||||
except Exception as e:
|
||||
print(f"Error reading ID3 tags: {e}")
|
||||
return MP3(file_path, ID3=EasyID3)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def read_mp3_tags(file_path):
|
||||
if (audio := try_mp3_tags(file_path)) is not None:
|
||||
print(f"File name: {safe_title(os.path.basename(file_path))}")
|
||||
print(f"{safe_title(audio['artist'][0])} - {safe_title(audio['title'][0])}")
|
||||
else:
|
||||
print(f"File name: {safe_title(os.path.basename(file_path))}")
|
||||
|
||||
class AudioFile:
|
||||
def __init__(self, file_path):
|
||||
self.file_path = file_path
|
||||
# file name / song name
|
||||
if (audio := try_mp3_tags(self.file_path)) is not None:
|
||||
self.song_name = safe_title(f"{audio['artist'][0]} - {audio['title'][0]}")
|
||||
audio = _try_mp3_tags(self.file_path)
|
||||
artist = audio.get('artist', [None])[0] if audio is not None else None
|
||||
title = audio.get('title', [None])[0] if audio is not None else None
|
||||
if artist and title:
|
||||
self.song_name = safe_title(f"{artist} - {title}")
|
||||
else:
|
||||
self.song_name = safe_title(os.path.basename(self.file_path))
|
||||
|
||||
# librosa.load normalises to [-1.0, 1.0]
|
||||
self.y, self.sr = librosa.load(file_path)
|
||||
# load automatically normalises everything to [-1.0, 1.0]
|
||||
# and that's alright
|
||||
self.y_mono = librosa.to_mono(self.y)
|
||||
self.max_amplitude = np.max(np.abs(self.y_mono))
|
||||
self.avg_amplitude = np.mean(np.abs(self.y_mono))
|
||||
self.bpm, _ = librosa.beat.beat_track(y=self.y_mono, sr=self.sr)
|
||||
|
||||
def display_song_name(self):
|
||||
print(self.song_name)
|
||||
|
||||
def get_amplitudes(self):
|
||||
return self.max_amplitude, self.avg_amplitude
|
||||
|
||||
def get_bpm(self):
|
||||
# librosa.beat.beat_track returns numpy array - extract scalar value
|
||||
if isinstance(self.bpm, np.ndarray):
|
||||
@@ -56,191 +41,25 @@ class AudioFile:
|
||||
return float(self.bpm)
|
||||
|
||||
def get_energy_levels_over_time(self, window=10, hop=2):
|
||||
"""_summary_
|
||||
"""Compute rolling RMS power.
|
||||
|
||||
Args:
|
||||
window (int, optional): Length of rolling RMS window in seconds. Defaults to 10.
|
||||
hop (int, optional): Length of window hop in seconds. Defaults to 2.
|
||||
window: Rolling window length in seconds.
|
||||
hop: Hop length in seconds.
|
||||
"""
|
||||
# check if the window and hop are the same as before
|
||||
if (not hasattr(self, 'window')) or ((self.window != window) or (self.hop != hop)):
|
||||
if (not hasattr(self, 'window')) or (self.window != window) or (self.hop != hop):
|
||||
self.window, self.hop = window, hop
|
||||
# only calculate if not already calculated
|
||||
if not hasattr(self, 'rms_array'):
|
||||
# window and hop are in seconds
|
||||
window_samples = window * self.sr
|
||||
hop_samples = hop * self.sr
|
||||
self.rms_array = librosa.feature.rms(
|
||||
y=self.y, frame_length=window_samples, hop_length=hop_samples
|
||||
)
|
||||
|
||||
# Calculate RMS over the rolling windows
|
||||
self.rms_array = librosa.feature.rms(y=self.y, frame_length=window_samples, hop_length=hop_samples)
|
||||
|
||||
def _get_times(self):
|
||||
"""Get time array for RMS data. Internal method for GUI integration."""
|
||||
def get_times(self):
|
||||
"""Time-axis values matching the RMS frames."""
|
||||
if not hasattr(self, 'rms_array'):
|
||||
self.get_energy_levels_over_time()
|
||||
return librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
|
||||
|
||||
def plot_energy_levels_over_time(self, display='window'):
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
display (str, optional): Option for where to display the plot. Defaults to 'window'.
|
||||
'window' - display in a pyplot window
|
||||
'gui' - for directing to the GUI (TBD)
|
||||
"""
|
||||
if not hasattr(self, 'rms_array'):
|
||||
self.get_energy_levels_over_time()
|
||||
|
||||
# Convert frame indices to time
|
||||
times = librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
|
||||
|
||||
|
||||
# Normalize RMS for color mapping
|
||||
# check maximum power to determine mastering headspace:
|
||||
# a -6 dBFS headroom should yield a max power of around 0.25
|
||||
# otherwise could go anywhere, but we take 0.6
|
||||
local_max_power = np.max(self.rms_array)
|
||||
if local_max_power > 0.3:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.6)
|
||||
maxpower = 0.6
|
||||
else:
|
||||
norm = mcolors.Normalize(vmin=0, vmax=0.3)
|
||||
maxpower = 0.3
|
||||
|
||||
# colour map
|
||||
cmap = cm.autumn
|
||||
|
||||
# Plot
|
||||
if display == 'window':
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
ax.set_ylim(0., maxpower)
|
||||
for i in range(len(times)-1):
|
||||
ax.fill_between(times[i:i+2], 0, self.rms_array[0][i], color=cmap(norm(self.rms_array[0][i])), edgecolor='none')
|
||||
|
||||
# Adding a colorbar to indicate the scale of RMS values
|
||||
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
|
||||
sm.set_array([])
|
||||
cbar = plt.colorbar(sm, ax=ax, label='RMS Power')
|
||||
# cbar.ax.set_yticklabels([f"{x-60.0:.0f} dBFS" for x in cbar.get_ticks()]) # Adjust labels to show true dBFS values
|
||||
|
||||
ax.set_ylabel('Power')
|
||||
ax.set_xlabel('Time')
|
||||
ax.set_title(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()
|
||||
return librosa.frames_to_time(
|
||||
np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop * self.sr
|
||||
)
|
||||
|
||||
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