# Moodwand handoff

Moodwand turns a **mood + seed** into a mesh gradient. The same mood and seed give the same gradient on every platform, so an app never stores colors: it stores a mood and derives the rest.

- Tool: https://itsjamik.com/tools/moodwand/
- This guide as a web page: https://itsjamik.com/tools/moodwand/handoff/
- Reference code to port (TypeScript, one file, music reaction included): https://itsjamik.com/tools/moodwand/reference.txt

## Use it in an app

1. Give every track a mood id: `low`, `anxious`, `sad`, `calm`, `happy` or `uplifted`.
2. `seed = fnv1a(track.id)`. Use a stable id, never the title (titles get edited).
3. `gradient = generate(mood, seed)` gives sRGB colors plus a `shape`.
4. Draw it with Paper's MeshGradient. Web: `@paper-design/shaders`. Flutter: the `PaperMeshGradient` port. Pass `shape` as `frame`, `rotation`, `scale`, `offset` (x, y), `distortion`, `swirl`.
5. Optional loop: every frame, `colors = frameAt(gradient, t)` with `t = (seconds / mood.period) % 1`. It returns to its start at `t = 1`, so the loop has no seam.

## How it works

- **Color space.** All math is in OKLCH; sRGB is only the output (Björn Ottosson's OKLab matrices).
- **Chroma is relative.** A mood's chroma range is a share of the most chroma sRGB can show at that lightness and hue (`maxChroma`, a 24-step binary search on 0–0.4). Every color is in gamut, and every hue looks equally vivid.
- **Randomness.** `mulberry32` seeded with `fnv1a(mood.id) ^ seed`. Port the calls in the same order, or the output will differ.
- **Variety.** Each seed picks a variant (`spread` 50%, `tonal` 25%, `duo` 25%), 2–4 colors, an optional highlight (65%) and the mesh shape.

## React to music

The gradient can follow a song. It needs two numbers a frame: how loud the mix is, and how loud the bass is.

1. Each frame, measure the RMS of the mix and of the bass (a low-pass at 150 Hz). On the web that's two `AnalyserNode`s.
2. `pulse = listen(pulse, mixRms, bassRms, dt)`. **energy** is how loud the song is next to its recent peak, from 12 dB under it up to it. It rises in 0.25 s and falls in 0.9 s.
3. **kick** is a spring (2.4 Hz, damping 0.5) that sudden bass jumps pull on. A drum hit becomes one soft bounce, never a jump.
4. `react(gradient.shape, pulse, amount, seconds)` gives this frame's shape and speed. Send them to the shader like the usual shape.

| shader input | follows | at 100% reactivity |
|---|---|---|
| `distortion` | energy | +0.3 |
| `swirl` | energy | +0.25 |
| `speed` | energy | 0.6× to 2× |
| `scale` | kick | +6% |
| `rotation` | kick | +4° |
| `offset` | kick | ±0.025, in a slowly turning direction |

- **In an app.** Many players don't hand you samples (Flutter's `just_audio` doesn't). Measure the two levels once per track, 30 times a second, ship them with the track, and read them at the playback position.
- **Latency.** Read the levels when the speakers play them, not earlier. Bluetooth can add 200 ms, and the motion would land before the beat.
- **Reduced motion.** Keep energy and set kick to 0.

## The scale (v1)

| mood | hue | spread | lightness | chroma | highlight L | loop |
|---|---|---|---|---|---|---|
| `low` | 278 | ±18 | 0.36–0.62 | 0.35–0.6 | 0.8 | 14s |
| `anxious` | 10 | ±22 | 0.55–0.72 | 0.75–1 | 0.92 | 6s |
| `sad` | 262 | ±16 | 0.55–0.72 | 0.7–1 | 0.93 | 12s |
| `calm` | 162 | ±22 | 0.7–0.84 | 0.6–0.9 | 0.95 | 12s |
| `happy` | 72 | ±20 | 0.75–0.9 | 0.8–1 | 0.96 | 8s |
| `uplifted` | 40 | ±14 | 0.66–0.78 | 0.85–1 | 0.94 | 6s |

Loop drift: hue ±10°, lightness ±0.04.

## Test values

A port is correct when it reproduces these exactly. Colors are in stop order, highlight first when there is one.

| mood | seed | variant | colors | shape |
|---|---|---|---|---|
| `calm` | `1` | spread | `#59d19d` `#6ebeb1` `#54e96d` | `{"frame":4218274,"rotation":276,"scale":0.84,"offsetX":-0.27,"offsetY":-0.3,"distortion":0.68,"swirl":0.53}` |
| `happy` | `42` | duo | `#f9cd6c` `#fdae71` `#dfb03a` `#f6b887` | `{"frame":1382230,"rotation":69,"scale":1.26,"offsetX":-0.11,"offsetY":-0.14,"distortion":0.81,"swirl":0.11}` |
| `anxious` | `7` | tonal | `#eee1e1` `#c73328` `#ee4635` `#fb7762` | `{"frame":4906465,"rotation":270,"scale":0.97,"offsetX":-0.09,"offsetY":0.07,"distortion":0.54,"swirl":0.39}` |
| `low` | `fnv1a("track-001")` | duo | `#b7bcd5` `#63569e` `#4169c0` `#463276` | `{"frame":4518555,"rotation":213,"scale":0.85,"offsetX":0.09,"offsetY":0.1,"distortion":0.93,"swirl":0.01}` |

Also check: `fnv1a("track-001") = 3931152574`.

## Gotchas

- **32-bit integers.** `Math.imul` and `>>> 0` mean 32-bit unsigned math. In Dart, mask with `& 0xFFFFFFFF` after each multiply and add. Native iOS and Android are fine; Flutter web needs extra care.
- **Changing the scale recolors everything.** Bump `version` and keep old versions if users have already seen the colors.
- **Performance.** Pause the shader when it's off screen; render small covers at a lower resolution.
