Commit 3afc4309 by PLN (Algolia)

feat(tide-table): emotion GT corpus + grader — training-knowledge priors vs CLAP

PLN's validation idea: famous tracks whose feeling/structure/genre the model already
knows = pseudo-ground-truth to grade the emotion engine, disagreements driving tuning +
coverage (self-correcting 30→100→300). emotion_corpus_gt.json: wave-1 22 tracks spanning
all 12 emotions + V/A quadrants + genres + structural archetypes (steady / quiet-loud /
long-build); GT = emotion labels + V/A centre + approx fractional section structure +
genre. emotion_corpus.py: fetch (yt-dlp android+bestaudio 403 workaround) → analyze
(demucs+stem-aware or --mix) → grade (emotion-hit, V/A error, quadrant, vocal-presence) →
gaps (coverage histogram → next wave).

BASELINE (whole-mix, 5 tracks): 40% emotion-hit, 40% quadrant-hit, mean V/A err 0.75 —
honest verdict: NOT yet trustworthy. Three systematic biases surfaced: (1) center/positive
compression (extremes pulled to mild +V/+A), (2) 'playful' over-fires (3/5), (3) whole-mix
misses aggression entirely (Prodigy Firestarter→playful +V) — the exact case stem-aware
(drums/bass arousal) should fix. Corpus now drives calibration before the #90 hexa handoff.
gaps: +- and -- quadrants thin, fill at wave 30.
parent 59fb2a11
#!/usr/bin/env python3
"""emotion_corpus — grade the CLAP emotion engine against training-knowledge priors.
PLN's idea: take famous tracks whose feeling/structure/genre I (the model) already
know, and use that knowledge as PSEUDO-GROUND-TRUTH to grade emotion_timeline — does
CLAP-on-audio agree with the cultural prior? Disagreements then DRIVE tuning (fusion
weights, ontology, window) and tell us which V/A quadrants / genres / structures are
under-covered, so the next wave (30→100→300) fills the gaps. Self-correcting by design.
Pipeline (each step cached in _corpus_cache, gitignored):
fetch yt-dlp each track (android client + bestaudio/best, the 403 workaround)
analyze Foundry demucs → emotion_timeline.analyze_stems (--mix = whole-mix, fast)
grade predicted vs GT → emotion-hit, V/A error, quadrant, structure/vocal
gaps coverage histogram → what to add next
status what's fetched / analyzed
python3 emotion_corpus.py fetch [slug…]
python3 emotion_corpus.py analyze [slug…] [--mix]
python3 emotion_corpus.py grade [slug…] [--mix]
python3 emotion_corpus.py gaps
See [[project_hexa_emotion_convergence]], [[feedback_build_katana_first]]."""
import json
import subprocess
import sys
from pathlib import Path
import numpy as np
import emotion_ontology as EMO
HERE = Path(__file__).resolve().parent
GT = HERE / "emotion_corpus_gt.json"
CACHE = HERE / "_corpus_cache"
AUDIO = CACHE / "audio"
ANALYSIS = CACHE / "analysis"
def _tracks(slugs=None):
data = json.loads(GT.read_text())["tracks"]
if slugs:
keep = set(slugs)
data = [t for t in data if t["slug"] in keep]
return data
def _audio_path(slug):
hits = list(AUDIO.glob(f"{slug}.*"))
return hits[0] if hits else None
# ── fetch ───────────────────────────────────────────────────────────────────────
def fetch_one(t):
if _audio_path(t["slug"]):
return _audio_path(t["slug"])
AUDIO.mkdir(parents=True, exist_ok=True)
cmd = ["yt-dlp", "-q", "--no-warnings",
"--extractor-args", "youtube:player_client=android",
"-f", "bestaudio/best", "-x", "--audio-format", "mp3", "--audio-quality", "5",
"-o", str(AUDIO / (t["slug"] + ".%(ext)s")), f"ytsearch1:{t['query']}"]
try:
subprocess.run(cmd, timeout=240, check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
return _audio_path(t["slug"])
def cmd_fetch(slugs):
ts = _tracks(slugs)
print(f"⛵ fetching {len(ts)} tracks…")
ok = 0
for t in ts:
p = fetch_one(t)
ok += bool(p)
print(f" {'✓' if p else '✗'} {t['slug']:<28} {t['artist']} – {t['title']}", flush=True)
print(f"\n{ok}/{len(ts)} present in {AUDIO}")
# ── analyze ───────────────────────────────────────────────────────────────────────
def analyze_one(t, mix=False):
ANALYSIS.mkdir(parents=True, exist_ok=True)
out = ANALYSIS / f"{t['slug']}{'_mix' if mix else ''}.json"
if out.exists():
return json.loads(out.read_text())
audio = _audio_path(t["slug"])
if not audio:
return None
import emotion_timeline as ET
if mix:
res = ET.analyze(audio)
else:
import foundry_stems as FS
res = ET.analyze_stems(FS.ensure_stems(audio, t["slug"]))
if res:
res["file"] = str(audio)
out.write_text(json.dumps(res, ensure_ascii=False, indent=1))
return res
def cmd_analyze(slugs, mix=False):
ts = _tracks(slugs)
mode = "whole-mix" if mix else "stem-aware (demucs)"
print(f"⛵ analyzing {len(ts)} tracks — {mode}…")
for t in ts:
r = analyze_one(t, mix=mix)
if r:
s = r["summary"]
print(f" ✓ {t['slug']:<28} {s['dominant']:<11} V/A {s['valence']:+.2f}/{s['arousal']:+.2f} {s['arc']}", flush=True)
else:
print(f" ✗ {t['slug']:<28} (no audio / failed)", flush=True)
# ── grade ───────────────────────────────────────────────────────────────────────
def _quadrant(v, a):
return ("+" if v >= 0 else "-") + ("+" if a >= 0 else "-")
def grade_one(t, res):
"""Predicted res vs GT prior → per-track scorecard row."""
s = res["summary"]
pv, pa = s["valence"], s["arousal"]
gv, ga = t["valence"], t["arousal"]
pred_emos = {sec["emotion"] for sec in res["sections"]} | {s["dominant"]}
gt_emos = set(t["emotions"])
return {
"slug": t["slug"],
"emotion_hit": s["dominant"] in gt_emos, # top-1 prior match
"emotion_overlap": bool(pred_emos & gt_emos), # any section matches a prior
"va_err": round(float(np.hypot(pv - gv, pa - ga)), 3),
"quadrant_hit": _quadrant(pv, pa) == _quadrant(gv, ga),
"pred": f"{s['dominant']} {pv:+.2f}/{pa:+.2f}",
"gt": f"{'/'.join(t['emotions'])} {gv:+.2f}/{ga:+.2f}",
"n_sec_pred": len(res["sections"]),
"n_sec_gt": len(t.get("structure", [])),
"vocal_ratio": s.get("vocal_ratio"),
"vocal_gt": round(np.mean([1.0 if seg[2] else 0.0 for seg in t.get("structure", [])]), 2)
if t.get("structure") else None,
}
def cmd_grade(slugs, mix=False):
ts = _tracks(slugs)
rows = []
for t in ts:
r = analyze_one(t, mix=mix)
if r:
rows.append(grade_one(t, r))
if not rows:
sys.exit("nothing analyzed — run fetch + analyze first")
print(f"⛵ grading {len(rows)} tracks vs training-knowledge priors "
f"({'whole-mix' if mix else 'stem-aware'})\n")
print(f" {'slug':<28} {'emo':>3} {'quad':>4} {'vaErr':>6} {'predicted':<22} {'← prior'}")
for r in rows:
print(f" {r['slug']:<28} {'✓' if r['emotion_hit'] else ('~' if r['emotion_overlap'] else '✗'):>3} "
f"{'✓' if r['quadrant_hit'] else '✗':>4} {r['va_err']:>6.2f} {r['pred']:<22} {r['gt']}")
n = len(rows)
print(f"\n top-1 emotion hit : {sum(r['emotion_hit'] for r in rows)}/{n} "
f"({sum(r['emotion_hit'] for r in rows)/n*100:.0f}%) "
f"(+overlap {sum(r['emotion_overlap'] for r in rows)}/{n})")
print(f" V/A quadrant hit : {sum(r['quadrant_hit'] for r in rows)}/{n} "
f"({sum(r['quadrant_hit'] for r in rows)/n*100:.0f}%)")
print(f" mean V/A error : {np.mean([r['va_err'] for r in rows]):.3f} "
f"(median {np.median([r['va_err'] for r in rows]):.3f})")
vt = [r for r in rows if r["vocal_ratio"] is not None and r["vocal_gt"] is not None]
if vt:
err = np.mean([abs(r["vocal_ratio"] - r["vocal_gt"]) for r in vt])
print(f" vocal-presence err: {err:.2f} (pred vs GT vocal fraction, {len(vt)} tracks)")
# ── gaps (self-correcting coverage) ───────────────────────────────────────────────
def cmd_gaps():
ts = _tracks()
from collections import Counter
quad = Counter(_quadrant(t["valence"], t["arousal"]) for t in ts)
emo = Counter(e for t in ts for e in t["emotions"])
genre = Counter(g for t in ts for g in t.get("genre", []))
print(f"⛵ corpus coverage — {len(ts)} tracks\n")
print(" V/A quadrants (want ≥5 each):")
for q in ("++", "+-", "-+", "--"):
names = {"++": "euphoric/energetic", "+-": "calm/pleasant",
"-+": "tense/aggressive", "--": "sad/dark-calm"}[q]
flag = " ⚠ thin" if quad[q] < 5 else ""
print(f" {q} {names:<22} {quad[q]}{flag}")
print(f"\n emotions covered ({len(emo)}/{len(EMO.NAMES)}): " + ", ".join(sorted(emo)))
missing = [e for e in EMO.NAMES if e not in emo]
if missing:
print(f" ⚠ NOT yet represented: {', '.join(missing)}")
print(f"\n genres ({len(genre)}): " + ", ".join(f"{g}×{n}" for g, n in genre.most_common()))
def cmd_status():
ts = _tracks()
f = sum(bool(_audio_path(t["slug"])) for t in ts)
a = sum((ANALYSIS / f"{t['slug']}.json").exists() for t in ts)
am = sum((ANALYSIS / f"{t['slug']}_mix.json").exists() for t in ts)
print(f"⛵ corpus: {len(ts)} tracks · fetched {f} · stem-analyzed {a} · mix-analyzed {am}")
def main():
args = sys.argv[1:]
cmd = args[0] if args else "status"
slugs = [a for a in args[1:] if not a.startswith("--")]
mix = "--mix" in args
if cmd == "fetch":
cmd_fetch(slugs)
elif cmd == "analyze":
cmd_analyze(slugs, mix=mix)
elif cmd == "grade":
cmd_grade(slugs, mix=mix)
elif cmd == "gaps":
cmd_gaps()
elif cmd == "status":
cmd_status()
else:
sys.exit("usage: emotion_corpus.py [fetch|analyze|grade|gaps|status] [slug…] [--mix]")
if __name__ == "__main__":
main()
{
"schema": "emotion ground-truth corpus — Claude training-knowledge priors per famous track",
"notes": "Pseudo-ground-truth authored from model knowledge to GRADE the CLAP emotion engine. emotions = expected emotion_ontology labels; valence/arousal = expected circumplex CENTRE (Russell, -1..1); structure = APPROXIMATE section sequence as fraction-of-track [at, section, vocal] (timings fuzzy by design — grade on order/presence/vocal-alignment, not exact seconds). Curated wave 1 for V/A-quadrant + genre + structure diversity; scale 30→100→300 via the gaps report.",
"tracks": [
{"slug": "daft-one-more-time", "artist": "Daft Punk", "title": "One More Time", "query": "Daft Punk One More Time official audio",
"emotions": ["euphoric", "playful"], "valence": 0.9, "arousal": 0.8, "genre": ["house", "french touch", "disco"],
"structure": [[0.0, "intro", false], [0.12, "verse", true], [0.28, "chorus", true], [0.5, "breakdown", false], [0.66, "drop", true], [0.9, "outro", true]]},
{"slug": "queen-dont-stop-me-now", "artist": "Queen", "title": "Don't Stop Me Now", "query": "Queen Don't Stop Me Now official",
"emotions": ["euphoric", "playful"], "valence": 0.88, "arousal": 0.78, "genre": ["rock", "pop rock"],
"structure": [[0.0, "intro", true], [0.12, "verse", true], [0.35, "chorus", true], [0.6, "solo", false], [0.78, "chorus", true], [0.9, "outro", true]]},
{"slug": "abba-dancing-queen", "artist": "ABBA", "title": "Dancing Queen", "query": "ABBA Dancing Queen official",
"emotions": ["euphoric", "playful", "warm"], "valence": 0.82, "arousal": 0.6, "genre": ["disco", "pop"],
"structure": [[0.0, "intro", false], [0.1, "verse", true], [0.35, "chorus", true], [0.6, "verse", true], [0.8, "chorus", true]]},
{"slug": "aretha-respect", "artist": "Aretha Franklin", "title": "Respect", "query": "Aretha Franklin Respect official",
"emotions": ["triumphant", "playful"], "valence": 0.8, "arousal": 0.65, "genre": ["soul", "r&b"],
"structure": [[0.0, "verse", true], [0.2, "chorus", true], [0.5, "bridge", true], [0.7, "chorus", true]]},
{"slug": "nirvana-teen-spirit", "artist": "Nirvana", "title": "Smells Like Teen Spirit", "query": "Nirvana Smells Like Teen Spirit official audio",
"emotions": ["aggressive", "tense"], "valence": -0.4, "arousal": 0.85, "genre": ["grunge", "alternative rock"],
"structure": [[0.0, "intro", false], [0.12, "verse", true], [0.3, "chorus", true], [0.45, "verse", true], [0.6, "chorus", true], [0.75, "solo", false], [0.85, "chorus", true]]},
{"slug": "ratm-killing-in-the-name", "artist": "Rage Against the Machine", "title": "Killing in the Name", "query": "Rage Against the Machine Killing in the Name",
"emotions": ["aggressive", "dark"], "valence": -0.55, "arousal": 0.92, "genre": ["rap metal", "nu metal"],
"structure": [[0.0, "intro", false], [0.15, "verse", true], [0.4, "breakdown", false], [0.7, "climax", true], [0.9, "outro", true]]},
{"slug": "prodigy-firestarter", "artist": "The Prodigy", "title": "Firestarter", "query": "The Prodigy Firestarter official",
"emotions": ["aggressive", "dark"], "valence": -0.4, "arousal": 0.9, "genre": ["big beat", "electronic", "breakbeat"],
"structure": [[0.0, "intro", false], [0.15, "verse", true], [0.35, "chorus", true], [0.6, "breakdown", false], [0.75, "drop", true]]},
{"slug": "radiohead-creep", "artist": "Radiohead", "title": "Creep", "query": "Radiohead Creep official",
"emotions": ["melancholic", "tense"], "valence": -0.6, "arousal": 0.35, "genre": ["alternative rock"],
"structure": [[0.0, "intro", false], [0.1, "verse", true], [0.35, "chorus", true], [0.5, "verse", true], [0.7, "chorus", true], [0.85, "outro", true]]},
{"slug": "adele-someone-like-you", "artist": "Adele", "title": "Someone Like You", "query": "Adele Someone Like You official",
"emotions": ["melancholic"], "valence": -0.6, "arousal": -0.3, "genre": ["pop ballad", "soul"],
"structure": [[0.0, "intro", false], [0.08, "verse", true], [0.35, "chorus", true], [0.55, "verse", true], [0.75, "chorus", true]]},
{"slug": "johnny-cash-hurt", "artist": "Johnny Cash", "title": "Hurt", "query": "Johnny Cash Hurt official",
"emotions": ["melancholic", "dark"], "valence": -0.7, "arousal": -0.1, "genre": ["country", "folk"],
"structure": [[0.0, "intro", false], [0.1, "verse", true], [0.35, "chorus", true], [0.55, "verse", true], [0.7, "climax", true], [0.9, "outro", false]]},
{"slug": "joy-division-love-will-tear", "artist": "Joy Division", "title": "Love Will Tear Us Apart", "query": "Joy Division Love Will Tear Us Apart official",
"emotions": ["melancholic", "cold"], "valence": -0.5, "arousal": 0.25, "genre": ["post-punk", "new wave"],
"structure": [[0.0, "intro", false], [0.15, "verse", true], [0.4, "chorus", true], [0.65, "verse", true], [0.85, "outro", false]]},
{"slug": "marley-three-little-birds", "artist": "Bob Marley", "title": "Three Little Birds", "query": "Bob Marley Three Little Birds official",
"emotions": ["warm", "serene", "playful"], "valence": 0.75, "arousal": -0.05, "genre": ["reggae"],
"structure": [[0.0, "intro", false], [0.1, "chorus", true], [0.35, "verse", true], [0.6, "chorus", true]]},
{"slug": "norah-jones-dont-know-why", "artist": "Norah Jones", "title": "Don't Know Why", "query": "Norah Jones Don't Know Why official",
"emotions": ["warm", "dreamy"], "valence": 0.5, "arousal": -0.4, "genre": ["jazz", "pop", "soul"],
"structure": [[0.0, "intro", false], [0.1, "verse", true], [0.4, "chorus", true], [0.6, "solo", false], [0.75, "verse", true]]},
{"slug": "eno-an-ending-ascent", "artist": "Brian Eno", "title": "An Ending (Ascent)", "query": "Brian Eno An Ending Ascent",
"emotions": ["serene", "dreamy"], "valence": 0.5, "arousal": -0.8, "genre": ["ambient"],
"structure": [[0.0, "intro", false], [0.2, "swell", false], [0.6, "swell", false]]},
{"slug": "bill-withers-lovely-day", "artist": "Bill Withers", "title": "Lovely Day", "query": "Bill Withers Lovely Day official",
"emotions": ["warm", "playful"], "valence": 0.78, "arousal": 0.1, "genre": ["soul", "funk"],
"structure": [[0.0, "intro", false], [0.1, "verse", true], [0.35, "chorus", true], [0.7, "outro", true]]},
{"slug": "daft-around-the-world", "artist": "Daft Punk", "title": "Around the World", "query": "Daft Punk Around the World official audio",
"emotions": ["hypnotic", "playful"], "valence": 0.2, "arousal": 0.5, "genre": ["house", "french touch"],
"structure": [[0.0, "intro", false], [0.15, "groove", true], [0.5, "groove", true], [0.85, "outro", false]]},
{"slug": "donna-summer-i-feel-love", "artist": "Donna Summer", "title": "I Feel Love", "query": "Donna Summer I Feel Love official",
"emotions": ["hypnotic", "euphoric"], "valence": 0.35, "arousal": 0.65, "genre": ["disco", "electronic"],
"structure": [[0.0, "intro", false], [0.15, "verse", true], [0.45, "chorus", true], [0.75, "outro", false]]},
{"slug": "kraftwerk-the-robots", "artist": "Kraftwerk", "title": "The Robots", "query": "Kraftwerk The Robots official",
"emotions": ["hypnotic", "cold"], "valence": -0.1, "arousal": 0.35, "genre": ["electronic", "synth-pop"],
"structure": [[0.0, "intro", false], [0.2, "verse", true], [0.55, "instrumental", false], [0.8, "verse", true]]},
{"slug": "zeppelin-stairway", "artist": "Led Zeppelin", "title": "Stairway to Heaven", "query": "Led Zeppelin Stairway to Heaven official",
"emotions": ["dreamy", "triumphant"], "valence": 0.3, "arousal": 0.3, "genre": ["rock", "folk rock"],
"structure": [[0.0, "intro", false], [0.15, "verse", true], [0.45, "build", true], [0.7, "solo", false], [0.82, "climax", true], [0.95, "outro", true]]},
{"slug": "beethoven-5th-1st", "artist": "Beethoven", "title": "Symphony No. 5 (1st movement)", "query": "Beethoven Symphony 5 first movement",
"emotions": ["tense", "triumphant", "dark"], "valence": -0.2, "arousal": 0.7, "genre": ["classical", "orchestral"],
"structure": [[0.0, "motif", false], [0.25, "development", false], [0.6, "recapitulation", false], [0.9, "coda", false]]},
{"slug": "miles-so-what", "artist": "Miles Davis", "title": "So What", "query": "Miles Davis So What official audio",
"emotions": ["serene", "warm", "dreamy"], "valence": 0.4, "arousal": -0.15, "genre": ["jazz", "modal jazz"],
"structure": [[0.0, "intro", false], [0.1, "head", false], [0.3, "trumpet solo", false], [0.6, "sax solo", false], [0.85, "head", false]]},
{"slug": "mj-billie-jean", "artist": "Michael Jackson", "title": "Billie Jean", "query": "Michael Jackson Billie Jean official audio",
"emotions": ["tense", "hypnotic"], "valence": 0.0, "arousal": 0.55, "genre": ["pop", "funk", "r&b"],
"structure": [[0.0, "intro", false], [0.12, "verse", true], [0.35, "chorus", true], [0.55, "verse", true], [0.75, "chorus", true]]}
]
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment