
Developer handoff
Build Moodwand into an app
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.
Overview
- idtrack-001
- idlow
- text
- seed3931152574
- seed
- mood
- colors
- shape
- colors
- shape
Nothing is stored but the mood. The rest is derived, the same way everywhere.
Use it in an app
- Give every track a mood id:
low,anxious,sad,calm,happyoruplifted. seed = fnv1a(track.id). Use a stable id, never the title (titles get edited).gradient = generate(mood, seed)gives sRGB colors plus ashape.- Draw it with Paper's MeshGradient. Web:
@paper-design/shaders. Flutter: thePaperMeshGradientport. Passshapeasframe,rotation,scale,offset(x, y),distortion,swirl. - Optional loop: every frame,
colors = frameAt(gradient, t)witht = (seconds / mood.period) % 1. It returns to its start att = 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.
Moodwand demo
Press play
- Each frame, measure the RMS of the mix and of the bass (a low-pass at 150 Hz). On the web that's two
AnalyserNodes. 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.- 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.
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 | Lightness | Chroma | Highlight | Loop |
|---|---|---|---|---|---|
low | 278° ±18 | 0.36–0.62 | 35%–60% | 0.8 | 14s |
anxious | 10° ±22 | 0.55–0.72 | 75%–100% | 0.92 | 6s |
sad | 262° ±16 | 0.55–0.72 | 70%–100% | 0.93 | 12s |
calm | 162° ±22 | 0.7–0.84 | 60%–90% | 0.95 | 12s |
happy | 72° ±20 | 0.75–0.9 | 80%–100% | 0.96 | 8s |
uplifted | 40° ±14 | 0.66–0.78 | 85%–100% | 0.94 | 6s |
Chroma is a share of the sRGB maximum. 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.
calmspreadseed 1
- #59d19d
- #6ebeb1
- #54e96d
frame: 4218274 rotation: 276 scale: 0.84 offsetX: -0.27 offsetY: -0.3 distortion: 0.68 swirl: 0.53
happyduoseed 42
- #f9cd6c
- #fdae71
- #dfb03a
- #f6b887
frame: 1382230 rotation: 69 scale: 1.26 offsetX: -0.11 offsetY: -0.14 distortion: 0.81 swirl: 0.11
anxioustonalseed 7
- #eee1e1
- #c73328
- #ee4635
- #fb7762
frame: 4906465 rotation: 270 scale: 0.97 offsetX: -0.09 offsetY: 0.07 distortion: 0.54 swirl: 0.39
lowduoseed fnv1a("track-001")
- #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.