kinaar111's picture
Production: mystery shell + physics-audio from staging
d46bde8 verified
Raw
History Blame Contribute Delete
5.89 kB
"""
Batch note clustering and key detection from real-audio fitting results.
"""
from __future__ import annotations
import json
import math
import numpy as np
from pathlib import Path
from typing import Optional
# Krumhansl-Kessler major/minor key profiles (pitch class 0=C)
_MAJOR_PROFILE = 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])
_MINOR_PROFILE = 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])
NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
KEYS_MAJOR = [f"{n} major" for n in NOTE_NAMES]
KEYS_MINOR = [f"{n} minor" for n in NOTE_NAMES]
def hz_to_midi(hz: float) -> float:
if hz <= 0:
return 0.0
return 69.0 + 12.0 * math.log2(hz / 440.0)
def midi_to_note(midi: float) -> str:
n = int(round(midi)) % 12
octave = int(round(midi)) // 12 - 1
return f"{NOTE_NAMES[n]}{octave}"
def f0_to_pitch_class(hz: float) -> int:
return int(round(hz_to_midi(hz))) % 12
def detect_key_from_f0_list(f0_hz_list: list[float]) -> dict:
"""
Estimate musical key from a list of fundamental frequencies using
Krumhansl-Schmuckler correlation with major/minor profiles.
"""
if not f0_hz_list:
return {'key': 'unknown', 'confidence': 0.0, 'chroma': [0.0] * 12}
chroma = np.zeros(12, dtype=np.float64)
for hz in f0_hz_list:
if hz > 20:
pc = f0_to_pitch_class(hz)
chroma[pc] += 1.0
if chroma.sum() < 1e-8:
return {'key': 'unknown', 'confidence': 0.0, 'chroma': chroma.tolist()}
chroma = chroma / chroma.sum()
best_key, best_corr, best_mode = 'C major', -2.0, 'major'
for shift in range(12):
rolled = np.roll(chroma, -shift)
corr_maj = float(np.corrcoef(rolled, _MAJOR_PROFILE)[0, 1])
corr_min = float(np.corrcoef(rolled, _MINOR_PROFILE)[0, 1])
if corr_maj > best_corr:
best_corr, best_key, best_mode = corr_maj, KEYS_MAJOR[shift], 'major'
if corr_min > best_corr:
best_corr, best_key, best_mode = corr_min, KEYS_MINOR[shift], 'minor'
return {
'key': best_key,
'mode': best_mode,
'confidence': best_corr,
'chroma': chroma.tolist(),
'n_notes': len(f0_hz_list),
}
def cluster_notes(
summaries: list[dict],
n_clusters: Optional[int] = None,
) -> dict:
"""
Cluster batch results by (f0, B, mean damping, recon_mse).
Uses sklearn KMeans when available; falls back to pitch-class bins.
"""
if not summaries:
return {'clusters': [], 'labels': [], 'method': 'none'}
features = []
for s in summaries:
damps = s.get('learned_damps') or []
mean_damp = float(np.mean(damps)) if damps else 0.0
features.append([
s.get('f0_est_hz', 0.0) / 500.0,
s.get('b_est', 0.0) * 1000.0,
mean_damp,
s.get('recon_mse', 0.0),
])
X = np.array(features, dtype=np.float64)
try:
from sklearn.cluster import KMeans
k = n_clusters or max(2, min(5, len(summaries)))
k = min(k, len(summaries))
labels = KMeans(n_clusters=k, n_init=10, random_state=42).fit_predict(X)
method = 'kmeans'
except ImportError:
labels = np.array([f0_to_pitch_class(s.get('f0_est_hz', 0)) for s in summaries])
k = len(set(labels))
method = 'pitch_class'
clusters = []
for cid in sorted(set(labels)):
members = [summaries[i] for i, lab in enumerate(labels) if lab == cid]
f0s = [m['f0_est_hz'] for m in members]
clusters.append({
'cluster_id': int(cid),
'count': len(members),
'names': [m['name'] for m in members],
'f0_mean_hz': float(np.mean(f0s)),
'f0_median_hz': float(np.median(f0s)),
'note_median': midi_to_note(hz_to_midi(float(np.median(f0s)))),
'members': members,
})
return {
'method': method,
'n_clusters': len(clusters),
'labels': labels.tolist(),
'clusters': clusters,
}
def analyze_batch_results(
results: list[dict],
output_dir: Path,
n_clusters: Optional[int] = None,
) -> dict:
"""Run clustering + key detection; write reports to output_dir."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
f0_list = [r['f0_est_hz'] for r in results if r.get('f0_est_hz', 0) > 20]
key_info = detect_key_from_f0_list(f0_list)
cluster_info = cluster_notes(results, n_clusters=n_clusters)
per_note_keys = []
for r in results:
f0 = r.get('f0_est_hz', 0)
per_note_keys.append({
'name': r['name'],
'f0_hz': f0,
'note': midi_to_note(hz_to_midi(f0)) if f0 > 20 else 'unknown',
'pitch_class': NOTE_NAMES[f0_to_pitch_class(f0)] if f0 > 20 else '?',
})
report = {
'key_detection': key_info,
'clustering': {
'method': cluster_info['method'],
'n_clusters': cluster_info['n_clusters'],
'clusters': [
{k: v for k, v in c.items() if k != 'members'}
for c in cluster_info['clusters']
],
},
'per_note': per_note_keys,
}
report_path = output_dir / 'batch_analysis.json'
with open(report_path, 'w') as f:
json.dump(report, f, indent=2)
print(f"\n--- Batch analysis ---")
print(f" Detected key: {key_info['key']} (confidence={key_info['confidence']:.3f})")
print(f" Clusters ({cluster_info['method']}): {cluster_info['n_clusters']}")
for c in cluster_info['clusters']:
print(f" [{c['cluster_id']}] {c['count']} notes | f0≈{c['f0_median_hz']:.1f}Hz ({c['note_median']}) | {c['names']}")
print(f" Report: {report_path}")
return report