Compare commits
21 Commits
b884cf9642
...
9ab1b0f5ae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab1b0f5ae | ||
|
|
47b51b98ca | ||
|
|
764e28b1c3 | ||
|
|
297ca8457f | ||
|
|
fb32d10991 | ||
|
|
fa948823a7 | ||
|
|
fc1bb307bc | ||
|
|
93c5e20ccf | ||
|
|
5e6e257ec0 | ||
|
|
3654b2f6ec | ||
|
|
55defaa695 | ||
|
|
00503b14c3 | ||
|
|
b6f1c59e15 | ||
|
|
9a7f94759c | ||
|
|
4e44533cdd | ||
|
|
d30cea469c | ||
|
|
2a705e3a35 | ||
|
|
35c094f8d8 | ||
|
|
5db568d9da | ||
|
|
78718e9459 | ||
|
|
5ffa3a4bfb |
@ -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.
|
||||
|
||||
@ -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^((midi−60)/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
292
ZERO_OMNI_BRIEF.md
Normal 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)
|
||||
|
||||
**Z0–Z2 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
216
auth.py
@ -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
105
build_manual.py
Normal 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
20
hub.py
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
1317
viz/index.html
1317
viz/index.html
File diff suppressed because it is too large
Load Diff
1191
viz/manual.html
Normal file
1191
viz/manual.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user