CoolFace
Apppublic

hardbanrecords/Metadata-Engine

sourceHugging Faceotherupdated 8mo agoView on Hugging Face
0likes
llm_ensemble.py913 linesDownload Raw Back to services
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