Files
uj-mastering-master/master_core.py
T
Mikkeli Matlock 4337a31b80 Implement threading, logging, and CJK font support
Major improvements to GUI stability and internationalization:

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-21 23:39:53 +09:00

247 lines
8.7 KiB
Python

import librosa
import numpy as np
import os
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import matplotlib.cm as cm
from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3
from font_manager import safe_title, initialize_fonts
def try_mp3_tags(file_path):
try:
# if there is metadata
audio = MP3(file_path, ID3=EasyID3)
return audio
except Exception as e:
print(f"Error reading ID3 tags: {e}")
return None
def read_mp3_tags(file_path):
if (audio := try_mp3_tags(file_path)) is not None:
print(f"File name: {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]}")
else:
self.song_name = safe_title(os.path.basename(self.file_path))
self.y, self.sr = librosa.load(file_path)
# load automatically normalises everything to [-1.0, 1.0]
# and that's alright
self.y_mono = librosa.to_mono(self.y)
self.max_amplitude = np.max(np.abs(self.y_mono))
self.avg_amplitude = np.mean(np.abs(self.y_mono))
self.bpm, _ = librosa.beat.beat_track(y=self.y_mono, sr=self.sr)
def display_song_name(self):
print(self.song_name)
def get_amplitudes(self):
return self.max_amplitude, self.avg_amplitude
def get_bpm(self):
# librosa.beat.beat_track returns numpy array - extract scalar value
if isinstance(self.bpm, np.ndarray):
return float(self.bpm[0]) if len(self.bpm) > 0 else 0.0
return float(self.bpm)
def get_energy_levels_over_time(self, window = 10, hop = 2):
"""_summary_
Args:
window (int, optional): Length of rolling RMS window in seconds. Defaults to 10.
hop (int, optional): Length of window hop in seconds. Defaults to 2.
"""
# check if the window and hop are the same as before
if (not hasattr(self, 'window')) or ((self.window != window) or (self.hop != hop)):
self.window, self.hop = window, hop
# only calculate if not already calculated
if not hasattr(self, 'rms_array'):
# window and hop are in seconds
window_samples = window * self.sr
hop_samples = hop * self.sr
# Calculate RMS over the rolling windows
self.rms_array = librosa.feature.rms(y=self.y, frame_length=window_samples, hop_length=hop_samples)
def _get_times(self):
"""Get time array for RMS data. Internal method for GUI integration."""
if not hasattr(self, 'rms_array'):
self.get_energy_levels_over_time()
return librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
def plot_energy_levels_over_time(self, display='window'):
"""_summary_
Args:
display (str, optional): Option for where to display the plot. Defaults to 'window'.
'window' - display in a pyplot window
'gui' - for directing to the GUI (TBD)
"""
if not hasattr(self, 'rms_array'):
self.get_energy_levels_over_time()
# Convert frame indices to time
times = librosa.frames_to_time(np.arange(self.rms_array.shape[1]), sr=self.sr, hop_length=self.hop*self.sr)
# Normalize RMS for color mapping
# check maximum power to determine mastering headspace:
# a -6 dBFS headroom should yield a max power of around 0.25
# otherwise could go anywhere, but we take 0.6
local_max_power = np.max(self.rms_array)
if local_max_power > 0.3:
norm = mcolors.Normalize(vmin=0, vmax=0.6)
maxpower = 0.6
else:
norm = mcolors.Normalize(vmin=0, vmax=0.3)
maxpower = 0.3
# colour map
cmap = cm.autumn
# Plot
if display == 'window':
fig, ax = plt.subplots(figsize=(10, 4))
ax.set_ylim(0., maxpower)
for i in range(len(times)-1):
ax.fill_between(times[i:i+2], 0, self.rms_array[0][i], color=cmap(norm(self.rms_array[0][i])), edgecolor='none')
# Adding a colorbar to indicate the scale of RMS values
sm = cm.ScalarMappable(cmap=cmap, norm=norm)
sm.set_array([])
cbar = plt.colorbar(sm, ax=ax, label='RMS Power')
# cbar.ax.set_yticklabels([f"{x-60.0:.0f} dBFS" for x in cbar.get_ticks()]) # Adjust labels to show true dBFS values
ax.set_ylabel('Power')
ax.set_xlabel('Time')
ax.set_title(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()