initial commit
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: audio-sonic-analysis
|
||||
description: Batch sonic/spectral analysis of music folders — tempo, energy, brightness, key estimation via librosa. Useful for Plex Sonic Analysis prep and music library characterization.
|
||||
category: media
|
||||
---
|
||||
|
||||
# Audio Sonic Analysis
|
||||
|
||||
Batch-extract sonic features from music folders for Plex Sonic Analysis or general library characterization.
|
||||
|
||||
## Triggers
|
||||
|
||||
- User wants sonic analysis, spectral analysis, or audio feature extraction of a music folder
|
||||
- User mentions Plex Sonic Analysis for a music library
|
||||
- User asks for tempo, energy, brightness, or key distribution of a music collection
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install --break-system-packages librosa
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Scope the folder
|
||||
|
||||
Count files first to gauge runtime:
|
||||
|
||||
```bash
|
||||
find "/path/to/music/folder" -type f \( -iname "*.mp3" -o -iname "*.flac" -o -iname "*.m4a" \) | wc -l
|
||||
```
|
||||
|
||||
### 2. Run batch analysis
|
||||
|
||||
Use the script at `scripts/batch_analyze.py`. It extracts per-track:
|
||||
|
||||
- **Tempo** (BPM) — beat tracking
|
||||
- **RMS energy** — perceived loudness
|
||||
- **Spectral centroid** (Hz) — brightness/darkness
|
||||
- **Zero-crossing rate** — noisiness
|
||||
- **Estimated key** — chroma CQT → pitch class
|
||||
- **Duration** (seconds)
|
||||
|
||||
```bash
|
||||
python3 scripts/batch_analyze.py "/path/to/music/folder"
|
||||
```
|
||||
|
||||
The script samples the first 45 seconds of each track at 22,050 Hz for speed. Output goes to stdout (summary) and `/tmp/<folder_name>_sonic_results.json` (per-track detail).
|
||||
|
||||
### 3. Interpret results for Plex
|
||||
|
||||
Key features Plex Sonic Analysis cares about:
|
||||
|
||||
| Feature | What it means for Plex |
|
||||
|---|---|
|
||||
| Tempo | Fast/slow radio seeding, BPM-based playlists |
|
||||
| Spectral centroid | "Bright" vs "dark" — acoustic vs electronic, vocal-forward vs bass-heavy |
|
||||
| RMS energy | Loudness/dynamics — quiet vs intense mood grouping |
|
||||
| Key | Harmonic mixing compatibility |
|
||||
|
||||
Plex computes these server-side via its own analysis pipeline. Running this script is useful for **previewing** what Plex will see before committing to a full library scan, or for libraries Plex can't access directly.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Go/songsee not available**: The `songsee` skill requires Go (rarely installed). Fall back to librosa — it's Python-only and covers 90% of the same features.
|
||||
- **Large folders**: 169 tracks took ~10 minutes. For 1000+ tracks, consider sampling (e.g., first 100 tracks) or running in the background with `notify_on_complete=true`.
|
||||
- **librosa warnings**: `librosa.beat.tempo` moved to `librosa.feature.rhythm.tempo` in 0.10+. The script uses the current path; warnings are cosmetic.
|
||||
- **Key estimation is approximate**: Chroma CQT works best on tonal music with clear pitch. Electronic/bass-heavy tracks may produce noisy estimates.
|
||||
|
||||
## Linked files
|
||||
|
||||
- `scripts/batch_analyze.py` — Reusable batch sonic analysis script
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Batch sonic/spectral analysis of all audio files in a directory.
|
||||
Outputs a summary and saves per-track JSON results.
|
||||
|
||||
Usage: python3 batch_analyze.py "/path/to/music/folder"
|
||||
"""
|
||||
|
||||
import os, sys, json
|
||||
import numpy as np
|
||||
import librosa
|
||||
from collections import Counter
|
||||
|
||||
def analyze_folder(music_dir: str):
|
||||
exts = ('.mp3', '.flac', '.m4a', '.ogg', '.wav')
|
||||
files = sorted(f for f in os.listdir(music_dir) if f.lower().endswith(exts))
|
||||
if not files:
|
||||
print("No audio files found.")
|
||||
return
|
||||
|
||||
results = []
|
||||
for i, f in enumerate(files):
|
||||
path = os.path.join(music_dir, f)
|
||||
try:
|
||||
y, sr = librosa.load(path, sr=22050, duration=45)
|
||||
tempo = librosa.feature.rhythm.tempo(y=y, sr=sr)[0]
|
||||
rms = float(np.nanmean(librosa.feature.rms(y=y)[0]))
|
||||
centroid = float(np.nanmean(librosa.feature.spectral_centroid(y=y, sr=sr)[0]))
|
||||
zcr = float(np.nanmean(librosa.feature.zero_crossing_rate(y=y)[0]))
|
||||
dur = float(librosa.get_duration(y=y, sr=sr))
|
||||
chroma = librosa.feature.chroma_cqt(y=y, sr=sr).mean(axis=1)
|
||||
keys = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
|
||||
key = keys[int(np.argmax(chroma))]
|
||||
results.append({
|
||||
'file': f,
|
||||
'tempo': round(tempo, 1),
|
||||
'rms': round(rms, 4),
|
||||
'centroid': round(centroid, 1),
|
||||
'zcr': round(zcr, 4),
|
||||
'duration': round(dur, 1),
|
||||
'key': key,
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({'file': f, 'error': str(e)})
|
||||
|
||||
good = [r for r in results if 'tempo' in r]
|
||||
bad = [r for r in results if 'error' in r]
|
||||
|
||||
tempos = [r['tempo'] for r in good]
|
||||
energies = [r['rms'] for r in good]
|
||||
centroids = [r['centroid'] for r in good]
|
||||
durations = [r['duration'] for r in good]
|
||||
keys = [r['key'] for r in good]
|
||||
kc = Counter(keys)
|
||||
|
||||
folder_name = os.path.basename(music_dir.rstrip('/'))
|
||||
print(f"=== {folder_name} — SONIC ANALYSIS ===")
|
||||
print(f"Total files: {len(files)} | Analyzed: {len(good)} | Errors: {len(bad)}")
|
||||
if bad:
|
||||
for r in bad:
|
||||
print(f" ✗ {r['file']}: {r['error']}")
|
||||
|
||||
print(f"\n--- COLLECTIVE STATS ---")
|
||||
print(f"Tempo: mean={np.mean(tempos):.1f} std={np.std(tempos):.1f} min={min(tempos):.1f} max={max(tempos):.1f}")
|
||||
print(f"Energy: mean={np.mean(energies):.4f} std={np.std(energies):.4f}")
|
||||
print(f"Brightness (centroid): mean={np.mean(centroids):.0f} Hz std={np.std(centroids):.0f} Hz")
|
||||
print(f"Duration: mean={np.mean(durations):.1f}s total={sum(durations)/60:.1f} min")
|
||||
|
||||
print(f"\n--- KEY DISTRIBUTION ---")
|
||||
for k, _ in kc.most_common():
|
||||
print(f" {k}: {kc[k]}")
|
||||
|
||||
print(f"\n--- FASTEST ---")
|
||||
for r in sorted(good, key=lambda x: x['tempo'], reverse=True)[:5]:
|
||||
print(f" {r['tempo']:.0f} BPM — {r['file']}")
|
||||
|
||||
print(f"\n--- SLOWEST ---")
|
||||
for r in sorted(good, key=lambda x: x['tempo'])[:5]:
|
||||
print(f" {r['tempo']:.0f} BPM — {r['file']}")
|
||||
|
||||
print(f"\n--- HIGHEST ENERGY ---")
|
||||
for r in sorted(good, key=lambda x: x['rms'], reverse=True)[:5]:
|
||||
print(f" {r['rms']:.4f} — {r['file']}")
|
||||
|
||||
print(f"\n--- BRIGHTEST (highest centroid) ---")
|
||||
for r in sorted(good, key=lambda x: x['centroid'], reverse=True)[:5]:
|
||||
print(f" {r['centroid']:.0f} Hz — {r['file']}")
|
||||
|
||||
print(f"\n--- DARKEST (lowest centroid) ---")
|
||||
for r in sorted(good, key=lambda x: x['centroid'])[:5]:
|
||||
print(f" {r['centroid']:.0f} Hz — {r['file']}")
|
||||
|
||||
out_path = f"/tmp/{folder_name.replace(' ', '_')}_sonic_results.json"
|
||||
with open(out_path, "w") as fh:
|
||||
json.dump(results, fh, indent=2, ensure_ascii=False)
|
||||
print(f"\n\nFull results saved to {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} /path/to/music/folder")
|
||||
sys.exit(1)
|
||||
analyze_folder(sys.argv[1])
|
||||
Reference in New Issue
Block a user