Three finds Opus's pre-ship review missed, all verified in-browser: the heart-connect button was unclickable (pointer-events inherited none from #readout), orb taps rang the 2.5s Moment bell per tap (now a short pluck via the newly exposed Synth.pluck), and Game Day's asleep copy leaked --sports jargon to civilians. Plus the Grimoire now names the gentle door, and the brief gains §14: review verdict and the v1.1 punch list (hub Cache-Control for .html, manual section, knob double-tap, per-vibe info sheets, PWA manifest, wake lock). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
291 lines
17 KiB
Markdown
291 lines
17 KiB
Markdown
# LIFESTRUMENT — build brief
|
||
|
||
> Coding instructions for a fresh Claude (Opus 4.8) session working in this repo.
|
||
> Read this whole file before writing code. Where this brief and the live code
|
||
> disagree, the code wins — verify against `viz/index.html`, `hub.py`,
|
||
> `config.json` before assuming.
|
||
|
||
## 1. What you are building
|
||
|
||
**LIFESTRUMENT** is a second, self-contained client for the existing godstrument
|
||
hub: one page, `viz/life.html`, served by the same server at
|
||
`godstrument.pro/life.html`. Where godstrument is the expert console — 80+
|
||
sources, a patchable modulation matrix, fourteen belief-system skins, MIDI/OSC/CV
|
||
out — Lifestrument is the **fun one**: only the sources about *living* (bodies,
|
||
births, weather, the sun and moon, play), a handful of one-tap **Vibes** instead
|
||
of a patch matrix, two big knobs, immediate sound, and little dopamine **Moments**
|
||
(a chime when somewhere a baby is born; a golden flash at sunset; a whistle at
|
||
kickoff). A visitor should hear something beautiful within **five seconds of
|
||
tapping one button**, and never see a cable.
|
||
|
||
Philosophy in one line: godstrument (and its ZERO mode) is *build anything*;
|
||
Lifestrument is *feel something, immediately*.
|
||
|
||
**What it is NOT**: no money (no crypto/fx/market/econ/debt — banned outright),
|
||
no transport machinery (planes, ISS), no quakes/deaths in the sound (life, not
|
||
mortality), no matrix editing, no skins, no arranger/grooves, no commune, no
|
||
MIDI/OSC/CV out, no grimoire. It links back to the full instrument for people
|
||
who outgrow it.
|
||
|
||
## 2. Non-negotiables
|
||
|
||
- **One file**: `viz/life.html`, self-contained (inline CSS/JS, no frameworks,
|
||
no CDN, no build step) — same house style as `viz/index.html`. Target ≤ 3,000
|
||
lines.
|
||
- **Zero changes to hub.py / run.py / config.json / workers.** Everything is
|
||
client-side. If you believe you need a server change, stop and leave a note
|
||
instead.
|
||
- **Do not modify `viz/index.html`** except (optionally) one additive line: a
|
||
"🌱 lifestrument" item in the right-click sky menu that opens `/life.html`.
|
||
Nothing else.
|
||
- **Mobile-first.** Design for a phone held in one hand (portrait, ~390px);
|
||
desktop is the adaptation, not the base. Godstrument is desktop-first;
|
||
Lifestrument is the opposite.
|
||
- Audio starts only after a user gesture (browser rule): one big button.
|
||
- This brief and all planning docs stay **out of the deploy** (see §12).
|
||
- Never `rsync --delete` to the box; never push `godstrument_users.db`,
|
||
`auth_secret`, or `patches/`.
|
||
|
||
## 3. Architecture (all decided — don't relitigate)
|
||
|
||
**Data in.** Connect to the same hub websocket the main viz uses. Copy the URL
|
||
derivation from `viz/index.html` `connect()` (~line 2240): localhost pages use
|
||
`ws://localhost:8765`, hosted pages use `wss://<host>/ws`, and a `?ws=`
|
||
query-param override wins. Consume only `msg.sources` frames: each key carries
|
||
`{raw, norm, label, event}`. Ignore `dests`, `routes`, `macros` — Lifestrument
|
||
has no use for the shared matrix. Keep a small per-key history ring (~120
|
||
values) like index.html does for its sparklines; the orb and meters draw from it.
|
||
|
||
**Routing.** A local mini-matrix, entirely client-side: each Vibe is a static
|
||
list of `{source, param, amount, curve}` wirings applied at audio-frame rate
|
||
(`requestAnimationFrame` is fine; the synth reads a params object). The hub's
|
||
shared routes are never touched — this also means Lifestrument works against the
|
||
**read-only public hub** exactly as well as a local one.
|
||
|
||
**Sound.** Its own compact Web Audio synth (§6). Do not try to reuse the
|
||
index.html synth — it is closure-scoped and expert-shaped. ~300 lines is enough.
|
||
|
||
**Auth.** Same invite-only accounts. Reuse the exact `/api/*` endpoints
|
||
index.html calls (read its auth IIFE, ~line 6400: me/login/signup/logout with a
|
||
session cookie) — do not invent new endpoints. Landing = one screen with the
|
||
LIFESTRUMENT wordmark, a one-line poem, login/signup (reuse the flow, restyle to
|
||
§9), and after auth one giant **begin** button that starts audio and drops you
|
||
into the default Vibe already playing.
|
||
|
||
**Local sources** (client-generated, never sent to the hub; they exist only in
|
||
the local matrix, prefixed `me.*`):
|
||
- `me.heart` — Web Bluetooth heart rate. Lift the pattern from index.html's
|
||
♥ implementation (`0x180D` HR service). Chrome/Edge/Android only — feature-
|
||
detect and show "not on this browser" honestly on iOS Safari.
|
||
- `me.tilt.x` / `me.tilt.y` — DeviceOrientation beta/gamma normalized 0..1.
|
||
On iOS call `DeviceOrientationEvent.requestPermission()` from the begin
|
||
button's gesture. This is the universal free sensor: every phone becomes a
|
||
controller with zero hardware.
|
||
- `me.shake` — DeviceMotion impulse (accel magnitude over threshold, 400ms
|
||
cooldown), an event source.
|
||
- `me.voice` — mic RMS via getUserMedia + AnalyserNode, opt-in per Vibe.
|
||
- `me.tap` — taps on the orb: sets tempo (rolling average of last 4 intervals)
|
||
and fires a pluck. Fallback "heartbeat" for iOS.
|
||
|
||
**Hub sources whitelist** (the ONLY hub keys Lifestrument may use — exact keys,
|
||
all verified present in `config.json` signals):
|
||
`clock.births`, `clock.pop`, `clock.popvel`, `world.lifeexp`, `world.fertility`,
|
||
`weather.temp`, `weather.wind`, `weather.precip`, `weather.humidity`,
|
||
`sky.day`, `sky.elev`, `season.north`, `season.south`,
|
||
`astro.moon`, `astro.harmony`, `almanac.fertile`, `almanac.waxing`,
|
||
`light.lux`, `light.flash.event`, `audio.rms`, `audio.centroid`,
|
||
`tof.near`, `tof.cx`, `tof.cy`, `hand.open`,
|
||
`sport.events_today`, `sport.inplay`, `sport.kickoff`, `wiki.rate`.
|
||
(`tof.*`/`light.*` flow when the sensor rig — or `workers/sim_esp32.py` — runs;
|
||
`sport.*` when the hub runs `--sports`. Absent keys must degrade gracefully:
|
||
a card shows a gentle "asleep" state, never an error.)
|
||
|
||
## 4. The Vibes (presets) — build exactly these eight
|
||
|
||
Each Vibe is: an emoji + name + one-line promise on a card; a wiring list; a
|
||
palette tint for the orb; and one or two "moment" hooks (§7). Switching Vibes
|
||
crossfades over ~2s (ramp old wirings' amounts to 0, new ones in). Store the
|
||
last Vibe + knob positions in `localStorage` (`ls_vibe`, `ls_energy`,
|
||
`ls_dream`).
|
||
|
||
| # | Vibe | Promise | Core wiring (source → param · amount · curve) |
|
||
|---|------|---------|-----------------------------------------------|
|
||
| 1 | 💓 **Heartbeat** (default) | *your pulse is the tempo* | `me.heart` raw BPM → tempo (clamped 50–140); `me.heart` norm → brightness ·0.6·lin; no heart? `me.tap` sets tempo; kick ticks every beat, hat every half |
|
||
| 2 | 🌅 **Daylight** | *the sun plays the room* | `sky.elev` → brightness ·1.0·scurve; `sky.day` → pad warmth ·0.8; inverse `sky.day` → space ·0.6 (night opens reverb); `weather.temp` → root note (see §5 key map) |
|
||
| 3 | 🌧 **Rain Room** | *the weather writes the melody* | `weather.precip` → pluck density ·1.0·exp (rain literally plays notes); `weather.wind` → pan-wobble rate ·0.7; `weather.humidity` → wash level ·0.5; `weather.temp` → root note |
|
||
| 4 | 👶 **The Living** | *somewhere, a baby* | `clock.births` → chime rate (§7 Poisson, throttled); `world.lifeexp` norm → pad brightness ·0.7; `world.fertility` → warmth ·0.5; `clock.popvel` → drone level ·0.4 |
|
||
| 5 | 🌕 **Moon Bath** | *slow silver* | `astro.moon` → shimmer level ·0.9 (full = maximal); `almanac.fertile` → pad bloom ·0.5; `astro.harmony` → consonance (major-ness) ·0.6; tempo fixed 60, everything legato |
|
||
| 6 | 🏟 **Game Day** | *the world at play* | `sport.inplay` → percussion density ·0.9; `sport.events_today` → base energy ·0.4; `sport.kickoff` → riser+crash moment; hub without `--sports` → card asleep: "the stadium sleeps — ask the keeper" |
|
||
| 7 | 🎤 **The Room** | *your space sings back* | `me.voice` → swell ·1.0·scurve; `audio.centroid` (or local mic centroid) → filter ·0.5; `light.lux` → brightness ·0.5; `light.flash.event` → sparkle hit; `tof.near` → pluck when hand approaches |
|
||
| 8 | 🤲 **In Hand** | *tilt, and it bends* | `me.tilt.x` → filter sweep ·1.0; `me.tilt.y` → space ·0.8; `me.shake` → big hit + orb burst; `hand.open`/`tof.cx/cy` join identically when the rig is present |
|
||
|
||
Curves are godstrument's: `lin`, `scurve` (ease in/out), `exp` (hugs the floor).
|
||
|
||
## 5. The two knobs (the only "mixer")
|
||
|
||
- **ENERGY** — one macro scaling: tempo ±20%, percussion density, pluck
|
||
probability, brightness ceiling. Low = ambient wash, high = it dances.
|
||
- **DREAM** — space macro: reverb size/mix, delay feedback, pad release, detune.
|
||
Low = dry and close, high = cathedral.
|
||
|
||
Both are big thumb-friendly dials (or vertical sliders on desktop), default 0.5,
|
||
persisted. Nothing else is user-adjustable in v1.
|
||
|
||
**Key map** (for `weather.temp` → root): map norm 0..1 across
|
||
`[D minor pent, F major pent, G mixolydian(pent subset), A major pent]` roots
|
||
38/41/43/45 — cold is minor and low, warm is major and higher. All melodic
|
||
content in Lifestrument is pentatonic — the no-wrong-notes rule is what makes it
|
||
un-mess-up-able.
|
||
|
||
## 6. The synth (compact, ~300 lines)
|
||
|
||
Voices (all Web Audio primitives, no worklets):
|
||
- **kick** — sine drop 120→45Hz, 90ms, on the beat when ENERGY > 0.2
|
||
- **hat** — filtered noise burst, offbeats, level ~ percussion density
|
||
- **pluck** — 4-voice poly: triangle osc → lowpass → exp-decay gain; pentatonic
|
||
notes chosen by the active Vibe's melody driver
|
||
- **pad** — 2 detuned saws → lowpass (brightness) → slow attack/release; chord =
|
||
root + 5th + 9th; "warmth" morphs triangle↔saw mix
|
||
- **drone** — one sine + one sine at the 5th, sub register, level per Vibe
|
||
- **shimmer** — high sine cluster with slow random pan, for Moon Bath
|
||
- **chime** — the Moment sound: 2-osc bell (sine + 3.01× partial), 2.5s decay
|
||
- master: delay (0.375s, feedback = DREAM·0.5) + reverb (generated impulse:
|
||
2s noise burst with exp decay into a ConvolverNode) + DynamicsCompressor
|
||
as a safety limiter.
|
||
|
||
The params object (what wirings write into, smoothed with setTargetAtTime):
|
||
`tempo, brightness, warmth, space, wash, density, pluckDensity, droneLevel,
|
||
shimmerLevel, consonance, panWobble, swell, root`.
|
||
|
||
## 7. Moments — the ding-ding engine
|
||
|
||
A Moment = toast (emoji + one line, ~4s), a chime (pitch varies), and an orb
|
||
burst (particles in the Vibe's tint). **Cooldowns are what keep them precious**
|
||
— enforce per-moment minimum gaps:
|
||
|
||
| Moment | Trigger | Cooldown |
|
||
|--------|---------|----------|
|
||
| 👶 *somewhere, a baby* | Poisson draw against `clock.births` raw (≈4/s worldwide — draw every frame, `p = rate·dt·THROTTLE` with THROTTLE tuned so it fires ~every 15–30s in The Living, ~2min elsewhere) | 12s |
|
||
| 🌅 / 🌇 *the sun rises/sets where you are* | `sky.elev` raw sign crossing (needs two frames straddling 0) | 6h |
|
||
| 🌕 *the moon is full tonight* | `astro.moon` norm > 0.97, once per session | session |
|
||
| ⚽ *kickoff — N matches begin* | `sport.kickoff` event arrives | 30s |
|
||
| 🌧 *rain has started* | `weather.precip` raw 0 → >0 | 1h |
|
||
| 💓 *new calm / new fire* | `me.heart` sets a session min/max (after 3min warmup) | 5min |
|
||
| 🎂 *your day* (v2, with natal) | — | — |
|
||
|
||
First-visit onboarding is also a Moment sequence: three toasts introducing the
|
||
orb, the Vibes, the knobs. Never a modal tutorial.
|
||
|
||
## 8. The orb (the whole visual)
|
||
|
||
One canvas, one big central orb (~55% viewport width on phone):
|
||
- radius breathes with the master output RMS; a ring pulses on each kick
|
||
- hue = active Vibe's tint; inner gradient brightness = `brightness` param
|
||
- slow particle drift around it; Moment bursts emit 20–40 particles
|
||
- background: a vertical gradient that tracks `sky.elev` when available (night
|
||
→ deep navy, day → pale warm) — the page itself knows what time it is
|
||
- tapping the orb = `me.tap` (pluck + tempo in Heartbeat)
|
||
- under the orb: current Vibe name + the *live fact* it runs on, in plain words,
|
||
updated ~10s: "144 hearts born this minute" / "the sun is 12° up" /
|
||
"3 matches in play". Real numbers, human phrasing — this is Lifestrument's
|
||
version of godstrument's data-honesty.
|
||
|
||
## 9. Look & feel
|
||
|
||
Dawn, not deep space: near-white warm background in day, dusky in night mode
|
||
(driven by `sky.elev`, not a toggle), one accent hue per Vibe, big rounded type
|
||
(system stack is fine; 17px minimum body), generous spacing, cards with soft
|
||
shadows, everything reachable by thumb. No tables, no panels, no right-click
|
||
anything. The wordmark is lowercase: **lifestrument** · "played by being alive".
|
||
Keep godstrument's honesty of voice ("the ECB fixes once a day" energy) but
|
||
warmer and shorter.
|
||
|
||
## 10. Degradation matrix (build these states, they are most of the UX)
|
||
|
||
- No Bluetooth API (iOS): Heartbeat card shows "🫱 tap your pulse" mode.
|
||
- Hub unreachable: local-only mode — me.* sources still play (tilt/tap/voice);
|
||
banner "the world is quiet — playing your half".
|
||
- Not signed in: landing; signup needs an invite code exactly like godstrument.
|
||
- Sensor rig absent: In Hand uses tilt only; The Room uses mic only.
|
||
- `--sports` off: Game Day asleep state.
|
||
- Reduced motion (`prefers-reduced-motion`): particles off, orb breathes gently.
|
||
|
||
## 11. Acceptance checks (run all before calling it done)
|
||
|
||
1. `python3 -m http.server 8899 --directory viz` + open
|
||
`http://localhost:8899/life.html?ws=ws://localhost:8790` against a running
|
||
hub (`godstrument-test` launch config) → sources flow, Daylight plays, zero
|
||
console errors.
|
||
2. Kill the hub → local-only banner, tilt/tap still make sound, no errors.
|
||
3. Chrome DevTools device mode iPhone 13: layout holds at 390×844; begin button
|
||
≥ 56px tall; knobs usable with a thumb.
|
||
4. Each of the 8 Vibes: switch to it, confirm audibly distinct within 3s, no
|
||
errors, crossfade smooth.
|
||
5. Moments: fake `sky.elev` crossing and a `sport.kickoff` frame in the console
|
||
(expose a tiny `window.__lifeDebug.inject(key, val)` hook, dev-only) → toast
|
||
+ chime + burst, cooldowns respected.
|
||
6. Reload → same Vibe and knob positions return.
|
||
7. Lighthouse mobile performance ≥ 90 (it's one file with no deps — this is
|
||
easy; if it isn't, the canvas loop is misbehaving).
|
||
8. Leave one runnable self-check in-repo: `test_lifestrument.py` (stdlib only)
|
||
asserting `life.html` parses as balanced HTML, whitelist keys ⊂
|
||
`config.json` signals ∪ `me.*`, and banned prefixes (`crypto.`, `fx.`,
|
||
`market.`, `econ.`, `debt.`, `quake.`, `planes.`, `iss.`) appear nowhere.
|
||
|
||
## 12. Deploy
|
||
|
||
`viz/life.html` deploys with the normal flow (it must ship — it's product).
|
||
This brief does NOT: it is in the deploy exclusion list in `CLAUDE.md`. Deploy =
|
||
rsync git-tracked files minus that exclusion list (never `--delete`), then
|
||
`sudo -n systemctl restart godstrument` on the box, then verify
|
||
`https://godstrument.pro/life.html` returns 200 and plays.
|
||
|
||
## 13. Explicitly deferred to v2 (do not build now)
|
||
|
||
Natal/birthday integration (needs the chart caster), a shareable "postcard"
|
||
image of your session, preset remixing/saving, Lifestrument-specific accounts or
|
||
theming per user, Apple Watch workarounds, camera-based pulse detection, any
|
||
server-side anything.
|
||
|
||
---
|
||
|
||
## 14. v1.1 notes — post-ship review (Fable, 2026-07-12)
|
||
|
||
**Verdict on the v1 build**: faithful to the brief and good. One deliberate,
|
||
sensible deviation — the default Vibe is Daylight (instant sound from world
|
||
data) rather than Heartbeat (silent until a tap/strap); keep it. The
|
||
understand→build→adversarial-review process caught 21 real issues pre-ship;
|
||
this review caught three more post-ship, all fixed:
|
||
|
||
- `#heartbtn` was unclickable — it lives inside `#readout`
|
||
(`pointer-events: none`, inherited). Fixed with `pointer-events: auto` on
|
||
`.on`. Lesson for future UI inside `#readout`: opt back in explicitly.
|
||
- Orb taps rang the 2.5s Moment bell per tap (4 taps to set tempo = 4
|
||
overlapping bells). `Synth.pluck(midi, vel)` is now exposed; taps pluck.
|
||
- Game Day's asleep copy leaked `--sports` CLI jargon; now says "ask the
|
||
keeper to open it". House rule: no flags, keys, or code words in
|
||
Lifestrument copy — civilians only.
|
||
- The Grimoire (index.html, after the zen bullet) now mentions Lifestrument as
|
||
"the gentle door". Keep that in sync if the URL or pitch changes.
|
||
|
||
**Follow-ups worth doing (v1.1, small):**
|
||
1. **Hub `Cache-Control`** — the hub serves HTML with no cache headers, so
|
||
browsers heuristically cache; after every deploy some users run a stale
|
||
page until a hard refresh (bit us during this very review). Add
|
||
`Cache-Control: no-cache` for `.html` responses in hub.py's HTTP handler.
|
||
Tiny, safe, helps godstrument too. (The v1 "zero server changes" rule was
|
||
for the build, not forever.)
|
||
2. **Manual** — GODSTRUMENT_MANUAL_SOURCE.md needs a Lifestrument section; a
|
||
background task may already be adding Earth Echo — check before editing to
|
||
avoid a collision.
|
||
3. **Knob double-tap** resets to 50%.
|
||
4. **Per-vibe info sheet** — long-press a vibe card → one honest paragraph on
|
||
what it listens to (the data-honesty ethos, one tap deeper).
|
||
5. **PWA manifest + icon** so phones can install it (needs a small manifest
|
||
file next to life.html; the single-file rule can bend for a manifest).
|
||
6. **Screen Wake Lock** while playing (optional; audio survives screen-off,
|
||
but the orb doesn't).
|
||
|
||
**v2 list in §13 stands.** If sports monetizes, remember `sport.goal`
|
||
(see the sports-source memory) would make Game Day genuinely great.
|