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

Trackinput
  • idtrack-001
Moodinput
  • idlow
fnv1ahash
  • text
  • seed3931152574
generatescale v1
  • seed
  • mood
  • colors
  • shape
MeshGradientPaper
  • colors
  • shape

Nothing is stored but the mood. The rest is derived, the same way everywhere.

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.

Moodwand demo

Press play

energyloudness next to the recent peak
kickbass hits, through a spring
  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 AnalyserNodes.
  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 inputFollowsAt 100% reactivity
distortionenergy+0.3
swirlenergy+0.25
speedenergy0.6× to 2×
scalekick+6%
rotationkick+4°
offsetkick±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

MoodHueLightnessChromaHighlightLoop
low278° ±180.36–0.6235%–60%0.814s
anxious10° ±220.55–0.7275%–100%0.926s
sad262° ±160.55–0.7270%–100%0.9312s
calm162° ±220.7–0.8460%–90%0.9512s
happy72° ±200.75–0.980%–100%0.968s
uplifted40° ±140.66–0.7885%–100%0.946s

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.

calmspread

seed 1

  • #59d19d
  • #6ebeb1
  • #54e96d
frame: 4218274
rotation: 276
scale: 0.84
offsetX: -0.27
offsetY: -0.3
distortion: 0.68
swirl: 0.53
happyduo

seed 42

  • #f9cd6c
  • #fdae71
  • #dfb03a
  • #f6b887
frame: 1382230
rotation: 69
scale: 1.26
offsetX: -0.11
offsetY: -0.14
distortion: 0.81
swirl: 0.11
anxioustonal

seed 7

  • #eee1e1
  • #c73328
  • #ee4635
  • #fb7762
frame: 4906465
rotation: 270
scale: 0.97
offsetX: -0.09
offsetY: 0.07
distortion: 0.54
swirl: 0.39
lowduo

seed 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.