Files
uj-mastering-master/master_core.py
T
mikkeli 5c918109d5 Keep master_core off Qt so the web image starts
master_core imported safe_title from font_manager, which imports PyQt5 at
module load. The web extra installs no Qt, so `ujm-web` crashed on startup
with "No module named 'PyQt5'" inside the container. safe_title is only a
UTF-8 round-trip, so master_core now carries its own Qt-free copy; the
desktop path keeps using font_manager's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-14 02:33:28 +09:00

70 lines
2.4 KiB
Python

# SPDX-License-Identifier: Apache-2.0
import os
import librosa
import numpy as np
from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3
def safe_title(title):
# Deliberately not font_manager.safe_title: that module imports PyQt5 at load
# time, and master_core is on the Qt-free web path (the `web` extra has no Qt).
if not title:
return title
return title.encode('utf-8', errors='replace').decode('utf-8')
def _try_mp3_tags(file_path):
try:
return MP3(file_path, ID3=EasyID3)
except Exception:
return None
class AudioFile:
def __init__(self, file_path):
self.file_path = file_path
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]. sr=None preserves the file's
# native sample rate; without it librosa resamples to 22050 Hz, which would
# discard everything above ~11 kHz (the entire top octave) before analysis.
self.y, self.sr = librosa.load(file_path, sr=None)
self.y_mono = librosa.to_mono(self.y)
self.max_amplitude = np.max(np.abs(self.y_mono))
self.avg_amplitude = np.mean(np.abs(self.y_mono))
# BPM intentionally not computed: librosa.beat.beat_track cost ~3.7s on a
# 4-min track for a number that's no better than tapping it by hand.
def get_energy_levels_over_time(self, window=10, hop=2):
"""Compute rolling RMS power.
Args:
window: Rolling window length in seconds.
hop: Hop length in seconds.
"""
if (not hasattr(self, 'window')) or (self.window != window) or (self.hop != hop):
self.window, self.hop = window, hop
if not hasattr(self, 'rms_array'):
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
)
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
)