This commit is contained in:
Mikkeli Matlock
2026-05-28 14:05:34 +09:00
parent c466de62b2
commit cf901a5686
14 changed files with 1848 additions and 648 deletions
+2 -3
View File
@@ -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
View File
@@ -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
+16 -12
View File
@@ -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
+46 -66
View File
@@ -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.
+1 -1
View File
@@ -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
) )
-52
View File
@@ -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.
-40
View File
@@ -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()
-208
View File
@@ -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}")
+12 -8
View File
@@ -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
View File
@@ -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()
-6
View File
@@ -1,6 +0,0 @@
{
"name": "uj-mastering-master",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+34
View File
@@ -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",
]
Generated
+1666
View File
File diff suppressed because it is too large Load Diff