# 01 — The machine that learned it was too warm

*2026-06-22 → 06-23 · tide-table emotion engine (#89 / #91 / #92)*

## Why

Shipow's [hydra-live-hexa](https://github.com/Shipow/hydra-live-hexa) visuals
platform already eats a ParVagues analysis bundle built by our tide-table engine
— boundaries, stem summaries, drops, tempo grid, role colors. He asked for one
more lane: **emotion**. His own offline analysis is biquad bands + BPM autocorr
+ per-hit intensity — all rhythm and energy, **zero mood**. That's the gap our
L2 semantic engine fills. So: build an emotion reader, prove it works, hand it over.

## The build

CLAP (`laion/clap-htsat-unfused`) embeds audio and text into one 512-d space, so
you can ask "how *aggressive* does this 12-second window sound" by cosine-comparing
the audio against the word. The naïve version (one word, one shot) is unreliable —
0.3s clips read as noise. The trick that works:

- **Synonym clusters, not single words.** Each of 12 emotions gets ~6 angle prompts
  (EN+FR) — "euphoric" is also *elated, ecstatic, on top of the world, planant*.
  Marginalize cluster→emotion by **mean cosine** (size-invariant), mirroring how our
  sample resolver folds descriptors into families.
- **Russell's circumplex.** Every emotion sits at a (valence, arousal) coordinate, so
  a track also gets a continuous V/A point — the thing a VJ can map to color/intensity.
- **Window scale is CLAP's sweet spot.** 10–30s reads decisively where 0.3s reads
  mush. So the section, not the transient, is the unit.

`emotion_ontology.py` → `emotion_timeline.py` (slides the kernel, smooths a V/A arc)
→ `emotion_corpus.py` (the test rig).

## How do you grade a feeling?

You can't ear-gate 60 tracks alone. So the ground truth is **my own training
knowledge**: yt-dlp the most famous tracks of all time — songs whose emotional read
is *culturally settled* — and label each with the emotions + V/A quadrant that any
listener would agree on. *Pharrell – Happy* is euphoric/+. *Metallica – Master of
Puppets* is aggressive/high-arousal/negative. 60 tracks, deliberately quadrant-balanced
across the circumplex, 50 genres. (yt-dlp footgun for the archive: the default client
403s; `--extractor-args youtube:player_client=android -f bestaudio/best -x` fixes it.)

## The surprise

The first honest grade on all 60 (whole-mix):

```
top-1 emotion hit : 22%   (47% with overlap)
V/A quadrant hit  : 42%
mean V/A error    : 0.573
```

22% looks bad — until you see *how* it's wrong. The engine reads **"playful" for ~18
of 60 tracks**: Fleetwood Mac, Marvin Gaye, Coltrane's *Naima*, Avicii, Journey,
Eminem's *Lose Yourself*. A solemn jazz ballad and a stadium rap anthem both come back
"playful, +0.4/+0.4." That's not random error — it's a **systematic pull toward the
warm-positive center of the wheel.** Everything reads mildly happy and mildly calm.

The cause is in the readout, not the model. We compute V/A as a *distribution-weighted
mean of the anchor coordinates*. A weighted mean of points scattered around a circle
**regresses to the centroid** — so euphoric gets dragged down, aggressive gets dragged
in, and the muddy middle ("playful") wins by default. CLAP isn't tone-deaf; our
averaging is flattening it.

## The fix: teach it that it's biased

Don't guess a correction — **fit one from data.** A per-axis affine map
`gt ≈ a·pred + b`, least-squares over all 60 tracks, reported under
**leave-one-out cross-validation** so "it helps" is a generalization claim, not an
in-sample mirage. What it learned:

```
valence:  ×1.03  −0.25      ← reads +0.25 too WARM (that's the playful pull, quantified)
arousal:  ×1.70  −0.39      ← compressed ~1.7×, needs the most stretch
```

The numbers *name the bias*: a near-pure **−0.25 valence offset** (the engine is an
optimist) and a real **1.7× arousal de-compression** (it mutes intensity hardest).
Post-calibration, LOO:

```
V/A err   0.573 → 0.519
quad hit  42%   → 50%
```

Modest, but real and cross-validated. The bigger win is *interpretability*: we now
have a one-line, defensible statement of exactly how a CLAP emotion read is wrong, and
a knob to undo it.

## The thing I refused to conclude

Early on, stem-aware analysis (demucs → fuse vocals+other for valence, drums+bass+mix
for arousal) **lost** to plain whole-mix on the first 5 tracks: 40% → 20%. Tempting to
write "stems don't help" and move on. PLN's call: *don't conclude on n=5.* Right — 5
tracks is weka. So the stem-demucs batch is running on all 60 right now, and the
calibrator already writes per-mode files (`calibration.whole-mix.json` /
`calibration.stem.json`) so the two can be compared head-to-head instead of one
clobbering the other. The decision waits for the data. ([#92])

## Lessons worth keeping

1. **A 22% score can hide a fixable systematic bias.** Look at *how* it's wrong before
   you call a model bad. The distribution of errors is the diagnosis.
2. **Weighted-mean-of-coordinates regresses to the center.** Anytime you average
   positions on a wheel/map, expect center-pull. Calibrate or use a different readout.
3. **Fit corrections, report under cross-validation.** An in-sample fit always "helps";
   LOO is the honesty check.
4. **n=5 is a vibe, not a result.** Balanced, confident priors beat raw volume — but 5
   is below even that floor.

*Commits: `cc0dc27` (ontology) · `0dcd2f1` (corpus wave 2 + calibrator) · `f67edc6`
(full-60 fit + per-mode files). Next: stem batch lands → settle #92 → fold the
calibration into the engine → ship the emotion lane to hexa (#90).*
