Compare commits

...

21 Commits

Author SHA1 Message Date
type-two
9ab1b0f5ae 🧹 health-sweep tidy-up: 5 low-severity loose ends (0 broken found)
A 13-agent adversarial whole-app sweep (client controls / runtime / server-auth /
new features / feed-config-honesty, each finding reproduced-or-refuted) found ZERO
broken functions — verdict healthy. Fixed the reachable low-severity edges it did
confirm:

- viz/index.html:1804 — arranger 'grooves' tab threw TypeError (nSel.isNote) when
  dests is empty in the cold-connect window (post-sign-in, pre-WS-hello). Guard:
  dests.get(arrSelKey) || {isNote:false} (the 'clips' tab already tolerates empty).
- viz/index.html — picking a skin from the sky menu / F4 next-skin / F4 prev while
  in ZERO mode force-enabled an invisible, un-toggleable chakraMode (every clear
  path is zeroMode-guarded). Added the same !zeroMode guard to all three sites
  (byte-identical outside zero — no regression).
- hub.py:117 — GODSTRUMENT_READONLY parse treated any non-empty non-'0' value as
  true (so 'false'/'no'/'off' enabled read-only). Now an explicit truthy set
  {1,true,yes,on}; prod uses '1' → stays read-only (verified before deploy).
- viz/index.html:2996 — removed a dead else branch in zero 'save as vibe' (its
  'vibes land in the next update' placeholder was unreachable; saveVibe is a real
  sibling method) → call this.saveVibe(gk) directly.
- transform.py:40 — collapsed a no-op ternary (both branches float(v)).
- viz/manual.html — regenerated (was stale vs build_manual.py; picked up the
  admin-panel CSS added in 764e28b; grimoire prose was already in sync).

Left as documented cosmetic: sanitizeVibe caps emoji at 2 code points (can split a
ZWJ/flag glyph on a shared vibe — display only, no XSS). Verified: node --check,
all .py compile, auth --selftest green, app boots into zero clean, console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:21:03 +10:00
type-two
47b51b98ca 🔒 session-token credential binding + admin-guard/CSRF hardening (adversarial review)
Fable flagged that stateless session tokens bound only to uid+expiry — so an
admin action couldn't actually invalidate a live 30-day cookie. Reproduced 3
facets, all one root cause, all fixed by binding the token to the account's
credential state:
- make_token/read_token/_bind: token = uid:exp:bind:sig where
  bind = hmac(secret, 'uid:created:salt')[:16]; read_token re-derives it from the
  user's CURRENT row and rejects on mismatch/missing. So a token dies on account
  DELETE (no ghost session/writes), password RESET (salt rotates → admin can truly
  lock someone out), and SQLite id-REUSE (different created → no identity takeover).
  Old 3-part tokens fail to parse → a one-time re-login. No schema change.

A follow-on adversarial review (4 security lenses × find→refute×2, GO gate) then
surfaced 3 low-severity issues, all fixed here too:
- self-de-admin guard used  (identity) — {admin:0}
  / '' / 'false' / [] slipped past and could strand a sole db-admin. Now only a
  real JSON bool toggles admin (closes the bypass AND the truthy-string mis-grant).
- signup invite claim was check-then-write (double-spend under concurrency) — now
  an atomic  +
  rowcount check; signup/admin-edit wrap the UNIQUE writes in try/except
  IntegrityError → friendly message instead of a 500.
- login-CSRF (Lax cookie only): hub.py _api now blocks cross-site mutating requests
  by Origin (allowlist godstrument.pro/localhost + Host-match fallback so legit
  traffic always passes; safe GETs never blocked).

Verified: auth.py --selftest (delete/reset/id-reuse kill the token; guard bypass
closed for every falsy value); live curl (CSRF 401/401/401/403, GET never blocked);
real-browser flow (new-format cookie authenticates through a page load, admin table
renders, same-origin admin POST passes CSRF). Console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:51:10 +10:00
type-two
764e28b1c3 👤 admin user management — list / edit / delete accounts + reset passwords
The admin panel could mint invite codes and read stats but not manage the people.
Adds, all under the existing admin-gated /api/admin/ block (default-closed:
no session → 401, non-admin → 403):

- GET  /api/admin/users        — every account: id, email, admin flag, patch count, joined
- POST /api/admin/user/<id>    — rename / change email / grant-or-revoke admin
                                 (validated like signup: format + uniqueness)
- POST /api/admin/user/<id>/reset — set a fresh random temp password, returned ONCE
                                 (never stored plaintext or logged) to relay to the user
- DELETE /api/admin/user/<id>  — delete the account + its saved patches (feedback +
                                 used invite kept as history)

Self-lockout guards: you can't delete your own account or revoke your own admin.
Admin granted via the GODSTRUMENT_ADMIN env is marked env_admin and its toggle is
hidden (can't be removed in the db). auth.py migration-free — uses existing tables;
the is_admin column was already migrated.

Client: the ⚙-account admin panel now lists every user with inline edit (username +
email), 🔑 reset-pw (shows a copyable temp), ★ make/revoke admin, and 🗑 delete
(confirm); the panel widens for the table. Names/emails escaped on render.

Verified: auth.py --selftest (list/edit/reset/delete + endpoint guards); live curl
against the local dev db (rename, bad-email/dup rejected, admin toggle, self-guards
400, non-admin 403, delete cascades presets); and the UI end-to-end (rows render
with correct per-row actions, edit expands, reset shows a temp password). Console
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 07:01:58 +10:00
type-two
297ca8457f 📖 add a polished standalone user manual (viz/manual.html) + generator
The public site only had the in-app grimoire (behind the ✦ overlay); there was no
shareable, linkable user manual. build_manual.py extracts the grimoire (the canon,
per CLAUDE.md) + its styles from viz/index.html into a self-contained, always-
visible page with a generated 27-section table of contents and a 'play the
instrument →' back-link. Single source → no drift; re-run after editing the grimoire.

Served at godstrument.pro/manual.html (docroot is viz/). Verified: renders as a
normal page (overlay/close-button overridden away), TOC anchors resolve, all
feature sections present (zero, OMNI panel, vibes, dimensions), console clean.

Also refine the deploy convention: exclude dev docs/scripts (CLAUDE.md, README,
ZERO_OMNI_BRIEF.md, GODSTRUMENT_MANUAL_SOURCE.md, godstrument.txt, build_manual.py,
tests) so working notes never land on the public box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 06:49:25 +10:00
type-two
fb32d10991 🐛 seed the synth idle (not 0) for cableless tweaked voices — fix-verify regression
The fix-verify pass flagged one CONFIRMED regression in the #1 floor loop: it
pinned any tweaked cableless non-note dest to a hard 0, but two dests have a
non-zero synth idle — filter.cutoff (d(...,0.3)) and tempo.nudge (d(...,0.5)).
So touching the filter knob in zero (or, before this, merely opening OMNI, which
created a neutral tweak) pinned filter.cutoff to 0 → 250 Hz, darkening the lead.

- new DEST_IDLE map; the floor loop seeds destVals[k]=DEST_IDLE[k]||0, so a
  neutral/reset knob returns a voice to its idle (filter 0.3), an offset rides
  from the idle, and mute still → 0. (verified: reset filter → 0.3 not 0;
  drag up → 0.767; mute → 0)
- OMNI update() + knob pointerdown now read tweaks[k] directly instead of tw(k),
  so rendering/opening the panel no longer creates phantom neutral tweaks (which
  also kept the tweaks map — and saved dimensions — clean). (verified: open OMNI
  → filter has no tweak)

A clean instrument (no tweaks) stays byte-identical; the public house patch was
already masked (filter.cutoff is cabled to sun.speed). Deploy gate: GO.
Console clean, syntax clean, 21 knobs, field healthy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 01:26:01 +10:00
type-two
fa948823a7 🐛 fix 8 adversarial-review findings (zero/vibes/OMNI)
A 30-agent adversarial review (5 dimensions × find→refute×2) confirmed 8 defects;
all fixed and live-verified:

BLOCKERS
- #1 unwired OMNI trim couldn't be silenced: the offset-floor only seeded a dest
  while offset>0.0015, so reset/mute/lower stopped emitting the key and the last
  floored value stuck in the synth (Synth.update merges). Now ANY tweaked cableless
  non-note dest is seeded 0 so the processing loop writes its real value, incl. 0.
  (verified: crush norm 0.5 on drag → 0 on reset)
- #2 leaving zero with an OMNI/offset-only build (no cables) was silently discarded
  — exitZeroToMood gated on routesArr.length===0. New zeroHasBuild() also counts
  picked orbs + any non-neutral tweak, so the keep/old/weave card always shows.
  (verified: offset-only build → 'you shaped this instrument by hand. keep it?')
- #3 vibe scale whitelist used truthiness (SCALES[q.scale]) → 'constructor'/'__proto__'
  passed → NaN into a Web Audio AudioParam → threw ~40×/s (remotely triggerable via
  public #vibe= links). Now Array.isArray(SCALES[...]) + quantize only on note dests;
  mxQuantize fallback hardened too. (verified: fx quantize dropped, note scale→minor_pent)

SHOULD-FIX
- #4 finishZeroExit keep/weave clobbered the kept mix (applyMood resets MOOD_KEYS):
  re-lay the build's tweaks after applyMood; weave merges build tweaks over prezero.
- #5 crush knob was fully dead (never in dests): OMNI.render ensureDest()s every
  voice/FX key → arc lives, floor works, right-click opens the menu. (verified)
- #6 OMNI drags in zero weren't persisted: knob pointerup/dblclick call zeroUI.persist()
  when zeroMode → survives reload. (verified: offset 0.467 in gs_zerobuild)
- #7 reloaded vibes showed OFF: persist activeVibes + per-route _vibes/_base to
  gs_zerovibes, restoreVibeState() on enterZero(true). (verified: vibe restores ◉)
- #8 GODSONIQ chip lit ~3s late: update() re-lights engine/♪ chips each frame.

Console clean, syntax clean, 21 knobs, field healthy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 01:10:30 +10:00
type-two
fc1bb307bc 🔒 harden vibe import trust boundary — strip markup from name/emoji (ship-check)
The vibe name + emoji arrive from user-pasted GSV1 codes and #vibe= URL fragments
and are rendered via innerHTML in the vibes panel + the shared-vibe offer card.
name was <-escaped but emoji was raw. sanitizeVibe now strips [<>&"'`] from both
at the trust boundary, so nothing dangerous reaches any render path.

Verified live: a crafted code with name='<img onerror=…>' + emoji='<svg onload=…>'
imported via #vibe= → no <img>/<svg> in the DOM, no script fired, both rendered as
inert text. (ship-check item 3: user strings → HTML must be escaped.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:41:40 +10:00
type-two
93c5e20ccf 📖 Z6 — grimoire + manual: zero-build, OMNI panel, dimensions & vibes
Canon (viz/index.html grimoire) is truth; manual synced to match.

- grimoire: two new tech-manual sections in the OMNI/Earth-Echo/GODSONIQ style
  (numbered how-to + spec table) — 'Building from zero — the empty instrument'
  (rings → orbs → drag/tap routing → synth-roll → keep/weave exit → persistence)
  and 'OMNI — the synth panel' (live arcs, trim-riding knobs, engine selector,
  earth echo, transport). Rewrote 'Saving your instrument' as dimensions & vibes.
- manual (had drifted — still said MY PATCHES): added Building-from-zero + the
  🎹 OMNI panel; 'Saving' → dimensions & vibes; keymap now lists F11 zero + O.

Verified: grimoire renders all three new h2 sections, no broken markup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:37:09 +10:00
type-two
5e6e257ec0 🎹 Z5 — OMNI: a NICE synth panel over the dests + tweaks
A 🎹 OMNI button (sibling of 🎛 tracks, key O) opens a floating panel — the
planet's synth, laid out like a synth. NO new audio paths: a view over the same
dests + tweaks the field already drives.

- knobs: each knob's ARC is the dest's live value (n.normDisp, the same number
  the field meters — it breathes with the world); dragging rides that dest's
  tweak OFFSET (setParamLocal) and a dot marks your hand; double-click resets to
  neutral; right-click opens the node's full context menu. Note voices (lead/bass)
  show a live pitch arc — display-only (their character is the engine selector).
- modules: VOICES (lead/bass/pad/drone/perc), FX RACK (filter/reverb/delay/
  saturation/glitch/granular/crush/morph/lfo/space), 🌀 EARTH ECHO (a toggle;
  its six echo.* knobs appear only when it's on).
- header: voice-engine selector (FM tine / 🎸 string / 🎛 GODSONIQ — the pluck's
  character, via Synth.stringVoice/godsoniq/sampling), ♪ start-stop, live BPM,
  🔒 godtime lock.

Matrix honesty fix (computeDests): a hand-set positive offset now gives a voice a
floor even with no cable — so an OMNI (or the existing 'trim') knob can make a
voice audible from nothing. Only affects dests a user explicitly offset; a clean
instrument (all offsets 0) is bit-identical to before.

Verified live (z0test, via a manual matrix pump since the headless preview's rAF
is paused): panel opens with 21 knobs + 3 modules; idle arcs track live data
(filter 0.41); dragging reverb up floored its value 0.09→0.59 with the tweak dot
showing; double-click reset it; engine selector swapped FM→string; earth-echo
toggle revealed its knobs; right-click opened the 'filter cutoff' node menu.
Tweaks persist in the saved dimension (serializePatch carries tweaks). Console
clean, syntax clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:32:59 +10:00
type-two
3654b2f6ec 🌌 Z4 — dimensions: MY PATCHES → DIMENSIONS (framing) + dimension zero
A dimension is everything (the whole scene: routes, tweaks, grooves, tempo);
a vibe is a feeling you can hand a friend. Same /api/presets storage — this is
a framing rename, existing saves appear unchanged.

- ⚙ panel: 'MY PATCHES' → 'DIMENSIONS', copy reworked; input placeholder
  'name this dimension…'.
- the load dropdown always leads with '✦ dimension zero — the empty field';
  loading it calls enterZero() (the place you build from nothing).
- grimoire 'Saving your instrument' rewritten (canon = truth): the dimension /
  vibe distinction, the GSV1 share code + #vibe= link, 'build a vibe, save a
  vibe, share a vibe'.

Verified live (z0test): panel shows DIMENSIONS +  VIBES, MY PATCHES gone,
dropdown leads with dimension zero. Console clean, syntax clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:11:59 +10:00
type-two
55defaa695 Z3 — vibes: build a feeling, save it, share it
A vibe = a small bundle of cables {v:1,name,emoji,routes[≤32]}. Distinct from a
dimension (the whole scene): a vibe is a feeling you can hand a friend.

- apply is additive + reversible: routes a vibe creates are tagged (route._vibes)
  so toggling it off removes exactly those; a route two vibes share survives until
  both are off; a hand-built cable (route._base) a vibe merely rides is NEVER
  deleted. Tags live in-memory only — they don't leak into saved patches.
- save from a zero orb (right-click → 💾), from any node's right-click in the full
  field (💾 save these as a vibe), or 'save current wiring' in the panel (≤32 cap).
-  vibes panel (zero pill + ⚙ gear): list, toggle on/off, ⧉ copy share code,
  × delete, paste-to-import. localStorage library (gs_vibes).
- share: GSV1.<base64url(json)> clipboard code + godstrument.pro/#vibe=… fragment
  that OFFERS on load (add it / just save) and never auto-applies.
- trust boundary: sanitizeVibe hard-validates every import — unknown source/dest
  skipped (not thrown), amounts/roots/octaves clamped, curves/scales whitelisted,
  32-route cap, try/catch the base64/JSON. Garbage codes are rejected cleanly.
- per-account cloud storage deferred: the code + local library already deliver
  build/save/share across devices and friends. (add a vibes table if users ask.)

Verified live (z0test): built a 2-cable orb → saved 'sunrise duo' (quantize
preserved) → panel listed it ◉ → share code round-tripped through import →
garbage code rejected → hard-reload kept the library → toggle ON created exactly
2 routes (lead.note lit), toggle OFF removed them → #vibe= fragment offered
'shared gift' in the full app, hash cleared, not auto-applied. Console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 00:08:36 +10:00
type-two
00503b14c3 🌱 C1-C3 — keep-your-build exit, reload persistence, orb tap/menu/storm
C1: leaving zero with a build no longer silently discards it — a compact
    three-way card (keep my build / bring the old world back / weave them
    together); esc stays in zero. 0-route exits stay quiet (restore prezero).
C2: the zero build survives a reload — picks + cables persist to
    gs_zeronodes/gs_zerobuild; enterZero(true) on the reload path restores them.
C3: tap-to-patch (arm an orb, tap a voice) as a touch fallback for drag;
    right-click an orb → member list (× to drop a feed + its cables),
    unpatch-this-orb, and a save-as-vibe stub (Z3); a ⛈ enter-the-storm pill.
Nit: the chakra-view key/menu is a no-op in zero (nothing to draw there).

Verified live (z0test): pick→orb→tap-patch lit lead.note; hard reload restored
the ☀ orb + lit socket; the exit card's keep-my-build landed in a healthy full
field on the storm mood with zero-storage cleared; orb menu drops members.
Console clean, syntax clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:57:14 +10:00
type-two
b6f1c59e15 📖 brief: STATUS + CONTINUATION — Z0-Z2 approved, C1-C4 instructions (Fable review)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:42:05 +10:00
type-two
9a7f94759c 🌱 Z2 — zero mode routing: drag an orb to a voice, right-click to assign
The functional heart — build the instrument by hand from zero.

- Orbs are draggable; the output-rack sockets are drop targets. Drop an orb on a
  voice/FX socket → lays a cable per member feed (registerRoute), auto-adding a
  default quantize (minor_pent / root 60 or 36 for bass) when the target is a
  note voice. The socket lights green when it carries routes; a ticker line
  confirms the patch and points to ♪.
- Right-click a socket → the "synth roll" assign menu: for note voices a scale
  (the 11 SCALES) / root / octaves picker; for CC voices an amount slider + curve
  picker (lin/exp/exp3/log/scurve/inv). Plus "× unpatch all". Edits the live routes.
- refreshSockets() reflects existing routes on open; hover-highlight on drag.

Z-AC verified live: pick sun.speed (cosmos ring) → drop on filter · pick
crypto.price (wealth ring) → drop on lead → both sockets light · right-click lead
→ scale picker (11 scales, root, octaves) · press ♪ → OMNI plays (button green).
Bitcoin sings through a sun-opened filter, built from nothing. Console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:38:03 +10:00
type-two
4e44533cdd 🌱 Z1.5 — stage nodes are spinning orbs (John's vision)
Each built group becomes a spinning orb on the stage, not a flat chip — a glowing
core in the group's hue with its picked feeds as petals orbiting around it (CSS
zspin/zpulse). They accumulate as you build: pick from fire → one orb; add cosmos
→ two; and on. Orbs breathe live — the core glows by the group's average value,
each petal brightens with its own feed. More feeds widen the orbit ring.

Verified live: fire (3 petals) + cosmos (2 petals) render as two spinning glowing
orbs; console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:33:43 +10:00
type-two
d30cea469c 🌱 Z1 — zero mode: the calm 7+7 field (rings, picker drawer, stage)
The overlay that replaces the sensory flood. In zero mode the node field is not
drawn (frame() skips drawCables/drawSourceNode/drawDestNode); a DOM overlay
takes its place over the starfield:

- 7 vibe-rings down each side — the 14 config groups (left = elements/feelings:
  light·dark·fire·water·earth·air·spirit; right = domains: planet·cosmos·heavens·
  human·market·wealth·summer). Each: a hued dot (GROUP_META — taste consts,
  retune freely), label, live feed count, and a breathing glow scaled by the
  group's live average value. Members come from groupsInfo (the hub's config).
- Picker drawer: click a ring → it slides in from that side listing the group's
  feeds (name, key, live value bar, ⊕ that toggles in place). Pick 1+ → a stage
  node (chip, colored by group) lands on the center stage.
- Output rack along the bottom: OMNI voices (lead·bass·pad·drone·perc) + space &
  grit (filter·reverb·delay·…) as labeled sockets. Visual only — routing is Z2.

Veil (z-8) sits below the ♪/tracks/grimoire controls (z10-13) so they stay
usable. Verified live: rings render with hues + live glow, heavens drawer opens
with its 5 feeds + value bars, picking lands stage chips, ⊕ toggles in place,
console clean. Routing to the rack + right-click assign = Z2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 23:29:19 +10:00
type-two
2a705e3a35 🌱 Z0 — zero mode: blank dimension + first-visit choice card
The calm front door. Zero is not a muted world (moods do that) — it's an EMPTY
one you build by hand, the same matrix started from nothing.

- enterZero(): snapshots the current patch → gs_prezero, then loadPatch({}) empties
  the field; sets clientMatrix so the hub can't re-populate the cables; gs_mood="0 zero".
- exitZeroToMood(name): restores gs_prezero (the full factory) then applies the mood —
  leaving zero brings the whole world back.
- First-visit choice card ("one world, three doors"): 🌱 start from zero · 🌅 first
  light · ⛈ the full storm. Replaces the silent default-into-first-light; backdrop/esc =
  first light. Returning users keep their last mood; a returning zero-user re-enters zero.
- Mood submenu gains "0 zero — build from nothing"; F11 toggles zero (KEY_ACTIONS).

Verified live (logged-in preview): card renders with 3 doors; "start from zero" empties
the field (56 factory cables cleared, snapshotted); F11 restores all 56 + first light
(92 BPM locked); returning-zero reload re-enters zero with no card; console clean.

The 7+7 calm slot UI is Z1. Not deployed yet (deploy is Z6, post-review).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:53:14 +10:00
type-two
35c094f8d8 📖 ZERO_OMNI_BRIEF — zero mode, vibes/dimensions, OMNI panel (build brief for Opus, by Fable)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:40:58 +10:00
type-two
5db568d9da 📖 name the softsynth OMNI + tech-manual sections (OMNI / Earth Echo / GODSONIQ)
Names the built-in browser synth OMNI (♪ tooltip, intro prose) and adds three
scannable "tech manual" reference sections to the grimoire — OMNI (full voice
roster + how-to), the Earth Echo (six world-driven tape knobs + Schumann), and
GODSONIQ (resample + tab feed) — each a numbered how-to plus a two-column
control/dest spec table (new .spec / ol.steps CSS; auto-indexed in the TOC).

Every label, dest key and world→voice mapping extracted from code and verified.
Fixes two drifted claims in the old Earth Echo prose the audit caught: tape
wear drives hiss only (grit/saturation is static, not world-driven), and the
Schumann sub is ×4 = two octaves (not "four times").

Manual: sections flow into PART I (extractor taught .spec/.row → markdown
bullets) + an OMNI umbrella entry in Appendix J.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:02:40 +10:00
type-two
78718e9459 🌐 feed a tab into the world — external audio via getDisplayMedia
Right-click sky → "🌐 feed a tab": getDisplayMedia({video:true, audio:{…}})
lets the user share another tab's audio (a YouTube tab, a stream). Wired via
createMediaStreamSource into preSat → the full FX chain + master, so it's
audible live, crush-able, and resampleable by GODSONIQ from one wiring. Video
track stopped immediately; a track 'ended' listener handles Stop-sharing.

Audio constraints (verified against MDN/W3C/Chrome docs): suppressLocalAudio
Playback so the tab isn't heard twice, and echoCancellation/noiseSuppression/
autoGainControl off so the WebRTC voice-DSP doesn't mangle music. Chromium-only
(feature-detected; graceful ticker hints for unsupported / no-audio-track);
macOS grabs a tab not all-system; DRM services capture silent, YouTube fine.

Grimoire (truth) + manual Appendix J updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:38:33 +10:00
type-two
5ffa3a4bfb 🎛 GODSONIQ — resample the world onto the keys (ASR-10 nod)
Record ~3s of the live master mix into a buffer via a recorder AudioWorklet
tapping the full post-FX bus, then replay it pitched across the keyboard
(playbackRate = 2^((midi-60)/12), middle C = unity). Runs through the same
FX chain as every voice, so `crush` gives it the classic 12-bit sampler grit.
Right-click sky → 🎛 GODSONIQ to sample / re-sample; 🎸 string voice exits
back to the FM tine. Capture path verified: full-amplitude sawtooth recorded
clean (peak 0.98, rms 0.49) over the realtime port round-trip.

Grimoire (truth) + manual Appendix J updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:30:25 +10:00
9 changed files with 3202 additions and 58 deletions

View File

@ -1,6 +1,6 @@
# Godstrument — Claude context
Music-instrument web app; hosted at godstrument.pro on the **botchat** VPS (`humanjing@100.71.119.27`, exposed via Cloudflare Tunnel — NOT dealgod, despite earlier notes). App dir `/home/humanjing/godstrument` (not a git checkout). Deploy = rsync git-tracked files only (`--files-from=<(git ls-files)`, never `--delete`, never push `godstrument_users.db`/`auth_secret`/`patches/`) then `sudo -n systemctl restart godstrument`. Origin is Gitea `ssh://git@100.71.119.27:222/monster/Godstrument.git`. See [[godstrument-pro-deploy]].
Music-instrument web app; hosted at godstrument.pro on the **botchat** VPS (`humanjing@100.71.119.27`, exposed via Cloudflare Tunnel — NOT dealgod, despite earlier notes). App dir `/home/humanjing/godstrument` (not a git checkout). Deploy = rsync git-tracked files only, **minus dev docs/scripts** (`--files-from=<(git ls-files | grep -vE '^(CLAUDE\.md|README\.md|ZERO_OMNI_BRIEF\.md|GODSTRUMENT_MANUAL_SOURCE\.md|godstrument\.txt|build_manual\.py|test_.*\.py)$')`, never `--delete`, never push `godstrument_users.db`/`auth_secret`/`patches/`) then `sudo -n systemctl restart godstrument`. The **user manual** ships as `viz/manual.html` (served at godstrument.pro/manual.html), generated from the in-app grimoire by `python3 build_manual.py` — re-run it after editing the grimoire. Working notes/planning docs stay OUT of the deploy. Origin is Gitea `ssh://git@100.71.119.27:222/monster/Godstrument.git`. See [[godstrument-pro-deploy]].
- **Canon gotcha:** the real spec prose lives in `viz/index.html` (grimoire text around line ~950); `GODSTRUMENT_MANUAL_SOURCE.md` claims to be exhaustive but has drifted before (Earth Echo / Schumann layer was missing). When they disagree, **viz/index.html is truth** — update the manual to match, not the other way.
- Secrets (Cloudflare token etc.): read from `.env`/local files, never from chat. A CF token was pasted in chat once and rolled — don't repeat that.

View File

@ -542,7 +542,7 @@ Learn these seven gestures and the whole planet is under your hands:
- **Press `Z` — zen.** Every word vanishes: labels, readouts, panels, the header, the ticker, the dial — all of it — and only the visuals remain, full screen. The spheres, the cables, the shockwaves, the sky wheel if it's up, still moving to the world; nothing left to read, everything left to watch. `Z` or `esc` brings the words back.
- **The keyboard is yours — `F1``F10` and beyond.** A default set of function keys is ready to fly: `F1` the grimoire, `F2` play/stop the synth, `F3` the skin view, `F4` next skin, `F5` cycle mood, `F6` tracks, `F7` performance mode, `F8` lock the godtime, `F9` the earth echo, `F10` the stage. But every key is remappable: right-click the sky → **⌨ keys** to bind any key to any action, or **map a key** from a voice's own right-click menu. Want `a` and `s` to raise and lower a drone's level like a fader? Right-click that voice → *map a key → raise its level*, press `a`; again for *lower*, press `s` — now they nudge it live, hold to sweep. Your whole flow, under your fingers.
- **The keyboard is yours — `F1``F11` and beyond.** A default set of function keys is ready to fly: `F1` the grimoire, `F2` play/stop the synth, `F3` the skin view, `F4` next skin, `F5` cycle mood, `F6` tracks, `F7` performance mode, `F8` lock the godtime, `F9` the earth echo, `F10` the stage, `F11` **zero mode** (build from nothing). Letter keys too: `O` opens the **🎹 OMNI** synth panel, `T` tracks, `C` chakra view, `P` performance, `W` the sky wheel, `Z` zen. But every key is remappable: right-click the sky → **⌨ keys** to bind any key to any action, or **map a key** from a voice's own right-click menu. Want `a` and `s` to raise and lower a drone's level like a fader? Right-click that voice → *map a key → raise its level*, press `a`; again for *lower*, press `s` — now they nudge it live, hold to sweep. Your whole flow, under your fingers.
- **Press `C` — chakra view.** The sources leave their column and take up their stations on the subtle body: seven wheels on a spine of light, crown to root, each feed orbiting the chakra it belongs to — the sky's machinery at the crown, the omens at the third eye, the species talking at the throat, weather and air at the heart, fire and money at the solar plexus, birth and water at the sacral, quakes and debt and the mortal weight at the root. Each wheel glows with the live average of its members and spins its traditional petals, counter-rotating wheel to wheel. **Drag empty space to spin the whole body 360°**, drag up/down to tilt, flick and let momentum carry it. Everything still works in there: click a node to mute it, drag it onto a voice to patch, shift-click for its tab. `C` again and the sources glide home.
@ -584,13 +584,19 @@ Because the orrery is rendered client-side, in each listener's own browser, **ev
The matrix pours out clean control values whether or not anyone is listening. Choosing *how* to hear it is choosing which instrument the planet plays.
**The built-in voice, up close.** Press `♪` and the browser synth wakes. Its plucked notes ring with a **DX7-style FM tine** — brighter the harder they're struck; its pads bloom into a **detuned supersaw**; a **Juno chorus** warms the whole thing. Prefer strings? Right-click the sky → **🎸 string voice** and every pluck becomes a real **Karplus-Strong** plucked string. And route anything to `crush` for a **12-bit-DAC bitcrusher** — the planet degrading its own converter into lo-fi grit. (Every one of these was tuned by spectral analysis before it shipped — rendered offline and measured, so it's in-tune and clean, not just plausible.)
**The built-in voice, up close.** Press `♪` and **OMNI** — the built-in synth — wakes. Its plucked notes ring with a **DX7-style FM tine** — brighter the harder they're struck; its pads bloom into a **detuned supersaw**; a **Juno chorus** warms the whole thing. Prefer strings? Right-click the sky → **🎸 string voice** and every pluck becomes a real **Karplus-Strong** plucked string. And route anything to `crush` for a **12-bit-DAC bitcrusher** — the planet degrading its own converter into lo-fi grit. (Every one of these was tuned by spectral analysis before it shipped — rendered offline and measured, so it's in-tune and clean, not just plausible.)
**🎛 GODSONIQ — resample the world onto the keys.** A cheeky nod to the Ensoniq ASR-10: right-click the sky → **🎛 GODSONIQ** and it records ~3 seconds of the *live* master mix — whatever the world is playing right now, this exact never-repeating take — into a buffer. From then on every pluck **replays that capture, pitched across the keyboard** (middle C plays it at speed, an octave up plays it twice as fast). It runs through the full FX chain, so route `crush` at it and you get the classic 12-bit sampler grit. Right-click → 🎛 again to grab a fresh slice; **🎸 string voice** (or toggling it off) drops you back to the FM tine. The browser-native bounce: freeze a moment of the planet, then play the planet like an instrument.
**🌐 Feed a tab into the world.** Right-click the sky → **🌐 feed a tab** and the browser's share-picker opens — choose another tab (a YouTube video, a live radio stream, a Bandcamp page), tick **“Share tab audio,”** and that sound pours straight into Godstrument's FX chain. It's *in the world* now: route `crush` to grit it, drench it in the world's reverb, or hit **🎛 GODSONIQ** to resample it and play it pitched across the keys. Click 🌐 again (or the browser's *Stop sharing*) to cut it. **The fine print:** this is a **Chrome / Edge** trick (Safari and Firefox can't capture tab audio at all); on a Mac it grabs one *tab's* sound, not the whole system (for that you'd route audio through a virtual device like BlackHole); and DRM'd services (Netflix, Spotify, Apple Music) usually come through silent — ordinary YouTube is fine.
**Zero — the moods, the gentle front door.** On your very first visit the instrument greets you with **🌅 first light** instead of the full flood: a bass line, soft hats, a small melody, modest space — a thing shaped like a song, with the world still playing underneath it. Right-click the sky → **🌗 mood** to move between *first light*, *ambient drift* (no drums, all atmosphere, the planet as weather), and **the full storm** — the factory instrument with every one of the world's hands on it at once. Ease in, then open the door.
**One — the built-in browser synth.** Click **♪** and a full voice rack wakes inside the page: **lead** (bitcoin's price and your market's average price both quantize to it — minor pentatonic and G dorian), **bass** (quake depth chooses its notes in a low minor), **pad** (brightened by Tokyo's warmth, daylight, harmony, the birth rate, longer lives), **drone** (thickened by aircraft aloft, barometric pressure, Saturn's slow wheel, the weight of the national debt), and **perc** (BTC volatility and market activity drive the hats). Over them sit the effects the world modulates: **reverb** (earthquakes open the void, room light sizes it), **delay** (your hand's Y feeds it, Mercury retrograde smears it), **saturation** (Delhi's air becomes grit, hard aspects add dissonance, wildfires burn it, hunger grinds beneath), and **glitch** (every Wikipedia edit on Earth, every light flash, every new wildfire fires a burst). Nothing to install; the planet plays a synth in your tab.
**Building from zero — the empty instrument.** The other door: the three-door card also offers **🌱 start from zero** (or press `F11`, or ⚙ → DIMENSIONS → **✦ dimension zero**, or the 🌗 mood menu → **0 zero**). The flood clears to an empty field. Down each side sit **seven vibe-rings** — the world grouped into seven elements (left) and seven domains (right), each breathing with its group's live average. Click a ring, **⊕ pick** the feeds you want, and each picked group becomes a **spinning orb** (petals = feeds, core = the group's pulse). **Wire an orb to a voice** by dragging it onto a rack socket, or — on touch — **tap the orb to arm it, then tap a voice**; a lit green socket carries cable. **Right-click a socket** to set its scale/root/octaves (note voices) or amount/curve (effects); press `♪` and your build plays. **Right-click an orb** to drop a feed, unpatch it, or **💾 save it as a vibe**. Leaving zero (**⛈ enter the storm**, or a mood) asks whether to **keep your build**, bring the old world back, or **weave them together** — nothing is ever discarded silently, and your build survives a reload.
**🌀 The Earth Echo — a Space Echo whose tape is the planet.** Right-click the sky → **🌀 earth echo**, press **♪**, and the whole mix runs onto a virtual Roland tape delay — but its knobs are wired to the living world. Its **repeat rate** is the wind (Tokyo's gusts whip the tape speed, and because a tape motor has inertia the pitch *glides* as it changes — that dubby seasick bend); its **intensity** is volatility (bitcoin's fever climbs the feedback toward a screaming, self-oscillating howl); its **tone** is the air (Delhi's PM2.5 rolls the treble off until the echoes melt into a dark, suffocating soup); its **wow & flutter** is the Moon (the lunar cycle warbles the pitch with an eerie, drifting vibrato); its **tape wear** is the world's fire (every wildfire adds hiss and grit, the machine growing more fragile as the planet burns); and a **quake** throws the whole echo forward. Best of all, these six knobs are ordinary **voices in the matrix**`echo.time`, `echo.feedback`, `echo.tone`, `echo.flutter`, `echo.wear`, `echo.wet` — so you can drag *any* feed onto any of them, groove them on the 16-step grid, or send them out as CV. Turn it on and the world doesn't just play the instrument; it plays the *space* the instrument lives in. And underneath it all, always, the **Schumann resonance**: the cavity between the Earth's surface and the ionosphere rings at **7.83 Hz** (predicted by Winfried Otto Schumann, 1952), pumped by every lightning strike on the planet — the Earth's electromagnetic heartbeat. It sits below human hearing, so it plays the *machine* instead of the ear: a 7.83 Hz pulse forever breathing the echo's tone and laying a second, planetary wow under the moon's — and, octaved up four times to **31.32 Hz**, it hums as a true sub beneath the drone voice. It is the one dial the world never turns, because it is the size of the Earth itself — and the Earth's size does not change.
**One — OMNI, the built-in browser synth.** Click **♪** and a full voice rack wakes inside the page: **lead** (bitcoin's price and your market's average price both quantize to it — minor pentatonic and G dorian), **bass** (quake depth chooses its notes in a low minor), **pad** (brightened by Tokyo's warmth, daylight, harmony, the birth rate, longer lives), **drone** (thickened by aircraft aloft, barometric pressure, Saturn's slow wheel, the weight of the national debt), and **perc** (BTC volatility and market activity drive the hats). Over them sit the effects the world modulates: **reverb** (earthquakes open the void, room light sizes it), **delay** (your hand's Y feeds it, Mercury retrograde smears it), **saturation** (Delhi's air becomes grit, hard aspects add dissonance, wildfires burn it, hunger grinds beneath), and **glitch** (every Wikipedia edit on Earth, every light flash, every new wildfire fires a burst). Nothing to install; the planet plays a synth in your tab.
**🌀 The Earth Echo — a Space Echo whose tape is the planet.** Right-click the sky → **🌀 earth echo**, press **♪**, and the whole mix runs onto a virtual Roland tape delay — but its knobs are wired to the living world. Its **repeat rate** is the wind (Tokyo's gusts whip the tape speed, and because a tape motor has inertia the pitch *glides* as it changes — that dubby seasick bend); its **intensity** is volatility (bitcoin's fever climbs the feedback toward a screaming, self-oscillating howl); its **tone** is the air (Delhi's PM2.5 rolls the treble off until the echoes melt into a dark, suffocating soup); its **wow & flutter** is the Moon (the lunar cycle warbles the pitch with an eerie, drifting vibrato); its **tape wear** is the world's fire (every wildfire adds hiss, the machine growing more fragile as the planet burns); and a **quake** throws the whole echo forward. Best of all, these six knobs are ordinary **voices in the matrix**`echo.time`, `echo.feedback`, `echo.tone`, `echo.flutter`, `echo.wear`, `echo.wet` — so you can drag *any* feed onto any of them, groove them on the 16-step grid, or send them out as CV. Turn it on and the world doesn't just play the instrument; it plays the *space* the instrument lives in. And underneath it all, always, the **Schumann resonance**: the cavity between the Earth's surface and the ionosphere rings at **7.83 Hz** (predicted by Winfried Otto Schumann, 1952), pumped by every lightning strike on the planet — the Earth's electromagnetic heartbeat. It sits below human hearing, so it plays the *machine* instead of the ear: a 7.83 Hz pulse forever breathing the echo's tone and laying a second, planetary wow under the moon's — and, octaved up ×4 (two octaves) to **31.32 Hz**, it hums as a true sub beneath the drone voice. It is the one dial the world never turns, because it is the size of the Earth itself — and the Earth's size does not change.
**Two — Web MIDI, out into a DAW or hardware.** In Chrome or Edge, open the ⚙ panel's WEB MIDI section, *enable MIDI*, and pick an **out** port. Every destination now streams as CC or notes straight to your rig — no Python, no IAC bus. The map is fixed and MIDI-learnable at the far end:
@ -614,11 +620,91 @@ MIDI-learn any of these at your synth and the planet drives your favorite hardwa
---
## Saving your instrument
## OMNI — the softsynth, in full
A patch you have played into being — the cables you dragged, the amounts you leaned, the mutes and freezes, the tempo you locked — is a whole instrument, and it deserves to survive the tab closing.
**OMNI** is the instrument's built-in voice: not one synth but a rack of classic machines in a single browser engine — a DX7-style FM tine, a JP-8000 supersaw, a Karplus-Strong string, a Juno-106 chorus, a morphing wavetable lead, a granular cloud, a 12-bit-DAC crusher, and the planet-driven tape echo below. Nothing to install; the world plays all of it at once. (Every voice was tuned by spectral analysis before it shipped — rendered offline and measured, so it's in-tune and clean, not just plausible.)
Open **⚙ → MY PATCHES** and **save**. The entire live state is written to your account, keyed to your login (*monsterrobotparty@gmail.com*): every route with its amount and curve, every group and orbit, the running mix of gains and mutes and freezes, and the godtime tempo. From any device, **load** it back and the exact instrument returns — the same planet, wired the same way, pulsing at the same locked BPM. (Locally, spells live as `patches/*.json` on the host; MY PATCHES is that same power for signed-in players, carried to your account and back.)
- Press `♪` (top-right, or `F2`) to wake OMNI — the whole audio graph builds on the first press.
- Your first visit opens gently in **🌅 first light**. Right-click the sky → **🌗 mood — how much world** to move between *first light*, *ambient drift* (all atmosphere, no drums) and *the full storm* (every hand at once).
- Play notes three ways: enter **chakra view** (`C`) and click a wheel's core for a 12-key console (play it with the `Q…P` row or a MIDI keyboard); open **🎛 tracks** (`T`) to sequence a song; or just wire the world's data to play it hands-free.
- Shape the character: swap the pluck voice (**🎸 string voice** / **🎛 GODSONIQ**), or drag any feed onto `crush`, `wavetable.morph` or `granular.density`.
- **The 🎹 OMNI panel** — press `O` (or the 🎹 button, above 🎛 tracks) for the synth laid out like a synth. It's a *view* over the same voices and effects, no new sound. Every knob's **arc** shows the voice's live world-driven value (it breathes with the planet); **drag** the knob to shape that voice (its **dot** marks your hand — it rides the voice's *trim*, so it works even before the world is wired to it and it saves with your dimension); **double-click** hands it back to the world; **right-click** opens the voice's full node menu. Modules: **VOICES** (lead/bass/pad/drone/perc), **FX RACK** (filter/reverb/delay/saturation/glitch/granular/crush/morph/lfo/space), and **🌀 EARTH ECHO** (its six tape knobs appear when it's on). Up top: the pluck's engine (FM tine / 🎸 string / 🎛 GODSONIQ), `♪` start/stop, the live BPM and the godtime 🔒 lock.
- **lead** — Morphing **wavetable** melody (mellow ↔ bright saw, swept by `wavetable.morph`). *World:* bitcoin's price & your market's average both sing the note (minor pentatonic / G dorian).
- **bass** — Square **sub**. *World:* earthquake depth chooses the note (low minor) — and every bass note also conducts the pad & drone pitch.
- **pad** — Nine-oscillator detuned **supersaw** (JP-8000, ±14¢). *World:* daylight, warmth, harmony, the birth rate & longer lives brighten it.
- **drone** — Sawtooth bed + a 31.32 Hz Schumann **sub**. *World:* aircraft aloft, air pressure, Saturn's wheel, poverty & the national debt thicken it.
- **perc** — Filtered-noise **hats** + the granular grain cloud. *World:* bitcoin volatility & market activity drive the density.
- **pluck** — Hand-played notes ring as a **DX7 FM tine** by default (harder = brighter) — or a **🎸 Karplus string**, or a **🎛 GODSONIQ** sample. All keys, MIDI, mind-mode & sequencer notes flow through it.
- **chorus · crush** — An always-on **Juno-106 chorus** widens the dry oscillators; `crush` is a **12-bit-DAC bitcrusher** on the master — route any feed at it to degrade the whole mix into lo-fi grit.
- **world FX****reverb** (quakes open the void, room light sizes it), **delay** (your hand's Y, Mercury retrograde smears it), **saturation** (Delhi's air, wildfires, hunger), **glitch** (every Wikipedia edit, light-flash & new fire fires a burst).
## The Earth Echo — the planet on tape
A virtual **Roland Space Echo** the entire mix runs onto — except its six knobs aren't yours, they're the living world's. And under all of it rings the **Schumann resonance**: the cavity between the Earth's surface and the ionosphere, pumped by every lightning strike on the planet, humming at **7.83 Hz** — the Earth's electromagnetic heartbeat, the one dial the world never turns.
- Right-click the sky → **🌀 earth echo — the planet on tape** (or press `F9`). It reads **🌀 earth echo: on** once live.
- Press `♪` — the tape only sings while OMNI is running.
- The six knobs are ordinary **matrix voices** — drag any feed onto them, sequence them on the 16-step grid, or send them out as CV. The world's default cables are already patched:
- **echo.time** — Repeat rate / tape speed — and because a tape motor has inertia, changes *glide* and pitch-bend the tail (that dubby seasick bend). *World:* the wind (Tokyo's gusts).
- **echo.feedback** — Intensity — climbs toward, but never past, a screaming self-oscillation. *World:* bitcoin volatility.
- **echo.tone** — Treble roll-off — darkens the repeats into a suffocating dub soup. *World:* air PM2.5 (Delhi).
- **echo.flutter** — Wow & flutter — an eerie, drifting pitch vibrato. *World:* the Moon (the lunar cycle).
- **echo.wear** — Tape hiss — the machine growing more fragile as the planet burns. *World:* the world's wildfire count.
- **echo.wet** — Wet/dry mix — also the true on/off gate (forced to silence when the echo is off). *World:* a quake throws the whole echo forward.
- **Schumann 7.83 Hz** — A fixed sub-audio pulse forever breathing the echo's *tone*, and laying a second planetary *wow* under the Moon's. Below hearing, so it plays the machine, not the ear.
- **Schumann sub 31.32 Hz** — That same heartbeat ×4 (two octaves up) — an audible sub hum beneath the **drone** voice.
## GODSONIQ — resampling the world
The browser-native **bounce** — a cheeky nod to the Ensoniq ASR-10. Capture a few seconds of the *live* world (this exact never-repeating take of Euclid fills, ratchets and drift), then play that moment back **pitched across the keys**. Freeze a moment of the planet, then play the planet like an instrument. Its sibling **🌐 feed a tab** pours any other browser tab's audio into the world so you can resample *that* too.
- Press `♪` first — GODSONIQ needs the audio worklet awake.
- Right-click the sky → **🎛 GODSONIQ — resample the world onto the keys**. It records ~3 seconds of the live master mix.
- Play the keys — every pluck now **replays your capture, pitched** (middle C = original speed, an octave up = twice as fast). Route `crush` at it for the classic 12-bit sampler grit.
- Right-click → 🎛 again for a fresh slice. Exit back to the FM tine with **🎸 string voice**.
- **🎛 GODSONIQ** — Records ~3s of the full master (post-FX, post-crush) into a buffer; every pluck then replays it. Tap again to re-sample.
- **playback pitch** — Note-spread ASR-style — `playbackRate = 2^((midi60)/12)`, so middle C plays it at speed and each octave doubles/halves it.
- **🌐 feed a tab** — Right-click → **feed a tab**, pick another tab in the share-picker and tick **“Share tab audio.”** That sound (a YouTube video, a live stream) is now *in the world* — crush it, reverb it, or GODSONIQ-resample it.
- **the fine print****Chrome / Edge only** (Safari & Firefox can't capture tab audio). On a Mac it grabs one *tab's* sound, not all-system (for that, route through a virtual device like BlackHole). DRM'd services (Netflix, Spotify, Apple Music) come through silent — ordinary YouTube is fine.
---
## Saving your instrument — dimensions & vibes
A patch you have played into being — the cables you dragged, the amounts you leaned, the mutes and freezes, the tempo you locked — is a whole instrument, and it deserves to survive the tab closing. There are two sizes of keepsake: a **dimension** is *everything*; a **vibe** is a feeling you can hand a friend.
**Dimensions — the whole world, saved.** Open **⚙ → DIMENSIONS** and **save**. The entire live state is written to your account, keyed to your login (*monsterrobotparty@gmail.com*): every route with its amount and curve, every group and orbit, the running mix of gains and mutes and freezes, and the godtime tempo. From any device, **load** it back and the exact instrument returns — the same planet, wired the same way, pulsing at the same locked BPM. The list always leads with **✦ dimension zero** — the empty field, the place you build from nothing. (Locally, spells live as `patches/*.json` on the host; DIMENSIONS is that same power for signed-in players, carried to your account and back.)
**Vibes — a feeling, shareable.** A vibe is smaller and lighter than a dimension — a little bundle of specific source→voice cables, the exact combo that made a moment sing. Right-click a spinning orb in zero (or any node in the full field) → **💾 save as vibe**, or open **✨ vibes** (the zero pill, or ⚙). Applying a vibe is *additive* — it lays its cables on top of whatever you already have, toggles straight back off, and never touches a cable you built by hand. Then **⧉ copy** its short `GSV1…` code and paste it to a friend, or share a `godstrument.pro/#vibe=…` link — they see *“someone shared a vibe”* and choose whether to add it. Imports are hard-validated (unknown feeds skipped, values clamped, 32-cable cap). **Build a vibe, save a vibe, share a vibe.**
---
@ -1013,3 +1099,18 @@ Every feed the world sends is one of eight temperaments; the shape dictates how
- **🎹 Supersaw pad** — each of the pad's chord tones is a detuned 9-saw cluster (JP-8000 style) for a lush, wide wall.
- **🎸 String voice** — right-click the sky → *string voice* swaps the FM tine for a real Karplus-Strong plucked string (an AudioWorklet: noise burst → damped delay loop, sample-accurate). Spectrally verified stable and in-tune.
- **🔊 12-bit bitcrush** — a world-drivable lo-fi stage on the synth's master bus (an AudioWorklet doing true bit-depth quantise + sample-rate reduction). Route anything to `crush` and the planet degrades its own DAC — clean by default (16-bit).
- **⬗ Parameter locks (p-locks)** — Pocket-Operator / Elektron style: a texture voice (filter, glitch, reverb, drive) gets a per-step `lock` lane that pins its value to a fixed level on that step (blocky, disjointed), overriding the world; drag a step below the lane to unlock it and hand that step back to the data. Applied in `computeDests` via `applyGroove`; travels with the patch.
- **🌊 Juno-106 chorus** — an always-on subtle BBD-style stereo chorus on the synth's master bus (two short delays warbled by slow LFOs, panned wide) that warms and widens the dry oscillators.
- **🔊 12-bit bitcrush** — a world-drivable lo-fi stage on the synth's master bus (an AudioWorklet doing true bit-depth quantise + sample-rate reduction). Route anything to `crush` and the planet degrades its own DAC — clean by default (16-bit).
- **🎛 GODSONIQ (resampler)** — a cheeky nod to the Ensoniq ASR-10: right-click the sky → *GODSONIQ* records ~3s of the live master mix (a recorder AudioWorklet taps the full post-FX bus) into a buffer, then every pluck replays that capture pitched across the keys (playbackRate = 2^((midi-60)/12), middle C = unity). Plays through the full FX chain, so `crush` gives it the classic 12-bit sampler grit. 🎸 string voice exits back to the FM tine. The browser-native bounce — capture the world's never-repeating take, then play it.
- **⬗ Parameter locks (p-locks)** — Pocket-Operator / Elektron style: a texture voice (filter, glitch, reverb, drive) gets a per-step `lock` lane that pins its value to a fixed level on that step (blocky, disjointed), overriding the world; drag a step below the lane to unlock it and hand that step back to the data. Applied in `computeDests` via `applyGroove`; travels with the patch.
- **🌊 Juno-106 chorus** — an always-on subtle BBD-style stereo chorus on the synth's master bus (two short delays warbled by slow LFOs, panned wide) that warms and widens the dry oscillators.
- **🔊 12-bit bitcrush** — a world-drivable lo-fi stage on the synth's master bus (an AudioWorklet doing true bit-depth quantise + sample-rate reduction). Route anything to `crush` and the planet degrades its own DAC — clean by default (16-bit).
- **🎛 GODSONIQ (resampler)** — a cheeky nod to the Ensoniq ASR-10: right-click the sky → *GODSONIQ* records ~3s of the live master mix (a recorder AudioWorklet taps the full post-FX bus) into a buffer, then every pluck replays that capture pitched across the keys (playbackRate = 2^((midi-60)/12), middle C = unity). Plays through the full FX chain, so `crush` gives it the classic 12-bit sampler grit. 🎸 string voice exits back to the FM tine. The browser-native bounce — capture the world's never-repeating take, then play it.
- **🌐 Tab feed (external audio in)** — right-click the sky → *feed a tab* calls `getDisplayMedia({video:true, audio:{...}})`; the user picks another browser tab and ticks 'Share tab audio', and its sound (a YouTube tab, a live stream) is wired via `createMediaStreamSource` into `preSat` → the full FX chain and master, so it's audible live, `crush`-able, and resampleable by GODSONIQ. Constraints disable echoCancellation/noiseSuppression/autoGainControl (voice DSP that mangles music) and set suppressLocalAudioPlayback (no double audio). Chromium-only (Chrome/Edge; Safari & Firefox don't deliver display audio); on macOS it captures a TAB not all-system (BlackHole/Loopback is the system-wide workaround); DRM'd services (Netflix/Spotify/Apple Music) usually capture silent, plain YouTube is fine. Video track is stopped immediately; a track 'ended' listener handles the browser's Stop-sharing.
- **⬗ Parameter locks (p-locks)** — Pocket-Operator / Elektron style: a texture voice (filter, glitch, reverb, drive) gets a per-step `lock` lane that pins its value to a fixed level on that step (blocky, disjointed), overriding the world; drag a step below the lane to unlock it and hand that step back to the data. Applied in `computeDests` via `applyGroove`; travels with the patch.
- **🎛 OMNI (the softsynth)** — the umbrella name for the built-in browser synth: five world-modulated core voices (lead/bass/pad/drone/perc) plus a rack of classic-synth characters in one Web Audio engine — DX7 FM tine, JP-8000 supersaw, Karplus-Strong string, Juno-106 chorus, morphing wavetable lead, granular cloud, 12-bit-DAC bitcrush, and the Earth Echo tape delay. Woken by ♪; the grimoire's 'OMNI — the softsynth, in full' section is the full reference.
- **🌊 Juno-106 chorus** — an always-on subtle BBD-style stereo chorus on the synth's master bus (two short delays warbled by slow LFOs, panned wide) that warms and widens the dry oscillators.
- **🔊 12-bit bitcrush** — a world-drivable lo-fi stage on the synth's master bus (an AudioWorklet doing true bit-depth quantise + sample-rate reduction). Route anything to `crush` and the planet degrades its own DAC — clean by default (16-bit).
- **🎛 GODSONIQ (resampler)** — a cheeky nod to the Ensoniq ASR-10: right-click the sky → *GODSONIQ* records ~3s of the live master mix (a recorder AudioWorklet taps the full post-FX bus) into a buffer, then every pluck replays that capture pitched across the keys (playbackRate = 2^((midi-60)/12), middle C = unity). Plays through the full FX chain, so `crush` gives it the classic 12-bit sampler grit. 🎸 string voice exits back to the FM tine. The browser-native bounce — capture the world's never-repeating take, then play it.
- **🌐 Tab feed (external audio in)** — right-click the sky → *feed a tab* calls `getDisplayMedia({video:true, audio:{...}})`; the user picks another browser tab and ticks 'Share tab audio', and its sound (a YouTube tab, a live stream) is wired via `createMediaStreamSource` into `preSat` → the full FX chain and master, so it's audible live, `crush`-able, and resampleable by GODSONIQ. Constraints disable echoCancellation/noiseSuppression/autoGainControl (voice DSP that mangles music) and set suppressLocalAudioPlayback (no double audio). Chromium-only (Chrome/Edge; Safari & Firefox don't deliver display audio); on macOS it captures a TAB not all-system (BlackHole/Loopback is the system-wide workaround); DRM'd services (Netflix/Spotify/Apple Music) usually capture silent, plain YouTube is fine. Video track is stopped immediately; a track 'ended' listener handles the browser's Stop-sharing.

292
ZERO_OMNI_BRIEF.md Normal file
View File

@ -0,0 +1,292 @@
# ZERO MODE · VIBES · DIMENSIONS · the OMNI panel
### Build brief for the implementing model (Opus). Read fully before writing code.
John's ask, verbatim spirit: *the full UI is an intense sensory assault. Give people a
**zero mode** — 7 sources left, 7 right, click a slot to see the feeds in that grouping
(same colors/chakra energy as the main UI), pick one or more, route them by hand to an
output, right-click to assign it (synth role / MIDI / …). They build everything from
zero. Save the whole thing as a **dimension** (a full hardcore scene save); save a
specific source→route→output combo as a **vibe** — "build a vibe, save a vibe, share a
vibe". And give OMNI a NICE UI panel.*
---
## 0. Ground rules (unchanged from every Godstrument session)
- **Canon:** `viz/index.html` is truth; the grimoire prose inside it must be updated to
match whatever ships (never the other way). Regenerate `GODSTRUMENT_MANUAL_SOURCE.md`
with the builder afterwards (`build_grimoire.py` pattern — carry-forward + dedupe).
- **Honesty rule:** nothing decorative. Every slot, knob, and control maps to a real
feed/dest/tweak. If a thing isn't wired, it doesn't render.
- **Verify with your own eyes:** preview server + login (invite + `/api/signup` recipe —
see memory `godstrument-local-verify`), console clean, then screenshot proof.
- **Deploy:** rsync git-tracked files to botchat (`humanjing@100.71.119.27`, app dir
`/home/humanjing/godstrument`), **never `--delete`**, never push `godstrument_users.db`
/ `auth_secret` / `patches/`; `sudo -n systemctl restart godstrument`; curl-verify
cache-busted. First load post-deploy is slow (cold start).
- **auth.py changes must be additive** (new table / new column with default) — the
server's SQLite holds real user accounts. Never a destructive migration.
- Commit per milestone with the Co-Authored-By line. Adversarially review at the end
(a Workflow, as in previous sessions) and fix confirmed findings before deploy.
## 1. What already exists — build on it, don't reinvent
| Thing | Where (verify at build time) | Use it for |
|---|---|---|
| **14 concept groups** with `label`, `orbit`, `members` | `config.json:31-46` ("groups"); grimoire "The vibes — grouping by feeling" (~line 848-900, has the emojis: 💰 wealth, 💧 water, …) | The 7+7 zero slots. They are ALREADY called vibes in canon. |
| MOODS + applyMood | `viz/index.html:2275-2322` | The pattern for entering/leaving a named state; zero joins this family |
| First-visit flow (`gs_seen`, `gs_mood`) | `viz/index.html:2841-2842` | Zero as the new-user front door |
| serializePatch / loadPatch | `viz/index.html:2532-2567` | Dimensions ARE this; a blank dimension = `{routes:[], …}` |
| addRouteLocal / registerRoute / removeRouteLocal | `viz/index.html:2452-2468, 2921` | Zero-mode routing + vibe apply/remove |
| "patch to →" context submenu | `viz/index.html:5521` | Reuse for assigning a node to an output |
| Node tweaks (mute/gain/offset), setParamLocal | `viz/index.html:2036+, 2451` | OMNI panel knobs = views over these |
| MY PATCHES (account presets, one JSON blob) | `auth.py:46 (presets table), :249+ (endpoints)`; ⚙ panel UI | Rename surface to **Dimensions**; vibes get a sibling store |
| CHAKRAS (colors) | `viz/index.html:3427` | The zero-slot color language |
| Arranger panel (`arrbtn`, resizable, tabbed) | `viz/index.html:1497+, 2325-2330` | The UI pattern for the OMNI panel |
| KEY_ACTIONS + remappable keys | `viz/index.html:~6060-6110` | Key bindings for zero + OMNI panel |
| quantize option on routes | `viz/index.html:1398, 2415` | The "assign like a synth roll" — note outputs get scale/root/octaves |
**Extraction discipline:** before each milestone, re-verify the exact lines above (they
drift) and extract any formula/shape you depend on into code comments citing
`viz/index.html:LINE`.
## 2. ZERO MODE — dimension zero
**Concept:** a fourth entry in the mood family, but deeper: not muted — **empty**.
No cables, no voices sounding, the field silent and almost blank. The user builds the
entire instrument by hand, one cable at a time. Leaving zero (to any mood / the storm)
restores the full factory experience.
### Entry & exit
- Right-click sky → **`0 zero — build from nothing`** (top of the mood submenu), plus a
key (suggest `F11` or a free slot in DEFAULT_KEYS; check collisions).
- **First-visit choice card** (replaces the current silent default into first light):
three big options — 🌱 **start from zero** (build it yourself) · 🌅 **first light**
(a gentle song) · ⛈ **the full storm** (everything at once). Persist choice like
`gs_mood`. Returning users keep whatever they last used.
- Entering zero: snapshot the current patch to a holding slot (localStorage
`gs_prezero`) so exiting zero can offer "restore what you had" vs "keep my build".
Implementation: `loadPatch({routes: [], tweaks: {}, seqs: {}, …})` + a `zeroMode`
UI flag. Do NOT touch the hub or other users — this is all client-local, like moods.
### The zero UI (the whole point: calm)
- **Hide the full node field.** Render instead:
- **7 slots down the left, 7 down the right** — the 14 groups from config.json in a
fixed, curated order (suggest: left = the elements/feelings: light, dark, fire,
water, earth, air, spirit · right = the domains: planet, cosmos, heavens, human,
market, wealth, summer). Each slot: a colored ring (its chakra/vibe hue — extract
or define a `GROUP_HUES` map; the grimoire's vibe emojis are the seed), the label,
and a live "breathing" glow driven by the group's average member value (already
computed for group nodes) — alive but quiet.
- **A center stage** — empty at first. Nodes the user builds land here.
- **An output rack** along the bottom (or right-center column): the destinations,
grouped and labeled in plain language:
- **OMNI voices:** lead (sings) · bass (roots) · pad (glows) · drone (hums) · perc (taps)
- **OMNI space & grit:** reverb.size · delay.feedback · saturation · glitch ·
granular.density · crush · wavetable.morph · lfo.rate · master.space · filter.cutoff
- **🌀 earth echo** knobs (echo.*) — shown only when earth echo is on
- **MIDI** (when an out port is enabled): the CC map + lead/bass note channels
Each output is a small labeled socket, dark until something is patched into it.
- **Slot → picker drawer:** clicking a slot slides open a drawer listing that group's
member feeds: name, one-line plain-English description (extract from the grimoire
source blurbs / `signals` labels), live value bar, chakra color. Each row has ⊕.
Picking 1+ feeds **creates a node on the stage** (or adds to the slot's existing
node). The node renders like a mini group-node: the slot's color, member count badge.
- **Routing:** drag from a node to an output socket → lays a cable **per member feed**
to that dest at a sensible default amount (0.5, curve lin) — reusing addRouteLocal.
Alternatively right-click node → the existing "patch to →" submenu filtered to the
rack outputs. Cables render exactly like the main UI (same renderer — don't fork it).
- **Right-click assign (the "synth roll" ask):** right-click an output socket or a
cable →
- note outputs (lead.note/bass.note): **quantize picker** — scale (the 11 SCALES),
root (note name picker), octaves (1-3); presets "minor pent @C4" etc.
- CC/level outputs: amount slider + curve picker (lin/exp/exp3/log/scurve/inv) —
the existing route controls, surfaced.
- MIDI: choose CC number / channel (when midi enabled).
- **Guidance, not tutorial:** use the existing eventTicker for 3-4 contextual nudges
("pick a source from a ring", "now drag it onto a voice", "press ♪ — that's YOUR
instrument"). No modal tutorial. The calm IS the feature.
- **Graduation:** a small "⛈ enter the storm" affordance; leaving zero shows the full
field with everything the user built still wired (their routes persist — zero is not
a sandbox, it's the same matrix).
### Acceptance criteria (Z-AC)
From a fresh account in zero mode, with no instructions beyond the ticker nudges, this
sequence works in under a minute and makes sound: click a ring → pick `sun.speed`
drag node to `filter.cutoff`; click another → pick `crypto.price` → drag to `lead`
right-click lead → minor pent @ 60 → press ♪ → bitcoin sings through a sun-opened
filter. Verified in the preview with a screenshot; console clean; leaving zero → first
light works; re-entering zero offers restore.
## 3. VIBES — build a vibe, save a vibe, share a vibe
A **vibe** = a small, additive, shareable bundle of wiring:
```json
{ "v": 1, "name": "sun filter", "emoji": "☀️",
"routes": [{"source": "sun.speed", "dest": "filter.cutoff", "amount": 0.85, "curve": "exp"},
{"source": "crypto.price", "dest": "lead.note", "amount": 1.0,
"quantize": {"scale": "minor_pent", "root": 60, "octaves": 2}}] }
```
- **Apply = additive** (registerRoute upserts; nothing else touched). Tag applied
routes with `viaVibe: "<name>"` so a vibe can be **toggled off** (remove exactly the
routes it added — removeRouteLocal). Multiple vibes stack; a route present in two
stays until both are off.
- **Save a vibe:** in zero mode, "💾 save as vibe" on the stage selection (or the whole
current zero build); in the full UI, right-click a node → "save these cables as a
vibe". Named + emoji.
- **Store:** account-side, in auth.py — **additive** migration: either a new `vibes`
table mirroring presets, or a `kind` column on presets defaulting `'dimension'`.
Endpoints mirror the preset ones (list/save/delete). Local fallback to localStorage
when signed out.
- **Share:** two mechanisms, both v1:
1. **Vibe code:** compact string `GSV1.<base64url(json)>` — "copy vibe code" puts it
on the clipboard; an "import vibe" box (⚙ panel + zero mode) accepts a pasted code.
2. **URL fragment:** `godstrument.pro/#vibe=GSV1....` — on load (post-login), offer
"apply the vibe you were sent?" Never auto-apply; show its name + route count
first. (Fragment never reaches the server — clean with the invite-only model.)
- **Validation:** parse defensively — unknown sources/dests in an imported vibe are
listed and skipped, not errors. Cap: 32 routes per vibe.
### Vibes AC
Save a 2-route vibe, toggle it off/on, copy its code, hard-reload, import the code,
apply — identical wiring returns. A vibe code from another account applies cleanly.
## 4. DIMENSIONS — the full scene save
- **Rename the user-facing surface**: ⚙ "MY PATCHES" → **"DIMENSIONS"** ("a dimension
is the whole world you built — every cable, mix, groove, tempo, key"). Same
serializePatch blob, same endpoints — this is a naming + framing change; do NOT
break existing saved presets (they simply appear as dimensions).
- Zero mode ships with the blank state as **"dimension zero"** (a virtual, always-
available entry at the top of the list — not stored, generated).
- Grimoire: rewrite the "Saving your instrument" section for the dimension/vibe split:
*a dimension is everything; a vibe is a feeling you can hand to a friend.*
## 5. The OMNI panel
A dedicated synth panel, sibling to the tracks panel (same open/close/resize pattern —
`arrbtn` precedent). Button: **🎹 OMNI** bottom-right next to 🎛 tracks; key-bindable.
- **Layout — a synth, not a mixer:** module strips:
- **VOICE:** the pluck voice selector — FM tine · 🎸 string · 🎛 GODSONIQ (with a
"sample 3s" button + sampling state) — wired to `Synth.stringVoice()` /
`Synth.godsoniq()` / `Synth.sampling()`; plus 🌐 tab feed toggle.
- **LEAD** (wavetable.morph knob) · **BASS** · **PAD** (pad.brightness) ·
**DRONE** (drone.voices; a small "7.83 Hz" schumann lamp when earth echo is on) ·
**PERC** (perc.density).
- **FX RACK:** filter.cutoff · reverb.size · delay.feedback · saturation · glitch ·
granular.density · crush · lfo.rate · master.space (+ chorus shown as a fixed,
labeled, non-decorative indicator — it IS always on; label it honestly).
- **🌀 EARTH ECHO:** on/off + the six echo.* knobs (only enabled when on).
- **TRANSPORT:** ♪ toggle, mood selector, the current scale (from gScale, click →
scale picker), BPM readout linked to godtime.
- **Knob semantics (the crucial design):** every knob shows the **live world-driven
value** as an animated arc (from the dest's current value — the world's hand).
Dragging a knob adjusts the dest's **tweak gain/offset** (existing setParamLocal
machinery) — you're riding a fader the world is also pushing. Double-click resets
the tweak to neutral (the ⌘⇧-click semantic). A small dot shows when a knob has a
user tweak. Right-click a knob = the node's existing context menu (mute/solo/level/
cables/patch to). **No new audio paths — the panel is a view over dests + tweaks.**
- Muted dests render dim; a grooved/p-locked dest shows a tiny grid icon.
- Works inside zero mode too (it doubles as zero's output rack detail view — if this
is cleanly achievable, the rack and the panel share components; if not, keep them
separate and simple).
### OMNI AC
Open the panel: every knob's arc moves with live data; drag reverb.size up — audibly
more reverb and the dest tweak persists in the saved dimension; voice selector swaps
FM→string→GODSONIQ and plucks confirm; screenshot for the session log; console clean.
## 6. Milestones (each: build → verify in preview → commit)
- **Z0** — blank dimension + zero entry/exit + first-visit choice card + prezero restore.
- **Z1** — 7+7 slots + picker drawer + stage nodes (no routing yet). Colors/labels/live glow.
- **Z2** — routing to the output rack + right-click assign (quantize/curve/amount) +
ticker nudges. **Z-AC passes.**
- **Z3** — vibes: tag/apply/toggle, save to account (additive auth.py migration),
vibe code export/import + #vibe= fragment. **Vibes AC passes.**
- **Z4** — dimensions rename + dimension-zero entry + grimoire "Saving" rewrite.
- **Z5** — OMNI panel. **OMNI AC passes.**
- **Z6** — grimoire sections (zero mode gets its own tech-manual section in the style
of OMNI/Earth Echo/GODSONIQ: numbered how-to + spec table), regenerate the manual,
adversarial review workflow over the whole feature set, fix confirmed findings,
deploy to botchat, curl-verify live, update memory.
## 7. Risks & taste notes
- `viz/index.html` is ~7.5k lines of interlocking IIFE — add new sections as their own
IIFEs near their dependencies; do not restructure existing code to "make room".
- The 14-group order and hues are a **taste call** — propose them, note them as
one-line consts John can retune by ear/eye (the skins pattern).
- Zero mode must never fork the matrix/renderer: same routes, same cables, same synth.
If zero starts needing its own compute path, stop — the design has gone wrong.
- Don't gate existing users into zero — it's a door, not a wall. `gs_mood` returning
users land where they left off.
- Keep the first-visit card copy short and Godstrument-voiced. No onboarding-speak.
---
# STATUS + CONTINUATION (Fable review, 2026-07-10)
**Z0Z2 verified and approved** (commits 2a705e3, d30cea4, 4e44533, 9a7f947): the three-door
card, the calm 7+7 ring field, spinning orbs, drag-to-route, the synth-roll assign menu —
all live-verified, Z-AC met (bitcoin sang through a sun-opened filter from zero), console
clean, syntax clean, field-draw correctly guarded. Genuinely good work.
Continue from here, in this order:
## C1. Fix: leaving zero silently DISCARDS the user's build (real bug)
`exitZeroToMood` always restores `gs_prezero`, replacing routesArr — so a user who built
cables in zero and clicks "first light" loses their build with no warning. The brief's
intent (§2 graduation): *"leaving zero shows the full field with everything the user
built still wired."* Fix: on exit, if the current (zero-built) patch has ≥1 route, offer a
small three-way choice (same visual language as the door card, but compact):
- **keep my build** — don't restore prezero; just drop the overlay + apply the mood's
mutes/gains only (the build stays as the whole patch);
- **bring the old world back** — current behavior (restore prezero);
- **weave them together** — restore prezero THEN re-registerRoute the zero-built routes
on top (registerRoute dedups by source|dest).
If the zero build has 0 routes, skip the prompt entirely (current behavior is right).
## C2. Zero build must survive a reload
Routes persist via the matrix, but `zeroUI.nodes` (picked feeds / stage orbs) don't — a
returning zero-user (gs_mood="0 zero") reloads into an empty stage even though their
cables exist. Persist the picks to localStorage (`gs_zeronodes`: {group: [srcKeys]}) on
every pick/route; restore in `zeroUI.open()`. Also reconstruct socket lit-state (already
done via refreshSockets) — verify with a reload test.
## C3. Orb interactions beyond drag (touch + depth)
HTML5 drag doesn't exist on touch, and orbs currently have no menu:
- **Tap-to-patch fallback:** click/tap an orb → it "arms" (glow ring, cursor hint);
click a socket → routeGroup(armed, dest); click elsewhere → disarm. Keep drag working.
- **Right-click an orb** → small menu: member list with × to remove a feed, "unpatch
everything from this orb", and (stub for Z3) "💾 save as vibe". Removing the last
member removes the orb.
- **⛈ graduation affordance:** a small fixed "⛈ enter the storm" pill inside the zero UI
(bottom-right above the rack) → runs the C1 exit flow with mood "the full storm".
## C4 = Z3 (vibes), Z5 (OMNI panel), Z4 (dimensions), Z6 (ship) — per the brief above
Follow the brief sections 3, 4, 5, 6 as written, with these additions learned since:
- The orb right-click "save as vibe" (C3) is the natural zero-mode entry to Z3 — a vibe
is exactly one orb's members + the routes they carry.
- auth.py vibes store: use the existing `CREATE TABLE IF NOT EXISTS` idiom (auth.py:44-46
style) so the server migrates itself on restart — additive only. Test locally against a
COPY of godstrument_users.db, never the live file, and remember the deploy never ships
the db.
- Vibe import parses user-pasted data → validate hard (source/dest against known sets,
numeric clamps, 32-route cap, try/catch the base64/JSON). This is a trust boundary.
- **Before the Z6 deploy, run the `/ship-check` skill** (pre-deploy security sweep) —
this feature set touches auth.py and user-pasted input, which is exactly its territory.
- Nit (low): while in zero, the chakra-view key (F3/C) flips chakraMode with nothing
drawn — make it a no-op in zero (or have it exit zero first via the C1 flow).
Keep the cadence: extraction-check → build → verify in the logged-in preview (z0test /
zeropass123 account exists) → screenshot → commit per milestone. Deploy only at Z6 after
the adversarial review. The grimoire is truth — Z6 writes zero/vibes/dimensions/OMNI-panel
sections in the tech-manual style (numbered how-to + spec table) and regenerates the manual.

216
auth.py
View File

@ -112,9 +112,27 @@ def verify_pw(pw: str, salt: str, expected: str) -> bool:
# ---- stateless signed session token -----------------------------------------
# A token is bound to the account's *credential state* — a tag over its immutable
# `created` time + its current password `salt`. So a token dies the moment the
# account is deleted (no row), its password is reset (salt rotates), or its id is
# reused by a different signup (different created). Without this, a signed uid:exp
# token stays valid for its full 30 days regardless of any of the above.
def _bind(user_id: int, db: str = DEFAULT_DB) -> str | None:
con = _con(db)
try:
row = con.execute("SELECT created, salt FROM users WHERE id=?", (user_id,)).fetchone()
finally:
con.close()
if not row:
return None
return hmac.new(_secret(db), f"{user_id}:{row[0]}:{row[1]}".encode(),
hashlib.sha256).hexdigest()[:16]
def make_token(user_id: int, db: str = DEFAULT_DB, days: int = SESSION_DAYS) -> str:
exp = int(time.time()) + days * 86400
payload = f"{user_id}:{exp}"
bind = _bind(user_id, db) or ""
payload = f"{user_id}:{exp}:{bind}"
sig = hmac.new(_secret(db), payload.encode(), hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode()
@ -122,12 +140,15 @@ def make_token(user_id: int, db: str = DEFAULT_DB, days: int = SESSION_DAYS) ->
def read_token(token: str, db: str = DEFAULT_DB) -> int | None:
try:
raw = base64.urlsafe_b64decode(token.encode()).decode()
uid, exp, sig = raw.split(":")
good = hmac.new(_secret(db), f"{uid}:{exp}".encode(), hashlib.sha256).hexdigest()
uid, exp, bind, sig = raw.split(":") # 4 parts; old 3-part tokens fail here -> re-login
good = hmac.new(_secret(db), f"{uid}:{exp}:{bind}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, good):
return None
if int(exp) < time.time():
return None
cur = _bind(int(uid), db) # current credential state
if cur is None or not hmac.compare_digest(bind, cur):
return None # deleted / password-reset / id-reused
return int(uid)
except (ValueError, TypeError, AttributeError):
return None
@ -164,10 +185,19 @@ def signup(username: str, email: str, pw: str, invite: str,
if con.execute("SELECT 1 FROM users WHERE username=? COLLATE NOCASE", (username,)).fetchone():
return False, "username already taken", None
salt, ph = hash_pw(pw)
cur = con.execute("INSERT INTO users (username,email,pw_hash,salt,created) VALUES (?,?,?,?,?)",
(username, email, ph, salt, time.time()))
try:
cur = con.execute("INSERT INTO users (username,email,pw_hash,salt,created) VALUES (?,?,?,?,?)",
(username, email, ph, salt, time.time()))
except sqlite3.IntegrityError: # lost a same-instant race on the UNIQUE(email/username)
con.rollback()
return False, "email or username already taken", None
uid = cur.lastrowid
con.execute("UPDATE invites SET used_by=? WHERE code=?", (uid, invite))
# atomic single-use claim: only the first concurrent signup flips a NULL used_by
claimed = con.execute("UPDATE invites SET used_by=? WHERE code=? AND used_by IS NULL",
(uid, invite)).rowcount
if not claimed: # another signup claimed this invite first
con.rollback()
return False, "invite code already used", None
con.commit()
return True, "ok", uid
finally:
@ -226,6 +256,93 @@ def submit_feedback(user_id: int, username: str, text: str, db: str = DEFAULT_DB
return True
def admin_list_users(db: str = DEFAULT_DB) -> list[dict]:
"""Every account with its id, admin flag, preset count and join date — for the
admin user-management panel. Read-only."""
con = _con(db)
try:
rows = con.execute(
"SELECT u.id, u.username, u.email, u.created, u.is_admin, "
"(SELECT COUNT(*) FROM presets p WHERE p.user_id = u.id) "
"FROM users u ORDER BY u.created DESC").fetchall()
finally:
con.close()
return [{"id": i, "username": un, "email": em,
"joined": time.strftime("%Y-%m-%d", time.localtime(c)),
"admin": bool(a or (ADMIN_EMAIL and em == ADMIN_EMAIL)),
"env_admin": bool(ADMIN_EMAIL and em == ADMIN_EMAIL), # admin via env — can't be toggled off in the db
"presets": pc}
for i, un, em, c, a, pc in rows]
def admin_update_user(user_id: int, db: str = DEFAULT_DB, username: str | None = None,
email: str | None = None, is_admin=None) -> tuple[bool, str]:
"""Edit an account (rename / change email / grant-or-revoke admin). Validates
format + uniqueness the same as signup. Only the fields passed are touched."""
con = _con(db)
try:
if not con.execute("SELECT 1 FROM users WHERE id=?", (user_id,)).fetchone():
return False, "no such user"
sets, vals = [], []
if username is not None:
username = username.strip()
if not USERNAME_RE.match(username):
return False, "username must be 2-24 letters, numbers, . _ or -"
if con.execute("SELECT 1 FROM users WHERE username=? COLLATE NOCASE AND id!=?",
(username, user_id)).fetchone():
return False, "username already taken"
sets.append("username=?"); vals.append(username)
if email is not None:
email = email.strip().lower()
if not EMAIL_RE.match(email):
return False, "invalid email"
if con.execute("SELECT 1 FROM users WHERE email=? AND id!=?",
(email, user_id)).fetchone():
return False, "email already registered"
sets.append("email=?"); vals.append(email)
if is_admin is not None:
sets.append("is_admin=?"); vals.append(1 if is_admin else 0)
if not sets:
return False, "nothing to change"
vals.append(user_id)
try:
con.execute("UPDATE users SET " + ", ".join(sets) + " WHERE id=?", vals)
con.commit()
except sqlite3.IntegrityError: # lost a race on UNIQUE(username/email)
con.rollback()
return False, "username or email already taken"
return True, "ok"
finally:
con.close()
def admin_reset_password(user_id: int, db: str = DEFAULT_DB) -> tuple[bool, str | None]:
"""Set a fresh random temporary password and return it ONCE (admin relays it;
it is never stored in plaintext or logged). For helping a locked-out friend."""
temp = secrets.token_urlsafe(9)
salt, ph = hash_pw(temp)
con = _con(db)
try:
cur = con.execute("UPDATE users SET pw_hash=?, salt=? WHERE id=?", (ph, salt, user_id))
con.commit()
return (True, temp) if cur.rowcount > 0 else (False, None)
finally:
con.close()
def admin_delete_user(user_id: int, db: str = DEFAULT_DB) -> bool:
"""Delete an account and its saved presets. Feedback rows are kept (they carry
the username as text) and the used invite stays used history is preserved."""
con = _con(db)
try:
con.execute("DELETE FROM presets WHERE user_id=?", (user_id,))
cur = con.execute("DELETE FROM users WHERE id=?", (user_id,))
con.commit()
return cur.rowcount > 0
finally:
con.close()
def admin_stats(db: str = DEFAULT_DB) -> dict:
con = _con(db)
try:
@ -234,16 +351,13 @@ def admin_stats(db: str = DEFAULT_DB) -> dict:
"SELECT code FROM invites WHERE used_by IS NULL ORDER BY created DESC")]
unused = len(open_codes)
presets = con.execute("SELECT COUNT(*) FROM presets").fetchone()[0]
accounts = [{"username": u, "email": e,
"joined": time.strftime("%Y-%m-%d", time.localtime(c))}
for u, e, c in con.execute("SELECT username,email,created FROM users ORDER BY created DESC")]
fb = [{"username": u, "text": t,
"when": time.strftime("%Y-%m-%d %H:%M", time.localtime(c))}
for u, t, c in con.execute("SELECT username,text,created FROM feedback ORDER BY created DESC LIMIT 100")]
finally:
con.close()
return {"users": users, "unused_codes": unused, "open_codes": open_codes,
"presets": presets, "accounts": accounts, "feedback": fb}
"presets": presets, "accounts": admin_list_users(db), "feedback": fb}
# ---- presets (a full personal patch, stored as one JSON blob) ---------------
@ -352,8 +466,35 @@ def handle_api(method: str, path: str, body: bytes, cookie_header: str,
return 403, {"error": "admins only"}, None
if path == "/api/admin/stats" and method == "GET":
return 200, admin_stats(db), None
if path == "/api/admin/users" and method == "GET":
return 200, {"users": admin_list_users(db)}, None
if path == "/api/admin/invite" and method == "POST":
return 200, {"code": mint_invite(db)}, None
if path.startswith("/api/admin/user/"):
parts = path[len("/api/admin/user/"):].split("/")
try:
target = int(parts[0])
except (ValueError, IndexError):
return 400, {"error": "bad user id"}, None
action = parts[1] if len(parts) > 1 else ""
if action == "reset" and method == "POST":
ok, temp = admin_reset_password(target, db)
return (200, {"ok": True, "password": temp}, None) if ok else (404, {"error": "no such user"}, None)
if not action and method == "POST":
# only a real JSON boolean toggles admin — a stray 0/""/"false" must not
# silently demote (the self-guard) or promote (truthy string) via coercion
flag = payload.get("admin")
if not isinstance(flag, bool):
flag = None
if target == uid and flag is False:
return 400, {"error": "you can't remove your own admin"}, None
ok, msg = admin_update_user(target, db, payload.get("username"),
payload.get("email"), flag)
return (200, {"ok": True}, None) if ok else (400, {"error": msg}, None)
if not action and method == "DELETE":
if target == uid:
return 400, {"error": "you can't delete your own admin account"}, None
return (200, {"ok": True}, None) if admin_delete_user(target, db) else (404, {"error": "no such user"}, None)
return 404, {"error": "not found"}, None
if path == "/api/presets" and method == "GET":
@ -431,6 +572,61 @@ def _selftest():
assert st == 200 and stats["users"] >= 1, "admin can read stats"
assert any("one bug tho" in f["text"] for f in stats["feedback"]), "feedback shows in stats"
assert handle_api("POST", "/api/admin/invite", b"", cookie, db)[1].get("code"), "admin can mint codes"
# --- admin user management: list / edit / reset / delete --------------------
ulist = admin_list_users(db)
assert any(u["username"] == "Nova" and u["admin"] for u in ulist), "list shows the admin"
assert all("id" in u and "presets" in u for u in ulist), "list carries id + preset count"
okv, _, vid = signup("Victim", "victim@x.co", "temppass123", mint_invite(db), db)
assert okv and vid
assert admin_update_user(vid, db, username="Renamed")[0], "rename works"
assert admin_update_user(vid, db, email="new@x.co")[0], "email change works"
assert admin_update_user(vid, db, username="no spaces!")[0] is False, "bad username rejected"
assert admin_update_user(vid, db, username="Nova")[0] is False, "duplicate username rejected"
assert admin_update_user(vid, db, is_admin=True)[0] and user_info(vid, db)["admin"], "grant admin"
assert admin_update_user(vid, db, is_admin=False)[0] and not user_info(vid, db)["admin"], "revoke admin"
okr, temp = admin_reset_password(vid, db)
assert okr and temp and login("Renamed", temp, db) == vid, "reset password lets them log in"
assert login("Renamed", "temppass123", db) is None, "old password no longer works"
# endpoints — admin-gated + self-guards
assert handle_api("GET", "/api/admin/users", b"", cookie, db)[0] == 200, "admin lists users"
assert handle_api("GET", "/api/admin/users", b"", "", db)[0] == 401, "no session blocked"
assert handle_api("POST", f"/api/admin/user/{vid}/reset", b"", cookie, db)[1].get("password"), "reset endpoint returns a temp"
assert handle_api("POST", f"/api/admin/user/{uid}", json.dumps({"admin": False}).encode(), cookie, db)[0] == 400, "can't de-admin self"
handle_api("POST", f"/api/admin/user/{uid}", json.dumps({"admin": 0}).encode(), cookie, db)
assert user_info(uid, db)["admin"], "self stays admin — a crafted falsy {admin:0} can't bypass the self-guard"
handle_api("POST", f"/api/admin/user/{vid}", json.dumps({"admin": "yes"}).encode(), cookie, db)
assert not user_info(vid, db)["admin"], "a non-boolean truthy admin flag does not grant admin"
assert handle_api("DELETE", f"/api/admin/user/{uid}", b"", cookie, db)[0] == 400, "can't delete self"
assert handle_api("POST", f"/api/admin/user/{vid}", json.dumps({"username": "Vic2"}).encode(), cookie, db)[0] == 200, "edit endpoint works"
# a non-admin can't reach any of it
_, _, ck2 = handle_api("POST", "/api/login", json.dumps({"identifier": "Mika", "password": "orbit12345"}).encode(), "", db)
mcookie = ck2.split(";")[0]
assert handle_api("GET", "/api/admin/users", b"", mcookie, db)[0] == 403, "non-admin blocked from users"
assert handle_api("DELETE", f"/api/admin/user/{vid}", b"", mcookie, db)[0] == 403, "non-admin blocked from delete"
# delete removes the user + its presets, leaves feedback/history
assert save_preset(vid, "gone", {"x": 1}, db) and list_presets(vid, db)
assert admin_delete_user(vid, db) and user_info(vid, db) is None, "delete removes the user"
assert not list_presets(vid, db), "delete cascades presets"
# --- session tokens die on delete / password-reset / id-reuse (credential binding) ---
_, _, sa = signup("Sessa", "sessa@x.co", "sessapass1", mint_invite(db), db)
stok = make_token(sa, db)
assert read_token(stok, db) == sa, "a fresh token authenticates"
admin_reset_password(sa, db)
assert read_token(stok, db) is None, "reset-password invalidates the old session (admin can lock out)"
stok2 = make_token(sa, db)
assert read_token(stok2, db) == sa, "a token minted after reset works"
admin_delete_user(sa, db)
assert read_token(stok2, db) is None, "delete invalidates the session — no ghost cookie, no ghost writes"
# id reuse (fresh db → deterministic): a deleted user's token must not become the reused occupant
import tempfile as _tf
rdb = os.path.join(_tf.mkdtemp(), "r.db"); init_db(rdb)
_, _, x = signup("Xavier", "x@x.co", "xavierpass1", mint_invite(rdb), rdb)
xtok = make_token(x, rdb)
admin_delete_user(x, rdb)
_, _, y = signup("Yara", "y@x.co", "yarapass111", mint_invite(rdb), rdb)
assert y == x, "fresh db: sqlite reuses the freed id"
assert read_token(xtok, rdb) is None, "a reused id must reject the deleted user's token (no identity takeover)"
assert read_token(make_token(y, rdb), rdb) == y, "the reused id's real owner still logs in fine"
print("auth self-test: all checks passed.")

105
build_manual.py Normal file
View File

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Generate viz/manual.html — the polished standalone user manual — from the
in-app grimoire in viz/index.html (the single source of truth per CLAUDE.md).
Re-run whenever the grimoire changes: python3 build_manual.py
ponytail: string-slice extraction, no HTML parser dep. The grimoire is a flat,
well-formed block; if its shape ever changes, this fails loudly (asserts) rather
than emitting a broken page.
"""
import re
import sys
import pathlib
HERE = pathlib.Path(__file__).parent
SRC = HERE / "viz" / "index.html"
OUT = HERE / "viz" / "manual.html"
html = SRC.read_text(encoding="utf-8")
# --- 1. the whole <style> block (includes :root vars + all #grimoire rules) ---
m = re.search(r"<style>(.*?)</style>", html, re.S)
assert m, "no <style> block found"
css = m.group(1)
# --- 2. the #grimoire block, balance-matched on <div>/</div> ------------------
start = html.find('<div id="grimoire">')
assert start != -1, "no #grimoire div found"
i, depth = start, 0
for tok in re.finditer(r"<div\b|</div>", html[start:]):
depth += 1 if tok.group() == "<div" else -1
if depth == 0:
end = start + tok.end()
break
else:
sys.exit("unbalanced #grimoire div")
grim = html[start:end]
# strip the in-app chrome: the × close button (the TOC lives outside this div)
grim = grim.replace('<span class="x">×</span>', "")
# --- 3. a generated table of contents from the <h2> headings -----------------
def slug(t):
return re.sub(r"[^a-z0-9]+", "-", re.sub(r"<[^>]+>", "", t).lower()).strip("-")
heads = re.findall(r"<h2>(.*?)</h2>", grim, re.S)
seen, toc = set(), []
for h in heads:
s = slug(h) or "section"
while s in seen:
s += "-x"
seen.add(s)
grim = grim.replace(f"<h2>{h}</h2>", f'<h2 id="{s}">{h}</h2>', 1)
toc.append(f'<a href="#{s}">{re.sub(r"<[^>]+>", "", h)}</a>')
assert toc, "no <h2> sections to build a TOC from"
toc_html = '<nav class="manual-toc"><div class="toc-h">Contents</div>' + "".join(toc) + "</nav>"
# --- 4. wrap into a standalone, always-visible page --------------------------
page = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Godstrument User Manual</title>
<meta name="description" content="How to play Godstrument — the instrument played by the living world. Every feature, from the world's feeds to the OMNI synth, zero mode, vibes and MIDI/CV out.">
<style>{css}</style>
<style>
/* standalone-manual overrides render the grimoire as a normal scrolling page */
html, body {{ margin: 0; }}
body {{ background: radial-gradient(ellipse at 50% 0%, #0c1533 0%, #070c1c 60%, #03050e 100%);
min-height: 100vh; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; }}
#grimoire {{ position: static !important; display: block !important; inset: auto !important;
overflow: visible !important; background: none !important; z-index: auto !important; }}
#grimoire .x {{ display: none !important; }}
.manual-bar {{ position: sticky; top: 0; z-index: 5; display: flex; align-items: center; justify-content: space-between;
padding: 11px 22px; background: rgba(6,10,20,0.72); -webkit-backdrop-filter: blur(9px); backdrop-filter: blur(9px);
border-bottom: 1px solid rgba(120,150,200,0.16); }}
.manual-bar .brand {{ color: #d6e2f4; font-weight: 800; letter-spacing: 2px; font-size: 14px; }}
.manual-bar a {{ color: #9fd0ff; text-decoration: none; font-size: 13px; letter-spacing: 0.4px; }}
.manual-bar a:hover {{ color: #fff; }}
.manual-toc {{ max-width: 720px; margin: 26px auto 8px; padding: 16px 22px; border-radius: 14px;
background: rgba(20,28,46,0.5); border: 1px solid rgba(120,150,220,0.2);
display: grid; grid-template-columns: 1fr 1fr; gap: 4px 22px; }}
.manual-toc .toc-h {{ grid-column: 1 / -1; font-weight: 800; letter-spacing: 1px; color: #d3e2ff; font-size: 13px;
text-transform: uppercase; margin-bottom: 4px; }}
.manual-toc a {{ color: rgba(180,200,235,0.85); text-decoration: none; font-size: 12.5px; line-height: 1.9;
border-bottom: 1px solid transparent; }}
.manual-toc a:hover {{ color: #eaf1ff; border-bottom-color: rgba(160,185,225,0.4); }}
#grimoire h2 {{ scroll-margin-top: 60px; }}
@media (max-width: 560px) {{ .manual-toc {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="manual-bar"><span class="brand"> GODSTRUMENT the manual</span><a href="/"> play the instrument </a></div>
{grim.replace('<div class="wrap">', '<div class="wrap">' + toc_html, 1)}
</body>
</html>
"""
OUT.write_text(page, encoding="utf-8")
print(f"wrote {OUT} ({len(page):,} bytes, {len(toc)} sections)")
# self-check: the page must be self-contained and carry the real feature sections
assert "OMNI" in page and "Building from zero" in page and "dimensions" in page.lower(), "manual missing key feature sections"
assert "id=\"grimoire\"" in page and page.count("<h2") >= 8, "manual structure looks wrong"
print("ok: self-contained, has OMNI / zero / dimensions sections")

20
hub.py
View File

@ -115,7 +115,7 @@ class Hub:
# (the ws control channel has no auth — safe to expose publicly this way).
# Set via config "readonly": true or env GODSTRUMENT_READONLY=1.
self.readonly = bool(cfg.get("readonly")) or \
os.environ.get("GODSTRUMENT_READONLY", "") not in ("", "0")
os.environ.get("GODSTRUMENT_READONLY", "").strip().lower() in ("1", "true", "yes", "on")
self.signals: dict[str, Signal] = {}
self._raw_lock = threading.Lock()
@ -610,6 +610,24 @@ def _serve_http(directory: str, port: int):
path = self.path.split("?", 1)[0]
if auth is None:
return self.send_error(503, "accounts unavailable")
# CSRF: a browser always sends Origin on a cross-site state-changing request.
# Block those (incl. login-CSRF); a same-origin request or a non-browser
# client (no Origin) passes. Allowlist the canonical host + localhost so a
# legitimate request always passes even if the tunnel rewrites Host.
if method in ("POST", "PUT", "DELETE"):
origin = self.headers.get("Origin", "")
if origin:
from urllib.parse import urlparse
o = urlparse(origin)
ok = (o.hostname in ("godstrument.pro", "localhost", "127.0.0.1")
or o.netloc == self.headers.get("Host", ""))
if not ok:
blob = json.dumps({"error": "cross-site request blocked"}).encode()
self.send_response(403)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(blob)))
self.end_headers(); self.wfile.write(blob)
return
length = int(self.headers.get("Content-Length", 0) or 0)
if length > 512 * 1024:
return self.send_error(413, "too large")

View File

@ -37,7 +37,7 @@ class Tweak:
if spec:
for k, v in spec.items():
if k in self.p:
self.p[k] = float(v) if not isinstance(v, bool) else float(v)
self.p[k] = float(v)
self._held: dict[str, float] = {} # freeze holds, keyed by signal name
self._lag: dict[str, float] = {} # smoothing state, keyed by signal name
self.orbit_lfo = 1.0 # external planetary-orbit gain (hub-set)

File diff suppressed because it is too large Load Diff

1191
viz/manual.html Normal file

File diff suppressed because one or more lines are too long