Godstrument/viz/life.html
type-two 350ad72782 🌱 LIFESTRUMENT — the gentle client (viz/life.html)
A second, self-contained client for the same hub, mobile-first: only
the sources about living (bodies, births, weather, sun & moon, play),
eight one-tap Vibes instead of a matrix, two macro knobs (ENERGY /
DREAM), a breathing orb, and dopamine Moments (somewhere a baby, the
sun where you stand, a kickoff). Its own compact Web Audio synth, all
routing client-side so it plays against the read-only public hub.
Reuses the invite-only /api auth; local sensors (heart BT, device tilt,
shake, mic, tap) never touch the hub. No money, no machinery, no
cables. Links back to the full instrument; added a "🌱 lifestrument"
item to godstrument's sky menu. Built understand→build→review with an
adversarial pass; 21 findings fixed (a11y sliders, zoom, contrast,
warm-dawn background, reachable heart connect, pluck de-click,
scheduler catch-up cap, moment tuning). test_lifestrument.py guards the
whitelist and the money/machinery ban.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:43:48 +10:00

1152 lines
59 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#10131f">
<meta name="description" content="lifestrument — a gentle instrument played by being alive">
<title>lifestrument</title>
<style>
:root {
--bg0: #11131d; --bg1: #1a1e2e;
--ink: #eef1f8; --dim: rgba(228,234,247,0.64); --faint: rgba(216,224,242,0.62);
--card: rgba(255,255,255,0.06); --cardline: rgba(255,255,255,0.12);
--accent: 255,150,120; /* live vibe tint, set by JS */
--safe-b: env(safe-area-inset-bottom, 0px);
--safe-t: env(safe-area-inset-top, 0px);
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
:focus-visible { outline: 2px solid rgb(var(--accent)); outline-offset: 2px; border-radius: 4px; }
button { font: inherit; }
html, body {
margin: 0; padding: 0; height: 100%; width: 100%; overflow: hidden;
background: var(--bg0); color: var(--ink);
font-family: ui-rounded, "SF Pro Rounded", "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility;
user-select: none; -webkit-user-select: none;
font-size: 17px; line-height: 1.4;
touch-action: manipulation;
}
#orb { position: fixed; inset: 0; display: block; z-index: 0; }
/* ---- main UI overlay (revealed after "begin") ---- */
#ui {
position: fixed; inset: 0; z-index: 2;
display: flex; flex-direction: column;
padding: calc(10px + var(--safe-t)) 14px calc(12px + var(--safe-b));
pointer-events: none; opacity: 0; transition: opacity .6s ease;
}
#ui.on { opacity: 1; }
#ui > * { pointer-events: auto; }
#top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
#top .mark { font-size: 15px; font-weight: 700; letter-spacing: .5px; color: var(--dim); }
#top .mark b { color: var(--ink); font-weight: 800; }
#top .right { display: flex; align-items: center; gap: 8px; }
#chip, #full {
font-size: 12.5px; font-weight: 600; color: var(--dim); font-family: inherit;
background: var(--card); border: 1px solid var(--cardline);
padding: 9px 13px; border-radius: 999px; cursor: pointer;
text-decoration: none; white-space: nowrap; -webkit-appearance: none; appearance: none;
display: inline-flex; align-items: center; min-height: 36px;
}
#chip:active, #full:active { transform: scale(.96); }
/* readout sits below the orb, above the controls */
#readout {
pointer-events: none; text-align: center; margin: auto auto 16px;
max-width: 92%;
}
#vibename { font-size: 26px; font-weight: 800; letter-spacing: .3px;
color: rgb(var(--accent)); filter: brightness(1.25); }
#promise { font-size: 14px; color: var(--dim); margin-top: 2px; font-style: italic; }
#fact { font-size: 15px; color: var(--ink); margin-top: 10px; min-height: 21px; opacity: .92; }
#heartbtn {
display: none; margin: 12px auto 0; font-size: 14px; font-weight: 600; color: rgb(var(--accent));
background: rgba(var(--accent),0.14); border: 1px solid rgba(var(--accent),0.5);
padding: 9px 16px; border-radius: 999px; cursor: pointer; min-height: 40px; font-family: inherit;
}
#heartbtn.on { display: inline-flex; align-items: center; }
/* vibe strip */
#vibes {
display: flex; gap: 9px; overflow-x: auto; overflow-y: hidden;
padding: 4px 2px 8px; margin: 0 -14px; padding-left: 14px; padding-right: 14px;
scrollbar-width: none; -webkit-overflow-scrolling: touch; scroll-snap-type: x proximity;
}
#vibes::-webkit-scrollbar { display: none; }
.vibe {
flex: 0 0 auto; scroll-snap-align: center;
display: flex; flex-direction: column; align-items: center; justify-content: center;
width: 74px; height: 74px; border-radius: 18px; cursor: pointer;
background: var(--card); border: 1.5px solid var(--cardline);
transition: transform .15s ease, border-color .3s ease, background .3s ease;
}
.vibe .e { font-size: 26px; line-height: 1; }
.vibe .n { font-size: 10.5px; font-weight: 600; color: var(--dim); margin-top: 5px; text-align: center; }
.vibe.sel { border-color: rgb(var(--accent)); background: rgba(var(--accent),0.14); }
.vibe.sel .n { color: var(--ink); }
.vibe.asleep { opacity: .5; }
.vibe:active { transform: scale(.93); }
/* knobs */
#knobs { display: flex; justify-content: center; gap: 34px; padding: 8px 0 2px; }
.knob { display: flex; flex-direction: column; align-items: center; touch-action: none; cursor: ns-resize; }
.knob svg { width: 74px; height: 74px; display: block; }
.knob .track { fill: none; stroke: rgba(255,255,255,0.12); stroke-width: 7; }
.knob .val { fill: none; stroke: rgb(var(--accent)); stroke-width: 7; stroke-linecap: round;
transform: rotate(-90deg); transform-origin: 40px 40px; transition: stroke-dashoffset .05s linear; }
.knob .klabel { font-size: 11.5px; font-weight: 700; letter-spacing: 1px; color: var(--dim); margin-top: 4px; }
.knob .kval { font-size: 11px; color: var(--faint); }
/* banner (degradation) */
#banner {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: calc(16px + var(--safe-b)); z-index: 4;
background: rgba(20,24,38,0.9); border: 1px solid var(--cardline);
color: var(--dim); font-size: 13px; padding: 8px 14px; border-radius: 999px;
display: none; max-width: 92vw; text-align: center;
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
}
#banner.on { display: block; }
/* toasts / moments */
#toasts { position: fixed; top: calc(58px + var(--safe-t)); left: 0; right: 0; z-index: 5;
display: flex; flex-direction: column; align-items: center; gap: 8px; pointer-events: none; }
.toast {
background: rgba(22,26,40,0.92); border: 1px solid rgba(var(--accent),0.5);
color: var(--ink); font-size: 15px; font-weight: 600;
padding: 10px 18px; border-radius: 999px;
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
animation: pop .4s ease both; box-shadow: 0 6px 26px rgba(0,0,0,0.35);
}
.toast.out { animation: fade .5s ease both; }
.toast .te { margin-right: 7px; }
@keyframes pop { from { transform: translateY(-14px) scale(.9); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes fade { to { transform: translateY(-10px); opacity: 0; } }
/* ---- landing / auth ---- */
#landing {
position: fixed; inset: 0; z-index: 20;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 4px; padding: 28px; text-align: center; overflow-y: auto;
background: radial-gradient(120% 90% at 50% 18%, #232842 0%, #14172400 60%), var(--bg0);
transition: opacity .7s ease;
}
#landing.hidden { opacity: 0; pointer-events: none; }
#landing .wordmark { font-size: 40px; font-weight: 800; letter-spacing: 1px;
background: linear-gradient(90deg,#ffd3a6,#ff9ab0,#b39cff); -webkit-background-clip: text;
background-clip: text; color: transparent; }
#landing .sub { font-size: 15px; color: var(--dim); margin-bottom: 6px; font-style: italic; }
#landing .poem { font-size: 14px; color: var(--faint); max-width: 320px; line-height: 1.6; margin: 10px 0 22px; }
.auth { width: 100%; max-width: 320px; display: none; flex-direction: column; gap: 10px; }
.auth.on { display: flex; }
.auth input {
width: 100%; font-size: 16px; color: var(--ink);
background: rgba(255,255,255,0.06); border: 1px solid var(--cardline);
border-radius: 12px; padding: 13px 14px; outline: none;
}
.auth input:focus { border-color: rgba(var(--accent),0.7); }
.auth .row { display: flex; gap: 8px; align-items: center; }
.auth .tabs { display: flex; gap: 6px; margin-bottom: 4px; }
.auth .tabs button {
flex: 1; font-size: 14px; font-weight: 600; color: var(--dim);
background: transparent; border: 1px solid var(--cardline); border-radius: 10px;
padding: 9px; cursor: pointer;
}
.auth .tabs button.on { color: var(--ink); background: rgba(var(--accent),0.16); border-color: rgba(var(--accent),0.6); }
.btn {
font-size: 17px; font-weight: 700; color: #14131a;
background: linear-gradient(90deg,#ffd3a6,#ff9ab0); border: none;
border-radius: 14px; padding: 15px; cursor: pointer; min-height: 56px;
}
.btn:active { transform: scale(.98); }
.btn.ghost { background: transparent; color: var(--dim); border: 1px solid var(--cardline); font-weight: 600; }
.err { color: #ff9a9a; font-size: 13px; min-height: 16px; }
.note { font-size: 12.5px; color: var(--faint); margin-top: 14px; }
form { display: none; flex-direction: column; gap: 10px; }
form.on { display: flex; }
#beginArea { display: none; flex-direction: column; align-items: center; gap: 14px; width: 100%; max-width: 320px; }
#beginArea.on { display: flex; }
#beginBtn { width: 100%; font-size: 20px; min-height: 64px; }
#whoami { font-size: 13px; color: var(--faint); }
@media (min-width: 720px) {
#ui { padding: 18px 22px calc(20px + var(--safe-b)); align-items: center; }
#top, #readout, #vibes, #knobs, #beginArea, .auth { max-width: 560px; width: 100%; }
#vibes { justify-content: center; margin: 0; padding: 4px 0 8px; }
}
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; }
}
</style>
</head>
<body>
<canvas id="orb"></canvas>
<div id="ui">
<div id="top">
<div class="mark"><b>life</b>strument</div>
<div class="right">
<a id="full" href="/" title="the full instrument">godstrument ↗</a>
<button id="chip" type="button">sign in</button>
</div>
</div>
<div id="readout">
<div id="vibename">Daylight</div>
<div id="promise">the sun plays the room</div>
<div id="fact"></div>
<button id="heartbtn" type="button">♥ connect a heart monitor</button>
</div>
<div id="vibes"></div>
<div id="knobs"></div>
</div>
<div id="banner"></div>
<div id="toasts"></div>
<div id="landing">
<div class="wordmark">lifestrument</div>
<div class="sub">played by being alive</div>
<div class="poem">your pulse, the weather, the sun going down, a baby born somewhere — the world plays, and so do you. no cables, no settings, just a feeling.</div>
<div class="auth" id="authArea">
<div class="tabs">
<button data-tab="login" class="on">log in</button>
<button data-tab="signup">create account</button>
</div>
<form id="loginform" class="on" autocomplete="on">
<input name="identifier" placeholder="email or username" aria-label="email or username" autocomplete="username">
<input name="password" type="password" placeholder="password" aria-label="password" autocomplete="current-password">
<div class="err" role="alert"></div>
<button type="submit" class="btn">enter</button>
</form>
<form id="signupform" autocomplete="on">
<input name="username" placeholder="username" aria-label="username" autocomplete="nickname">
<input name="email" type="email" placeholder="email" aria-label="email" autocomplete="email">
<input name="password" type="password" placeholder="password (8+ chars)" aria-label="password" autocomplete="new-password">
<input name="confirm" type="password" placeholder="confirm password" aria-label="confirm password" autocomplete="new-password">
<input name="invite" placeholder="invite code" aria-label="invite code" autocomplete="off">
<div class="err" role="alert"></div>
<button type="submit" class="btn">create account</button>
</form>
<div class="note">✦ invite-only ✦ ask a friend for a code</div>
</div>
<div id="beginArea">
<button id="beginBtn" class="btn">begin</button>
<div id="whoami"></div>
</div>
</div>
<script>
(function () {
"use strict";
// ===========================================================================
// LIFESTRUMENT — a gentle, self-contained client for the godstrument hub.
// One page, life-only sources, eight one-tap Vibes, two knobs, an orb, and
// little dopamine Moments. It never touches the shared matrix; all routing is
// local, so it plays fine against the read-only public hub. See
// LIFESTRUMENT_BRIEF.md (out of deploy) for the full spec.
// ===========================================================================
const clamp = (v, a, b) => v < a ? a : (v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
const now = () => performance.now() / 1000;
const $ = (s) => document.querySelector(s);
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---- pentatonic scales (life never hits a wrong note) --------------------
const SCALES = { pentatonic: [0, 2, 4, 7, 9], minor_pent: [0, 3, 5, 7, 10] };
const mtof = (m) => 440 * Math.pow(2, (m - 69) / 12);
// ---- source state --------------------------------------------------------
// hub sources land here keyed by name; me.* are local (never sent to the hub)
const sources = new Map(); // key -> { raw, norm, hist, label, isEvent }
const WHITELIST = new Set([
"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",
]);
const me = {
heart: { raw: 0, norm: 0, on: false, min: 999, max: 0, warmAt: 0 },
tilt: { x: 0.5, y: 0.5, on: false },
shake: { t: -99, on: false, mag: 0 },
voice: { rms: 0, centroid: 0.5, on: false },
tap: { tempo: 0, times: [] },
};
function ensureSource(key, info) {
let n = sources.get(key);
if (!n) {
n = { key, label: (info && info.label) || key, raw: 0, norm: 0,
hist: [], isEvent: !!(info && info.event), lastRaw: null, edgeT: -99 };
sources.set(key, n);
}
return n;
}
// read a wiring source as a 0..1 value; null when the source is asleep/absent
function readNorm(key) {
if (key === "me.heart") return me.heart.on ? me.heart.norm : (me.tap.tempo ? clamp((me.tap.tempo - 40) / 140, 0, 1) : null);
if (key === "me.tilt.x") return me.tilt.on ? me.tilt.x : null;
if (key === "me.tilt.y") return me.tilt.on ? me.tilt.y : null;
if (key === "me.voice") return me.voice.on ? me.voice.rms : null;
if (key === "me.voice.centroid") return me.voice.on ? me.voice.centroid : null;
const n = sources.get(key);
return n && typeof n.norm === "number" && n.hist.length ? n.norm : null;
}
// ===========================================================================
// WEBSOCKET — lifted from index.html connect()/handleMessage, trimmed to
// msg.sources only. localhost -> ws://host:8765, hosted -> wss://host/ws,
// ?ws= override always wins.
// ===========================================================================
let ws = null, connected = false, reconnectTimer = null, everConnected = false, lastFrameT = 0;
function connect() {
try {
const override = new URLSearchParams(location.search).get("ws");
const h = location.hostname;
const isLocal = h === "localhost" || h === "127.0.0.1" || h === "";
let url;
if (override) url = override;
else if (isLocal) url = "ws://" + (h || "localhost") + ":8765";
else url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
ws = new WebSocket(url);
} catch (e) { scheduleReconnect(); return; }
ws.onopen = () => { connected = true; everConnected = true; if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } };
ws.onclose = () => { connected = false; scheduleReconnect(); };
ws.onerror = () => { try { ws.close(); } catch (e) {} };
ws.onmessage = (ev) => { let m; try { m = JSON.parse(ev.data); } catch (e) { return; } handleMessage(m); };
}
function scheduleReconnect() {
connected = false;
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 1000);
}
function handleMessage(msg) {
if (!msg.sources) return;
lastFrameT = now();
for (const k in msg.sources) {
if (!WHITELIST.has(k)) continue; // life-only: ignore money & machinery entirely
const s = msg.sources[k];
const n = ensureSource(k, s);
if (s.label) n.label = s.label;
if (typeof s.raw === "number") {
n.raw = s.raw;
const hh = n.hist;
if (hh[hh.length - 1] !== s.raw || hh.length < 2) { hh.push(s.raw); if (hh.length > 120) hh.shift(); }
}
if (typeof s.norm === "number") n.norm = clamp(s.norm, 0, 1);
if (s.event) n.isEvent = true;
// rising-edge detection for event sources (kickoff, light flash)
if (n.isEvent && typeof s.raw === "number") {
if ((n.lastRaw == null || n.lastRaw <= 0.001) && s.raw > 0.001) n.edgeT = now();
n.lastRaw = s.raw;
}
}
}
// ===========================================================================
// THE VIBES — eight presets. Each: tint, promise, base params, a wiring list
// {src, param, amt, curve, inv?}, a tempo rule, and needs (for asleep state).
// curves: lin, scurve (ease), exp (hugs floor).
// ===========================================================================
const DEFAULTS = {
brightness: 0.35, warmth: 0.45, space: 0.30, wash: 0.15, density: 0.25,
pluckDensity: 0.15, droneLevel: 0.30, shimmerLevel: 0.0, consonance: 0.6,
panWobble: 0.2, swell: 0.0,
};
const VIBES = [
{ id: "heartbeat", emoji: "💓", name: "Heartbeat", promise: "your pulse is the tempo",
tint: [255, 96, 116], tempo: "heart", wantsHeart: true,
base: { droneLevel: 0.34, density: 0.42, pluckDensity: 0.22, brightness: 0.4 },
wires: [ { src: "me.heart", param: "brightness", amt: 0.6, curve: "lin" } ],
fact: () => me.heart.on ? Math.round(me.heart.raw) + " beats a minute"
: (me.tap.tempo ? "tapping at " + Math.round(me.tap.tempo) + " bpm" : "tap the orb to set your pulse") },
{ id: "daylight", emoji: "🌅", name: "Daylight", promise: "the sun plays the room",
tint: [255, 184, 96], tempo: 76, tempRoot: true,
base: { droneLevel: 0.32, density: 0.2, pluckDensity: 0.12 },
wires: [ { src: "sky.elev", param: "brightness", amt: 1.0, curve: "scurve" },
{ src: "sky.day", param: "warmth", amt: 0.8, curve: "lin" },
{ src: "sky.day", param: "space", amt: 0.6, curve: "lin", inv: true } ],
fact: () => { const e = sources.get("sky.elev"); return e && e.hist.length
? "the sun is " + Math.round(e.raw) + "° " + (e.raw >= 0 ? "up" : "down") : "waiting for the sky" } },
{ id: "rain", emoji: "🌧", name: "Rain Room", promise: "the weather writes the melody",
tint: [120, 176, 224], tempo: 82, tempRoot: true,
base: { droneLevel: 0.3, density: 0.18, brightness: 0.4 },
wires: [ { src: "weather.precip", param: "pluckDensity", amt: 1.0, curve: "exp" },
{ src: "weather.wind", param: "panWobble", amt: 0.7, curve: "lin" },
{ src: "weather.humidity", param: "wash", amt: 0.5, curve: "lin" } ],
fact: () => { const p = sources.get("weather.precip"); return p && p.hist.length
? (p.raw > 0.05 ? p.raw.toFixed(1) + " mm of rain falling" : "dry over the city, for now") : "watching the weather" } },
{ id: "living", emoji: "👶", name: "The Living", promise: "somewhere, a baby",
tint: [150, 214, 150], tempo: 72,
base: { droneLevel: 0.36, density: 0.16, pluckDensity: 0.1, brightness: 0.45 },
wires: [ { src: "world.lifeexp", param: "brightness", amt: 0.7, curve: "lin" },
{ src: "world.fertility", param: "warmth", amt: 0.5, curve: "lin" },
{ src: "clock.popvel", param: "droneLevel", amt: 0.4, curve: "lin" } ],
babyThrottle: 0.012,
fact: () => { const b = sources.get("clock.births"); return b && b.hist.length
? "about " + Math.round(b.raw * 60) + " born this minute" : "listening for new life" } },
{ id: "moon", emoji: "🌕", name: "Moon Bath", promise: "slow silver",
tint: [190, 200, 235], tempo: 60, fixedTempo: true, legato: true,
base: { droneLevel: 0.4, density: 0.05, pluckDensity: 0.05, space: 0.6, brightness: 0.35 },
wires: [ { src: "astro.moon", param: "shimmerLevel", amt: 0.9, curve: "lin" },
{ src: "almanac.fertile", param: "brightness", amt: 0.5, curve: "lin" },
{ src: "astro.harmony", param: "consonance", amt: 0.6, curve: "lin" } ],
fact: () => { const mo = sources.get("astro.moon"); return mo && mo.hist.length
? "the moon is " + Math.round(mo.norm * 100) + "% full tonight" : "waiting for the moon" } },
{ id: "gameday", emoji: "🏟", name: "Game Day", promise: "the world at play",
tint: [255, 150, 70], tempo: 96, needs: ["sport.inplay", "sport.events_today", "sport.kickoff"],
base: { droneLevel: 0.28, density: 0.4, pluckDensity: 0.2, brightness: 0.5 },
wires: [ { src: "sport.inplay", param: "density", amt: 0.9, curve: "scurve" },
{ src: "sport.events_today", param: "droneLevel", amt: 0.4, curve: "lin" } ],
fact: () => { const ip = sources.get("sport.inplay"); return ip && ip.hist.length
? Math.round(ip.raw) + " matches in play right now" : "the stadium sleeps — ask the keeper for --sports" } },
{ id: "room", emoji: "🎤", name: "The Room", promise: "your space sings back",
tint: [200, 150, 240], tempo: 84, wantsVoice: true,
base: { droneLevel: 0.3, density: 0.22, pluckDensity: 0.16 },
wires: [ { src: "me.voice", param: "swell", amt: 1.0, curve: "scurve" },
{ src: "audio.centroid", param: "brightness", amt: 0.5, curve: "lin" },
{ src: "me.voice.centroid", param: "brightness", amt: 0.4, curve: "lin" }, // local-mic fallback for the filter
{ src: "light.lux", param: "brightness", amt: 0.5, curve: "scurve" },
{ src: "tof.near", param: "pluckDensity", amt: 0.7, curve: "exp" } ], // hand nears the rig → plucks
fact: () => me.voice.on ? "your room is " + (me.voice.rms > 0.25 ? "singing" : "quiet") + " back"
: "let the mic hear the room" },
{ id: "inhand", emoji: "🤲", name: "In Hand", promise: "tilt, and it bends",
tint: [130, 210, 200], tempo: 86,
base: { droneLevel: 0.3, density: 0.24, pluckDensity: 0.18, brightness: 0.4 },
wires: [ { src: "me.tilt.x", param: "brightness", amt: 1.0, curve: "lin" },
{ src: "me.tilt.y", param: "space", amt: 0.8, curve: "lin" },
{ src: "hand.open", param: "swell", amt: 0.6, curve: "lin" },
{ src: "tof.cx", param: "brightness", amt: 0.6, curve: "lin" },
{ src: "tof.cy", param: "space", amt: 0.6, curve: "lin" } ],
fact: () => me.tilt.on ? "tilt the phone to bend the sound"
: (sources.get("tof.near") ? "wave your hand over the sensor" : "tilt needs motion access — reload and allow it") },
];
const VIBE_BY_ID = {}; VIBES.forEach(v => VIBE_BY_ID[v.id] = v);
// ===========================================================================
// KNOBS (macros) + persistence
// ===========================================================================
let energy = 0.5, dream = 0.5;
let activeVibe = VIBE_BY_ID.daylight, prevVibe = activeVibe, crossfade = 1;
try {
const sv = localStorage.getItem("ls_vibe"); if (sv && VIBE_BY_ID[sv]) activeVibe = prevVibe = VIBE_BY_ID[sv];
const se = parseFloat(localStorage.getItem("ls_energy")); if (se >= 0 && se <= 1) energy = se;
const sd = parseFloat(localStorage.getItem("ls_dream")); if (sd >= 0 && sd <= 1) dream = sd;
} catch (e) {}
const persist = () => { try {
localStorage.setItem("ls_vibe", activeVibe.id);
localStorage.setItem("ls_energy", energy.toFixed(3));
localStorage.setItem("ls_dream", dream.toFixed(3));
} catch (e) {} };
function curve(type, x) {
x = clamp(x, 0, 1);
if (type === "scurve") return x * x * (3 - 2 * x);
if (type === "exp") return x * x * x;
return x;
}
// temperature -> tonic (cold=low&minor, warm=high&major); all pentatonic
function tonicFor(vibe) {
if (vibe.tempRoot) {
const t = readNorm("weather.temp");
if (t != null) {
if (t < 0.25) return { root: 38, scale: "minor_pent" };
if (t < 0.50) return { root: 41, scale: "pentatonic" };
if (t < 0.75) return { root: 43, scale: "pentatonic" };
return { root: 45, scale: "pentatonic" };
}
}
return { root: 45, scale: params.consonance > 0.5 ? "pentatonic" : "minor_pent" };
}
function computeParams(vibe) {
const P = Object.assign({}, DEFAULTS, vibe.base);
for (const w of vibe.wires) {
let v = readNorm(w.src);
if (v == null) continue;
if (w.inv) v = 1 - v;
P[w.param] = clamp((P[w.param] || 0) + curve(w.curve, v) * w.amt, 0, 1.5);
}
return P;
}
// the live, smoothed params the synth reads
const params = Object.assign({}, DEFAULTS, { tempo: 76, root: 45, scale: "pentatonic" });
function updateParams(dt) {
if (crossfade < 1) crossfade = clamp(crossfade + dt / 2, 0, 1); // 2s crossfade
const A = computeParams(activeVibe);
let P = A;
if (crossfade < 1) {
const B = computeParams(prevVibe);
P = {}; for (const k in A) P[k] = lerp(B[k], A[k], crossfade);
}
// energy macro
P.density *= (0.35 + energy * 0.9);
P.pluckDensity *= (0.30 + energy * 1.1);
P.brightness = clamp(P.brightness * (0.6 + energy * 0.55), 0, 1);
// tempo
let tempo = activeVibe.tempo;
if (tempo === "heart") {
const bpm = me.heart.on ? me.heart.raw : me.tap.tempo;
tempo = bpm ? clamp(bpm, 50, 140) : 78;
}
if (!activeVibe.fixedTempo) tempo *= (0.8 + energy * 0.4); // ±20%
// smooth into the live params
const K = 1 - Math.pow(0.001, dt); // ~fast follow
for (const k in DEFAULTS) params[k] = lerp(params[k], P[k] != null ? P[k] : DEFAULTS[k], K);
params.tempo = lerp(params.tempo, tempo, K);
const ton = tonicFor(activeVibe);
params.root = ton.root; params.scale = ton.scale;
Synth.setParams(params, energy, dream);
}
// ===========================================================================
// SYNTH — compact Web Audio. Voices: kick, hat, pluck(pool), pad(tri/saw
// morph), drone, shimmer, chime. Master: delay + convolver reverb + limiter.
// ===========================================================================
const Synth = (function () {
const AC = window.AudioContext || window.webkitAudioContext;
let ctx = null, N = null, running = false;
let nextStep = 0, step = 0, schedTimer = null;
function impulse(sec, decay) {
const n = Math.floor(ctx.sampleRate * sec), b = ctx.createBuffer(2, n, ctx.sampleRate);
for (let c = 0; c < 2; c++) { const ch = b.getChannelData(c);
for (let i = 0; i < n; i++) ch[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, decay); }
return b;
}
function build() {
const out = ctx.createGain(); out.gain.value = 0.9;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -14; comp.ratio.value = 3.2; comp.attack.value = 0.004; comp.release.value = 0.2;
const analyser = ctx.createAnalyser(); analyser.fftSize = 512;
out.connect(comp); comp.connect(analyser); analyser.connect(ctx.destination);
const sumBus = ctx.createGain(); sumBus.gain.value = 1;
const dry = ctx.createGain(); dry.gain.value = 1; sumBus.connect(dry); dry.connect(out);
// delay
const delay = ctx.createDelay(1.0); delay.delayTime.value = 0.375;
const delayFb = ctx.createGain(); delayFb.gain.value = 0.3;
const delayWet = ctx.createGain(); delayWet.gain.value = 0.15;
sumBus.connect(delay); delay.connect(delayFb); delayFb.connect(delay); delay.connect(delayWet); delayWet.connect(out);
// reverb
const conv = ctx.createConvolver(); conv.buffer = impulse(2.4, 3.0);
const revSend = ctx.createGain(); revSend.gain.value = 0.2;
const revWet = ctx.createGain(); revWet.gain.value = 0.9;
sumBus.connect(revSend); revSend.connect(conv); conv.connect(revWet); revWet.connect(out);
// pad — 3 chord notes, each a triangle+saw pair, morphed by warmth
const padLP = ctx.createBiquadFilter(); padLP.type = "lowpass"; padLP.frequency.value = 900; padLP.Q.value = 0.6;
const padGain = ctx.createGain(); padGain.gain.value = 0.0;
padLP.connect(padGain); padGain.connect(sumBus);
const triBus = ctx.createGain(), sawBus = ctx.createGain();
triBus.gain.value = 0.5; sawBus.gain.value = 0.5; triBus.connect(padLP); sawBus.connect(padLP);
const padTri = [], padSaw = [];
for (let i = 0; i < 3; i++) {
const t = ctx.createOscillator(); t.type = "triangle"; t.connect(triBus); t.start();
const s = ctx.createOscillator(); s.type = "sawtooth"; s.detune.value = (i - 1) * 6; s.connect(sawBus); s.start();
padTri.push(t); padSaw.push(s);
}
// drone — root & 5th, sub
const droneG = ctx.createGain(); droneG.gain.value = 0.0; droneG.connect(sumBus);
const droneLP = ctx.createBiquadFilter(); droneLP.type = "lowpass"; droneLP.frequency.value = 320; droneLP.connect(droneG);
const drone = [0, 7].map(() => { const o = ctx.createOscillator(); o.type = "sine"; o.connect(droneLP); o.start(); return o; });
// shimmer — high sines, slow pan
const shimmerG = ctx.createGain(); shimmerG.gain.value = 0.0;
const pan = ctx.createStereoPanner ? ctx.createStereoPanner() : null;
if (pan) { shimmerG.connect(pan); pan.connect(sumBus); } else shimmerG.connect(sumBus);
const shim = [0, 7, 12, 16].map(() => { const o = ctx.createOscillator(); o.type = "sine"; const g = ctx.createGain(); g.gain.value = 0.25; o.connect(g); g.connect(shimmerG); o.start(); return o; });
const panLFO = ctx.createOscillator(); panLFO.type = "sine"; panLFO.frequency.value = 0.08;
const panAmt = ctx.createGain(); panAmt.gain.value = 0.6; panLFO.connect(panAmt); if (pan) panAmt.connect(pan.pan); panLFO.start();
// pluck pool
const plucks = [];
for (let i = 0; i < 6; i++) {
const o = ctx.createOscillator(); o.type = "triangle";
const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 2400;
const g = ctx.createGain(); g.gain.value = 0.0001;
o.connect(lp); lp.connect(g); g.connect(sumBus); o.start();
plucks.push({ o, lp, g });
}
// kick — persistent sine, retriggered by freq+gain env
const kickG = ctx.createGain(); kickG.gain.value = 0.0001; kickG.connect(sumBus);
const kickOsc = ctx.createOscillator(); kickOsc.type = "sine"; kickOsc.frequency.value = 45; kickOsc.connect(kickG); kickOsc.start();
return { out, comp, analyser, sumBus, delay, delayFb, delayWet, revSend, revWet, conv,
padLP, padGain, triBus, sawBus, padTri, padSaw, droneG, drone, shimmerG, shim,
panLFO, plucks, pi: 0, kickG, kickOsc };
}
function noiseHit(freq, dur, vol, at) {
const n = Math.floor(ctx.sampleRate * dur), b = ctx.createBuffer(1, n, ctx.sampleRate), ch = b.getChannelData(0);
for (let i = 0; i < n; i++) ch[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / n, 3);
const s = ctx.createBufferSource(); s.buffer = b;
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = freq;
const g = ctx.createGain(); g.gain.value = vol;
s.connect(hp); hp.connect(g); g.connect(N.sumBus); s.start(at != null ? at : ctx.currentTime);
}
function kickAt(t, vel) {
const g = N.kickG.gain, f = N.kickOsc.frequency;
f.cancelScheduledValues(t); f.setValueAtTime(120, t); f.exponentialRampToValueAtTime(45, t + 0.09);
g.cancelScheduledValues(t); g.setValueAtTime(0.0001, t);
g.linearRampToValueAtTime(vel, t + 0.006); g.exponentialRampToValueAtTime(0.0001, t + 0.18);
}
function pluckAt(t, midi, vel) {
const v = N.plucks[N.pi = (N.pi + 1) % N.plucks.length];
v.o.frequency.setValueAtTime(mtof(midi), t);
v.lp.frequency.setValueAtTime(1400 + params.brightness * 3200, t);
const g = v.g.gain;
// a 4ms declick down from whatever the stolen voice is still ringing at,
// so round-robin steals don't click
g.cancelScheduledValues(t); g.setValueAtTime(Math.max(g.value, 0.0001), t);
g.linearRampToValueAtTime(0.0001, t + 0.004);
g.linearRampToValueAtTime(vel, t + 0.010);
g.setTargetAtTime(0.0001, t + 0.02, 0.18 + (activeVibe.legato ? 0.5 : 0.15));
}
function chime(midi) {
if (!ctx) return;
const t = ctx.currentTime;
[1, 3.01].forEach((mul, i) => {
const o = ctx.createOscillator(); o.type = "sine"; o.frequency.value = mtof(midi) * mul;
const g = ctx.createGain(); g.gain.value = 0.0001;
o.connect(g); g.connect(N.sumBus);
g.gain.linearRampToValueAtTime(i ? 0.05 : 0.14, t + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t + 2.5);
o.start(t); o.stop(t + 2.6);
});
}
function pentUp() {
const steps = SCALES[params.scale] || SCALES.pentatonic;
const deg = steps[(Math.random() * steps.length) | 0];
const oct = 12 * ((Math.random() * 2) | 0);
return params.root + 12 + deg + oct;
}
function scheduleStep(s, t) {
const dens = params.density, pd = params.pluckDensity;
if (s % 4 === 0 && energy > 0.2) kickAt(t, 0.5 + dens * 0.4);
if (s % 2 === 1 && Math.random() < dens * 0.9) noiseHit(7200, 0.045, 0.05 + dens * 0.09, t);
if (Math.random() < pd * (activeVibe.legato ? 0.5 : 1.1)) pluckAt(t, pentUp(), 0.06 + pd * 0.09);
}
function scheduler() {
if (!ctx) return;
// if the timer was starved (backgrounded tab, sleep/wake), drop the missed
// steps instead of replaying them all at one instant (avoids a burst/pop)
if (nextStep < ctx.currentTime) nextStep = ctx.currentTime;
while (nextStep < ctx.currentTime + 0.12) {
scheduleStep(step, Math.max(nextStep, ctx.currentTime));
nextStep += 60 / clamp(params.tempo, 40, 160) / 4; // a sixteenth
step = (step + 1) % 16;
}
}
return {
start() {
if (!AC) return false;
if (!ctx) { ctx = new AC(); N = build(); }
ctx.resume();
running = true; nextStep = ctx.currentTime + 0.05;
if (!schedTimer) schedTimer = setInterval(scheduler, 25);
return true;
},
chime, ctx: () => ctx,
rms() {
if (!N) return 0;
const buf = new Uint8Array(N.analyser.fftSize); N.analyser.getByteTimeDomainData(buf);
let s = 0; for (let i = 0; i < buf.length; i++) { const x = (buf[i] - 128) / 128; s += x * x; }
return Math.sqrt(s / buf.length);
},
setParams(P, en, dr) {
if (!ctx || !N) return;
const t = ctx.currentTime, sm = 0.12;
N.padGain.gain.setTargetAtTime((0.05 + P.swell * 0.10) + P.brightness * 0.09, t, 0.25);
N.padLP.frequency.setTargetAtTime(320 + P.brightness * 3600, t, sm);
N.triBus.gain.setTargetAtTime(0.2 + P.warmth * 0.5, t, 0.3);
N.sawBus.gain.setTargetAtTime(0.5 - P.warmth * 0.35, t, 0.3);
N.droneG.gain.setTargetAtTime(P.droneLevel * 0.16, t, 0.3);
N.shimmerG.gain.setTargetAtTime(P.shimmerLevel * 0.11, t, 0.4);
N.revSend.gain.setTargetAtTime(clamp(P.space * 0.4 + dr * 0.5, 0, 1) * 0.6, t, 0.25);
N.delayFb.gain.setTargetAtTime(dr * 0.52, t, 0.25);
N.delayWet.gain.setTargetAtTime(0.10 + P.wash * 0.30, t, 0.25);
N.panLFO.frequency.setTargetAtTime(0.05 + P.panWobble * 0.7, t, 0.3);
// chord: root + 5th + 9th, with the pad an octave under the plucks
const chord = [0, 7, 14];
const majMinor = P.consonance > 0.5 ? 0 : -1; // flatten the third-ish colour when tense
for (let i = 0; i < 3; i++) {
const f = mtof(P.root + chord[i] + (i === 1 ? majMinor : 0));
N.padTri[i].frequency.setTargetAtTime(f, t, 0.2);
N.padSaw[i].frequency.setTargetAtTime(f, t, 0.2);
}
N.drone[0].frequency.setTargetAtTime(mtof(P.root - 12), t, 0.3);
N.drone[1].frequency.setTargetAtTime(mtof(P.root - 5), t, 0.3);
for (let i = 0; i < N.shim.length; i++)
N.shim[i].frequency.setTargetAtTime(mtof(P.root + 24 + [0, 4, 7, 11][i]), t, 0.4);
},
};
})();
// ===========================================================================
// LOCAL SENSORS — heart (BT), tilt/shake (device motion), voice (mic), tap.
// ===========================================================================
let hrDevice = null;
async function connectHeart() {
if (!navigator.bluetooth) { banner("heart needs chrome or edge — tap the orb for a pulse instead", 4); return; }
try {
const dev = await navigator.bluetooth.requestDevice({ filters: [{ services: ["heart_rate"] }] });
const server = await dev.gatt.connect();
const svc = await server.getPrimaryService("heart_rate");
const ch = await svc.getCharacteristic("heart_rate_measurement");
await ch.startNotifications();
ch.addEventListener("characteristicvaluechanged", (e) => {
const dv = e.target.value, flags = dv.getUint8(0);
const bpm = (flags & 1) ? dv.getUint16(1, true) : dv.getUint8(1);
if (!bpm || bpm > 250) return;
me.heart.raw = bpm; me.heart.norm = clamp((bpm - 40) / 140, 0, 1); me.heart.on = true;
if (!me.heart.warmAt) me.heart.warmAt = now() + 180; // 3-min warmup before calm/fire moments
});
hrDevice = dev;
dev.addEventListener("gattserverdisconnected", () => { hrDevice = null; me.heart.on = false; });
banner("♥ your heart is in the choir", 3);
} catch (e) { /* user closed the chooser — no fuss */ }
}
function enableTilt() {
const attach = () => {
window.addEventListener("deviceorientation", (e) => {
if (e.gamma == null && e.beta == null) return;
me.tilt.x = clamp((e.gamma + 45) / 90, 0, 1); // left/right
me.tilt.y = clamp((e.beta + 45) / 90, 0, 1); // front/back
me.tilt.on = true;
});
window.addEventListener("devicemotion", (e) => {
const a = e.accelerationIncludingGravity; if (!a) return;
const mag = Math.abs(a.x || 0) + Math.abs(a.y || 0) + Math.abs(a.z || 0);
if (mag > 32 && now() - me.shake.t > 0.4) { me.shake.t = now(); me.shake.mag = mag; onShake(); }
});
};
// iOS 13+ needs an explicit permission request from a user gesture
if (typeof DeviceOrientationEvent !== "undefined" && typeof DeviceOrientationEvent.requestPermission === "function") {
DeviceOrientationEvent.requestPermission().then(r => { if (r === "granted") attach(); }).catch(() => {});
if (typeof DeviceMotionEvent !== "undefined" && DeviceMotionEvent.requestPermission)
DeviceMotionEvent.requestPermission().catch(() => {});
} else attach();
}
function onShake() {
if (!Synth.ctx()) return;
Synth.chime(params.root + 12 + [0, 4, 7][(Math.random() * 3) | 0]);
burst(30);
}
let micReq = false;
async function enableVoice() {
if (me.voice.on || micReq) return; micReq = true;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ac = Synth.ctx(); if (!ac) return;
const src = ac.createMediaStreamSource(stream);
const an = ac.createAnalyser(); an.fftSize = 1024; src.connect(an);
const buf = new Uint8Array(an.fftSize), freq = new Uint8Array(an.frequencyBinCount);
me.voice.on = true;
(function poll() {
an.getByteTimeDomainData(buf);
let s = 0; for (let i = 0; i < buf.length; i++) { const x = (buf[i] - 128) / 128; s += x * x; }
me.voice.rms = clamp(Math.sqrt(s / buf.length) * 3, 0, 1);
an.getByteFrequencyData(freq);
let num = 0, den = 0; for (let i = 0; i < freq.length; i++) { num += i * freq[i]; den += freq[i]; }
me.voice.centroid = den ? clamp((num / den) / freq.length, 0, 1) : 0.5;
requestAnimationFrame(poll);
})();
} catch (e) { micReq = false; banner("no mic — tap The Room again to retry microphone access", 5); }
}
function tapOrb() {
const t = now(), T = me.tap.times;
T.push(t); if (T.length > 5) T.shift();
if (T.length >= 2) {
let sum = 0, cnt = 0;
for (let i = 1; i < T.length; i++) { const dt = T[i] - T[i - 1]; if (dt < 2) { sum += dt; cnt++; } }
if (cnt) me.tap.tempo = clamp(60 / (sum / cnt), 40, 160);
}
if (Synth.ctx()) pluckTap();
burst(8);
}
function pluckTap() {
const ac = Synth.ctx(); if (!ac) return;
const steps = SCALES[params.scale] || SCALES.pentatonic;
Synth.chime(params.root + 12 + steps[(Math.random() * steps.length) | 0]);
}
// ===========================================================================
// MOMENTS — toast + chime + orb burst, with precious cooldowns.
// ===========================================================================
const MOMENTS = [
// Poisson against real births/sec (~4.5). Throttle governs, not the cooldown:
// ~20s in The Living, ~2min elsewhere, so the chime stays precious.
{ id: "baby", emoji: "👶", cool: 12, last: -99, text: () => "somewhere, a baby",
test: (dt) => { const b = sources.get("clock.births"); if (!b || !b.hist.length) return false;
const thr = (activeVibe.babyThrottle || 0.0018); return Math.random() < b.raw * dt * thr; } },
{ id: "sun", emoji: () => sunDir > 0 ? "🌅" : "🌇", cool: 6 * 3600, last: -99,
text: () => sunDir > 0 ? "the sun rises where you are" : "the sun sets where you are",
test: () => { const e = sources.get("sky.elev"); if (!e || e.hist.length < 2) return false;
const prev = e.hist[e.hist.length - 2], cur = e.raw;
if ((prev < 0) !== (cur < 0)) { sunDir = cur - prev; return true; } return false; } },
{ id: "moon", emoji: "🌕", cool: 1e9, last: -99, text: () => "the moon is full tonight",
test: () => { const m = sources.get("astro.moon"); return m && m.hist.length && m.norm > 0.97; } },
{ id: "kickoff", emoji: "⚽", cool: 30, last: -99, text: () => { const k = sources.get("sport.kickoff"); const n = k ? Math.max(1, Math.round(k.raw)) : 1; return "kickoff — " + n + " match" + (n > 1 ? "es" : "") + " begin"; },
test: () => { const k = sources.get("sport.kickoff"); return k && now() - k.edgeT < 0.5; } },
{ id: "rain", emoji: "🌧", cool: 3600, last: -99, text: () => "rain has started",
test: () => { const p = sources.get("weather.precip"); if (!p || p.hist.length < 2) return false;
return p.hist[p.hist.length - 2] <= 0.02 && p.raw > 0.02; } },
{ id: "flash", emoji: "✨", cool: 8, last: -99, text: () => "a flash of light",
test: () => { const f = sources.get("light.flash.event"); return f && now() - f.edgeT < 0.5; } },
{ id: "heart", emoji: "💓", cool: 300, last: -99, text: () => heartMsg,
test: () => { if (!me.heart.on || now() < me.heart.warmAt) return false;
const b = me.heart.raw; let hit = false;
if (b > me.heart.max) { me.heart.max = b; if (now() > me.heart.warmAt + 1) { heartMsg = "a new fire — " + Math.round(b) + " bpm"; hit = true; } }
if (b < me.heart.min) { me.heart.min = b; if (now() > me.heart.warmAt + 1) { heartMsg = "a new calm — " + Math.round(b) + " bpm"; hit = true; } }
return hit; } },
];
let sunDir = 1, heartMsg = "";
const MOMENT_BY_ID = {}; MOMENTS.forEach(m => MOMENT_BY_ID[m.id] = m);
function checkMoments(dt) {
const t = now();
for (const m of MOMENTS) {
if (t - m.last < m.cool) continue;
let ok = false; try { ok = m.test(dt); } catch (e) { ok = false; }
if (ok) { m.last = t; fireMoment(m); }
}
}
function fireMoment(m) {
toast(typeof m.emoji === "function" ? m.emoji() : m.emoji, typeof m.text === "function" ? m.text() : m.text);
const steps = SCALES[params.scale] || SCALES.pentatonic;
Synth.chime(params.root + 24 + steps[(Math.random() * steps.length) | 0]);
burst(reducedMotion ? 0 : 24 + (Math.random() * 16 | 0));
}
// ===========================================================================
// ORB — one canvas: background gradient (by sun elevation), breathing orb,
// kick-ring, particles.
// ===========================================================================
const cv = $("#orb"), g = cv.getContext("2d");
let W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
W = innerWidth; H = innerHeight;
cv.style.width = W + "px"; cv.style.height = H + "px";
cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR);
g.setTransform(DPR, 0, 0, DPR, 0, 0);
}
addEventListener("resize", resize); resize();
const particles = [];
function burst(n) { if (reducedMotion) return; for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2, sp = 40 + Math.random() * 130;
particles.push({ x: W / 2, y: orbCY(), vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: 1 });
} }
const orbCY = () => H * 0.34;
let kickFlash = 0, lastRms = 0, prevStepForFlash = -1;
function tint() { return activeVibe.tint; }
function dayAmt() { const e = readNorm("sky.elev"); return e == null ? 0.5 : e; } // 0 night .. 1 noon
let dayNow = null;
function render(dt) {
// background — cool deep navy by night, warm light dawn by day; the page
// knows what time it is where you are.
dayNow = dayNow == null ? dayAmt() : lerp(dayNow, dayAmt(), 0.05);
const d = dayNow;
const top = [lerp(15, 138, d), lerp(17, 106, d), lerp(30, 112, d)]; // warm rose at noon
const bot = [lerp(9, 54, d), lerp(11, 42, d), lerp(18, 52, d)];
const grd = g.createLinearGradient(0, 0, 0, H);
grd.addColorStop(0, `rgb(${top[0]|0},${top[1]|0},${top[2]|0})`);
grd.addColorStop(1, `rgb(${bot[0]|0},${bot[1]|0},${bot[2]|0})`);
g.fillStyle = grd; g.fillRect(0, 0, W, H);
// particles
if (!reducedMotion) {
const c = tint();
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]; p.life -= dt * 0.9; if (p.life <= 0) { particles.splice(i, 1); continue; }
p.x += p.vx * dt; p.y += p.vy * dt; p.vy += 26 * dt; p.vx *= 0.98;
g.globalAlpha = clamp(p.life, 0, 1) * 0.8;
g.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`;
g.beginPath(); g.arc(p.x, p.y, 2.4 * p.life + 0.6, 0, 7); g.fill();
}
g.globalAlpha = 1;
}
// orb
const rms = Synth.rms(); lastRms = lerp(lastRms, rms, 0.3);
if (kickFlash > 0) kickFlash = Math.max(0, kickFlash - dt * 2.6);
const cx = W / 2, cy = orbCY();
const base = Math.min(W * 0.32, H * 0.20) * (reducedMotion ? 0.92 : 1);
const r = base * (1 + lastRms * 1.1) * (1 + (reducedMotion ? 0 : Math.sin(now() * 1.3) * 0.02));
const c = tint();
// outer glow
const halo = g.createRadialGradient(cx, cy, r * 0.2, cx, cy, r * 2.1);
halo.addColorStop(0, `rgba(${c[0]},${c[1]},${c[2]},${0.18 + lastRms * 0.3})`);
halo.addColorStop(1, "rgba(0,0,0,0)");
g.fillStyle = halo; g.beginPath(); g.arc(cx, cy, r * 2.1, 0, 7); g.fill();
// body
const bright = 0.5 + params.brightness * 0.5;
const body = g.createRadialGradient(cx - r * 0.3, cy - r * 0.3, r * 0.1, cx, cy, r);
body.addColorStop(0, `rgba(${Math.min(255,c[0]+70)},${Math.min(255,c[1]+70)},${Math.min(255,c[2]+70)},${0.5 + bright * 0.4})`);
body.addColorStop(1, `rgba(${c[0]},${c[1]},${c[2]},${0.5 + bright * 0.35})`);
g.fillStyle = body; g.beginPath(); g.arc(cx, cy, r, 0, 7); g.fill();
// kick ring
if (kickFlash > 0.01) {
g.strokeStyle = `rgba(${c[0]},${c[1]},${c[2]},${kickFlash * 0.6})`;
g.lineWidth = 2 + kickFlash * 4;
g.beginPath(); g.arc(cx, cy, r * (1.05 + (1 - kickFlash) * 0.5), 0, 7); g.stroke();
}
}
// ===========================================================================
// UI — vibe cards, knobs, readout, banners, toasts.
// ===========================================================================
const elVibes = $("#vibes"), elKnobs = $("#knobs");
const elName = $("#vibename"), elPromise = $("#promise"), elFact = $("#fact"), elHeart = $("#heartbtn");
elHeart.addEventListener("click", () => connectHeart());
function updateHeartBtn() { // offer BT heart only where it can work and isn't already on
const show = started && activeVibe.wantsHeart && !!navigator.bluetooth && !me.heart.on;
elHeart.classList.toggle("on", show);
}
function buildVibes() {
elVibes.innerHTML = "";
for (const v of VIBES) {
const d = document.createElement("button");
d.className = "vibe" + (v === activeVibe ? " sel" : "");
d.type = "button"; d.dataset.id = v.id;
d.innerHTML = `<span class="e">${v.emoji}</span><span class="n">${v.name}</span>`;
d.addEventListener("click", () => selectVibe(v.id));
elVibes.appendChild(d);
}
refreshVibeCards();
}
function refreshVibeCards() {
for (const d of elVibes.children) {
const v = VIBE_BY_ID[d.dataset.id];
d.classList.toggle("sel", v === activeVibe);
const asleep = v.needs && v.needs.every(k => !sources.get(k) || !sources.get(k).hist.length);
d.classList.toggle("asleep", !!asleep);
}
}
function selectVibe(id) {
const v = VIBE_BY_ID[id]; if (!v || v === activeVibe) return;
prevVibe = activeVibe; activeVibe = v; crossfade = 0;
document.documentElement.style.setProperty("--accent", v.tint.join(","));
elName.textContent = v.name; elPromise.textContent = v.promise;
if (v.wantsVoice) enableVoice();
refreshVibeCards(); updateHeartBtn(); persist();
}
function makeKnob(label, get, set) {
const wrap = document.createElement("div"); wrap.className = "knob";
wrap.setAttribute("role", "slider"); wrap.tabIndex = 0;
wrap.setAttribute("aria-label", label.toLowerCase());
wrap.setAttribute("aria-valuemin", "0"); wrap.setAttribute("aria-valuemax", "100");
const C = 2 * Math.PI * 32;
wrap.innerHTML =
`<svg viewBox="0 0 80 80" aria-hidden="true"><circle class="track" cx="40" cy="40" r="32"></circle>` +
`<circle class="val" cx="40" cy="40" r="32" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${C}"></circle></svg>` +
`<div class="klabel">${label}</div><div class="kval"></div>`;
const valC = wrap.querySelector(".val"), kv = wrap.querySelector(".kval");
const draw = () => { const f = get(), pct = Math.round(f * 100);
valC.style.strokeDashoffset = (C * (1 - f)).toFixed(1); kv.textContent = pct + "%";
wrap.setAttribute("aria-valuenow", pct); wrap.setAttribute("aria-valuetext", pct + " percent"); };
let dragging = false, startY = 0, startV = 0;
const down = (e) => { dragging = true; startY = (e.touches ? e.touches[0].clientY : e.clientY); startV = get(); e.preventDefault(); };
const move = (e) => { if (!dragging) return; const y = (e.touches ? e.touches[0].clientY : e.clientY);
set(clamp(startV + (startY - y) / 160, 0, 1)); draw(); persist(); };
const up = () => { dragging = false; };
wrap.addEventListener("mousedown", down); wrap.addEventListener("touchstart", down, { passive: false });
addEventListener("mousemove", move); addEventListener("touchmove", move, { passive: false });
addEventListener("mouseup", up); addEventListener("touchend", up);
wrap.addEventListener("keydown", (e) => { // keyboard operability
let step = 0;
if (e.key === "ArrowUp" || e.key === "ArrowRight") step = 0.05;
else if (e.key === "ArrowDown" || e.key === "ArrowLeft") step = -0.05;
else if (e.key === "PageUp") step = 0.2;
else if (e.key === "PageDown") step = -0.2;
else if (e.key === "Home") { set(0); draw(); persist(); e.preventDefault(); return; }
else if (e.key === "End") { set(1); draw(); persist(); e.preventDefault(); return; }
else return;
set(clamp(get() + step, 0, 1)); draw(); persist(); e.preventDefault();
});
draw();
return wrap;
}
function buildKnobs() {
elKnobs.innerHTML = "";
elKnobs.appendChild(makeKnob("ENERGY", () => energy, (v) => energy = v));
elKnobs.appendChild(makeKnob("DREAM", () => dream, (v) => dream = v));
}
function toast(emoji, text) {
const box = $("#toasts");
const el = document.createElement("div"); el.className = "toast";
el.innerHTML = `<span class="te">${emoji}</span>${text}`;
box.appendChild(el);
setTimeout(() => { el.classList.add("out"); setTimeout(() => el.remove(), 500); }, 3600);
}
let bannerTimer = null, transientUntil = 0;
function banner(text, secs) { // secs>0 = a transient notice that owns the banner for that long
const b = $("#banner"); b.textContent = text; b.classList.add("on");
if (bannerTimer) { clearTimeout(bannerTimer); bannerTimer = null; }
if (secs) { transientUntil = now() + secs; bannerTimer = setTimeout(() => { b.classList.remove("on"); transientUntil = 0; }, secs * 1000); }
}
function statusBanner(text) { // the persistent hub-status line — yields to any live transient notice
if (now() < transientUntil) return;
const b = $("#banner"); b.textContent = text; b.classList.add("on");
}
function hideBanner() { if (now() < transientUntil) return; $("#banner").classList.remove("on"); }
// live fact + degradation, refreshed slowly
let factTimer = 0;
function refreshFact(dt) {
factTimer += dt; if (factTimer < 1.2) return; factTimer = 0;
try { elFact.textContent = activeVibe.fact(); } catch (e) { elFact.textContent = ""; }
refreshVibeCards(); updateHeartBtn();
// hub-status banner (yields to transient notices)
if (started) {
const stale = connected && lastFrameT && now() - lastFrameT > 15; // socket open but no frames
if (everConnected && !connected) statusBanner("reconnecting to the world…");
else if (!everConnected && now() - startedAt > 4) statusBanner("the world is quiet — playing your half");
else if (stale) statusBanner("the world went quiet — playing your half");
else hideBanner();
}
}
// ===========================================================================
// MAIN LOOPS — the "brain" (params, moments, fact) runs on a steady interval
// so the instrument keeps evolving even when the browser throttles rendering
// (a backgrounded or occluded tab). The rAF loop only paints.
// ===========================================================================
let started = false, startedAt = 0, lastBrain = now(), lastPaint = now();
function brain() {
const t = now(), dt = Math.min(0.2, t - lastBrain); lastBrain = t;
if (!started) return;
updateParams(dt);
checkMoments(dt);
refreshFact(dt);
// pulse the ring roughly on the beat
const beat = 60 / clamp(params.tempo, 40, 160);
const ph = (t % beat) / beat;
if (ph < 0.12 && prevStepForFlash < 0) { kickFlash = 1; prevStepForFlash = 1; }
if (ph > 0.5) prevStepForFlash = -1;
}
setInterval(brain, 33);
function paint() {
const t = now(), dt = Math.min(0.05, t - lastPaint); lastPaint = t;
render(dt);
requestAnimationFrame(paint);
}
requestAnimationFrame(paint);
// ===========================================================================
// AUTH — reuse the godstrument /api/* endpoints exactly.
// ===========================================================================
let user = null;
async function api(path, method, body) {
const opt = { method, headers: { "Content-Type": "application/json" } };
if (body) opt.body = JSON.stringify(body);
try { const r = await fetch(path, opt); let data = {}; try { data = await r.json(); } catch (e) {} return { ok: r.ok, status: r.status, data }; }
catch (e) { return { ok: false, status: 0, data: { error: "no connection" } }; }
}
const authArea = $("#authArea"), beginArea = $("#beginArea"), chip = $("#chip");
function setUser(u) {
user = u;
chip.textContent = u ? ("@" + u.username) : "sign in";
if (u) {
authArea.classList.remove("on"); beginArea.classList.add("on");
$("#whoami").textContent = "signed in as @" + u.username;
if (!ws || ws.readyState > 1) connect(); // data flows behind the begin screen
} else {
authArea.classList.add("on"); beginArea.classList.remove("on");
}
}
// tabs + forms
document.querySelectorAll(".tabs button").forEach(b => b.addEventListener("click", () => {
const tab = b.dataset.tab;
document.querySelectorAll(".tabs button").forEach(x => x.classList.toggle("on", x === b));
$("#loginform").classList.toggle("on", tab === "login");
$("#signupform").classList.toggle("on", tab === "signup");
}));
$("#loginform").addEventListener("submit", async (e) => {
e.preventDefault(); const f = e.target;
const r = await api("/api/login", "POST", { identifier: f.identifier.value, password: f.password.value });
if (r.ok) setUser(r.data); else f.querySelector(".err").textContent = r.data.error || "could not log in";
});
$("#signupform").addEventListener("submit", async (e) => {
e.preventDefault(); const f = e.target;
if (f.password.value !== f.confirm.value) { f.querySelector(".err").textContent = "passwords don't match"; return; }
const r = await api("/api/signup", "POST", { username: f.username.value, email: f.email.value, password: f.password.value, invite: f.invite.value.trim() });
if (r.ok) setUser(r.data); else f.querySelector(".err").textContent = r.data.error || "could not create account";
});
chip.addEventListener("click", async () => {
if (user) { await api("/api/logout", "POST"); setUser(null); location.reload(); }
});
// ---- begin: the one gesture that starts audio + sensors ----
function begin() {
if (started) return;
document.documentElement.style.setProperty("--accent", activeVibe.tint.join(","));
elName.textContent = activeVibe.name; elPromise.textContent = activeVibe.promise;
Synth.start();
enableTilt();
if (activeVibe.wantsVoice) enableVoice();
started = true; startedAt = now();
$("#landing").classList.add("hidden");
$("#ui").classList.add("on");
// gentle onboarding — three toasts, not a modal
let seen = false; try { seen = !!localStorage.getItem("ls_seen"); } catch (e) {}
if (!seen) {
try { localStorage.setItem("ls_seen", "1"); } catch (e) {}
setTimeout(() => toast("🌱", "this is you, playing the living world"), 900);
setTimeout(() => toast("👇", "pick a vibe below — each one listens differently"), 4200);
setTimeout(() => toast("🎛", "energy makes it dance · dream makes it vast"), 7500);
}
}
$("#beginBtn").addEventListener("click", begin);
cv.addEventListener("pointerdown", () => { if (started) tapOrb(); });
// ---- boot ----
buildVibes(); buildKnobs();
document.documentElement.style.setProperty("--accent", activeVibe.tint.join(","));
elName.textContent = activeVibe.name; elPromise.textContent = activeVibe.promise;
// dev backdoor (localhost only), else restore session
const devH = location.hostname;
if (new URLSearchParams(location.search).get("dev") === "1" && (devH === "localhost" || devH === "127.0.0.1" || devH === ""))
setUser({ username: "dev" });
else
api("/api/me", "GET").then(r => { if (r.ok) setUser(r.data); });
// ---- dev-only debug hook (acceptance check #5) ----
window.__lifeDebug = {
inject(key, raw, norm) {
handleMessage({ sources: { [key]: { raw, norm: norm != null ? norm : raw, event: /event|kickoff|flash/.test(key) } } });
},
fire(id) { const m = MOMENT_BY_ID[id]; if (m) { m.last = -99; fireMoment(m); } },
state: { sources, me, params, get vibe() { return activeVibe.id; } },
begin,
};
})();
</script>
</body>
</html>