song-analyzer / analysis.py
fhaarman's picture
Add chord detection + sound profile (zo klinkt het)
1f6b848 verified
Raw
History Blame Contribute Delete
14.8 kB
"""
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))