Spaces:
Sleeping
Sleeping
| """ | |
| Core music analysis engine (audio-only, no lyrics). | |
| Pure-DSP analysis with librosa/scikit-learn: tempo, key, time signature, | |
| structure, energy and timbre. Fast on CPU, no model downloads needed. | |
| Deep-learning tagging (instruments, genre, mood) and stem separation live in | |
| tagging.py and stems.py and are imported lazily by the app, so this core | |
| always works even if those heavier models are absent. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import librosa | |
| import scipy.ndimage | |
| import scipy.linalg | |
| import scipy.sparse.csgraph | |
| import sklearn.cluster | |
| NOTES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] | |
| # Krumhansl-Schmuckler key profiles | |
| KS_MAJOR = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88]) | |
| KS_MINOR = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17]) | |
| HOP = 512 | |
| def _scalar(x) -> float: | |
| return float(np.atleast_1d(x).ravel()[0]) | |
| def load_audio(path, sr=22050, mono=True, max_seconds=480): | |
| """Load audio, trimming very long files so analysis stays responsive.""" | |
| y, sr = librosa.load(path, sr=sr, mono=mono) | |
| duration = len(y) / sr | |
| if max_seconds and duration > max_seconds: | |
| y = y[: int(max_seconds * sr)] | |
| return y, sr, duration | |
| def detect_tempo(y, sr, onset_env=None): | |
| if onset_env is None: | |
| onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=HOP) | |
| tempo, beats = librosa.beat.beat_track(onset_envelope=onset_env, sr=sr, hop_length=HOP) | |
| tempo = _scalar(tempo) | |
| beat_times = librosa.frames_to_time(beats, sr=sr, hop_length=HOP) | |
| if len(beat_times) > 3: | |
| ibi = np.diff(beat_times) | |
| regularity = float(1.0 - min(1.0, np.std(ibi) / (np.mean(ibi) + 1e-9))) | |
| else: | |
| regularity = 0.0 | |
| return { | |
| "bpm": round(tempo, 1), | |
| "beats": beats, | |
| "beat_times": beat_times, | |
| "beat_count": int(len(beat_times)), | |
| "regularity": round(regularity, 3), | |
| } | |
| def detect_key(y, sr, chroma=None): | |
| if chroma is None: | |
| chroma = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=HOP) | |
| cm = chroma.mean(axis=1) | |
| cmc = cm - cm.mean() | |
| results = [] | |
| for i in range(12): | |
| maj = np.corrcoef(np.roll(KS_MAJOR - KS_MAJOR.mean(), i), cmc)[0, 1] | |
| minr = np.corrcoef(np.roll(KS_MINOR - KS_MINOR.mean(), i), cmc)[0, 1] | |
| results.append((NOTES[i], "majeur", maj)) | |
| results.append((NOTES[i], "mineur", minr)) | |
| results.sort(key=lambda r: r[2], reverse=True) | |
| best, second = results[0], results[1] | |
| return { | |
| "key": best[0], | |
| "mode": best[1], | |
| "key_name": f"{best[0]} {best[1]}", | |
| "correlation": round(float(best[2]), 3), | |
| "confidence": round(float(max(0.0, best[2] - second[2])), 3), | |
| "chroma_mean": cm.tolist(), | |
| "alternatives": [f"{r[0]} {r[1]}" for r in results[1:4]], | |
| } | |
| def estimate_time_signature(onset_env, sr, beat_times): | |
| """Best-effort beats-per-bar guess. Meter detection is inherently noisy, so | |
| when the cue is weak we fall back to the 4/4 prior and flag low confidence.""" | |
| if len(beat_times) < 8: | |
| return {"beats_per_bar": None, "label": "onbekend", "confidence": 0.0} | |
| beat_frames = librosa.time_to_frames(beat_times, sr=sr, hop_length=HOP) | |
| beat_frames = beat_frames[(beat_frames >= 0) & (beat_frames < len(onset_env))] | |
| bs = onset_env[beat_frames] | |
| bs = bs - bs.mean() | |
| ac = librosa.autocorrelate(bs) | |
| ac = ac / (ac[0] + 1e-9) | |
| cand = {b: (ac[b] if b < len(ac) else -1) for b in (2, 3, 4, 6)} | |
| best = max(cand, key=cand.get) | |
| if best == 6: | |
| best = 3 | |
| others = [v for k, v in cand.items() if k != max(cand, key=cand.get)] | |
| confidence = float(max(0.0, min(1.0, cand[max(cand, key=cand.get)] - (max(others) if others else 0)))) | |
| if confidence < 0.05: | |
| return {"beats_per_bar": 4, "label": "4/4 (waarschijnlijk)", "confidence": round(confidence, 3)} | |
| return {"beats_per_bar": int(best), "label": f"{best}/4", "confidence": round(confidence, 3)} | |
| def analyze_structure(y, sr, beats=None, max_k=6, min_section_sec=6.0): | |
| """Laplacian structural segmentation (McFee & Ellis): cluster beat-synchronous | |
| CQT into repeated sections, then label them A/B/C by order of appearance.""" | |
| dur = len(y) / sr | |
| if beats is None: | |
| _, beats = librosa.beat.beat_track(y=y, sr=sr, hop_length=HOP, trim=False) | |
| beat_times = librosa.frames_to_time(beats, sr=sr, hop_length=HOP) | |
| if len(beats) < 12: | |
| return {"sections": [{"label": "A", "start": 0.0, "end": round(dur, 2), | |
| "duration": round(dur, 2)}], "n_unique": 1} | |
| C = librosa.amplitude_to_db(np.abs(librosa.cqt(y=y, sr=sr, hop_length=HOP, | |
| bins_per_octave=36, n_bins=36 * 7)), ref=np.max) | |
| Csync = librosa.util.sync(C, beats, aggregate=np.median) | |
| n = Csync.shape[1] | |
| R = librosa.segment.recurrence_matrix(Csync, width=3, mode='affinity', sym=True) | |
| df = librosa.segment.timelag_filter(scipy.ndimage.median_filter) | |
| Rf = df(R, size=(1, 7)) | |
| mfcc = librosa.feature.mfcc(y=y, sr=sr, hop_length=HOP) | |
| Msync = librosa.util.sync(mfcc, beats) | |
| path_dist = np.sum(np.diff(Msync, axis=1) ** 2, axis=0) | |
| sigma = np.median(path_dist) + 1e-9 | |
| path_sim = np.exp(-path_dist / sigma) | |
| R_path = np.diag(path_sim, 1) + np.diag(path_sim, -1) | |
| deg_path = np.sum(R_path, axis=1) | |
| deg_rec = np.sum(Rf, axis=1) | |
| mu = deg_path.dot(deg_path + deg_rec) / (np.sum((deg_path + deg_rec) ** 2) + 1e-9) | |
| A = mu * Rf + (1 - mu) * R_path | |
| L = scipy.sparse.csgraph.laplacian(A, normed=True) | |
| _, evecs = scipy.linalg.eigh(L) | |
| evecs = scipy.ndimage.median_filter(evecs, size=(9, 1)) | |
| k = int(np.clip(round(dur / 25) + 1, 2, max_k)) | |
| k = min(k, n) | |
| Cn = np.cumsum(evecs ** 2, axis=1) ** 0.5 | |
| X = evecs[:, :k] / (Cn[:, k - 1:k] + 1e-9) | |
| seg_ids = sklearn.cluster.KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(X) | |
| def btime(j): | |
| if j <= 0: | |
| return 0.0 | |
| if j >= n: | |
| return float(dur) | |
| return float(beat_times[min(j - 1, len(beat_times) - 1)]) | |
| # contiguous runs of identical cluster id -> raw sections | |
| bounds = np.flatnonzero(np.diff(seg_ids)) + 1 | |
| starts = np.concatenate([[0], bounds]) | |
| ends = np.concatenate([bounds, [n]]) | |
| raw = [[int(seg_ids[s]), btime(s), btime(e)] for s, e in zip(starts, ends)] | |
| # merge sections shorter than the minimum into the previous one | |
| merged = [] | |
| for cid, s, e in raw: | |
| if merged and (e - s) < min_section_sec: | |
| merged[-1][2] = e | |
| else: | |
| merged.append([cid, s, e]) | |
| if len(merged) > 1 and (merged[0][2] - merged[0][1]) < min_section_sec: | |
| merged[1][1] = merged[0][1] | |
| merged.pop(0) | |
| # relabel cluster ids -> A/B/C by first appearance | |
| mapping, order = {}, 0 | |
| sections = [] | |
| for cid, s, e in merged: | |
| if cid not in mapping: | |
| mapping[cid] = chr(ord('A') + order) | |
| order += 1 | |
| sections.append({"label": mapping[cid], "start": round(s, 2), | |
| "end": round(e, 2), "duration": round(e - s, 2)}) | |
| return {"sections": sections, "n_unique": len(mapping)} | |
| def analyze_dynamics(y, sr): | |
| rms = librosa.feature.rms(y=y, hop_length=HOP)[0] | |
| rms_db = librosa.amplitude_to_db(rms + 1e-9) | |
| centroid = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=HOP)[0] | |
| zcr = librosa.feature.zero_crossing_rate(y, hop_length=HOP)[0] | |
| return { | |
| "energy": round(float(np.clip((np.mean(rms) / 0.15) * 100, 0, 100)), 1), | |
| "dynamic_range_db": round(float(np.percentile(rms_db, 95) - np.percentile(rms_db, 5)), 1), | |
| "brightness_hz": round(float(np.mean(centroid)), 0), | |
| "rms_curve": rms.tolist(), | |
| "zcr_mean": round(float(np.mean(zcr)), 4), | |
| } | |
| def estimate_danceability(tempo_info, dynamics): | |
| reg = tempo_info["regularity"] | |
| bpm = tempo_info["bpm"] | |
| tempo_fit = float(np.exp(-((bpm - 120) ** 2) / (2 * 35 ** 2))) | |
| e = dynamics["energy"] / 100.0 | |
| return round(float(np.clip(100 * (0.5 * reg + 0.3 * tempo_fit + 0.2 * e), 0, 100)), 1) | |
| def estimate_valence(key_info, tempo_info, dynamics): | |
| mode_term = 0.6 if key_info["mode"] == "majeur" else 0.3 | |
| tempo_term = float(np.clip((tempo_info["bpm"] - 60) / 120, 0, 1)) * 0.25 | |
| bright_term = float(np.clip(dynamics["brightness_hz"] / 4000, 0, 1)) * 0.15 | |
| return round(float(np.clip((mode_term + tempo_term + bright_term) * 100, 0, 100)), 1) | |
| # ----------------------------------------------------------------- chords | |
| FLATS = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'] | |
| # Roman-cijfers per interval t.o.v. de grondtoon (diatonisch majeur/mineur, met | |
| # nette terugval voor niet-diatonische akkoorden). Hoofd-/kleine letter wordt | |
| # later op basis van het werkelijke akkoord (maj/min) gezet. | |
| _ROMAN_MAJOR = {0: "I", 2: "II", 4: "III", 5: "IV", 7: "V", 9: "VI", 11: "VII"} | |
| _ROMAN_MINOR = {0: "I", 2: "II", 3: "III", 5: "IV", 7: "V", 8: "VI", 10: "VII"} | |
| _ROMAN_FALLBACK = {0: "I", 1: "bII", 2: "II", 3: "bIII", 4: "III", 5: "IV", | |
| 6: "bV", 7: "V", 8: "bVI", 9: "VI", 10: "bVII", 11: "VII"} | |
| def _chord_templates(): | |
| """24 binaire triad-templates (12 majeur + 12 mineur), genormaliseerd.""" | |
| temps, labels = [], [] | |
| for r in range(12): | |
| for quality, third in (("maj", 4), ("min", 3)): | |
| v = np.zeros(12) | |
| v[[r, (r + third) % 12, (r + 7) % 12]] = 1.0 | |
| temps.append(v / np.linalg.norm(v)) | |
| labels.append((r, quality)) | |
| return np.array(temps), labels | |
| def _chord_name(chord): | |
| r, q = chord | |
| return FLATS[r] + ("m" if q == "min" else "") | |
| def _roman_numeral(chord, key_root, mode): | |
| r, q = chord | |
| interval = (r - key_root) % 12 | |
| table = _ROMAN_MINOR if mode == "mineur" else _ROMAN_MAJOR | |
| base = table.get(interval) or _ROMAN_FALLBACK[interval] | |
| acc = "" | |
| if base and base[0] == "b": | |
| acc, base = "b", base[1:] | |
| return acc + (base.lower() if q == "min" else base.upper()) | |
| def _find_loop(bars, lengths=(4, 8, 2, 3, 6)): | |
| """Vind de meest voorkomende terugkerende reeks maten (de 'loop').""" | |
| from collections import Counter | |
| n = len(bars) | |
| if n == 0: | |
| return [] | |
| if n < 4: | |
| out = [bars[0]] | |
| for b in bars[1:]: | |
| if b != out[-1]: | |
| out.append(b) | |
| return out[:4] | |
| best, best_score = None, -1 | |
| for L in lengths: | |
| if n < L * 2: | |
| continue | |
| windows = [tuple(bars[i:i + L]) for i in range(0, n - L + 1)] | |
| win, freq = Counter(windows).most_common(1)[0] | |
| score = freq * L | |
| if freq >= 2 and score > best_score: | |
| best, best_score = list(win), score | |
| if best is None: | |
| out = [bars[0]] | |
| for b in bars[1:]: | |
| if b != out[-1]: | |
| out.append(b) | |
| best = out[:4] | |
| return best | |
| def detect_chords(chroma, beats, beats_per_bar=4, key="C", mode="majeur"): | |
| """Schat akkoorden per maat en leid een progressie/harmonisch palet af. | |
| Akkoordherkenning uit audio is benaderend, zeker bij dichte mixen.""" | |
| if chroma is None or beats is None or len(beats) < 4: | |
| return {"available": False} | |
| from collections import Counter | |
| bpb = max(1, int(beats_per_bar or 4)) | |
| Csync = librosa.util.sync(chroma, beats, aggregate=np.median) # [12, n_beats] | |
| nbeats = Csync.shape[1] | |
| temps, labels = _chord_templates() | |
| # akkoord per maat: gemiddelde chroma over de beats in de maat (ruisarm) | |
| bar_vecs = [] | |
| for i in range(0, nbeats, bpb): | |
| seg = Csync[:, i:i + bpb] | |
| if seg.shape[1] == 0: | |
| break | |
| bar_vecs.append(seg.mean(axis=1)) | |
| if not bar_vecs: | |
| return {"available": False} | |
| B = np.array(bar_vecs).T | |
| B = B / (np.linalg.norm(B, axis=0, keepdims=True) + 1e-9) | |
| sims = temps @ B | |
| best = np.argmax(sims, axis=0) | |
| conf = float(np.mean(np.max(sims, axis=0))) | |
| bars = [labels[int(b)] for b in best] | |
| key_root = NOTES.index(key) if key in NOTES else 0 | |
| # harmonisch palet: meest voorkomende akkoorden, tonica eerst | |
| counts = Counter(bars) | |
| tonic = (key_root, "min" if mode == "mineur" else "maj") | |
| palette = ([tonic] if tonic in counts else []) + \ | |
| [c for c, _ in counts.most_common() if c != tonic] | |
| palette = palette[:4] | |
| # terugkerende loop (alleen als zinnig: minstens 2 verschillende akkoorden) | |
| loop = _find_loop(bars) | |
| loop_ok = bool(loop) and len(set(loop)) >= 2 | |
| prog = loop if loop_ok else palette | |
| return { | |
| "available": True, | |
| "is_loop": loop_ok, | |
| "progression": [_chord_name(c) for c in prog], | |
| "progression_abs": " – ".join(_chord_name(c) for c in prog), | |
| "progression_roman": " – ".join(_roman_numeral(c, key_root, mode) for c in prog), | |
| "palette": [_chord_name(c) for c in palette], | |
| "bars": [_chord_name(c) for c in bars[:32]], | |
| "confidence": round(conf, 3), | |
| } | |
| def analyze_core(path): | |
| """Run the full DSP analysis and return one JSON-friendly dict.""" | |
| y, sr, duration = load_audio(path) | |
| onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=HOP) | |
| chroma = librosa.feature.chroma_cqt(y=y, sr=sr, hop_length=HOP) | |
| tempo_info = detect_tempo(y, sr, onset_env=onset_env) | |
| key_info = detect_key(y, sr, chroma=chroma) | |
| ts = estimate_time_signature(onset_env, sr, tempo_info["beat_times"]) | |
| structure = analyze_structure(y, sr, beats=tempo_info["beats"]) | |
| dynamics = analyze_dynamics(y, sr) | |
| danceability = estimate_danceability(tempo_info, dynamics) | |
| valence = estimate_valence(key_info, tempo_info, dynamics) | |
| chords = detect_chords(chroma, tempo_info["beats"], | |
| ts.get("beats_per_bar") or 4, key_info["key"], key_info["mode"]) | |
| return { | |
| "duration_sec": round(duration, 1), | |
| "sample_rate": sr, | |
| "tempo": {"bpm": tempo_info["bpm"], "beat_count": tempo_info["beat_count"], | |
| "regularity": tempo_info["regularity"], | |
| "beat_times": [round(float(t), 3) for t in tempo_info["beat_times"]]}, | |
| "key": key_info, | |
| "time_signature": ts, | |
| "structure": structure, | |
| "dynamics": dynamics, | |
| "danceability": danceability, | |
| "valence": valence, | |
| "chords": chords, | |
| } | |
| if __name__ == "__main__": | |
| import json, sys | |
| res = analyze_core(sys.argv[1] if len(sys.argv) > 1 else "test_track.wav") | |
| res["dynamics"].pop("rms_curve", None) | |
| res["tempo"].pop("beat_times", None) | |
| res["key"].pop("chroma_mean", None) | |
| print(json.dumps(res, indent=2, ensure_ascii=False)) | |