Commit 4e5d6a23 by PLN (Algolia)

docs(blog): start long-form devlog series — 01 emotion calibration

CLAUDE.md blog-archival rule: commit messages + task logs are blog source
material. This adds the *narrative* layer above the terse armada/tasks/ log —
the documentary 'what happened / what surprised us / what we learned.'

01 tells the emotion-engine story: building CLAP synonym-cluster emotion reads,
the surprise that a 22%% score hid a *systematic* warm-positive center-pull
('playful' fired for 18/60 tracks), and the affine-calibration fix that named
the bias (valence +0.25 too warm, arousal compressed 1.7x) and corrected it
under leave-one-out CV. Includes the n=5 stem-vs-mix call PLN refused to
conclude on — honesty over a clean win.
parent f67edc67
# 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).*
# blog/ — the long-form devlog 📓
Narrative notes on building L'Armada — the *documentary* layer above the terse
[`armada/tasks/`](../armada/tasks/) Captain's Log. Where the task log answers
"what landed," these answer **"what happened, what surprised us, what we learned."**
Raw material for blog posts, devlog videos, and talk material.
## Convention
- One file per story beat / session arc: `NN-slug.md` (`NN` = zero-padded sequence).
- Write it like you'd want to *read* it months later with no memory of the work:
the problem, the approach, the **surprise**, the numbers, the fix.
- Honesty over polish. A finding that *didn't* work (stem-aware lost to whole-mix
on n=5) is better blog material than a clean win — it shows the reasoning.
- Concrete artifacts: commit hashes, file paths, measured numbers, screenshots.
- These are drafts/notes, not published posts — they capture the *texture* while
it's fresh. Editing into final posts happens later.
## Index
- [01 — The machine that learned it was too warm](01-emotion-calibration.md)
building a CLAP emotion engine, discovering it over-feels "playful," and
teaching it humility with affine calibration.
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