177 lines
5.7 KiB
Python
177 lines
5.7 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = [
|
|
# "librosa>=0.10.0",
|
|
# "numpy",
|
|
# "scipy",
|
|
# "soundfile",
|
|
# ]
|
|
# ///
|
|
|
|
import librosa
|
|
import numpy as np
|
|
import sys
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
def analyze_track(audio_path):
|
|
"""Analyze a single track and return features."""
|
|
print(f" Analyzing: {os.path.basename(audio_path)}")
|
|
|
|
y, sr = librosa.load(audio_path, sr=22050, duration=180)
|
|
|
|
# Tempo
|
|
tempo, _ = librosa.beat.beat_track(y=y, sr=sr)
|
|
tempo = float(tempo) if not hasattr(tempo, '__len__') else float(tempo[0])
|
|
|
|
# Energy
|
|
rms = librosa.feature.rms(y=y)[0]
|
|
energy = float(np.mean(rms))
|
|
energy_std = float(np.std(rms))
|
|
|
|
# Spectral features
|
|
spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr)[0]
|
|
brightness = float(np.mean(spectral_centroid))
|
|
|
|
# Harmonic/Percussive
|
|
y_harmonic, y_percussive = librosa.effects.hpss(y)
|
|
harmonic_ratio = float(np.sum(np.abs(y_harmonic)) / (np.sum(np.abs(y)) + 1e-6))
|
|
|
|
# Chroma
|
|
chroma = librosa.feature.chroma_stft(y=y, sr=sr)
|
|
chroma_mean = float(np.mean(chroma))
|
|
|
|
# Onset strength
|
|
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
|
|
rhythmic_density = float(np.mean(onset_env))
|
|
|
|
# Zero crossing rate
|
|
zcr = librosa.feature.zero_crossing_rate(y)[0]
|
|
percussiveness = float(np.mean(zcr))
|
|
|
|
return {
|
|
"tempo_bpm": tempo,
|
|
"energy_rms": energy,
|
|
"energy_std": energy_std,
|
|
"brightness_hz": brightness,
|
|
"harmonic_ratio": harmonic_ratio,
|
|
"chroma_mean": chroma_mean,
|
|
"rhythmic_density": rhythmic_density,
|
|
"percussiveness": percussiveness,
|
|
}
|
|
|
|
def normalize_features(raw):
|
|
"""Convert raw features to 0-1 Genre Universe scales."""
|
|
# Tempo: 60-180 BPM
|
|
tempo_norm = np.clip((raw["tempo_bpm"] - 60) / 120, 0, 1)
|
|
|
|
# Energy: RMS 0.01-0.25
|
|
energy_norm = np.clip(raw["energy_rms"] / 0.2, 0, 1)
|
|
|
|
# Brightness: 1000-4000 Hz
|
|
brightness_norm = np.clip((raw["brightness_hz"] - 1000) / 3000, 0, 1)
|
|
|
|
# Organic score
|
|
organic_norm = np.clip(raw["harmonic_ratio"] * 1.2 - brightness_norm * 0.2, 0, 1)
|
|
|
|
# Valence estimate (rough)
|
|
valence_norm = np.clip(0.25 + brightness_norm * 0.25 + raw["chroma_mean"] * 0.3, 0, 1)
|
|
|
|
# Danceability
|
|
dance_tempo = 1 - abs(raw["tempo_bpm"] - 120) / 60
|
|
danceability = np.clip(dance_tempo * 0.4 + raw["rhythmic_density"] * 0.35 + energy_norm * 0.25, 0, 1)
|
|
|
|
# Acousticness (related to organic but different)
|
|
acousticness = np.clip(raw["harmonic_ratio"] * 0.7 + (1 - brightness_norm) * 0.3, 0, 1)
|
|
|
|
# Production density
|
|
prod_density = np.clip((1 - raw["harmonic_ratio"]) * 0.5 + raw["energy_std"] * 8 + energy_norm * 0.3, 0, 1)
|
|
|
|
return {
|
|
"position": {
|
|
"valence": round(float(valence_norm), 2),
|
|
"tempo": round(float(tempo_norm), 2),
|
|
"organic": round(float(organic_norm), 2),
|
|
},
|
|
"spikes": {
|
|
"energy": round(float(energy_norm), 2),
|
|
"acousticness": round(float(acousticness), 2),
|
|
"danceability": round(float(danceability), 2),
|
|
"production_density": round(float(prod_density), 2),
|
|
}
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
tracks_dir = Path("surya_tracks")
|
|
mp3_files = list(tracks_dir.glob("*.mp3"))
|
|
|
|
if not mp3_files:
|
|
print("No MP3 files found!")
|
|
sys.exit(1)
|
|
|
|
print(f"\n🎵 ANALYZING {len(mp3_files)} TRACKS FROM SURYA ALBUM\n")
|
|
print("="*60)
|
|
|
|
all_raw = []
|
|
track_results = []
|
|
|
|
for mp3 in sorted(mp3_files):
|
|
raw = analyze_track(str(mp3))
|
|
all_raw.append(raw)
|
|
normalized = normalize_features(raw)
|
|
track_results.append({
|
|
"track": mp3.stem,
|
|
"raw": raw,
|
|
"normalized": normalized
|
|
})
|
|
print(f" Tempo: {raw['tempo_bpm']:.1f} BPM | Energy: {raw['energy_rms']:.3f} | Harmonic: {raw['harmonic_ratio']:.2f}")
|
|
|
|
# Average all tracks
|
|
print("\n" + "="*60)
|
|
print("📊 ALBUM AVERAGES")
|
|
print("="*60)
|
|
|
|
avg_raw = {
|
|
key: np.mean([t[key] for t in all_raw])
|
|
for key in all_raw[0].keys()
|
|
}
|
|
|
|
print(f"\n Average Tempo: {avg_raw['tempo_bpm']:.1f} BPM")
|
|
print(f" Average Energy: {avg_raw['energy_rms']:.4f}")
|
|
print(f" Average Brightness: {avg_raw['brightness_hz']:.1f} Hz")
|
|
print(f" Average Harmonic Ratio: {avg_raw['harmonic_ratio']:.3f}")
|
|
|
|
avg_normalized = normalize_features(avg_raw)
|
|
|
|
print("\n" + "="*60)
|
|
print("🎯 RECOMMENDED DAS POSITION (Album Average)")
|
|
print("="*60)
|
|
|
|
pos = avg_normalized["position"]
|
|
spikes = avg_normalized["spikes"]
|
|
|
|
print(f"\n Position in Genre Universe:")
|
|
print(f" valence: {pos['valence']} (mood: {'sad' if pos['valence'] < 0.4 else 'happy' if pos['valence'] > 0.6 else 'bittersweet'})")
|
|
print(f" tempo: {pos['tempo']} ({'slow' if pos['tempo'] < 0.4 else 'fast' if pos['tempo'] > 0.6 else 'medium'})")
|
|
print(f" organic: {pos['organic']} ({'electronic' if pos['organic'] < 0.4 else 'organic' if pos['organic'] > 0.6 else 'balanced'})")
|
|
|
|
print(f"\n Spike Values:")
|
|
for key, val in spikes.items():
|
|
bar = "█" * int(val * 20) + "░" * (20 - int(val * 20))
|
|
print(f" {key:20} [{bar}] {val}")
|
|
|
|
# Output JSON for programmatic use
|
|
result = {
|
|
"tracks_analyzed": len(mp3_files),
|
|
"track_names": [mp3.stem for mp3 in sorted(mp3_files)],
|
|
"raw_averages": {k: round(v, 4) for k, v in avg_raw.items()},
|
|
"recommended_position": pos,
|
|
"recommended_spikes": spikes,
|
|
}
|
|
|
|
print("\n" + "="*60)
|
|
print("📄 JSON OUTPUT")
|
|
print("="*60)
|
|
print(json.dumps(result, indent=2))
|