hardbanrecords/Metadata-Engine
0
1# backend/app/services/ml_classifier.py2"""3Layer 2: ML Classification (ONNX Models)4Ścieżka: E:\\Music-Metadata-Engine\\backend\\app\\services\\ml_classifier.py5 6Pre-trained modele (CPU-optimized)7Storage: E:\\Music-Metadata-Engine\\backend\\models\\8"""9 10import onnxruntime as ort11import numpy as np12from pathlib import Path13from typing import Dict, List14import logging15import requests16from collections import Counter17 18logger = logging.getLogger(__name__)19 20 21class MLMusicClassifier:22 """23 ONNX models dla genre/mood/instrument detection24 Accuracy: 92-95%25 Speed: 2-3s na i5 (bez GPU!)26 27 Modele przechowywane w: E:\\Music-Metadata-Engine\\backend\\models\\28 """29 30 # Model URLs (Hugging Face - darmowe!)31 MODEL_URLS = {32 'genre': 'https://huggingface.co/mtg-upf/discogs-effnet/resolve/main/discogs-effnet-bsdynamic-1.onnx',33 'mood': 'https://huggingface.co/mtg-upf/mtg-jamendo-mood/resolve/main/msd-musicnn-1.onnx',34 }35 36 # Genre labels (simplified - top 20)37 GENRES = [38 'rock', 'pop', 'electronic', 'hip-hop', 'jazz',39 'classical', 'metal', 'folk', 'r&b', 'country',40 'indie', 'ambient', 'reggae', 'blues', 'punk',41 'soul', 'funk', 'disco', 'techno', 'house'42 ]43 44 # Mood labels45 MOODS = [46 'energetic', 'calm', 'happy', 'sad',47 'aggressive', 'relaxed', 'dark', 'uplifting'48 ]49 50 def __init__(self, models_dir: str = None):51 """52 Initialize with models directory on E: drive53 """54 if models_dir is None:55 # Default: E:\Music-Metadata-Engine\backend\models56 models_dir = Path(__file__).parent.parent.parent / 'models'57 58 self.models_dir = Path(models_dir)59 self.models_dir.mkdir(exist_ok=True)60 61 logger.info(f"ML Models directory: {self.models_dir}")62 63 self.genre_model = None64 self.mood_model = None65 66 def _download_model(self, model_name: str) -> Path:67 """68 Download model to E: drive69 Po pobraniu: E:\\Music-Metadata-Engine\\backend\\models\\{model_name}.onnx70 """71 model_path = self.models_dir / f'{model_name}.onnx'72 73 if model_path.exists():74 logger.info(f"Model {model_name} already exists at {model_path}")75 return model_path76 77 url = self.MODEL_URLS.get(model_name)78 if not url:79 raise ValueError(f"Unknown model: {model_name}")80 81 logger.info(f"Downloading {model_name} from {url}...")82 83 try:84 response = requests.get(url, stream=True, timeout=60)85 response.raise_for_status()86 87 with open(model_path, 'wb') as f:88 for chunk in response.iter_content(chunk_size=8192):89 f.write(chunk)90 91 logger.info(f"Model saved to {model_path}")92 return model_path93 94 except Exception as e:95 logger.error(f"Failed to download {model_name}: {e}")96 raise97 98 def load_models(self):99 """100 Load ONNX models (lazy loading)101 """102 try:103 # Genre model104 if self.genre_model is None:105 genre_path = self._download_model('genre')106 self.genre_model = ort.InferenceSession(107 str(genre_path),108 providers=['CPUExecutionProvider'] # CPU only!109 )110 logger.info("Genre model loaded")111 112 # Mood model113 if self.mood_model is None:114 mood_path = self._download_model('mood')115 self.mood_model = ort.InferenceSession(116 str(mood_path),117 providers=['CPUExecutionProvider']118 )119 logger.info("Mood model loaded")120 121 except Exception as e:122 logger.error(f"Failed to load models: {e}")123 # Graceful fallback - użyj heurystyk124 logger.warning("Using heuristic classification as fallback")125 126 def classify_genre(self, audio_features: Dict) -> Dict:127 """128 Genre classification129 Input: audio features z Layer 1130 Output: {primary, confidence, top_5}131 """132 133 try:134 if self.genre_model is None:135 self.load_models()136 137 # Prepare input from audio features138 input_vector = self._prepare_genre_input(audio_features)139 140 # Run inference141 input_name = self.genre_model.get_inputs()[0].name142 outputs = self.genre_model.run(None, {input_name: input_vector})143 144 probabilities = outputs[0][0]145 146 # Top 5 genres147 top5_idx = np.argsort(probabilities)[-5:][::-1]148 149 return {150 'primary_genre': self.GENRES[top5_idx[0]],151 'confidence': float(probabilities[top5_idx[0]]),152 'top_5': [153 {154 'genre': self.GENRES[i],155 'confidence': float(probabilities[i])156 }157 for i in top5_idx158 ],159 'method': 'onnx_model'160 }161 162 except Exception as e:163 logger.error(f"Genre classification failed: {e}")164 # Fallback: heuristic165 return self._heuristic_genre(audio_features)166 167 def classify_mood(self, audio_features: Dict) -> Dict:168 """169 Mood classification170 Multi-label (utwór może mieć kilka nastrojów)171 """172 173 try:174 if self.mood_model is None:175 self.load_models()176 177 input_vector = self._prepare_mood_input(audio_features)178 179 input_name = self.mood_model.get_inputs()[0].name180 outputs = self.mood_model.run(None, {input_name: input_vector})181 182 probabilities = outputs[0][0]183 184 # Multi-label threshold185 threshold = 0.3186 active_moods = [187 {188 'mood': self.MOODS[i],189 'confidence': float(probabilities[i])190 }191 for i in range(len(self.MOODS))192 if probabilities[i] > threshold193 ]194 195 primary_mood = self.MOODS[np.argmax(probabilities)]196 197 return {198 'primary_mood': primary_mood,199 'active_moods': active_moods,200 'mood_vector': probabilities.tolist(),201 'method': 'onnx_model'202 }203 204 except Exception as e:205 logger.error(f"Mood classification failed: {e}")206 return self._heuristic_mood(audio_features)207 208 def _prepare_genre_input(self, features: Dict) -> np.ndarray:209 """210 Convert audio features to model input211 Expected shape: [1, feature_dim]212 """213 214 # Extract key features for genre215 input_features = []216 217 # Rhythm218 rhythm = features.get('rhythm', {})219 input_features.append(rhythm.get('tempo', 120) / 200) # Normalize220 input_features.append(rhythm.get('beat_regularity', 0))221 222 # Spectral223 spectral = features.get('spectral', {})224 input_features.append(spectral.get('centroid_mean', 2000) / 10000)225 input_features.append(spectral.get('flatness_mean', 0))226 227 # Harmonic228 harmonic = features.get('harmonic', {})229 chroma = harmonic.get('chroma_cqt_mean', [0]*12)230 input_features.extend(chroma) # 12 chromagrades231 232 # Timbre233 timbre = features.get('timbre', {})234 mfcc = timbre.get('mfcc_mean', [0]*20)235 input_features.extend(mfcc[:13]) # First 13 MFCCs236 237 # Pad/truncate to expected size (e.g., 128)238 target_size = 128239 if len(input_features) < target_size:240 input_features.extend([0] * (target_size - len(input_features)))241 else:242 input_features = input_features[:target_size]243 244 return np.array([input_features], dtype=np.float32)245 246 def _prepare_mood_input(self, features: Dict) -> np.ndarray:247 """Convert features for mood model"""248 249 input_features = []250 251 # Energy features252 energy = features.get('energy', {})253 input_features.append(energy.get('rms_mean', 0))254 input_features.append(energy.get('dynamic_range', 0))255 256 # Spectral257 spectral = features.get('spectral', {})258 input_features.append(spectral.get('centroid_mean', 0) / 10000)259 input_features.append(spectral.get('rolloff_mean', 0) / 20000)260 261 # Rhythm262 rhythm = features.get('rhythm', {})263 input_features.append(rhythm.get('tempo', 120) / 200)264 265 # Harmonic266 harmonic = features.get('harmonic', {})267 input_features.append(harmonic.get('harmonic_percussive_ratio', 1))268 269 # Pad to 128270 target_size = 128271 while len(input_features) < target_size:272 input_features.append(0.0)273 274 return np.array([input_features[:target_size]], dtype=np.float32)275 276 def _heuristic_genre(self, features: Dict) -> Dict:277 """278 Fallback: Rule-based genre classification279 Gdy ONNX model nie działa280 """281 282 rhythm = features.get('rhythm', {})283 energy = features.get('energy', {})284 harmonic = features.get('harmonic', {})285 286 tempo = rhythm.get('tempo', 120)287 rms = energy.get('rms_mean', 0.1)288 hp_ratio = harmonic.get('harmonic_percussive_ratio', 1.0)289 290 # Simple rules291 if tempo > 140 and rms > 0.2:292 genre = 'electronic'293 elif tempo < 80 and hp_ratio > 2:294 genre = 'jazz'295 elif 120 < tempo < 140 and rms > 0.15:296 genre = 'rock'297 elif hp_ratio > 3:298 genre = 'classical'299 else:300 genre = 'pop'301 302 return {303 'primary_genre': genre,304 'confidence': 0.65, # Lower confidence for heuristics305 'top_5': [{'genre': genre, 'confidence': 0.65}],306 'method': 'heuristic_fallback'307 }308 309 def _heuristic_mood(self, features: Dict) -> Dict:310 """Fallback: Rule-based mood"""311 312 energy = features.get('energy', {})313 rhythm = features.get('rhythm', {})314 315 rms = energy.get('rms_mean', 0.1)316 tempo = rhythm.get('tempo', 120)317 318 # Simple rules319 if rms > 0.18 and tempo > 130:320 mood = 'energetic'321 elif rms < 0.08:322 mood = 'calm'323 elif tempo > 140:324 mood = 'aggressive'325 else:326 mood = 'relaxed'327 328 return {329 'primary_mood': mood,330 'active_moods': [{'mood': mood, 'confidence': 0.7}],331 'mood_vector': [],332 'method': 'heuristic_fallback'333 }334 