# Liner's on-device mood model

Quick match reads the lyrics with a small network that runs in the visitor's browser
(`../mood.js`, weights in `../mood-model.bin`, 345 KB). No API key and no server: it was
distilled from Claude's scene choices on 360 made-up songs.

## What it predicts

- **For the whole song:** setting, people, lettering, campfire.
- **For each section:** mood (10 kinds), energy, time of day, particles, visualizer,
  lyric motion.
- **Keywords** to highlight.

The page splits the lyrics into sections at blank lines and headings like `[Chorus]`.
Long runs without breaks, as in LRC files, are cut at the longer pauses. Section choices
are smoothed, and a part of the scene only changes when the next section clearly reads
differently. The scene then crossfades over about 2.4 s, starting 1.2 s before the
section's first line. The strip under the player shows the arc. A hand edit to the time
of day, particles, visualizer, motion or colours holds that look for the whole song.

## How it works

Each lyric line becomes hashed character 2–4-grams, single CJK/kana/hangul characters and
whole words, put into 8,192 buckets (32-bit FNV-1a over code points). Those buckets index a
32-dimensional embedding table, stored as int8 with a scale per row. A section is the mean
of its embeddings. The section head sees `[section, whole song, neighbouring sections,
position, how much of it repeats elsewhere]` through one 64-unit ReLU layer, and the song
head sees the whole-song mean. Training uses soft labels: Claude gave a probability spread
for every choice, and the loss is cross-entropy against that spread. There's no framework:
`train.py` is NumPy with gradients written out by hand. They were checked against finite
differences.

## Results on 60 held-out songs written in different voices

"ok" means Claude gave the model's choice at least 20% probability, so it's a choice
Claude considered reasonable. Quick match is the old word-rule version.

| | model top-1 | model ok | Quick match ok | always most common ok |
|---|---|---|---|---|
| setting | 43% | 70% | 33% | 78% |
| people | 75% | 98% | 93% | 98% |
| lettering | 35% | 82% | 52% | 57% |
| mood (per section) | 32% | 52% | — | 28% |
| energy | 57% | 86% | — | 68% |
| time of day | 63% | 76% | 43% | 79% |
| particles | 47% | 84% | 39% | 89% |
| visualizer | 53% | 83% | 51% | 61% |
| lyric motion | 50% | 82% | 60% | 63% |

Keyword precision/recall is 0.47/0.43 for the model and 0.45/0.03 for the word rules.
The weak spot is the setting. The held-out "chart" songs are mostly city at night with few
scenery words, so always guessing the most common answer beats the model there. Chinese
songs are weaker than English ones (mood ok 40% vs 56%), because only about 70 training
songs are in Chinese. More data is the main lever: changing the dimension, number of
buckets, dropout, weight decay or learning rate all landed within ±1 point on validation.

## Retraining

```
cd projects/liner/model
python3 validate.py data/raw/*.jsonl            # check the data
python3 train.py                                 # validation split, report on held-out
python3 train.py --epochs 40 --full --out ../mood-model.bin    # the shipped model (reproducible)
python3 tools/parity.py ../mood-model.bin "data/heldout/*.jsonl" /tmp/py.json
node tools/parity.mjs ../mood.js ../mood-model.bin "$(ls data/heldout/*.jsonl | paste -sd,)" /tmp/py.json
```

The parity check confirms `mood.js` gives the same probabilities as Python, to within
about 1e-6. Run it after any change to `feats.py` or `mood.js`; the two must tokenise
identically. `tools/compare.py <report.json>` prints the table above (run
`python3 train.py --report r.json` first, plus `cat data/heldout/*.jsonl | node tools/quick.mjs ../index.html > tools/quick_out.json`
for the Quick match column).

## More data

`gen_data.py` asks the Anthropic API for more songs in the same format, rotating through
languages, genres and writing styles, and keeps only valid ones:

```
export ANTHROPIC_API_KEY=sk-ant-...
python3 gen_data.py --batches 30 --per 10 --prefix m     # about 300 songs into data/raw/
```

Then retrain. Keep `data/heldout/` untouched so the scores stay comparable. The data files
are made-up lyrics written for this project, not real songs.
