hardbanrecords/Metadata-Engine
0
1"""2Advanced Audio Analysis Service3Zero-cost local audio analysis using open-source libraries.4"""5 6import os7import logging8import numpy as np9from typing import Dict, Any10 11logger = logging.getLogger(__name__)12 13 14# === LAZY IMPORTS (to avoid startup crashes if lib missing) ===15def get_librosa():16 import librosa17 18 return librosa19 20 21def get_soundfile():22 import soundfile as sf23 24 return sf25 26 27# No crepe here to avoid tensorflow dependency28 29 30def get_pyloudnorm():31 import pyloudnorm as pyln32 33 return pyln34 35 36def get_tinytag():37 from tinytag import TinyTag38 39 return TinyTag40 41 42def get_spleeter():43 from spleeter.separator import Separator44 45 return Separator46 47 48class AdvancedAudioAnalyzer:49 """50 Comprehensive audio analysis using local, zero-cost libraries.51 """52 53 @staticmethod54 def is_available() -> bool:55 try:56 import librosa57 58 return True59 except ImportError:60 return False61 62 @staticmethod63 def _interpret_vibe(scale: str, energy: float) -> str:64 """Logic for Szwadron Hydra (Marketing) interpretations."""65 scale = scale.lower()66 if scale == "minor":67 return "Deep/Atmospheric" if energy < 0.05 else "Dark/Melancholic"68 return "Energetic/Happy" if energy > 0.08 else "Chill/Calm"69 70 @staticmethod71 def analyze_with_essentia(file_path: str, fast: bool = False) -> Dict[str, Any]:72 """73 Analyze audio using Essentia (User Preferred Method).74 """75 try:76 import essentia.standard as es77 import numpy as np78 logger.info(f"Using Essentia Standard for analysis (Fast Mode: {fast})...")79 80 # Loader81 loader = es.MonoLoader(filename=file_path)82 audio = loader()83 duration = len(audio) / 44100.084 85 # Rhythm86 # We use "multifeature" (High Quality) but on a smart slice to meet 20s limit87 # This ensures Professional Accuracy without timeouts88 analysis_audio = audio89 if fast and duration > 90:90 # Smart Slicing: Analyze middle 90s for best BPM/Key detection91 # This drastically reduces CPU time while keeping algorithm quality MAXED92 mid_point = duration / 293 start_sample = int((mid_point - 45) * 44100)94 end_sample = int((mid_point + 45) * 44100)95 # Bounds check96 start_sample = max(0, start_sample)97 end_sample = min(len(audio), end_sample)98 analysis_audio = audio[start_sample:end_sample]99 logger.info(f"Smart Slicing active: Analyzing {len(analysis_audio)/44100:.1f}s segment")100 101 rhythm_extractor = es.RhythmExtractor2013(method="multifeature")102 bpm, ticks, confidence, estimates, bpm_intervals = rhythm_extractor(analysis_audio)103 104 # Key105 key_extractor = es.KeyExtractor()106 key, scale, strength = key_extractor(analysis_audio)107 108 # Extra metrics to avoid Librosa double-load109 # Energy (RMS) - Calculate on full audio for accuracy, it's fast (numpy)110 energy_mean = float(np.sqrt(np.mean(audio**2)))111 112 # Danceability - Calculate on slice (expensive)113 danceability, _ = es.Danceability()(analysis_audio)114 115 # Zero Crossing Rate - Calculate on slice116 zcr = es.ZeroCrossingRate()(analysis_audio)117 118 # Essentia returns BPM as float, Key as string119 return {120 "bpm": round(float(bpm), 1),121 "key": key,122 "mode": scale, # Essentia returns 'major'/'minor' (lowercase usually)123 "full_key": f"{key} {scale}",124 "energy_mean": energy_mean,125 "danceability": danceability,126 "duration": duration,127 "zcr": zcr,128 "success": True129 }130 except Exception as e:131 logger.warning(f"Essentia analysis failed: {e}")132 return {"success": False, "error": str(e)}133 134 @staticmethod135 def analyze_core(file_path: str, fast: bool = False) -> Dict[str, Any]:136 """137 Core analysis: BPM, Key, Spectral features, Duration.138 Priority: Essentia -> Librosa139 """140 141 # 1. Try Essentia First (User Request)142 essentia_results = AdvancedAudioAnalyzer.analyze_with_essentia(file_path, fast=fast)143 144 # FAST PATH: If Essentia succeeded and we are in fast mode, return immediately145 # This avoids double-loading audio (Essentia + Librosa) which saves ~5-10s146 if fast and essentia_results["success"]:147 final_mode = essentia_results["mode"].capitalize()148 energy_mean = essentia_results["energy_mean"]149 mood_vibe = AdvancedAudioAnalyzer._interpret_vibe(final_mode, energy_mean)150 151 detected_moods = ["Uplifting"] if final_mode == "Major" else ["Emotional"]152 153 return {154 "bpm": essentia_results["bpm"],155 "key": essentia_results["key"],156 "mode": final_mode,157 "full_key": f"{essentia_results['key']} {final_mode}",158 "mood_vibe": mood_vibe,159 "energy_level": round(energy_mean, 4),160 "duration_seconds": round(essentia_results["duration"], 2),161 "structure": [],162 "moods": detected_moods,163 "danceability": round(essentia_results.get("danceability", 0.0), 2),164 "spectral": {165 "brightness": "bright" if essentia_results.get("zcr", 0) > 0.05 else "warm"166 }167 }168 169 # 2. Run Librosa for spectral features and fallback170 librosa = get_librosa()171 172 try:173 # Load audio for Librosa (needed for spectral features anyway)174 y, sr = librosa.load(file_path, duration=20 if fast else 60)175 176 rms = librosa.feature.rms(y=y)177 energy_mean = float(np.mean(rms))178 energy_max = float(np.max(rms))179 energy_std = float(np.std(rms))180 181 duration = len(y) / sr182 structure = []183 detected_moods = []184 185 # --- MERGE RESULTS ---186 if essentia_results["success"]:187 # Use Essentia values for BPM/Key188 final_bpm = essentia_results["bpm"]189 final_key = essentia_results["key"]190 final_mode = essentia_results["mode"].capitalize() # Ensure Title Case191 else:192 # Librosa Fallback for BPM/Key193 tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr)194 final_bpm = float(tempo) if isinstance(tempo, (int, float)) else float(tempo[0])195 196 chroma = librosa.feature.chroma_stft(y=y, sr=sr)197 key_idx = int(np.argmax(np.mean(chroma, axis=1)))198 keys = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]199 final_key = keys[key_idx]200 201 major_energy = np.mean(chroma[(key_idx + 4) % 12])202 minor_energy = np.mean(chroma[(key_idx + 3) % 12])203 final_mode = "Major" if major_energy > minor_energy else "Minor"204 205 if final_mode == "Major":206 detected_moods.append("Uplifting")207 else:208 detected_moods.append("Emotional")209 210 # Derived "Hardban OS" metrics for premium feel211 mood_vibe = AdvancedAudioAnalyzer._interpret_vibe(final_mode, energy_mean)212 213 if fast:214 # Fast mode: Simplified metadata derivation215 # ERA - Estimate based on energy profile216 if energy_mean > 0.08:217 era = "2020s" # Modern high-energy production218 elif energy_mean > 0.05:219 era = "2010s"220 else:221 era = "Classic" # Lower energy, vintage feel222 223 # QUALITY - Based on energy standard deviation224 if energy_std > 0.08:225 quality = "Professional"226 elif energy_std > 0.04:227 quality = "Studio"228 else:229 quality = "Demo"230 231 # DYNAMICS - Based on energy standard deviation232 if energy_std > 0.07:233 dynamics = "High"234 elif energy_std > 0.04:235 dynamics = "Medium"236 else:237 dynamics = "Compressed"238 239 # AUDIENCE - Based on energy characteristics240 if energy_mean > 0.08:241 audience = "Mainstream"242 elif energy_mean < 0.05:243 audience = "Indie"244 else:245 audience = "Commercial"246 247 return {248 "bpm": final_bpm,249 "key": final_key,250 "mode": final_mode,251 "full_key": f"{final_key} {final_mode}",252 "mood_vibe": mood_vibe,253 "energy_level": round(energy_mean, 4),254 "duration_seconds": round(duration, 2),255 "structure": [],256 "moods": detected_moods,257 "era": era,258 "quality": quality,259 "dynamics": dynamics,260 "audience": audience,261 }262 263 spectral_centroid = float(np.mean(librosa.feature.spectral_centroid(y=y, sr=sr)))264 spectral_rolloff = float(np.mean(librosa.feature.spectral_rolloff(y=y, sr=sr)))265 spectral_bandwidth = float(np.mean(librosa.feature.spectral_bandwidth(y=y, sr=sr)))266 spectral_flatness = float(np.mean(librosa.feature.spectral_flatness(y=y)))267 spectral_contrast = float(np.mean(librosa.feature.spectral_contrast(y=y, sr=sr)))268 zero_crossing_rate = float(np.mean(librosa.feature.zero_crossing_rate(y)))269 270 onset_env = librosa.onset.onset_strength(y=y, sr=sr)271 pulse = librosa.beat.plp(onset_envelope=onset_env, sr=sr)272 danceability = float(np.mean(pulse))273 274 mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13)275 mfcc_mean = [float(x) for x in np.mean(mfcc, axis=1)]276 277 rms_smooth = np.convolve(rms[0], np.ones(10)/10, mode='same')278 structure = []279 peak_idx = np.argmax(rms_smooth)280 peak_time = float(librosa.frames_to_time(peak_idx, sr=sr))281 structure.append({282 "section": "Core Segment",283 "startTime": round(max(0, peak_time - 10), 2),284 "endTime": round(peak_time + 10, 2),285 "description": "Peak energy flow / Main hook area."286 })287 288 # Full mode: Advanced metadata derivation with spectral features289 # ERA - Estimate based on production characteristics290 if spectral_centroid > 4000:291 era = "2020s" # Modern bright production292 elif spectral_centroid > 3000:293 era = "2010s"294 elif spectral_centroid > 2000:295 era = "2000s"296 else:297 era = "Classic" # Warmer, vintage sound298 299 # QUALITY - Based on spectral flatness and dynamic range300 if energy_std > 0.08 and spectral_flatness < 0.5:301 quality = "Professional"302 elif energy_std > 0.04:303 quality = "Studio"304 else:305 quality = "Demo"306 307 # DYNAMICS - Based on energy standard deviation308 if energy_std > 0.07:309 dynamics = "High"310 elif energy_std > 0.04:311 dynamics = "Medium"312 else:313 dynamics = "Compressed"314 315 # AUDIENCE - Based on complexity and energy316 if energy_mean > 0.08 and spectral_contrast > 20:317 audience = "Mainstream"318 elif energy_mean < 0.05:319 audience = "Indie"320 elif spectral_contrast < 15:321 audience = "Underground"322 else:323 audience = "Commercial"324 325 return {326 "bpm": final_bpm,327 "key": final_key,328 "mode": final_mode,329 "full_key": f"{final_key} {final_mode}",330 "mood_vibe": mood_vibe,331 "energy_level": round(energy_mean, 4),332 "duration_seconds": round(duration, 2),333 "structure": structure,334 "moods": detected_moods, 335 "spectral": {336 "centroid": round(spectral_centroid, 2),337 "rolloff": round(spectral_rolloff, 2),338 "bandwidth": round(spectral_bandwidth, 2),339 "flatness": round(spectral_flatness, 6),340 "contrast": round(spectral_contrast, 2),341 "zero_crossing_rate": round(zero_crossing_rate, 4),342 "brightness": "bright" if spectral_centroid > 3000 else "warm",343 },344 "energy": {345 "mean": round(energy_mean, 4),346 "peak": round(energy_max, 4),347 "std": round(energy_std, 4),348 "dynamic_range": "high" if energy_std > 0.05 else "compressed",349 },350 "rhythm": {351 "danceability": round(danceability, 2),352 "beat_count": 0, # Simplify353 },354 "mfcc": mfcc_mean,355 "era": era,356 "quality": quality,357 "dynamics": dynamics,358 "audience": audience,359 }360 except Exception as e:361 logger.error(f"Core analysis failed: {e}")362 raise363 364 @staticmethod365 def analyze_loudness(file_path: str) -> Dict[str, Any]:366 """367 Loudness analysis: LUFS, True Peak.368 Uses: pyloudnorm, librosa (for reliable decoding)369 """370 try:371 librosa = get_librosa()372 pyln = get_pyloudnorm()373 374 # Load 60s for a very accurate LUFS estimate without decoding the whole file375 y, sr = librosa.load(file_path, duration=60, mono=False)376 377 # Reshape for pyloudnorm (Samples, Channels)378 if y.ndim == 1:379 data = y.reshape(-1, 1)380 else:381 data = y.T 382 383 meter = pyln.Meter(sr)384 loudness = meter.integrated_loudness(data)385 386 # True Peak387 true_peak = float(np.max(np.abs(y)))388 true_peak_db = 20 * np.log10(true_peak) if true_peak > 0 else -np.inf389 390 # Loudness range (simplified dynamic range)391 loudness_range = 0392 if data.shape[0] > sr * 3:393 # Check first 30s for range if long enough394 segment = data[:sr * 30]395 loudness_range = 8.0 # fallback or implement range logic if needed396 397 # Normalization recommendation398 target_lufs = -14 399 gain_needed = target_lufs - loudness if not np.isinf(loudness) else 0400 401 return {402 "lufs": round(float(loudness), 2) if not np.isinf(loudness) else None,403 "true_peak_db": round(float(true_peak_db), 2) if not np.isinf(true_peak_db) else None,404 "loudness_range_lu": round(float(loudness_range), 2),405 "normalization": {406 "target_lufs": target_lufs,407 "gain_needed_db": round(float(gain_needed), 2),408 "is_compliant": bool(abs(gain_needed) < 1),409 },410 }411 except Exception as e:412 logger.error(f"Loudness analysis failed: {e}")413 return {"error": str(e)}414 415 @staticmethod416 def analyze_pitch(file_path: str) -> Dict[str, Any]:417 """418 Pitch and vocal analysis.419 Uses: librosa.pyin (faster CPU alternative to CREPE)420 """421 try:422 librosa = get_librosa()423 424 # pYIN is very slow on CPU. Limit to 15s to stay under 25s total limit.425 try:426 y, sr = librosa.load(file_path, sr=None, mono=True, duration=15)427 except Exception as load_err:428 logger.warning(f"Librosa load failed, trying safe load: {load_err}")429 return {"error": "Audio load failed"}430 431 # Estimate f0 using pYIN432 # fmin=65 (C2), fmax=2093 (C7) covers most vocal ranges433 f0, voiced_flag, voiced_probs = librosa.pyin(434 y, 435 fmin=librosa.note_to_hz('C2'), 436 fmax=librosa.note_to_hz('C7'), 437 sr=sr438 )439 440 # Filter out NaNs (unvoiced)441 if f0 is None:442 return {"vocal_presence": 0, "message": "No pitch detected"}443 444 confident_freqs = f0[~np.isnan(f0)]445 446 if len(confident_freqs) > 0:447 avg_pitch = float(np.mean(confident_freqs))448 pitch_range = float(np.max(confident_freqs) - np.min(confident_freqs))449 450 # Convert to musical note (simple look-up)451 def freq_to_note(freq):452 if freq <= 0: return "N/A"453 notes = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]454 try:455 note_num = 12 * np.log2(freq / 440) + 49456 if np.isnan(note_num) or np.isinf(note_num): return "N/A"457 note_idx = int(round(note_num) % 12)458 octave = int((round(note_num) + 8) // 12)459 return f"{notes[note_idx]}{octave}"460 except:461 return "N/A"462 463 return {464 "average_pitch_hz": round(avg_pitch, 2),465 "average_note": freq_to_note(avg_pitch),466 "pitch_range_hz": round(pitch_range, 2),467 "vocal_presence": round(len(confident_freqs) / len(f0), 2)468 }469 470 return {"vocal_presence": 0, "message": "Instrumental/No clear pitch"}471 472 except Exception as e:473 logger.error(f"Pitch analysis failed: {e}")474 return {"error": str(e)}475 476 @staticmethod477 def read_metadata(file_path: str) -> Dict[str, Any]:478 """479 Read existing metadata from file.480 Uses: tinytag (fast) or mutagen (detailed)481 """482 483 def safe_str(val):484 """Sanitize strings to ASCII to avoid encoding issues with AI APIs"""485 if val is None:486 return None487 try:488 return str(val).encode("ascii", "replace").decode("ascii")489 except:490 return None491 492 try:493 TinyTag = get_tinytag()494 tag = TinyTag.get(file_path, image=True)495 496 return {497 "title": safe_str(tag.title),498 "artist": safe_str(tag.artist),499 "album": safe_str(tag.album),500 "year": safe_str(tag.year),501 "genre": safe_str(tag.genre),502 "duration": tag.duration,503 "bitrate": tag.bitrate,504 "samplerate": tag.samplerate,505 "channels": tag.channels,506 "has_cover": (507 tag.get_image() is not None if hasattr(tag, "get_image") else False508 ),509 }510 except Exception as e:511 logger.error(f"Metadata read failed: {e}")512 return {"error": str(e)}513 514 @staticmethod515 async def separate_stems(516 file_path: str, output_dir: str, stems: int = 2517 ) -> Dict[str, str]:518 """519 Separate audio into stems (vocals, accompaniment, drums, bass, other).520 Uses: Spleeter521 522 stems: 2 (vocals/accompaniment), 4 (vocals/drums/bass/other), 5 (vocals/drums/bass/piano/other)523 """524 try:525 Separator = get_spleeter()526 527 separator = Separator(f"spleeter:{stems}stems")528 separator.separate_to_file(file_path, output_dir)529 530 # Return paths to separated files531 base_name = os.path.splitext(os.path.basename(file_path))[0]532 stem_dir = os.path.join(output_dir, base_name)533 534 stem_files = {}535 if os.path.exists(stem_dir):536 for f in os.listdir(stem_dir):537 stem_name = os.path.splitext(f)[0]538 stem_files[stem_name] = os.path.join(stem_dir, f)539 540 return stem_files541 except Exception as e:542 logger.error(f"Stem separation failed: {e}")543 return {"error": str(e)}544 545 @staticmethod546 def full_analysis(file_path: str, fast: bool = False) -> Dict[str, Any]:547 """548 Run all available analyses and combine results.549 """550 results = {}551 file_name = os.path.basename(file_path)552 logger.info(f"--- [AudioAnalyzer] Starting analysis for file: {file_name} ---")553 554 # Core analysis (always run)555 try:556 logger.info(f"[AudioAnalyzer] 1/4: Analyzing Core (BPM, Key, Structure)...")557 results["core"] = AdvancedAudioAnalyzer.analyze_core(file_path, fast=fast)558 logger.info(f"[AudioAnalyzer] 1/4: Done.")559 except Exception as e:560 logger.error(f"[AudioAnalyzer] 1/4: Failed: {e}")561 results["core"] = {"error": str(e)}562 563 if fast:564 try:565 logger.info(f"[AudioAnalyzer] 2/2: Reading ID3/Metadata tags...")566 results["existing_metadata"] = AdvancedAudioAnalyzer.read_metadata(567 file_path568 )569 logger.info(f"[AudioAnalyzer] 2/2: Done.")570 except Exception as e:571 logger.error(f"[AudioAnalyzer] 2/2: Failed: {e}")572 results["existing_metadata"] = {"error": str(e)}573 574 logger.info(f"--- [AudioAnalyzer] Finished all tasks for {file_name} ---")575 return results576 577 # Loudness578 try:579 logger.info(f"[AudioAnalyzer] 2/4: Analyzing Loudness (LUFS)...")580 results["loudness"] = AdvancedAudioAnalyzer.analyze_loudness(581 file_path582 )583 logger.info(f"[AudioAnalyzer] 2/4: Done.")584 except Exception as e:585 logger.error(f"[AudioAnalyzer] 2/4: Failed: {e}")586 results["loudness"] = {"error": str(e)}587 588 # Pitch (optional, can be slow)589 try:590 logger.info(f"[AudioAnalyzer] 3/4: Analyzing Pitch (Vocal Presence)...")591 results["pitch"] = AdvancedAudioAnalyzer.analyze_pitch(file_path)592 logger.info(f"[AudioAnalyzer] 3/4: Done.")593 except Exception as e:594 logger.error(f"[AudioAnalyzer] 3/4: Failed: {e}")595 results["pitch"] = {"error": str(e)}596 597 # Existing metadata598 try:599 logger.info(f"[AudioAnalyzer] 4/4: Reading ID3/Metadata tags...")600 results["existing_metadata"] = AdvancedAudioAnalyzer.read_metadata(601 file_path602 )603 logger.info(f"[AudioAnalyzer] 4/4: Done.")604 except Exception as e:605 logger.error(f"[AudioAnalyzer] 4/4: Failed: {e}")606 results["existing_metadata"] = {"error": str(e)}607 608 logger.info(f"--- [AudioAnalyzer] Finished all tasks for {file_name} ---")609 return results610 