hardbanrecords/Metadata-Engine
0
1# backend/app/services/llm_ensemble.py2"""3Layer 3: Multi-LLM Consensus Voting4Ścieżka: E:\\Music-Metadata-Engine\\backend\\app\\services\\llm_ensemble.py5 6Najważniejszy layer dla świeżych utworów!73 LLMs vote = 92-94% accuracy8Zero MB dla Dockera (tylko API calls)9"""10 11import asyncio12from typing import List, Dict, Any13from collections import Counter14import numpy as np15import logging16import json17from .standards import MAIN_GENRES, SUB_GENRES, MOODS, INSTRUMENTATION, VOCAL_STYLES18 19logger = logging.getLogger(__name__)20 21MUSIC_EXPERT_SYSTEM_PROMPT = """You are a professional music metadata expert with 20+ years of experience in music classification, A&R, and metadata curation for Spotify, Apple Music, and Beatport.22 23Your expertise includes:24- Deep knowledge of 500+ music genres and subgenres25- Understanding of regional music scenes (UK Bass, Detroit Techno, LA Beats)26- Familiarity with production techniques and their genre indicators27- Knowledge of music theory, harmony, and rhythm patterns28- Experience with sync licensing and music library categorization29 30You classify music based on:311. AUDIO FEATURES: BPM, key, energy, spectral characteristics, rhythm patterns322. PRODUCTION STYLE: mixing, mastering, sound design, instrumentation333. CULTURAL CONTEXT: scene, movement, era, influences344. FUNCTIONAL USE: emotional impact, use cases, target audience35 36You ALWAYS provide:37- Precise genre classifications (not vague terms like "electronic" when "progressive house" is accurate)38- Evidence-based reasoning tied to specific audio features39- Confidence scores that reflect certainty40- Alternative classifications when uncertain41"""42 43 44class LLMEnsemble:45 """46 Consensus voting z 3 LLMs:47 - Groq (Llama 3.3 70B) - szybki, bezpłatny48 - Gemini 2.0 Flash - kreatywny49 - Claude Sonnet 4 - ekspert50 51 Accuracy boost: 85% → 94% dla nowych utworów52 Docker size: 0 MB (tylko API)53 Cost: $0 (free tiers)54 """55 56 def __init__(self, groq_key: str = None, gemini_key: str = None, claude_key: str = None):57 """58 Klucze API z .env (E:\\Music-Metadata-Engine\\backend\\.env)59 """60 import os61 62 self.groq_key = groq_key or os.getenv('GROQ_API_KEY')63 self.gemini_key = gemini_key or os.getenv('GEMINI_API_KEY')64 self.claude_key = claude_key or os.getenv('CLAUDE_API_KEY')65 66 logger.info("LLM Ensemble initialized (0 MB Docker footprint)")67 68 async def consensus_classification(69 self, 70 audio_features: Dict,71 ml_predictions: Dict = None,72 model_preference: str = 'flash'73 ) -> Dict:74 """75 Główna funkcja: LLMs równolegle76 - 'flash' mode: Groq + Gemini only (faster, ~15-20s)77 - 'pro' mode: All 3 models for max accuracy (~35-40s)78 """79 80 # Build enhanced prompt81 user_prompt = self._build_enhanced_prompt(audio_features, ml_predictions)82 83 # Wybierz modele na podstawie preferencji84 if model_preference == 'flash':85 logger.info("Fast Mode: Using Groq + Gemini only")86 tasks = [87 self._groq_classify(user_prompt, system_prompt=MUSIC_EXPERT_SYSTEM_PROMPT),88 self._gemini_classify(user_prompt, system_prompt=MUSIC_EXPERT_SYSTEM_PROMPT),89 ]90 else:91 logger.info("Pro Mode: Using all 3 LLMs (Groq + Gemini + Claude)")92 tasks = [93 self._groq_classify(user_prompt, system_prompt=MUSIC_EXPERT_SYSTEM_PROMPT),94 self._gemini_classify(user_prompt, system_prompt=MUSIC_EXPERT_SYSTEM_PROMPT),95 self._claude_classify(user_prompt, system_prompt=MUSIC_EXPERT_SYSTEM_PROMPT)96 ]97 98 llm_results = await asyncio.gather(*tasks, return_exceptions=True)99 100 # Filtruj błędy101 valid_results = [102 r for r in llm_results 103 if not isinstance(r, Exception) and r and not r.get('error')104 ]105 106 min_required = 1 if model_preference == 'flash' else 2107 if len(valid_results) < min_required:108 logger.warning(f"Only {len(valid_results)} LLMs responded successfully (min: {min_required})")109 # Fallback do jednego wyniku lub heurystyk110 if valid_results:111 final_result = valid_results[0]112 else:113 return self._fallback_classification(audio_features)114 else:115 logger.info(f"Consensus voting with {len(valid_results)} LLMs")116 # Głosowanie konsensusowe117 final_result = self._vote(valid_results)118 119 # FINAL SAFETY CHECK: If voting produced "Unknown", use fallback120 if final_result.get('mainGenre') == 'Unknown':121 logger.warning("Consensus voting failed (Unknown Genre). Reverting to DSP fallback.")122 return self._fallback_classification(audio_features)123 124 # Validate and refine final result125 final_result = self._validate_and_refine_classification(final_result, audio_features)126 127 return final_result128 129 def _build_enhanced_prompt(self, audio_features: Dict, ml_hints: Dict) -> str:130 """Build a premium prompt with detailed audio context"""131 132 # Extract key features133 rhythm = audio_features.get('rhythm', {})134 energy = audio_features.get('energy', {})135 harmonic = audio_features.get('harmonic', {})136 spectral = audio_features.get('spectral', {})137 138 def _safe_float(val, default=0.0):139 try:140 if isinstance(val, (list, np.ndarray)):141 return float(np.mean(val)) if len(val) > 0 else default142 return float(val)143 except:144 return default145 146 tempo = _safe_float(rhythm.get('tempo'), 120)147 key_sig = harmonic.get('key', 'C')148 mode = harmonic.get('mode', 'Major')149 rms = _safe_float(energy.get('rms_mean'), 0.1)150 dynamic_range = _safe_float(energy.get('dynamic_range'), 0.2)151 centroid = _safe_float(spectral.get('centroid_mean'), 2000)152 rolloff = _safe_float(spectral.get('rolloff_mean'), 5000)153 flatness = _safe_float(spectral.get('flatness_mean'), 0.5)154 hp_ratio = _safe_float(harmonic.get('harmonic_percussive_ratio'), 1.0)155 156 # Explicit contrast check (previously a list)157 contrast = _safe_float(spectral.get('contrast_mean'), 0)158 159 if not ml_hints: ml_hints = {}160 161 prompt = f"""Analyze this audio track and provide PRECISE music metadata.162 163══════════════════════════════════════════════════════════════164AUDIO FEATURES ANALYSIS:165══════════════════════════════════════════════════════════════166 167RHYTHM:168- BPM: {tempo}169- Time signature probability: {rhythm.get('time_signature', '4/4')}170- Rhythm complexity: {rhythm.get('rhythm_complexity', 'medium')}171 172HARMONY:173- Key: {key_sig} {mode}174- Harmonic/Percussive Ratio: {hp_ratio:.2f} (>2.0 = melodic, <1.0 = rhythmic)175- Chord complexity: {harmonic.get('chord_complexity', 'medium')}176 177ENERGY & DYNAMICS:178- RMS Energy: {rms:.3f} (0-0.1=quiet, 0.1-0.2=moderate, >0.2=loud)179- Dynamic Range: {dynamic_range:.2f} (>0.3=high dynamics, <0.15=compressed)180- Peak Energy: {_safe_float(energy.get('peak_energy'), 0):.3f}181 182SPECTRAL CHARACTERISTICS:183- Spectral Centroid: {centroid:.0f} Hz (brightness indicator)184- Spectral Rolloff: {rolloff:.0f} Hz (frequency distribution)185- Spectral Flatness: {flatness:.3f} (0=tonal, 1=noisy)186- Spectral Contrast: {contrast:.2f}187 188INITIAL HEURISTIC HINTS:189- Genre hints: {ml_hints.get('genre', {}).get('hints', [])}190- Mood hints: {ml_hints.get('mood', {}).get('hints', [])}191 192══════════════════════════════════════════════════════════════193CLASSIFICATION RULES:194══════════════════════════════════════════════════════════════195 196GENRE CLASSIFICATION:1971. Use SPECIFIC subgenres, not broad categories198 ❌ BAD: "electronic", "rock", "pop"199 ✅ GOOD: "progressive house", "indie rock", "synth-pop"200 2012. Consider BPM ranges for genre:202 - Downtempo/Ambient: 60-90 BPM203 - Hip-Hop/Boom Bap: 85-95 BPM204 - House: 120-130 BPM205 - Techno: 125-135 BPM206 - Drum & Bass: 160-180 BPM207 - Dubstep: 140 BPM (half-time feel)208 2093. Use Harmonic/Percussive Ratio:210 - HP > 3.0: Classical, Jazz, Folk, Singer-Songwriter211 - HP 1.5-3.0: Rock, Indie, Alternative212 - HP < 1.5: Electronic, Hip-Hop, Trap213 2144. Regional/Scene-specific terms when applicable:215 - "UK Garage" not just "garage"216 - "Detroit Techno" not just "techno"217 - "Reggaeton" not just "latin"218 219MOOD CLASSIFICATION:220Choose moods that reflect BOTH energy and emotional tone:221- High Energy + Major Key = "Euphoric", "Uplifting", "Energetic"222- High Energy + Minor Key = "Aggressive", "Intense", "Dark"223- Low Energy + Major Key = "Peaceful", "Serene", "Hopeful"224- Low Energy + Minor Key = "Melancholic", "Atmospheric", "Introspective"225 226INSTRUMENTATION:227List MAIN instruments (3-5 max), prioritize by prominence:228- If HP ratio > 2: Focus on melodic instruments229- If HP ratio < 1: Focus on drums, bass, percussion230 231KEYWORDS (10-15 terms):232Include:233- Genre-related terms234- Mood descriptors235- Use cases (e.g., "workout", "meditation", "cinematic")236- Production style (e.g., "polished", "lo-fi", "vintage")237- Cultural references if clear238 239USE CASES (3-7 scenarios):240Be specific about where this track fits:241- Sync licensing categories242- Playlist types243- Activities/situations244- Media contexts245 246══════════════════════════════════════════════════════════════247CONFIDENCE SCORING:248══════════════════════════════════════════════════════════════249 250Rate your confidence (0.0-1.0) based on:251- 0.90-1.00: Very clear genre with distinctive features252- 0.75-0.89: Clear primary genre, some ambiguity in subgenre253- 0.60-0.74: Multiple possible interpretations254- Below 0.60: Highly experimental or genre-defying255 256══════════════════════════════════════════════════════════════257OUTPUT FORMAT (STRICT JSON):258══════════════════════════════════════════════════════════════259 260{{261 "mainGenre": "string (specific subgenre, not broad category)",262 "additionalGenres": ["array", "of", "related", "subgenres"],263 "moods": ["array", "of", "3-6", "mood", "descriptors"],264 "mainInstrument": "string (most prominent)",265 "instrumentation": ["array", "of", "3-5", "instruments"],266 "keywords": ["array", "of", "10-15", "descriptive", "terms"],267 "useCases": ["array", "of", "3-7", "use", "scenarios"],268 "trackDescription": "2-3 sentence professional description for music library",269 "vocalStyle": {{270 "gender": "male|female|mixed|instrumental",271 "timbre": "description if vocals present",272 "delivery": "singing style if applicable",273 "emotionalTone": "vocal emotion if present"274 }},275 "energy_level": "Low|Medium|High|Very High",276 "mood_vibe": "One sentence capturing overall vibe",277 "confidence": 0.85,278 "reasoning": "Brief explanation of classification decisions based on audio features",279 "similar_artists": ["optional array of 3-5 similar artists if confident"]280}}281 282IMPORTANT: Base your analysis PRIMARILY on the audio features provided above, not on assumptions. If features indicate an unexpected combination (e.g., slow BPM but high energy), trust the data and classify accordingly."""283 284 return prompt285 286 def _validate_and_refine_classification(self, raw_result: Dict, audio_features: Dict) -> Dict:287 """288 Post-process LLM output to ensure consistency and accuracy289 """290 if not isinstance(raw_result, dict):291 return raw_result292 293 # Extract features for validation294 rhythm = audio_features.get('rhythm', {})295 energy = audio_features.get('energy', {})296 297 tempo = float(rhythm.get('tempo', 120))298 rms = float(energy.get('rms_mean', 0.1))299 300 # Validation rules301 validated = raw_result.copy()302 303 # Rule 1: Validate BPM-genre consistency304 genre = str(validated.get('mainGenre', '')).lower()305 if 'house' in genre and not (115 <= tempo <= 135):306 validated['confidence'] = validated.get('confidence', 0.8) * 0.8 # Reduce confidence307 reasoning = validated.get('reasoning', '')308 validated['reasoning'] = f"BPM mismatch: {tempo} unusual for {genre}. " + str(reasoning)309 310 # Rule 2: Validate energy-mood consistency311 moods = validated.get('moods', [])312 if rms > 0.18 and moods and any(str(m).lower() in ['calm', 'peaceful', 'relaxed'] for m in moods):313 # Remove contradictory moods314 validated['moods'] = [m for m in moods if str(m).lower() not in ['calm', 'peaceful', 'relaxed']]315 if 'Energetic' not in validated['moods']:316 validated['moods'].append('Energetic')317 318 # Rule 3: Ensure specific subgenres319 broad_genres = ['electronic', 'rock', 'pop', 'hip hop', 'metal']320 if genre in broad_genres:321 validated['confidence'] = validated.get('confidence', 0.8) * 0.7 # Penalize broad classification322 323 # Rule 4: Deduplicate and limit arrays324 if isinstance(validated.get('moods'), list):325 validated['moods'] = list(set(validated.get('moods', [])))[:6]326 if isinstance(validated.get('keywords'), list):327 validated['keywords'] = list(set(validated.get('keywords', [])))[:15]328 if isinstance(validated.get('additionalGenres'), list):329 validated['additionalGenres'] = list(set(validated.get('additionalGenres', [])))[:4]330 331 return validated332 333 async def _groq_classify(self, context: str, system_prompt: str = None, retries: int = 3) -> Dict:334 """335 Groq: Llama 3.3 70B with Retry Logic336 """337 if not self.groq_key:338 return {'error': 'no_api_key'}339 340 from groq import Groq341 client = Groq(api_key=self.groq_key)342 343 if system_prompt:344 messages = [345 {"role": "system", "content": system_prompt},346 {"role": "user", "content": context}347 ]348 else:349 # Fallback for legacy calls (should not be reached in new flow)350 prompt = f"""{context}351 352 STRICT INSTRUCTIONS FROM MUSIC SUPERVISOR:353 1. Use these STANDARD LISTS as a guide (choose from them when applicable, but stay accurate):354 - GENRES: {", ".join(MAIN_GENRES[:20])}...355 - SUB-GENRES: {", ".join(SUB_GENRES[:20])}...356 - MOODS: {", ".join(MOODS[:20])}...357 - INSTRUMENTS: {", ".join(INSTRUMENTATION[:20])}...358 359 2. STRICT QUANTITY REQUIREMENTS:360 - mainGenre: EXACTLY 1 tag361 - additionalGenres: 1-2 tags (Sub-Genres)362 - moods: 2-3 tags363 - instrumentation: 2-3 tags364 - mainInstrument: EXACTLY 1 tag. BAN "Vocals" as mainInstrument. If vocal-heavy, choose the backing instrument (e.g., Synthesizer, Guitar, Piano).365 - keywords: EXACTLY 5 tags366 - useCases: EXACTLY 3 examples367 - trackDescription: MINIMUM 400 characters. Emotional, practical, marketing-ready bio. NOT technical analysis.368 - mood_vibe: REQUIRED. detailed atmospheric description.369 - energy_level: REQUIRED.370 371 3. VOCAL STYLE RULES:372 - If instrumental: "gender": "Instrumental", others "none".373 - If vocals exist: NEVER use "none". Guess "Male", "Female", "Duet" or "Processed". Populate timbre/delivery/emotionalTone.374 375 4. Never return empty arrays or placeholders like "No tags" – always provide the best possible tags.376 377 5. RETURN STRICT JSON378 """379 messages = [{"role": "user", "content": prompt}]380 381 for attempt in range(retries):382 try:383 response = client.chat.completions.create(384 model="llama-3.3-70b-versatile",385 messages=messages,386 temperature=0.2,387 max_tokens=1000,388 response_format={"type": "json_object"}389 )390 391 content = response.choices[0].message.content392 if not content:393 raise ValueError("Empty response from Groq")394 395 result = json.loads(content)396 result['llm_source'] = 'groq'397 return result398 except Exception as e:399 logger.warning(f"Groq attempt {attempt+1} failed: {e}")400 if attempt == retries - 1:401 logger.error(f"Groq classification failed after {retries} attempts")402 return {'error': str(e), 'llm_source': 'groq'}403 await asyncio.sleep(2 ** attempt)404 405 async def _gemini_classify(self, context: str, system_prompt: str = None, retries: int = 3) -> Dict:406 """407 Gemini 2.0 Flash with REST Transport & Retry Logic408 """409 if not self.gemini_key:410 return {'error': 'no_api_key'}411 412 try:413 import google.generativeai as genai414 genai.configure(api_key=self.gemini_key, transport="rest")415 416 # Configure model with system instruction if possible or fallback417 model = genai.GenerativeModel('gemini-2.0-flash-exp')418 419 if system_prompt:420 prompt = f"""SYSTEM INSTRUCTIONS:421{system_prompt}422 423USER REQUEST:424{context}425 426Response must be valid JSON."""427 else:428 # Fallback legacy prompt429 prompt = f"""{context}430 431STRICT INSTRUCTIONS FROM MUSIC SUPERVISOR:4321. Use these STANDARD LISTS as a guide:433 - GENRES: {", ".join(MAIN_GENRES)}...434 - SUB-GENRES: {", ".join(SUB_GENRES)}...435 - MOODS: {", ".join(MOODS)}...436 4372. STRICT QUANTITY REQUIREMENTS:438 - mainGenre: EXACTLY 1 tag439 - additionalGenres: 1-2 tags440 - moods: 2-3 tags441 - instrumentation: 2-3 tags442 - mainInstrument: EXACTLY 1 tag. BAN "Vocals". Use backing instrument.443 - keywords: EXACTLY 5 tags444 - useCases: EXACTLY 3 examples445 - trackDescription: MINIMUM 400 characters. Emotional, practical, marketing-ready bio. NOT technical.446 4473. VOCAL STYLE RULES:448 - If instrumental: "gender": "Instrumental", others "none".449 - If vocals exist: NEVER use "none". Populate all fields.450 4514. Never return empty arrays or placeholders like "No tags".452 453Analyze this track and return STRICT JSON:454{{455 "mainGenre": "string",456 "additionalGenres": ["string", "string"],457 "moods": ["string", "string", "string"],458 "mainInstrument": "string (NOT Vocals)",459 "instrumentation": ["string", "string"],460 "vocalStyle": {{461 "gender": "Male/Female/Instrumental",462 "timbre": "string",463 "delivery": "string",464 "emotionalTone": "string"465 }},466 "keywords": ["k1", "k2", "k3", "k4", "k5"],467 "useCases": ["u1", "u2", "u3"],468 "mood_vibe": "Detailed description (REQUIRED)",469 "energy_level": "Low/Medium/High (REQUIRED)",470 "musicalEra": "string (REQUIRED)",471 "productionQuality": "string (REQUIRED)",472 "dynamics": "string (REQUIRED)",473 "targetAudience": "string (REQUIRED)",474 "trackDescription": "Engaging, emotional, market-ready description (min 400 chars).",475 "similar_artists": ["string"],476 "confidence": 0.95477}}"""478 479 for attempt in range(retries):480 try:481 response = await asyncio.to_thread(482 model.generate_content,483 prompt,484 generation_config={"response_mime_type": "application/json", "temperature": 0.4}485 )486 487 result = json.loads(response.text)488 result['llm_source'] = 'gemini'489 return result490 except Exception as e:491 logger.warning(f"Gemini attempt {attempt+1} failed: {e}")492 if "429" in str(e) or "Quota" in str(e):493 if attempt == retries - 1:494 return {'error': 'quota_exceeded', 'llm_source': 'gemini'}495 await asyncio.sleep(2 + (attempt * 2)) # Aggressive backoff for quota496 elif attempt == retries - 1:497 raise498 else:499 await asyncio.sleep(2 ** attempt)500 501 except Exception as e:502 logger.error(f"Gemini classification failed: {e}")503 return {'error': str(e), 'llm_source': 'gemini'}504 505 async def _claude_classify(self, context: str, system_prompt: str = None) -> Dict:506 """507 Claude Sonnet 3.5508 """509 if not self.claude_key:510 return {'error': 'no_api_key'}511 512 try:513 import aiohttp514 515 if system_prompt:516 system_arg = system_prompt517 user_content = context518 else:519 system_arg = "You are a helpful music tagging assistant."520 user_content = f"""{context}521 522STRICT INSTRUCTIONS FROM MUSIC SUPERVISOR:5231. Use these STANDARD LISTS as a guide:524 - GENRES: {", ".join(MAIN_GENRES)}...525 - SUB-GENRES: {", ".join(SUB_GENRES)}...526 - MOODS: {", ".join(MOODS)}...527 5282. STRICT QUANTITY REQUIREMENTS:529 - mainGenre: EXACTLY 1 tag530 - additionalGenres: 1-2 tags531 - moods: 2-3 tags532 - instrumentation: 2-3 tags533 - mainInstrument: EXACTLY 1 tag. BAN "Vocals". Use backing instrument.534 - keywords: EXACTLY 5 tags535 - useCases: EXACTLY 3 examples536 - trackDescription: MINIMUM 400 characters. Emotional, practical, marketing-ready bio. NOT technical.537 5383. VOCAL STYLE RULES:539 - If instrumental: "gender": "Instrumental", others "none".540 - If vocals exist: NEVER use "none". Populate all fields.541 5424. Never return empty arrays or placeholders like "No tags".543 544Analyze this track and return STRICT JSON:545{{546 "mainGenre": "string",547 "additionalGenres": ["string", "string"],548 "moods": ["string", "string", "string"],549 "mainInstrument": "string (NOT Vocals)",550 "instrumentation": ["string", "string"],551 "vocalStyle": {{"gender": "Male/Female/Instrumental", "timbre": "string", "delivery": "string", "emotionalTone": "string"}},552 "keywords": ["k1", "k2", "k3", "k4", "k5"],553 "useCases": ["u1", "u2", "u3"],554 "mood_vibe": "Detailed description (REQUIRED)",555 "energy_level": "Low/Medium/High (REQUIRED)",556 "musicalEra": "string (REQUIRED)",557 "productionQuality": "string (REQUIRED)",558 "dynamics": "string (REQUIRED)",559 "targetAudience": "string (REQUIRED)",560 "trackDescription": "Engaging, emotional, market-ready description (min 400 chars).",561 "similar_artists": ["string"],562 "confidence": 0.92563}}"""564 565 async with aiohttp.ClientSession() as session:566 payload = {567 'model': 'claude-3-5-sonnet-20241022',568 'max_tokens': 1000,569 'messages': [{'role': 'user', 'content': f"Return ONLY valid JSON: {user_content}"}]570 }571 572 if system_arg:573 payload['system'] = system_arg574 575 async with session.post(576 'https://api.anthropic.com/v1/messages',577 headers={'x-api-key': self.claude_key, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json'},578 json=payload,579 timeout=aiohttp.ClientTimeout(total=20)580 ) as resp:581 data = await resp.json()582 if 'error' in data:583 logger.error(f"Claude API error: {data['error']}")584 return {'error': str(data['error']), 'llm_source': 'claude'}585 586 text = data['content'][0]['text']587 if "```json" in text:588 text = text.split("```json")[1].split("```")[0]589 result = json.loads(text)590 result['llm_source'] = 'claude'591 return result592 except Exception as e:593 logger.error(f"Claude classification failed: {e}")594 return {'error': str(e), 'llm_source': 'claude'}595 596 def _vote(self, results: List[Dict]) -> Dict:597 """598 Extended Consensus algorithm599 """600 def get_best_string(field, default="Unknown"):601 # Normalize keys if needed or check slightly different casing602 values = []603 for r in results:604 val = r.get(field)605 if val and isinstance(val, str):606 values.append(val)607 608 votes = Counter(values)609 return votes.most_common(1)[0][0] if votes else default610 611 def get_consensus_list(field, max_items=5, min_votes=1):612 votes = Counter()613 for r in results:614 items = r.get(field, [])615 if isinstance(items, list):616 for item in items:617 if isinstance(item, str):618 votes[item.lower().strip()] += 1619 return [i for i, count in votes.items() if count >= min_votes][:max_items]620 621 # Basic Fields622 main_genre = get_best_string('mainGenre')623 624 # CRITICAL FIX: If Main Genre is Unknown, force fallback classification625 if main_genre == "Unknown":626 logger.warning("LLMs returned 'Unknown' for Main Genre. Triggering full fallback.")627 # We need audio_features to run fallback. 628 # In _vote we only have results. 629 # Strategy: Use the first result's structure if possible, or return a special flag?630 # Better: In consensus_classification, check the result of _vote.631 pass 632 633 main_instrument = get_best_string('mainInstrument')634 635 # Aggregate Lists636 additional_genres = get_consensus_list('additionalGenres', 3)637 moods = get_consensus_list('moods', 5, min_votes=2)638 if not moods: moods = get_consensus_list('moods', 3, min_votes=1) # Fallback639 640 instrumentation = get_consensus_list('instrumentation', 8, min_votes=1)641 keywords = get_consensus_list('keywords', 15, min_votes=1)642 use_cases = get_consensus_list('useCases', 5, min_votes=1)643 similar_artists = get_consensus_list('similar_artists', 5, min_votes=2)644 if not similar_artists: similar_artists = get_consensus_list('similar_artists', 3, min_votes=1)645 646 # Vocal Style Consensus647 vocal_styles = [r.get('vocalStyle') for r in results if isinstance(r.get('vocalStyle'), dict)]648 cons_vocal = {"gender": "none", "timbre": "none", "delivery": "none", "emotionalTone": "none"}649 650 if vocal_styles:651 # 1. Determine Gender (Primary Driver)652 genders = [str(vs.get('gender', 'none')).lower().strip() for vs in vocal_styles]653 valid_genders = [g for g in genders if g != 'none']654 655 if valid_genders:656 cons_vocal['gender'] = Counter(valid_genders).most_common(1)[0][0]657 else:658 cons_vocal['gender'] = 'none' # or 'instrumental' if we want to be safe, but 'none' is honest659 660 # 2. If Instrumental, force others to none661 if cons_vocal['gender'] == 'instrumental':662 cons_vocal['timbre'] = 'none'663 cons_vocal['delivery'] = 'none'664 cons_vocal['emotionalTone'] = 'none'665 else:666 # 3. For others, filter 'none' and pick best non-empty value667 for part in ["timbre", "delivery", "emotionalTone"]:668 vals = [str(vs.get(part, 'none')).lower().strip() for vs in vocal_styles]669 valid_vals = [v for v in vals if v != 'none']670 if valid_vals:671 cons_vocal[part] = Counter(valid_vals).most_common(1)[0][0]672 else:673 cons_vocal[part] = 'none'674 675 # Vibe & Energy (take most frequent or longest)676 # Improved Fallback: If empty, try to construct from mood/genre677 mood_vibe = get_best_string('mood_vibe', '')678 if not mood_vibe and moods:679 mood_vibe = f"{moods[0]} atmosphere with {main_genre} elements."680 elif not mood_vibe:681 mood_vibe = "Dynamic musical composition."682 683 energy_level = get_best_string('energy_level', 'Medium')684 musical_era = get_best_string('musicalEra', 'Modern')685 prod_quality = get_best_string('productionQuality', 'Studio Polished')686 dynamics = get_best_string('dynamics', 'Medium')687 audience = get_best_string('targetAudience', 'General')688 689 # Description (take longest/most detailed)690 descriptions = [r.get('trackDescription') for r in results if r.get('trackDescription') and isinstance(r.get('trackDescription'), str)]691 track_desc = max(descriptions, key=len) if descriptions else "No description available."692 693 # Confidence Calculation694 agreement = len([r for r in results if str(r.get('mainGenre', '')).lower() == main_genre.lower()]) / len(results) if results else 0695 avg_conf = np.mean([r.get('confidence', 0.8) for r in results if 'confidence' in r]) if results else 0696 final_conf = round((agreement * 0.4 + avg_conf * 0.6), 2)697 698 return {699 "mainGenre": main_genre,700 "additionalGenres": additional_genres,701 "moods": moods,702 "mainInstrument": main_instrument,703 "instrumentation": instrumentation,704 "vocalStyle": cons_vocal,705 "keywords": keywords,706 "useCases": use_cases,707 "trackDescription": track_desc,708 "mood_vibe": mood_vibe,709 "energy_level": energy_level,710 "musicalEra": musical_era,711 "productionQuality": prod_quality,712 "dynamics": dynamics,713 "targetAudience": audience,714 "similar_artists": similar_artists,715 "confidence": final_conf,716 "meta": {717 "llm_count": len(results),718 "agreement_rate": f"{agreement*100:.0f}%",719 "sources": [r.get('llm_source') for r in results]720 }721 }722 723 def _fallback_classification(self, audio_features: Dict) -> Dict:724 """725 Deterministyczna, awaryjna klasyfikacja oparta wyłącznie na danych DSP.726 Gwarantuje brak "Unknown" tagów.727 """728 logger.warning("Using DSP-only fallback classification (no LLMs available)")729 rhythm = audio_features.get('rhythm', {})730 energy = audio_features.get('energy', {})731 harmonic = audio_features.get('harmonic', {})732 spectral = audio_features.get('spectral', {})733 meta = audio_features.get('meta', {})734 735 tempo = float(rhythm.get('tempo', 120))736 rms = float(energy.get('rms_mean', 0.1))737 zcr = float(energy.get('zcr_mean', 0.1))738 centroid = float(spectral.get('centroid_mean', 2000))739 flatness = float(spectral.get('flatness_mean', 0.5))740 duration = float(meta.get('duration', 0.0))741 742 # Default Fallback (Safe Middle Ground)743 genre = 'Pop'744 moods = ['Happy', 'Bright']745 energy_level = 'Medium'746 mood_vibe = 'Upbeat and accessible pop soundscape.'747 748 # 1. Very Low BPM (Ambient, Downtempo)749 if tempo < 80:750 if zcr < 0.05 and flatness < 0.2:751 genre = 'Ambient'752 moods = ['Calm', 'Ethereal', 'Meditative']753 energy_level = 'Low'754 mood_vibe = 'Slow, spacious ambient textures with minimal rhythmic elements.'755 elif rms > 0.15:756 genre = 'Dubstep' # Slow but heavy757 moods = ['Heavy', 'Dark', 'Aggressive']758 energy_level = 'High'759 mood_vibe = 'Heavy, slow-tempo bass music with aggressive wobble bass.'760 else:761 genre = 'Downtempo'762 moods = ['Relaxed', 'Chill', 'Groovy']763 energy_level = 'Low'764 mood_vibe = 'Laid-back downtempo groove with relaxed atmosphere.'765 766 # 2. Low-Mid BPM (Hip Hop, R&B, Lo-Fi)767 elif 80 <= tempo < 105:768 if flatness < 0.3 and rms < 0.12:769 genre = 'Lo-Fi'770 moods = ['Nostalgic', 'Mellow', 'Relaxed']771 energy_level = 'Low'772 mood_vibe = 'Dusty, nostalgic lo-fi beat with warm textures.'773 elif rms > 0.15:774 genre = 'Hip Hop'775 moods = ['Confident', 'Urban', 'Rhythmic']776 energy_level = 'Medium'777 mood_vibe = 'Punchy hip-hop beat with strong kick and snare groove.'778 else:779 genre = 'R&B'780 moods = ['Smooth', 'Romantic', 'Soulful']781 energy_level = 'Medium'782 mood_vibe = 'Smooth R&B flow with soulful instrumentation.'783 784 # 3. Mid BPM (Pop, Rock, Disco, Moombahton)785 elif 105 <= tempo < 118:786 if zcr > 0.1:787 genre = 'Rock'788 moods = ['Energetic', 'Raw', 'Driving']789 energy_level = 'High'790 mood_vibe = 'Driving rock rhythm with energetic guitar textures.'791 elif flatness > 0.4:792 genre = 'Moombahton'793 moods = ['Danceable', 'Tropical', 'Fun']794 energy_level = 'High'795 mood_vibe = 'Rhythmic moombahton beat with reggaeton influence.'796 else:797 genre = 'Pop'798 moods = ['Catchy', 'Upbeat', 'Radio-Ready']799 energy_level = 'Medium'800 mood_vibe = 'Modern pop arrangement with accessible melody and rhythm.'801 802 # 4. House Range (House, Tech House, Deep House)803 elif 118 <= tempo < 128:804 if flatness < 0.35 and centroid < 3000:805 genre = 'Deep House'806 moods = ['Deep', 'Hypnotic', 'Sophisticated']807 energy_level = 'Medium'808 mood_vibe = 'Warm, deep house groove with soulful elements.'809 elif flatness > 0.5:810 genre = 'Electro House'811 moods = ['Aggressive', 'Dirty', 'Party']812 energy_level = 'High'813 mood_vibe = 'Dirty electro basslines with punchy drums.'814 else:815 genre = 'House'816 moods = ['Groovy', 'Uplifting', 'Club']817 energy_level = 'High'818 mood_vibe = 'Classic house four-on-the-floor beat with uplifting energy.'819 820 # 5. Techno/Trance Range821 elif 128 <= tempo < 145:822 if flatness > 0.55:823 genre = 'Trance'824 moods = ['Euphoric', 'Soaring', 'Epic']825 energy_level = 'Very High'826 mood_vibe = 'Euphoric trance energy with big supersaw chords.'827 elif rms > 0.18 and centroid < 4000:828 genre = 'Techno'829 moods = ['Dark', 'Industrial', 'Driving']830 energy_level = 'High'831 mood_vibe = 'Driving, mechanical techno rhythm with repetitive elements.'832 else:833 genre = 'EDM'834 moods = ['Big Room', 'Festival', 'Energetic']835 energy_level = 'High'836 mood_vibe = 'Festival-ready EDM sound with high energy drops.'837 838 # 6. Fast BPM (Dubstep, Trap, DnB)839 elif 145 <= tempo < 165:840 if rms > 0.2:841 genre = 'Dubstep'842 moods = ['Aggressive', 'Heavy', 'Chaotic']843 energy_level = 'Very High'844 mood_vibe = 'High-tempo dubstep energy with aggressive bass design.'845 else:846 genre = 'Trap'847 moods = ['Hype', 'Dark', 'Urban']848 energy_level = 'High'849 mood_vibe = 'Fast trap hi-hats with deep 808 bass.'850 851 # 7. Very Fast (DnB, Hardstyle)852 elif tempo >= 165:853 genre = 'Drum & Bass'854 moods = ['Fast', 'Intense', 'Liquid']855 energy_level = 'Very High'856 mood_vibe = 'Fast-paced drum & bass breakbeats with high energy.'857 858 # Instrument Mapping based on Genre859 if genre in ('Electronic', 'House', 'Techno', 'Trance', 'EDM', 'Dubstep', 'Trap', 'Drum & Bass', 'Deep House', 'Electro House', 'Moombahton', 'Downtempo', 'Ambient'):860 main_instrument = 'Synthesizer'861 instrumentation = ['Synthesizer', 'Drum Machine', 'Bass Synth', 'FX']862 additional_genres = ['Electronic', 'Club']863 elif genre in ('Rock', 'Metal', 'Punk'):864 main_instrument = 'Electric Guitar'865 instrumentation = ['Electric Guitar', 'Bass Guitar', 'Drum Kit', 'Vocals']866 additional_genres = ['Alternative']867 elif genre in ('Hip Hop', 'R&B', 'Lo-Fi'):868 main_instrument = 'Sampler'869 instrumentation = ['Sampler', 'Drum Machine', 'Synthesizer']870 additional_genres = ['Urban']871 elif genre == 'Classical':872 main_instrument = 'Piano'873 instrumentation = ['Piano', 'Strings', 'Woodwinds']874 additional_genres = ['Orchestral']875 elif genre == 'Acoustic':876 main_instrument = 'Acoustic Guitar'877 instrumentation = ['Acoustic Guitar', 'Percussion', 'Vocals']878 additional_genres = ['Folk']879 else: # Pop and others880 main_instrument = 'Vocals'881 instrumentation = ['Vocals', 'Synthesizer', 'Drum Kit']882 additional_genres = ['Commercial']883 884 # Keywords generation885 keywords = [genre.lower(), moods[0].lower()]886 if len(moods) > 1: keywords.append(moods[1].lower())887 keywords.append(main_instrument.lower().replace(' ', ''))888 keywords.append(energy_level.lower())889 890 # Ensure description is set891 track_desc = f"A {energy_level.lower()} energy {genre} track featuring {main_instrument} and {moods[0].lower()} atmosphere. {mood_vibe}"892 893 return {894 "mainGenre": genre,895 "additionalGenres": additional_genres,896 "moods": moods,897 "mainInstrument": main_instrument,898 "instrumentation": instrumentation,899 "vocalStyle": {"gender": "none", "timbre": "none", "delivery": "none", "emotionalTone": "none"},900 "keywords": keywords,901 "useCases": ["Background Music", "Advertising", "Social Media"],902 "trackDescription": track_desc,903 "mood_vibe": mood_vibe,904 "energy_level": energy_level,905 "musicalEra": "Modern",906 "productionQuality": "Studio Polished",907 "dynamics": "High",908 "targetAudience": "General",909 "similar_artists": [],910 "confidence": 0.65,911 "meta": {"source": "dsp_fallback"}912 }913 