⚭ convergence — the pantheon meets the crossover (fable)
Merge pantheon/integrate (GODSPEED ⚡ · GODSQUASH 🫀 · GODSPEAK 🗣 · UNGODLY ⏪ + the ε closer, reviewed & committed from the m3ultra rig) into main (keyroll · sats · planes cache · god's eye · 18 review fixes). Two conflict hunks, both in the beat panel where behavior fixes met undo instrumentation — resolved keeping both: the bpm field stays always-editable AND marks undo; master ▶/■ keeps per-lane mute memory AND marks undo. Manual regenerated from the merged grimoire. Verified: zero console errors, groove self-check, beat/G panels, godspeak chip + keyroll CSS all present, python gates green, the godspeak worker skips gracefully where MLX is absent (and the supervisor now retires clean exits instead of churning them). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
19f17705e6
189
PANTHEON_BRIEF.md
Normal file
189
PANTHEON_BRIEF.md
Normal file
@ -0,0 +1,189 @@
|
||||
# THE PANTHEON — build brief · GODSPEED · GODSQUASH · GODSPEAK · UNGODLY
|
||||
|
||||
> **You are an execution agent (Opus).** You have been assigned one lane of a four-feature build. Read **Part 0**, then execute ONLY your lane. Your output is reviewed by the planning model (Fable) before merge; end your run with the §0.8 report. This brief follows the GODRUM process (see `GODRUM_BRIEF.md` for the full history — GODRUM shipped through it in four staged, reviewed lanes).
|
||||
|
||||
---
|
||||
|
||||
## Part 0 — shared context (every agent reads this)
|
||||
|
||||
### 0.1 The four gods
|
||||
|
||||
| name | what | where it lives |
|
||||
|---|---|---|
|
||||
| **⚡ GODSPEED** | the arpeggiator — hold a chord, the machine runs with it, on godtime with the groove's swing | client, app scope |
|
||||
| **🫀 GODSQUASH** | the master-bus compressor — one world-modulatable knob that makes the whole planet pump | client, Synth engine |
|
||||
| **🗣 GODSPEAK** | the voice of god — the user's own wersperr (local MLX Whisper) speaks commands into the sky | Python worker + hub + client |
|
||||
| **⏪ UNGODLY** | undo — *un-doing what god did*. ⌘Z time-travels the patch | client, app scope |
|
||||
|
||||
### 0.2 Ground truth
|
||||
|
||||
- Repo `/Users/m3ultra/Documents/godstrument`, branch base = **`main`** (post-GODRUM, ≥ `0fb6410`). The app is one file, `viz/index.html` (~9,100 lines): grimoire prose up top, one giant IIFE, the `Synth` IIFE inside it near the bottom. Vanilla JS + raw Web Audio, no libraries. Python side: `hub.py` (OSC 9000 in → normalize → WebSocket out, port from `config.json`), `run.py` (starts hub + workers; note the `--sports` opt-in-worker precedent), `workers/*.py` (house pattern: argparse `--host/--port`, **lazy imports with graceful "skipping (optional)" exit**, `SimpleUDPClient` to OSC 9000).
|
||||
- **Line numbers drift — grep for names.** Verified anchors as of `main@0fb6410`: `OMNI.MODULES`/`LABELS` ~3947–3956 · `handleMessage` ~4357 · `mindStrike` ~5019 (resolves `mindKeys.notes[i]`, fires `Synth.pluck(note, vel)` + `midiPluck(2, note, vel, 320)` — the mind console's own MIDI channel) · `seqTick` ~2922 (16th-note tick, **swing already applied at the clock**, see the GODRUM `fireDrum` precedent inside it) · main keydown handler ~6890–6907 (taken: `p c o w z t b` + digits `1–5`; **`g` is free**; NOTE the single-letter handlers currently have **no meta/ctrl guards** — ⌘Z toggles zen today, a pre-existing quirk UNGODLY fixes) · `serializePatch`/`loadPatch` ~3160s · ctx menu `ctxItem(label, fn, kbd)` entries ~7100s · `Synth` IIFE opens ~8400s (`const Synth = (function () {`).
|
||||
- **GODRUM conventions carry over verbatim**: worktree per agent, emoji commit style, surgical edits only, match the house voice in comments, params normalized 0..1, temporary verify hooks allowed but removed + grep-verified before commit, static-serve `viz/` to verify (`python3 -m http.server 89xx --directory viz`, dismiss `#landing` client-side), §0.7-style regression every time (**zero console errors; `♪ T O B G` and panels all work**), never deploy, never commit db/secrets/`patches/`, never touch `main`.
|
||||
- **Hard territorial rule from GODRUM's lesson:** if your lane and a parallel lane could both insert at the same seam, they WILL conflict — your brief names your regions; stay inside them, and if you must cross, STOP and flag it in your report instead.
|
||||
|
||||
### 0.3 Git
|
||||
|
||||
```sh
|
||||
cd /Users/m3ultra/Documents/godstrument
|
||||
git worktree add ../godstrument-<LANE> -b pantheon/<branch> main
|
||||
```
|
||||
Branches: `pantheon/a-godspeed` · `pantheon/b-godsquash` · `pantheon/c-godspeak-worker` · `pantheon/d-ungodly` · `pantheon/e-closer`.
|
||||
|
||||
### 0.8 Report format (same as GODRUM)
|
||||
|
||||
1. Branch + worktree + commit(s). 2. What was built + decisions where the brief left room. 3. `git diff main --stat` + the full diff. 4. How you verified (deterministic tests + measurements + regression results). 5. Deviations & open questions. 6. Territory confirmation (hooks removed, grep-clean, nothing outside your regions).
|
||||
|
||||
### Stage map
|
||||
|
||||
| stage | lanes | parallel-safe because | status |
|
||||
|---|---|---|---|
|
||||
| 1 | **α GODSPEED ∥ β GODSQUASH** | α is app-scope-only; β is Synth-IIFE + three named one-line touches α never makes | ✅ APPROVED — α @ 0e17dbf, β @ ea13b2e; trial-merge clean, zero conflicts. β's quadratic makeup `1+sq²` is a reviewer-endorsed deviation (measured: reduction ≈ 5.95·sq² dB); the linear `1+sq·1.4` in B.2 below is superseded — do not re-implement it |
|
||||
| 2 | **γ GODSPEAK-worker ∥ δ UNGODLY** | γ is Python-only (zero index.html); δ is index.html-only (zero Python) | ✅ APPROVED — γ @ e56e3bc, δ @ d2c8ad6; merged to main @ b8b1188. Reviewer live-verified undo/redo + ⌘Z-no-longer-zen + worker graceful-skip |
|
||||
| 3 (current) | **ε the closer** | GODSPEAK client grammar + grimoire chapter for all four + manual + cross-feature QA + deploy checklist | full brief below |
|
||||
|
||||
Reviews between every stage. Do not start a later stage's work.
|
||||
|
||||
---
|
||||
|
||||
# Stage 1α — GODSPEED ⚡ (the arpeggiator)
|
||||
|
||||
**Territory:** viz/index.html app scope, OUTSIDE the Synth IIFE. You may touch: `mindStrike`, `seqTick` (additive hook, GODRUM's `fireDrum` style), the main keydown handler (the `G` key), one `ctxItem`, your own panel block + CSS block (place the panel function after `closeAmpler`/the AMPLER block; CSS after `#ampler`'s block). You may NOT touch: the Synth IIFE, `OMNI.MODULES`/`LABELS`, anything `squash` (that's β), exportMid, serializePatch.
|
||||
|
||||
**A.1 State & capture.**
|
||||
```js
|
||||
const arp = { on:false, held:[], rate:1, dir:"up", oct:1, gate:0.8, idx:0, up:true };
|
||||
// rate: 4=1/4 · 2=1/8 · 1=1/16 · 0.5=1/32 dir: up|down|updown|random oct: 1..3 held: [{note, vel}] cap 8
|
||||
```
|
||||
Capture point is `mindStrike(i, vel)`: when `arp.on`, a strike **toggles** `{note: mindKeys.notes[i], vel}` in `arp.held` (latch — strike again to remove; keep the `mindKeys.flash[i] = 1` feedback) and does **not** pluck immediately. When `arp.on` is false, `mindStrike` is byte-identical to today. Since MIDI-in already routes note-ons through `mindStrike`, hardware gets latch capture for free — note that in your report; true hold-mode from MIDI note-offs is a flagged future upgrade, not v1.
|
||||
|
||||
**A.2 The clock.** Ride `seqTick()` — one additive call at its tail (like `beatRefresh`): `arpTick()`. Inside: bail unless `arp.on && arp.held.length`; a 16th-counter fires when `counter % arp.rate === 0` for rates ≥ 1; for 1/32, fire twice (immediate + `setTimeout(sub/2)`, `sub = 60000/bpm/4`, the ratchet idiom). Because seqTick fires at the swung onset, **GODSPEED inherits the groove** — that's the whole soul of the feature; verify it.
|
||||
|
||||
**A.3 Note selection.** Pool = `held` sorted ascending, expanded across `oct` octaves (`note + 12*o`). `up`/`down` cycle; `updown` ping-pongs without repeating the turnaround note; `random` picks uniformly. Fire via `Synth.pluck(note, vel)` + `midiPluck(2, note, vel, durMs)` where `durMs = sub * arp.rate * arp.gate` (clamped ≥ 30) — channel 2, the mind console's channel, exactly like `mindStrike`.
|
||||
|
||||
**A.4 UI.** `G` key + `ctxItem("⚡ GODSPEED — hold a chord, the machine runs", …)` toggle a compact `#godspeed` panel (house style, model on the beat header row): **on/off** (`.midibtn.on`) · rate select (1/4 1/8 1/16 1/32) · dir select · oct (1–3) · gate slider · a held-notes readout (note names — there's a `midiToName` in scope; grep it) · **clear** button. Arp toggled off → `held` cleared, `idx` reset. Add the `G` handler beside `B` (~6906); verify `g` is truly unbound first (grep, incl. the capture-phase keymap handler — digits precedent from GODRUM lane E).
|
||||
|
||||
**A.5 Verify.** Deterministic: drive `seqTick` (hook the internals temporarily), spy `Synth.pluck` — assert the exact note order for up/down/updown/random over 2 bars at each rate; 1/32 double-fires; gate scales `durMs`; swing: with "mpc 8-4" the off-16th fires late (assert via the seqTick call time, or by construction + note it). Live: open panel via `G` and the sky menu, latch three mind-mode notes, ▶ nothing needed — the arp runs on the clock; confirm audible + zero console errors + §0.7 regression + **GODRUM untouched** (beat panel plays, keys 1–5 still finger-drum — they must NOT feed the arp).
|
||||
|
||||
---
|
||||
|
||||
# Stage 1β — GODSQUASH 🫀 (the master squeeze)
|
||||
|
||||
**Territory:** inside the Synth IIFE, PLUS exactly three named external touches: (1) the `OMNI.MODULES` "fx rack" `keys` array — append `"squash"`; (2) `OMNI.LABELS` — add `"squash": "squash"`; (3) wherever `crush` is structurally registered as a destination in viz/index.html (grep `"crush"` and mirror EVERY structural appearance — dest declaration/registry, matrix nodes if any — so `squash` is routable by the world exactly like `crush`; `crush` is NOT in config.json, so config.json stays untouched unless your grep proves otherwise). Nothing else outside the IIFE.
|
||||
|
||||
**B.1 The chain.** In `build()`, where the master currently does `out.connect(lim)`: insert `out → comp → makeup → lim` (`comp = ctx.createDynamicsCompressor()`, `makeup = ctx.createGain()` at 1). Return `comp` and `makeup` on `N` (as `squash`, `squashMakeup`). Do NOT move the GODSONIQ tap (`N.out.connect(recNode)` happens later in `loadWorklets` and correctly stays pre-squash — captures are pre-limiter by design; say so in a comment). Set static: `knee 12`, `attack 0.004`.
|
||||
|
||||
**B.2 The one knob.** In `params()`:
|
||||
```js
|
||||
const sq = c01(d("squash", 0)); // 0 = transparent (no route, no squeeze)
|
||||
S(N.squash.threshold, -6 - sq * 30, 0.1); // −6 → −36 dB
|
||||
S(N.squash.ratio, 1 + sq * 7, 0.1); // 1:1 → 8:1
|
||||
N.squash.release.setTargetAtTime(0.12 + sq * 0.13, t, 0.2);
|
||||
S(N.squashMakeup.gain, 1 + sq * 1.4, 0.1); // gain-compensate the squeeze — tune this by measurement
|
||||
```
|
||||
Tune the makeup curve so perceived loudness stays roughly level while crest factor drops — measure, don't guess.
|
||||
|
||||
**B.3 Verify.** Offline renders (the house method): a busy program (pad + drums via `Synth.drum` if merged base has them — it does) at `squash` 0 / 0.5 / 1.0 → show **crest factor (peak/RMS) falls monotonically**, RMS roughly held by makeup, no clipping into the limiter (peak ≤ pre-squash peak + ε). **Null-ish test at 0:** ratio 1 → output within ~0.5 dB of a bypass render (DynamicsCompressor has fixed lookahead latency — compare envelopes, not sample-aligned nulls, and note the latency in your report). Live: OMNI shows the new `squash` knob in the fx rack; dragging it audibly pumps; a world route onto `squash` modulates it. §0.7 regression + GODRUM regression (drums still balanced — squash defaults to 0).
|
||||
|
||||
---
|
||||
|
||||
# Stage 2γ — GODSPEAK worker 🗣 (Python only — ZERO viz/index.html)
|
||||
|
||||
**Territory:** new file `workers/godspeak_worker.py`, a small addition to `hub.py`'s `_osc_handler` (+ broadcast), an opt-in flag in `run.py`. Nothing else. **This feature is local-rig only**: MLX Whisper is Apple-Silicon-only, so it runs where the user's Mac runs the hub — the VPS simply never has it (like the ToF/vision workers). Say this in the file's docstring.
|
||||
|
||||
**C.1 The worker.** Adapt the user's wersperr (`ssh://git@100.71.119.27:222/monster/wersperr.git`, 99 lines — clone it read-only and study it; model: `mlx-community/whisper-large-v3-turbo`, 16 kHz mono, hold **Right ⌥** via pynput, transcribe on release, ignore < ⅓ s). Changes from wersperr: instead of pbcopy/paste, send OSC to the hub — `/godspeak/rec 1` on key-down, `/godspeak/rec 0` on release, `/godspeak <text>` after transcription. House worker pattern throughout: argparse `--host 127.0.0.1 --port 9000`, **lazy imports** (`mlx_whisper`, `sounddevice`, `pynput`, `numpy`) with the graceful `"godspeak worker: … not available — skipping (optional)"` exit, warm-up transcribe of silence on boot, `--check` self-test. Deps do NOT go in requirements.txt (VPS must stay clean) — document `pip install mlx-whisper sounddevice pynput` (into the venv, Mac only) in the docstring.
|
||||
|
||||
**C.2 The hub.** In `_osc_handler`, special-case the `/godspeak` addresses (they're text/state, not norm signals — mirror how non-signal messages like the patch list reach clients): broadcast `{"godspeak": {"text": <str>}}` and `{"godspeak": {"rec": 0|1}}` to all ws clients immediately. Respect the hub's **read-only mode** (~line 114): in read-only, drop godspeak entirely (it's a command channel).
|
||||
|
||||
**C.3 run.py.** `--godspeak` opt-in flag exactly like the `--sports` precedent (~line 132): starts the worker, prints a one-liner. Never in the default set.
|
||||
|
||||
**C.4 Verify.** `--check` passes on this Mac (model loads, silence transcribes). With hub running locally: hold Right ⌥, say something, show the hub log/ws broadcast carrying the text (a 5-line ws test client is fine). Graceful-skip path: run with the imports missing (e.g. `python3 -m venv` bare env) → clean skip, exit 0. Confirm `git diff main --stat` touches ONLY the three Python files.
|
||||
|
||||
---
|
||||
|
||||
# Stage 2δ — UNGODLY ⏪ (undo — viz/index.html only, ZERO Python)
|
||||
|
||||
**Territory:** viz/index.html app scope, outside the Synth IIFE. Touches: a new snapshot module (place after `loadPatch`), `ungodlyMark(label)` calls at the mutation sites listed below, the keydown handler (⌘Z/⌘⇧Z **plus meta-guards on the existing single-letter keys** — today ⌘Z toggles zen; that quirk dies in this lane), one `ctxItem`. Do NOT touch: the Synth IIFE, GODSPEED's/AMPLER's blocks, exportMid.
|
||||
|
||||
**D.1 The engine.** Undo = time-travel over `serializePatch()` — `loadPatch` already restores everything (routes, tweaks, seqs incl. drums, arr, bpm, groove, scale, kit) and rebuilds open panels. So:
|
||||
```js
|
||||
const ungodly = { past: [], future: [], muted: false, MAX: 32, lastT: 0, lastLabel: "" };
|
||||
function ungodlyMark(label) { // call BEFORE a mutation applies — the snapshot is the world as it was
|
||||
if (ungodly.muted) return;
|
||||
const s = JSON.stringify(serializePatch());
|
||||
if (ungodly.past.length && ungodly.past[ungodly.past.length - 1].s === s) return; // no empty history
|
||||
ungodly.past.push({ s, label }); if (ungodly.past.length > ungodly.MAX) ungodly.past.shift();
|
||||
ungodly.future.length = 0;
|
||||
}
|
||||
function ungodlyGesture(label) { // continuous drags: one mark per gesture burst (same label within 800ms = same gesture)
|
||||
const tNow = performance.now();
|
||||
if (label === ungodly.lastLabel && tNow - ungodly.lastT < 800) { ungodly.lastT = tNow; return; }
|
||||
ungodly.lastLabel = label; ungodly.lastT = tNow; ungodlyMark(label);
|
||||
}
|
||||
```
|
||||
Undo pops `past` → pushes the CURRENT state onto `future` → `muted = true; loadPatch(JSON.parse(p.s)); muted = false` → ticker `"⏪ ungodly: " + p.label`. Redo mirrors. Empty → ticker `"⏪ nothing to take back — god has done nothing yet"`. Memory-only v1 (reload clears history — note it).
|
||||
|
||||
**D.2 The mark sites** (find each by name; `ungodlyMark` for discrete acts, `ungodlyGesture` for drags — always BEFORE the mutation):
|
||||
route patch/unpatch via the UI (the cable actions + the ctx-menu route items — NOT `registerRoute` itself, which `loadPatch` calls) · `applyMood` entry (`"mood: " + name`) · dimension/patch load + vibe apply (`"loaded <name>"`) · zero enter and exit · groove preset clicks + groove sliders (gesture) in BOTH the beat panel and the arranger grooves tab · euclid fills (both panels) · beat-panel step toggles (gesture, `"beat edit"`) + expression-lane drags (gesture) + kit-knob drags (gesture) · arranger clip-cell cycles (gesture) · scale root/name changes · `resetTweaks` and mute/solo toggles (mark). Anything you find that mutates patch state and isn't listed: add it and report it.
|
||||
|
||||
**D.3 Keys & menu.** In the keydown handler: FIRST, `if (ev.metaKey || ev.ctrlKey) { if z → undo (shift → redo), preventDefault, return; }` — placed before the single-letter block so ⌘Z no longer reaches zen; then add `!ev.metaKey && !ev.ctrlKey` guards to the existing letter handlers (p/c/o/w/z/t/b/g — small, mechanical, in-territory). `ctxItem("⏪ UNGODLY — take it back (⌘Z)" + depth count, …)`.
|
||||
|
||||
**D.4 Verify.** Deterministic script: draw beat steps → apply a mood → patch a route → three undos restore each state exactly (compare `JSON.stringify(serializePatch())` to the recorded snapshots) → three redos return → new action after undo clears `future`. Gesture-debounce: a 20-event slider drag = ONE history entry. `muted` holds: undo itself creates no marks. ⌘Z does NOT toggle zen; plain `z` still does. Depth cap at 32. §0.7 + GODRUM regression, zero console errors.
|
||||
|
||||
---
|
||||
|
||||
# Stage 3ε — the closer (FINAL BRIEF)
|
||||
|
||||
> **You are Agent ε (Opus).** All four gods are built and merged to `main @ b8b1188`: GODSPEED (arp, `G`), GODSQUASH (`squash` dest + OMNI knob), the GODSPEAK worker (OSC `/godspeak` + `/godspeak/rec` → ws `{"godspeak":{text|rec}}`), and UNGODLY (⌘Z/⌘⇧Z, `ungodlyMark`/`ungodlyGesture`/`ungodlyUndo` in app scope). You close the pantheon: the voice grammar, the polish list, the grimoire chapter, the QA matrix, the deploy checklist. Branch `pantheon/e-closer` from `main` (confirm `arpTick`, `squash`, `godspeak_worker.py`, and `ungodlyUndo` are all in your base). Part 0 rules and §0.8 report bind.
|
||||
|
||||
## E.1 The GODSPEAK client (viz/index.html)
|
||||
|
||||
In `handleMessage` (grep it), add the `godspeak` branch:
|
||||
- `msg.godspeak.rec` → a small 🗣 indicator pulses while recording (a fixed chip near the ♪ button, or a ticker line — house style, your call; it must appear on 1 and clear on 0).
|
||||
- `msg.godspeak.text` → normalize (lowercase, strip punctuation, map number-words to digits over the 40–180 range — a small word-number table: "ninety" → 90, "one twenty" → 120) and run the grammar. **First match wins; every executed command lands in the ticker as `🗣 <what happened>`; unmatched text falls through to the ticker as `🗣 "<text>"` — the word arrives in the world, never an error.**
|
||||
|
||||
Grammar (be forgiving with filler words — match on keywords, not exact phrases):
|
||||
| utterance contains | action |
|
||||
|---|---|
|
||||
| `tempo`/`bpm` + N (digits or words) | `godtime.bpm = clamp(N, 40, 180)` unless `godtime.locked` (then ticker "godtime is locked") |
|
||||
| `mood` + a fuzzy `MOODS` key (or a bare mood name) | `applyMood(match)` |
|
||||
| `play`/`start` + `beat` · `stop` + `beat` | flip all five `drum.*` lanes on/off (reuse the beat panel's master-play logic — extract or mirror it) |
|
||||
| `open`/`close` + `beat`\|`tracks`\|`omni`\|`ampler`\|`keys`\|`godspeed` | the matching panel toggle |
|
||||
| `capture` (+ N `seconds`)? | `Synth.amplerArm(N || 3)` — if it returns false, ticker the same "press ♪ first" hint AMPLER uses |
|
||||
| `chop` + `eight`/`sixteen`/8/16 | `Synth.amplerChop(n)` + re-render the AMPLER panel if open |
|
||||
| `skin` + fuzzy skin name | `setSkin(match)` |
|
||||
| `zen` | `setZen(!zenMode)` |
|
||||
| `squash` + N `percent` | set the squash trim: `setParamLocal("squash","offset", N/100)` (register the dest first if needed) |
|
||||
| `take it back` / `undo` | `ungodlyUndo()` — the voice of god, un-doing |
|
||||
|
||||
Fuzzy matching: lowercase-prefix or substring against the real key lists (`MOODS`, `SKINS`) — never invent names; no match → fall through to the ticker. **Voice commands are user acts: let the actions they call fire their existing `ungodlyMark`s naturally (applyMood already marks; don't double-mark).**
|
||||
|
||||
## E.2 The polish list (small, reviewed carry-overs — all in-scope)
|
||||
|
||||
1. Prune GODSPEED's dead `arp.up` state field (α flagged it).
|
||||
2. Apply `groove.velo` velocity-deviation to arp notes in `arpFire` (mirror the drum branch's line) — the arp should breathe like the lanes do.
|
||||
3. `ungodlyMark("tempo")` on the beat panel's BPM input commit (the one known unmarked mutation).
|
||||
4. GODSPEAK worker docstring: add the two macOS one-time permissions (Microphone + Accessibility/Input Monitoring for pynput) — mirror wersperr's README note.
|
||||
|
||||
## E.3 The grimoire chapter (canon) + manual
|
||||
|
||||
Four `<li>` entries alongside the GODRUM chapter (same placement pattern Agent F used, before the stage; house voice; fact-check every claim against what you verified):
|
||||
- **⚡ GODSPEED** — press `G`; enter a chakra, turn it on, strike keys to latch a chord; it runs on godtime and *swings with the groove*; rate/direction/octaves/gate; a MIDI keyboard latches too (it rides channel 2 out).
|
||||
- **🫀 GODSQUASH** — the master squeeze in OMNI's fx rack; one knob, threshold dives and ratio climbs together, loudness stays level while the peaks kneel; route the world onto it and the planet pumps the mix.
|
||||
- **🗣 GODSPEAK** — the voice of god (local rig only, Apple Silicon): `run.py --godspeak`, hold Right ⌥, speak — "tempo ninety", "storm mood", "capture six seconds", "chop sixteen", "take it back"; unmatched words arrive in the ticker; nothing leaves the machine (the user's own local Whisper).
|
||||
- **⏪ UNGODLY** — ⌘Z takes it back, ⌘⇧Z re-does; thirty-two acts deep; every cable, mood, groove, kit knob and beat step; memory-only (a reload forgives everything).
|
||||
|
||||
Then `python3 build_manual.py` and commit the regenerated `viz/manual.html` (the Stage-4/F exception applies to closers).
|
||||
|
||||
## E.4 Cross-feature QA matrix
|
||||
|
||||
1. **Voice→everything** (drive `handleMessage` directly with synthetic `{godspeak:{text}}` messages — the worker needs a mic+model, so the ws path is verified by γ already): tempo, mood, play/stop beat, open/close each panel, capture/chop (audio unlocked first), skin, zen, squash N percent, **"take it back" undoes the mood it just set**. Unmatched → ticker verbatim. Junk/number-word parsing table.
|
||||
2. **Undo × the new gods:** arp latch isn't in the patch (transient — confirm undo doesn't clear held notes); squash trim set by voice → ⌘Z reverts it (it's a tweak).
|
||||
3. **GODSPEED × GODRUM:** arp + beat playing together, both swung, keys 1–5 don't latch, `G`/`B` panels coexist.
|
||||
4. **GODSQUASH × GODRUM:** squash at 0.5 while the beat plays — offline-render crest drop, no limiter clip.
|
||||
5. Full §0.7 regression (`♪ T O B G` + ⌘Z/⌘⇧Z + zen-not-on-⌘Z) — zero console errors.
|
||||
6. `rec` indicator appears/clears on synthetic `{godspeak:{rec:1|0}}`.
|
||||
|
||||
## E.5 Deploy checklist (prepare, do NOT execute) & deliverables
|
||||
|
||||
Same shape as GODRUM's F.4: merge main is already done for stages 1–2, so: your branch → `main` (reviewer does it) · rsync per CLAUDE.md (briefs auto-excluded; `manual.html` ships; `workers/godspeak_worker.py` ships but is inert on the VPS — graceful skip) · restart · cold-start wait · smoke-test godstrument.pro (`B`,`G`, ⌘Z, OMNI squash knob, manual chapter) · note GODSPEAK is local-rig-only so the live site's voice test is `run.py --godspeak` on the Mac. Commit(s) on `pantheon/e-closer`; §0.8 report + the E.4 matrix ✓/✗ + the E.2 list each done/deviated.
|
||||
130
REVELATION_BRIEF.md
Normal file
130
REVELATION_BRIEF.md
Normal file
@ -0,0 +1,130 @@
|
||||
# REVELATION — build brief · TESTAMENT · WHEELS · PSALM · CANON
|
||||
|
||||
> **You are an execution agent (Opus).** One lane of a four-feature wave. Read **Part 0**, execute ONLY your lane, end with the §0.8 report (defined in `GODRUM_BRIEF.md`, our process root — GODRUM and THE PANTHEON both shipped through this loop). The planning model (Fable) reviews every stage before merge.
|
||||
|
||||
---
|
||||
|
||||
## Part 0 — shared context
|
||||
|
||||
### 0.1 The four revelations
|
||||
|
||||
| name | what | maps to (the DAW-study gap) |
|
||||
|---|---|---|
|
||||
| **📼 TESTAMENT** | record the performance — the master mix to a file, one button | a live instrument that can't record itself |
|
||||
| **☸ WHEELS** | per-lane pattern lengths (1–16) — polymeter, the wheel within a wheel | flexible MIDI/pattern editing |
|
||||
| **🕊 PSALM** | sing to the instrument — mic pitch, quantized to the scale, played as notes (or latched into GODSPEED) | vocal integration, Godstrument-style |
|
||||
| **✦ CANON** | factory dimensions — curated patches that ship with the instrument | Logic's stock-content advantage |
|
||||
|
||||
### 0.2 Ground truth & hard gate
|
||||
|
||||
- **GATE: do not branch until PANTHEON Stage 3ε has merged to `main`.** Confirm your base has ε's work (`git log --oneline -5` shows the ε commit; grep `godspeak` reaches a `handleMessage` branch). If ε hasn't landed, STOP and report. All line anchors below were verified at `main@b8b1188` (pre-ε) — **they WILL have drifted; grep for names, never trust raw numbers.**
|
||||
- One-file app `viz/index.html` (~9.4k lines): app IIFE with the `Synth` IIFE near the bottom (`const Synth = (function () {`). Python side: `hub.py` (OSC→ws), `run.py`, `workers/`. Conventions, worktrees, commit style, §0.7 regression (zero console errors; `♪ T O B G` + ⌘Z all work), temp-hook discipline, never-deploy/never-main: all per `GODRUM_BRIEF.md` Part 0 — read it.
|
||||
- Relevant machinery you inherit: the groove clock (`frame()` advances `seqPhase`; on each new 16th `seqCurStep = st16; seqTick()`), `seqs` (`{on, steps[16], vel, chance, rat, lock}` via `seqFor`/`drumSeqFor`, normalized by `migrateSeq`, serialized wholesale by `serializePatch`), the beat panel (`openBeat`, lanes, `beatRefresh`), the arranger grooves tab, `exportMid()` (ch-9 drum block added by GODRUM-E), `Synth.pluck(midi, vel)`, GODSPEED's `arp`/`arpLatch(note, vel)`, UNGODLY's `ungodlyMark`/`ungodlyGesture` (mark BEFORE mutating), the master chain `out → squash → squashMakeup → lim → destination` (~8585 pre-ε), the `Recorder` AudioWorklet (fixed-length captures — NOT what TESTAMENT uses), `gScale`/`SCALES`/`scaleRows`, and the hub's `_scan_patches` (serves named template patches from a dir to the client's template select).
|
||||
- Branches: `revelation/t-testament` · `revelation/w-wheels` · `revelation/p-psalm` · `revelation/c-canon` · `revelation/o-closer` (ω), each from post-ε `main`.
|
||||
|
||||
### Stage map
|
||||
|
||||
| stage | lanes | parallel-safe because |
|
||||
|---|---|---|
|
||||
| 1 | **T 📼 ∥ W ☸** | T = Synth IIFE + one header button; W = app-scope sequencer/panels/exportMid — disjoint, and neither touches ε's `handleMessage` |
|
||||
| 2 (after 1 merges) | **P 🕊 ∥ C ✦** | P = index.html only (new self-contained block); C = Python + a new `factory/` dir + JSON — file-disjoint |
|
||||
| 3 (after 2 merges) | **ω the closer** | grimoire chapter (all four), manual, cross-feature QA, deploy checklist |
|
||||
|
||||
Reviews between stages. Do not start later-stage work.
|
||||
|
||||
---
|
||||
|
||||
# Stage 1T — 📼 TESTAMENT ("it is written")
|
||||
|
||||
**Territory:** inside the `Synth` IIFE, PLUS: one header button (`#testament`, created app-scope beside the ♪ button — note the ♪ button itself is DOM built *inside* the Synth IIFE at its tail; mirror that pattern and build yours there too, keeping you 100% IIFE-resident), one `ctxItem`, one small CSS block. Nothing else.
|
||||
|
||||
**T.1 The tap.** Recording captures the TRUE final mix — post-squash, post-limiter. In `build()`: expose `lim` on the returned `N` (one word added to the return object). The recorder itself is lazy:
|
||||
|
||||
```js
|
||||
let testDest = null, testRec = null, testChunks = [], testT0 = 0, testTimer = null;
|
||||
function testamentStart() {
|
||||
if (!ctx) start(); // wake the graph — a recording can begin the session
|
||||
if (testRec) return false; // already rolling
|
||||
if (!testDest) { testDest = ctx.createMediaStreamDestination(); N.lim.connect(testDest); }
|
||||
const mime = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"]
|
||||
.find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m));
|
||||
if (!mime) return false; // (Safari fallback lands on audio/mp4)
|
||||
testChunks = []; testRec = new MediaRecorder(testDest.stream, { mimeType: mime, audioBitsPerSecond: 256000 });
|
||||
testRec.ondataavailable = e => { if (e.data && e.data.size) testChunks.push(e.data); };
|
||||
testRec.start(1000); // timesliced — a crash loses ≤1s
|
||||
testT0 = ctx.currentTime; return true;
|
||||
}
|
||||
function testamentStop() { ... } // → Promise<{blob, secs, ext}> resolved in onstop
|
||||
function testamentState() { return { on: !!testRec, secs: testRec ? ctx.currentTime - testT0 : 0 }; }
|
||||
```
|
||||
|
||||
Export `testamentStart/Stop/State` on the Synth return object. The worklet `Recorder` (GODSONIQ/AMPLER) is untouched — different machinery, different purpose.
|
||||
|
||||
**T.2 The button.** `#testament` next to `#sound` (♪): shows `⏺`; while rolling it turns red (`.on`), pulses gently, and shows elapsed `m:ss` (update via a 1s interval only while rolling). Click toggles. On stop: build the file name `godstrument-testament-<yyyymmdd-hhmm>.<ext>` (derive the timestamp from `new Date()` at stop time), trigger the download (the `a[download]` + `URL.createObjectURL` idiom), and ticker: `📼 testament: <m:ss> — it is written`. Failure (no MediaRecorder) → ticker `📼 this browser cannot bear witness`, button stays off. Add `ctxItem("📼 TESTAMENT — record what god does", …)` near GODSONIQ.
|
||||
|
||||
**T.3 Verify.** Live: record ~6s of the sounding world (drums playing), stop → blob size > 0, elapsed matches ±0.5s, and **prove the audio is real**: load the blob into an `<audio>` element and play it, or decode it back through `decodeAudioData`/an offline probe and measure nonzero RMS. Record-while-♪-off (graph wakes, captures the beat you then start). Two consecutive recordings (state resets). §0.7 regression, zero console errors. Note honestly what the headless env can't do (audition by ear).
|
||||
|
||||
---
|
||||
|
||||
# Stage 1W — ☸ WHEELS ("a wheel within a wheel")
|
||||
|
||||
**Territory:** viz/index.html app scope, OUTSIDE the Synth IIFE: the seq factories (`seqFor`, `drumSeqFor`), `migrateSeq` (+ its self-check), the clock lines in `frame()`, `seqTick`, the S&H block in `computeDests`, `applyGroove` call sites, the beat panel + arranger grooves tab (lane-length UI + playhead paint), `exportMid`'s drum block. NOT the Synth IIFE, NOT `handleMessage`, NOT GODSPEED's arp block.
|
||||
|
||||
**W.1 The data.** Every seq gains `len` (int 1–16, default 16): add to both factories; `migrateSeq` clamps/defaults it (`Number.isFinite` → round → clamp 1..16, else 16) and the self-check gains one assertion (a len-12 seq round-trips; a len-less legacy seq gets 16). It rides `serializePatch` automatically — verify, don't assume.
|
||||
|
||||
**W.2 The clock.** Add ONE global absolute step counter: `let seqAbsStep = 0;`, incremented exactly where `seqCurStep` is assigned in `frame()` (`seqCurStep = st16; seqAbsStep++; seqTick();`). Then one tiny helper next to `seqFor`:
|
||||
|
||||
```js
|
||||
const wheelStep = (sq) => (sq && sq.len && sq.len < 16) ? seqAbsStep % sq.len : seqCurStep;
|
||||
```
|
||||
|
||||
Replace the per-seq step index in: `seqTick`'s drum branch (`const cur = wheelStep(sq)`), `seqTick`'s note branch, the S&H sampling in `computeDests`, and every `applyGroove(sq, step, v)` call site (grep them — pass `wheelStep(sq)`). A full-length lane (`len` 16) must behave **bit-identically to today** (the helper returns `seqCurStep` — that's the regression guarantee).
|
||||
|
||||
**W.3 The UI.** Per-lane length control in BOTH grid editors, minimal and house-style:
|
||||
- Beat panel: a small `16`-showing number chip in each lane's lead (beside ⬢); click-drag or click-to-edit 1–16; `ungodlyGesture("wheel")` before mutating. Cells at index ≥ `len` render dimmed (a `.off` class, ~35% opacity) and clicking them does nothing; the playhead for that lane paints at `wheelStep(sq)`.
|
||||
- Arranger grooves tab: the same control in the euclid row for the selected lane; the big grid dims ≥ `len` and its playhead uses `wheelStep`.
|
||||
- Euclid fills should fill within `len` (pass `len` as the default `len` argument where the lane's control is present — keep the explicit euclid-steps input working as an override).
|
||||
|
||||
**W.4 Export.** In `exportMid`'s ch-9 drum block: replace the `bar × 16-step` nesting with one absolute loop — `for (let sa = 0; sa < arr.bars * 16; sa++) { const s2 = sq.len < 16 ? sa % sq.len : sa % 16; … tick = sa * TICKS16 … }` — so a 12-wheel hat exports exactly as it plays. Note lanes stay as-is (they're arranged by clips; polymeter for note clips is a flagged future, not v1).
|
||||
|
||||
**W.5 Verify.** Deterministic: kick len 16 + hat len 12 → drive 48 ticks (LCM) and assert the hat's fire pattern wraps at 12 while kick wraps at 16 (spy `Synth.drum` or the branch internals via a temp hook); len 1 fires every step; legacy patch (no len) loads → all 16, identical behavior to pre-WHEELS (byte-level: same fires over 32 ticks). S&H on a source with len 8 samples on the 8-wheel. UI: dimmed cells, per-lane playheads diverge visibly, undo reverts a len change. Export: byte-parse a 12-wheel pattern → note-ons at ticks matching `sa % 12`. §0.7 + GODRUM/PANTHEON regression, zero console errors.
|
||||
|
||||
---
|
||||
|
||||
# Stage 2P — 🕊 PSALM ("sing, and the instrument answers")
|
||||
|
||||
**Territory:** viz/index.html only — one new self-contained block (place after the GODSPEED block), one `ctxItem`, one small CSS chip. Nothing in the Synth IIFE; you consume `Synth.pluck` and `arpLatch` (both exist).
|
||||
|
||||
**P.1 The listener.** Mic analysis lives in its OWN throwaway `AudioContext` — the mic never enters the music graph (zero feedback risk; say so in a comment). `getUserMedia({ audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false } })` → `AnalyserNode` (fftSize 2048) → a `setInterval(…, 25)` loop (NOT rAF — hidden tabs freeze it) pulling time-domain frames.
|
||||
|
||||
**P.2 The pitch.** Autocorrelation in plain JS (house-idiomatic, no libraries): normalize the frame, compute ACF over lags spanning 80–1000 Hz, pick the first peak above 0.9 × max with parabolic interpolation; reject frames with RMS below the sensitivity threshold or ACF clarity < ~0.6. Convert `freq → midi` (float). Write the detector as a **pure function of a Float32Array** (`psalmDetect(buf, sampleRate) → {freq, clarity, rms}`) so it's unit-testable without a mic.
|
||||
|
||||
**P.3 The note.** Quantize the float midi to the global scale — reuse the instrument's own machinery (`gScale` + `scaleRows`/the quantize helpers — grep how the piano roll folds to scale and mirror it; nearest scale degree across octaves). Note-on logic: gate opens when RMS > threshold AND the same quantized note holds for 2 consecutive frames → fire ONCE; re-fire only when the quantized note *changes* (legato) or after the gate closes (RMS below threshold for ~150ms) and reopens. Velocity from RMS (clamped 0.3–1). Where it goes: **if `arp.on` → `arpLatch(note, vel)`** (sing your chord into GODSPEED, one note at a time — the killer demo); **else → `Synth.pluck(note, vel)`**.
|
||||
|
||||
**P.4 The UI.** `ctxItem("🕊 PSALM — sing, and the instrument answers", …)` toggles it (requests the mic on first enable; denial → ticker `🕊 the instrument cannot hear you — allow the microphone`, state off). While on: a small fixed chip (near the launcher stack) showing 🕊 + the live note name (`midiToName`), dimmed when the gate is closed; clicking the chip toggles off. A sensitivity slider on the chip's hover/click is optional — a sane fixed threshold with a `PSALM_SENS` const is acceptable v1. Privacy note in a comment: local analysis only, nothing leaves the page.
|
||||
|
||||
**P.5 Verify.** The detector is pure — unit-test it headlessly: synthesize Float32Array sines (220, 440, 554.37 Hz + a noisy one + silence) → assert freq within ±1%, clarity high, silence rejected; run the note-logic as a pure state machine on scripted `{freq, rms}` sequences → assert single-fire, legato re-fire, gate close/reopen, correct scale-fold (set `gScale` to minor pent, feed 445 Hz → the folded degree). Live mic = human lane; say so. UI chip renders/toggles; mic-denied path degrades cleanly. §0.7, zero console errors.
|
||||
|
||||
---
|
||||
|
||||
# Stage 2C — ✦ CANON (factory dimensions)
|
||||
|
||||
**Territory:** Python + data only: `hub.py` (`_scan_patches` region), a NEW git-tracked `factory/` directory of patch JSONs, `run.py` only if genuinely needed (it shouldn't be). ZERO index.html.
|
||||
|
||||
**C.1 The shelf.** Today `_scan_patches` serves user patches from the gitignored `patches/` dir. Add a second scan of `factory/` (git-tracked, ships with the deploy — confirm the CLAUDE.md rsync filter doesn't exclude it; it doesn't) whose entries appear in the same `patchList` with names prefixed `✦ ` (e.g. `✦ first light in stereo`). Factory patches are read-only: the hub's save path must never write into `factory/` (guard by name prefix). Client-side needs nothing — the template select already renders whatever `patchList` carries.
|
||||
|
||||
**C.2 The canon itself.** Author **four** patches as JSON (the `serializePatch` shape: `routes, tweaks, seqs, arr, bpm, groove, scale, kit`). Derive the exact shape by reading `serializePatch`/`loadPatch` and one existing saved patch if available. The four, each a one-line lesson in an instrument feature:
|
||||
1. **`✦ the heartbeat`** — a GODRUM showcase: mpc 8-4 groove, a tasteful boom-bap kit (use the seeded pattern as a base, vary it), kick duck 0.5, bpm 88.
|
||||
2. **`✦ the wheel within the wheel`** — WHEELS showcase: kick len 16, hat len 12, clap len 10, sparse, hypnotic, bpm 96. (Gate: only author this if WHEELS has merged — it has, you're Stage 2.)
|
||||
3. **`✦ the squeeze`** — GODSQUASH showcase: a route from a pumping world source onto `squash` at high amount, drums under it, bpm 120.
|
||||
4. **`✦ quiet arrival`** — a gentle first-light-style ambient patch: earth echo audible, sparse routes, no drums, slow bpm.
|
||||
Validate each by round-tripping through the client's `migrateSeq` expectations (16-step arrays, sane ranges) — write a tiny `--check`-style python validator in the lane (a `test_factory.py`-named file so the deploy filter excludes it) that asserts shape.
|
||||
**Taste disclaimer in your report:** these are structural drafts for the human to re-voice by ear; correctness of shape is your deliverable, beauty is his.
|
||||
|
||||
**C.3 Verify.** Run the hub locally (`--profile minimal`), confirm `patchList` carries the four `✦` names, load one via the client template select in a browser (full path), confirm the read-only guard (attempt a save named `✦ x` → rejected/redirected), and that a missing/empty `factory/` dir degrades silently. Zero index.html changes (`git diff --stat`).
|
||||
|
||||
---
|
||||
|
||||
# Stage 3ω — the closer (brief finalized after Stage 2 review)
|
||||
|
||||
Scope preview: grimoire chapter for all four (Ezekiel 1:16 belongs in the WHEELS entry — *"as it were a wheel in the middle of a wheel"*; PSALM's privacy line — the instrument listens locally, nothing leaves the machine; TESTAMENT's "it is written"; the CANON as the shelf of worked examples) · `build_manual.py` + commit manual · cross-feature QA (record a TESTAMENT of a WHEELS beat while PSALM latches the arp — the whole wave in one take) · deploy checklist. Do not start early.
|
||||
29
hub.py
29
hub.py
@ -122,6 +122,7 @@ class Hub:
|
||||
self._raw_inbox: dict[str, tuple[float, float]] = {} # key -> (val,t)
|
||||
self._last_raw: dict[str, float] = {} # last value fed to the normalizer
|
||||
self._event_q: "queue.Queue[tuple[str,float,float]]" = queue.Queue()
|
||||
self._godspeak_q: "queue.Queue[dict]" = queue.Queue() # 🗣 GODSPEAK text/state → clients (a voice command channel, not a signal)
|
||||
|
||||
self.out = SimpleUDPClient(cfg.get("osc_out_host", "127.0.0.1"),
|
||||
cfg.get("osc_out_port", 9001))
|
||||
@ -146,6 +147,24 @@ class Hub:
|
||||
|
||||
# ---- OSC ingest (runs in the OSC server thread) --------------------
|
||||
def _osc_handler(self, address: str, *args):
|
||||
# 🗣 GODSPEAK — text/state, not a norm signal: relay it straight to clients.
|
||||
# Muted entirely in read-only mode (voice is a command channel, and the
|
||||
# ws control path has no auth). Queued here (OSC thread) → drained and
|
||||
# broadcast in control_loop (asyncio thread), like every other OSC ingest.
|
||||
if address == "/godspeak/rec" or address == "/godspeak":
|
||||
if self.readonly:
|
||||
return
|
||||
if address == "/godspeak/rec":
|
||||
try:
|
||||
rec = 1 if (args and float(args[0]) >= 0.5) else 0
|
||||
except (TypeError, ValueError):
|
||||
rec = 0
|
||||
self._godspeak_q.put({"rec": rec})
|
||||
elif args:
|
||||
text = str(args[0]).strip()
|
||||
if text:
|
||||
self._godspeak_q.put({"text": text})
|
||||
return
|
||||
if not args:
|
||||
return
|
||||
try:
|
||||
@ -191,6 +210,16 @@ class Hub:
|
||||
self.recent_events.append(ev)
|
||||
self.recent_events = self.recent_events[-40:]
|
||||
|
||||
# 🗣 GODSPEAK — relay queued voice text/state to clients immediately.
|
||||
# Always drain (so it can't grow unbounded); broadcast only if anyone's listening.
|
||||
while True:
|
||||
try:
|
||||
obj = self._godspeak_q.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if self.clients:
|
||||
websockets.broadcast(self.clients, json.dumps({"godspeak": obj}))
|
||||
|
||||
# snapshot raw inbox
|
||||
with self._raw_lock:
|
||||
inbox = dict(self._raw_inbox)
|
||||
|
||||
6
run.py
6
run.py
@ -132,6 +132,8 @@ def main():
|
||||
help="also run the dealgod market worker (reads the warehouse)")
|
||||
ap.add_argument("--sports", action="store_true",
|
||||
help="also run the sports worker (TheSportsDB free tier — rate-limited shared key, so opt-in)")
|
||||
ap.add_argument("--godspeak", action="store_true",
|
||||
help="also run the GODSPEAK voice worker (local MLX Whisper — Apple-Silicon only, opt-in)")
|
||||
ap.add_argument("--no-browser", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
@ -171,6 +173,10 @@ def main():
|
||||
if start_worker("workers/world_sport.py"):
|
||||
print(" sports worker -> the world's fixtures (thesportsdb, opt-in)")
|
||||
|
||||
if args.godspeak:
|
||||
if start_worker("workers/godspeak_worker.py"):
|
||||
print(" godspeak worker -> hold Right ⌥ to speak commands into the sky (local, Apple-Silicon only)")
|
||||
|
||||
if args.record:
|
||||
if start_worker("recorder.py"):
|
||||
print(" recording -> godstrument.db")
|
||||
|
||||
430
viz/index.html
430
viz/index.html
@ -345,6 +345,39 @@
|
||||
#ampler .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||||
#ampler .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
|
||||
/* ⚡ GODSPEED — the arpeggiator (compact, left side) */
|
||||
#godspeed {
|
||||
position: fixed; left: 12px; top: 64px; width: 244px; z-index: 13;
|
||||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
}
|
||||
#godspeed.open { display: flex; flex-direction: column; gap: 6px; }
|
||||
#godspeed .atop { display: flex; align-items: center; gap: 8px; }
|
||||
#godspeed .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; }
|
||||
#godspeed .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||||
#godspeed .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
#godspeed .grow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
#godspeed .gctl { display: inline-flex; align-items: center; gap: 4px; }
|
||||
#godspeed .glbl { color: rgba(160,180,215,0.8); font-size: 10px; }
|
||||
#godspeed .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||||
border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; }
|
||||
#godspeed .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
|
||||
#godspeed .bmini { padding: 2px 7px; }
|
||||
#godspeed .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||||
border-radius: 6px; padding: 2px 5px; font-size: 10px; }
|
||||
#godspeed input[type=range] { width: 88px; accent-color: #7fa0e6; height: 3px; }
|
||||
#godspeed .held { color: #bcd; font-size: 11px; letter-spacing: 0.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#godspeed .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
|
||||
/* 🗣 GODSPEAK — a small "listening" chip near the ♪ button, pulses while the mic is held */
|
||||
#godspeakrec { position: fixed; top: 12px; right: 92px; z-index: 40; width: 30px; height: 30px;
|
||||
border-radius: 8px; display: none; align-items: center; justify-content: center; font-size: 15px;
|
||||
background: rgba(230,90,90,0.18); color: #ffd2d2; border: 1px solid rgba(240,110,110,0.5);
|
||||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
|
||||
#godspeakrec.on { display: flex; animation: gspk-pulse 1.1s ease-in-out infinite; }
|
||||
@keyframes gspk-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
||||
|
||||
/* right-click context menu — every setting, on everything */
|
||||
#ctxmenu {
|
||||
position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh;
|
||||
@ -1154,6 +1187,10 @@
|
||||
<li><b>Keys <kbd>1</kbd>–<kbd>5</kbd> & ⏺ rec — finger-drum the kit.</b> With the beat open the number row <i>is</i> the kit — <kbd>1</kbd> kick, <kbd>2</kbd> snare, <kbd>3</kbd> clap, <kbd>4</kbd> hat, <kbd>5</kbd> open hat — played by hand, live, straight into the world's FX. Hold <b>⇧</b> for the ghost note, softer. Arm <b>⏺ rec</b> and every tap writes itself into the grid, <b>quantized to the nearest sixteenth</b> — tap a hi-hat pattern into a quiet pocket and it's a lane — so you play the beat in rather than clicking it out.</li>
|
||||
<li><b>🌾 AMPLER — there is ample, for god is abundant.</b> The SP-404 move, made of the planet. Right-click the sky → <b>🌾 AMPLER</b>: <b>⏺ capture</b> a stretch of the sounding world (1.5, 3 or 6 seconds of the live master — and since a fed tab <i>is</i> the world, that YouTube loop counts), then <b>✂ chop</b> it into 8 or 16 slices. Tap a slice to audition it, <b>⇄</b> to play it backwards, then <b>seat a slice onto a drum voice</b> — and that lane stops synthesising and starts <i>playing the world</i>. The slice rides the voice's own <i>level</i> and <i>pan</i> and, on the kick, its <b>duck</b>; a slice on the open hat still chokes under a closed one — the machine's rules don't care whether a hit is oscillator or sample. Sample anything playing on the machine, chop it, and it's a kit. Session-only for now: the slices don't travel with your patch (yet).</li>
|
||||
<li><b>The beat leaves the building.</b> The drum lanes ride <b>MIDI out on channel 10</b> — General MIDI percussion, <i>kick 36, snare 38, clap 39, closed hat 42, open hat 46</i> — so with an out port chosen in ⚙ they play your hardware and DAW exactly as the note lanes do. And they land in <b>export .mid</b>: draw a beat, hit export, and the Standard MIDI File carries a channel-10 drum track (looped across the arrangement's bars) alongside your lead and bass — drag it into Ableton and the planet's rhythm is already programmed.</li>
|
||||
<li><b>⚡ GODSPEED — hold a chord, the machine runs.</b> Press <kbd>G</kbd>. Enter a chakra (<kbd>C</kbd>), turn GODSPEED <b>on</b>, and <b>strike keys to latch a chord</b> — each note stays held until you strike it again. Then the machine runs with it, one note at a time, <i>on the godtime clock</i> — so it <b>swings with the groove</b>, laying its off-beats back exactly as the drums do, because it plays from the same swung tick. Set the <b>rate</b> (1/4 → 1/32), the <b>direction</b> (up, down, up/down, random), the <b>octaves</b> (×1–3) and the <b>gate</b> (how long each note rings). A <b>MIDI keyboard latches too</b> — its notes route through the same hands — and the arp plays <i>outward</i> on channel 2, the mind console's channel, so it drives your rig as it drives the synth.</li>
|
||||
<li><b>🫀 GODSQUASH — the master squeeze the world can pump.</b> In <b>OMNI</b>'s fx rack sits one new knob: <b>squash</b>, a compressor across the whole master bus. Turn it up and the <b>threshold dives while the ratio climbs together</b>, a matched gain riding underneath so the <i>loudness stays level while the peaks kneel</i> — the mix gets denser, not louder. And because it's a destination like any other, <b>route a feed onto it</b> — the quakes, your heartbeat, the market's velocity — and the planet <b>pumps the mix</b>, breathing the whole instrument in time with the world.</li>
|
||||
<li><b>🗣 GODSPEAK — the voice of god.</b> On your own machine (<i>Apple-Silicon only</i>), run <code>run.py --godspeak</code>, then <b>hold Right ⌥ and speak</b>. Your words are heard <b>locally</b> — your own Whisper, nothing leaving the machine — and become commands: <i>"tempo ninety", "storm mood", "capture six seconds", "chop sixteen", "open the beat", "squash sixty percent", "zen"</i>, and — the voice of god undoing its own works — <i>"take it back"</i>. A 🗣 chip glows by the ♪ while it listens. Anything it doesn't recognise simply <b>arrives in the world</b>, printed to the ticker — the word is never an error, only another feed.</li>
|
||||
<li><b>⏪ UNGODLY — take it back.</b> <kbd>⌘Z</kbd> undoes; <kbd>⌘⇧Z</kbd> redoes. The instrument remembers your last <b>thirty-two acts</b> — every cable patched, mood applied, groove and scale changed, kit knob turned, beat step drawn, level and mute — and steps back through them, un-doing what god (or you) did. A held drag counts as one act, not fifty. It is <b>memory-only</b>: a reload forgives everything and starts the history clean.</li>
|
||||
<li><b>📽 The stage — send the visual to any screen.</b> Right-click the sky → <b>the stage</b> and a clean black mirror window opens, fed live from the canvas. Drag it to a projector, an AirPlay display, a second monitor — macOS treats it like any window — and <b>double-click for fullscreen</b>. The stage has a <b>camera</b>: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → <b>project this node</b> and the camera glides after that one sphere through its orbits, or <b>project its station</b> to frame a whole wheel and its satellites. Change cameras live from the menus while the projector runs; the audience sees the camera breathe from subject to subject.</li>
|
||||
<li><b>🕸 The commune — play together, properly.</b> Right-click the sky → <b>the commune</b>. This is for <b>signed-in players</b>: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you <b>hosts</b>: name the room, then <b>✉ invite</b> the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and <b>join or decline</b>; anyone may <b>leave</b> whenever they wish, and if the host leaves, the room closes with them. Then the sections: members <b>claim voices</b> (right-click any voice, or the panel's picker), or the <b>host allocates</b> them — this bass to her, those pads to him, <b>the godtime itself</b> to whoever conducts — and the hub enforces it: only the section's holder can speak for it. From that moment your levels, mutes, grooves and arranger lanes for what you hold follow your hands on every screen, same living room or opposite sides of the earth. The world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</li></ul>
|
||||
|
||||
@ -2061,11 +2098,11 @@
|
||||
scRow.appendChild(scL);
|
||||
const rootSel = document.createElement("select"); rootSel.className = "asel";
|
||||
NOTE_NAMES.forEach((nm, i) => { const o = document.createElement("option"); o.value = String(i); o.textContent = nm; if (i === gScale.root) o.selected = true; rootSel.appendChild(o); });
|
||||
rootSel.onchange = () => { gScale.root = +rootSel.value; openArranger(); };
|
||||
rootSel.onchange = () => { ungodlyMark("scale"); gScale.root = +rootSel.value; openArranger(); };
|
||||
scRow.appendChild(rootSel);
|
||||
const scaleSel = document.createElement("select"); scaleSel.className = "asel";
|
||||
Object.keys(SCALES).forEach(nm => { const o = document.createElement("option"); o.value = nm; o.textContent = nm.replace("_", " "); if (nm === gScale.name) o.selected = true; scaleSel.appendChild(o); });
|
||||
scaleSel.onchange = () => { gScale.name = scaleSel.value; SEQ_ROWS = scaleRows(gScale.name); openArranger(); };
|
||||
scaleSel.onchange = () => { ungodlyMark("scale"); gScale.name = scaleSel.value; SEQ_ROWS = scaleRows(gScale.name); openArranger(); };
|
||||
scRow.appendChild(scaleSel);
|
||||
const scHint = document.createElement("span"); scHint.className = "albl";
|
||||
scHint.textContent = "every groove is in this key — change it and your patterns re-harmonise (the roll is always folded to the scale)";
|
||||
@ -2081,7 +2118,7 @@
|
||||
const vv = document.createElement("span"); vv.className = "gval";
|
||||
const show = () => vv.textContent = Math.round(get() / max * 100) + "%";
|
||||
sl.value = String(Math.round(get() / max * 1000)); show();
|
||||
sl.oninput = () => { set(clamp(+sl.value / 1000 * max, 0, max)); show(); };
|
||||
sl.oninput = () => { ungodlyGesture("groove"); set(clamp(+sl.value / 1000 * max, 0, max)); show(); };
|
||||
w.appendChild(lb); w.appendChild(sl); w.appendChild(vv);
|
||||
return w;
|
||||
};
|
||||
@ -2092,7 +2129,7 @@
|
||||
for (const pn of Object.keys(GROOVE_PRESETS)) {
|
||||
const pb = document.createElement("button"); pb.className = "midibtn gpreset"; pb.textContent = pn;
|
||||
pb.classList.toggle("on", groove.preset === pn);
|
||||
pb.onclick = () => { Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openArranger(); };
|
||||
pb.onclick = () => { ungodlyMark("groove preset"); Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openArranger(); };
|
||||
gr.appendChild(pb);
|
||||
}
|
||||
body.appendChild(gr);
|
||||
@ -2107,7 +2144,7 @@
|
||||
const fsel = document.createElement("select"); fsel.className = "asel";
|
||||
[["", "— stay"], ["again", "play again"], ["next", "next clip"], ["any", "any clip"], ["other", "any other"], ["stop", "stop"]]
|
||||
.forEach(([v, t]) => { const o = document.createElement("option"); o.value = v; o.textContent = t; if (v === fa.act) o.selected = true; fsel.appendChild(o); });
|
||||
fsel.onchange = () => { setFA({ act: fsel.value }); openArranger(); };
|
||||
fsel.onchange = () => { ungodlyMark("follow"); setFA({ act: fsel.value }); openArranger(); };
|
||||
fr.appendChild(flbl); fr.appendChild(fsel);
|
||||
if (fa.act) {
|
||||
const cwrap = document.createElement("span"); cwrap.className = "gctl";
|
||||
@ -2115,7 +2152,7 @@
|
||||
const csl = document.createElement("input"); csl.type = "range"; csl.min = "0"; csl.max = "100";
|
||||
csl.value = String(Math.round((fa.chance != null ? fa.chance : 1) * 100));
|
||||
const cval = document.createElement("span"); cval.className = "gval"; cval.textContent = csl.value + "%";
|
||||
csl.oninput = () => { setFA({ chance: clamp(+csl.value / 100, 0, 1) }); cval.textContent = csl.value + "%"; };
|
||||
csl.oninput = () => { ungodlyGesture("follow"); setFA({ chance: clamp(+csl.value / 100, 0, 1) }); cval.textContent = csl.value + "%"; };
|
||||
cwrap.appendChild(clab); cwrap.appendChild(csl); cwrap.appendChild(cval);
|
||||
fr.appendChild(cwrap);
|
||||
}
|
||||
@ -2134,7 +2171,7 @@
|
||||
const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "auto"; l.textContent = label;
|
||||
const n = document.createElement("input"); n.type = "number"; n.min = String(min); n.max = String(max);
|
||||
n.value = String(get()); n.className = "eunum";
|
||||
n.oninput = () => { set(clamp(Math.round(+n.value || 0), min, max));
|
||||
n.oninput = () => { ungodlyGesture("euclid"); set(clamp(Math.round(+n.value || 0), min, max));
|
||||
euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); };
|
||||
w.appendChild(l); w.appendChild(n);
|
||||
return w;
|
||||
@ -2143,7 +2180,7 @@
|
||||
euRow.appendChild(mkEuNum("steps", () => euclidP.len, v => euclidP.len = v, 2, 16));
|
||||
euRow.appendChild(mkEuNum("rotate", () => euclidP.rot, v => euclidP.rot = v, 0, 15));
|
||||
const euBtn = document.createElement("button"); euBtn.className = "midibtn"; euBtn.textContent = "fill";
|
||||
euBtn.onclick = () => { euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); };
|
||||
euBtn.onclick = () => { ungodlyMark("euclid"); euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); };
|
||||
euRow.appendChild(euBtn);
|
||||
const euHint = document.createElement("span"); euHint.className = "albl";
|
||||
euHint.textContent = "spread the pulses evenly — E(3,8) tresillo · E(5,8) cinquillo · E(4,16) four-on-floor";
|
||||
@ -2162,10 +2199,10 @@
|
||||
if (stp % 4 === 0) cell.classList.add("bar");
|
||||
if (nSel.isNote) {
|
||||
cell.title = midiToName(seqRootFor(arrSelKey) + SEQ_ROWS[rw]) + " · step " + (stp + 1);
|
||||
cell.onclick = () => { stepToggle(steps, stp, rw); paintG(); };
|
||||
cell.onclick = () => { ungodlyGesture("groove edit"); stepToggle(steps, stp, rw); paintG(); };
|
||||
} else {
|
||||
cell.title = "step " + (stp + 1);
|
||||
cell.onclick = () => { steps[stp] = steps[stp] ? 0 : 1; paintG(); };
|
||||
cell.onclick = () => { ungodlyGesture("groove edit"); steps[stp] = steps[stp] ? 0 : 1; paintG(); };
|
||||
}
|
||||
cells.push({ el: cell, rw, stp });
|
||||
grid.appendChild(cell);
|
||||
@ -2223,7 +2260,7 @@
|
||||
cell.appendChild(fill);
|
||||
const setY = (cy) => { const r = cell.getBoundingClientRect();
|
||||
const frac = clamp(1 - (cy - r.top) / r.height, 0, 1);
|
||||
arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); };
|
||||
ungodlyGesture("groove expr"); arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); };
|
||||
cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY);
|
||||
const mv = (e2) => setY(e2.clientY);
|
||||
const up = () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); };
|
||||
@ -2258,7 +2295,7 @@
|
||||
cell.appendChild(fill);
|
||||
const setY = (cy) => { const r = cell.getBoundingClientRect();
|
||||
const val = 1 - (cy - r.top) / r.height;
|
||||
sqx.lock[stp] = val < 0 ? -1 : clamp(val, 0, 1); // dragged below the lane = unlock
|
||||
ungodlyGesture("groove expr"); sqx.lock[stp] = val < 0 ? -1 : clamp(val, 0, 1); // dragged below the lane = unlock
|
||||
paintLock(); };
|
||||
cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY);
|
||||
const mv = (e2) => setY(e2.clientY);
|
||||
@ -2306,7 +2343,7 @@
|
||||
if (arr.playing && b === arr.bar) cell.classList.add("acur");
|
||||
};
|
||||
cell.onclick = () => {
|
||||
lane[b] = CYCLE[(CYCLE.indexOf(lane[b]) + 1) % CYCLE.length];
|
||||
ungodlyGesture("clip"); lane[b] = CYCLE[(CYCLE.indexOf(lane[b]) + 1) % CYCLE.length];
|
||||
if (arr.playing && b === arr.bar) applyArrColumn();
|
||||
paint();
|
||||
};
|
||||
@ -2625,6 +2662,7 @@
|
||||
function applyMood(name) {
|
||||
const m = MOODS[name];
|
||||
if (!m) return;
|
||||
ungodlyMark("mood: " + name); // ⏪ before a mood rewires the mix
|
||||
try { localStorage.setItem("gs_mood", name); } catch (e) {} // your vibe survives a reload
|
||||
for (const k of MOOD_KEYS) { const t2 = tw(k); t2.mute = 0; t2.gain = 1; t2.offset = 0; }
|
||||
if (m.storm) {
|
||||
@ -2726,7 +2764,8 @@
|
||||
// always editable — this mirrors the dial's write path. 🔒 lock means "the
|
||||
// world's hands (heart, tempo.nudge) can't drive tempo", never "the user can't".
|
||||
bpm.title = "beats per minute (40–180)";
|
||||
bpm.onchange = () => { godtime.bpm = clamp(Math.round(+bpm.value || godtime.bpm), 40, 180);
|
||||
bpm.onchange = () => { const nv = clamp(Math.round(+bpm.value || godtime.bpm), 40, 180);
|
||||
if (nv !== godtime.bpm) { ungodlyMark("tempo"); godtime.bpm = nv; } // ⏪ a tempo change is an act
|
||||
bpm.value = String(Math.round(godtime.bpm)); };
|
||||
const x2 = document.createElement("span"); x2.className = "aclose"; x2.textContent = "×"; x2.onclick = closeBeat;
|
||||
const hint = document.createElement("span"); hint.className = "albl";
|
||||
@ -2747,7 +2786,7 @@
|
||||
for (const pn of Object.keys(GROOVE_PRESETS)) {
|
||||
const pb = document.createElement("button"); pb.className = "midibtn gpreset"; pb.textContent = pn;
|
||||
pb.classList.toggle("on", groove.preset === pn);
|
||||
pb.onclick = () => { Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openBeat(); };
|
||||
pb.onclick = () => { ungodlyMark("groove preset"); Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openBeat(); };
|
||||
gr.appendChild(pb); presetBtns.push({ pb, pn });
|
||||
}
|
||||
const paintPresets = () => presetBtns.forEach(({ pb, pn }) => pb.classList.toggle("on", groove.preset === pn));
|
||||
@ -2758,7 +2797,7 @@
|
||||
const vv = document.createElement("span"); vv.className = "gval";
|
||||
const show = () => vv.textContent = Math.round(get() / max * 100) + "%";
|
||||
sl.value = String(Math.round(get() / max * 1000)); show();
|
||||
sl.oninput = () => { set(clamp(+sl.value / 1000 * max, 0, max)); show(); paintPresets(); }; // a drag drops the preset to "custom" — relight the buttons to match
|
||||
sl.oninput = () => { ungodlyGesture("groove"); set(clamp(+sl.value / 1000 * max, 0, max)); show(); paintPresets(); }; // a drag drops the preset to "custom" — relight the buttons to match
|
||||
w.appendChild(l); w.appendChild(sl); w.appendChild(vv); return w;
|
||||
};
|
||||
gr.appendChild(mkGS("swing", () => groove.swing, v => { groove.swing = v; groove.preset = "custom"; }, 1));
|
||||
@ -2776,6 +2815,7 @@
|
||||
if (!parts.length || !Number.isFinite(parts[0])) return; // junk / empty confirm → no-op; an explicit "0" still clears
|
||||
const pulses = clamp(Math.round(parts[0]), 0, 16),
|
||||
rot = clamp(Math.round(Number.isFinite(parts[1]) ? parts[1] : 0), 0, 15);
|
||||
ungodlyMark("euclid");
|
||||
euclidFill(sq.steps, pulses, 16, rot, false);
|
||||
};
|
||||
const lanes = [];
|
||||
@ -2789,7 +2829,7 @@
|
||||
mute._on = on; mute.classList.toggle("on", on); mute.textContent = on ? "●" : "○"; };
|
||||
mute._on = null; paintMute();
|
||||
mute.title = "mute / unmute this lane";
|
||||
mute.onclick = () => { sq.on = !sq.on; paintMute(); paintPlay(); };
|
||||
mute.onclick = () => { ungodlyMark("beat mute"); sq.on = !sq.on; paintMute(); paintPlay(); };
|
||||
const eu = document.createElement("button"); eu.className = "midibtn bmini"; eu.textContent = "⬢";
|
||||
eu.title = "euclid — spread N pulses evenly across the 16 steps";
|
||||
eu.onclick = () => { euclidPrompt(sq, v); paintGrid(); };
|
||||
@ -2801,7 +2841,7 @@
|
||||
const cell = document.createElement("div"); cell.className = "seqcell";
|
||||
if (stp % 4 === 0) cell.classList.add("bar"); // beats 1 / 5 / 9 / 13
|
||||
cell.title = v + " · step " + (stp + 1);
|
||||
cell.onclick = () => { sq.steps[stp] = sq.steps[stp] ? 0 : 1; paintGrid(); };
|
||||
cell.onclick = () => { ungodlyGesture("beat edit"); sq.steps[stp] = sq.steps[stp] ? 0 : 1; paintGrid(); };
|
||||
cells.push(cell); steps.appendChild(cell);
|
||||
}
|
||||
lane.appendChild(steps); body.appendChild(lane);
|
||||
@ -2824,6 +2864,7 @@
|
||||
beatRefresh = paintGrid;
|
||||
|
||||
play.onclick = () => { // master ▶/■ — remembers per-lane mutes
|
||||
ungodlyMark("beat play"); // ⏪ transport is an act
|
||||
if (anyOn()) { // ■ — note who was playing, then stop all
|
||||
beat.wasOn = lanes.filter(L => L.sq.on).map(L => L.sq);
|
||||
lanes.forEach(L => { L.sq.on = false; L.paintMute(); });
|
||||
@ -2872,7 +2913,7 @@
|
||||
const fill = document.createElement("div"); fill.className = "exprbar"; cell.appendChild(fill);
|
||||
const setY = (cy) => { const r = cell.getBoundingClientRect();
|
||||
const frac = clamp(1 - (cy - r.top) / r.height, 0, 1);
|
||||
arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); };
|
||||
ungodlyGesture("beat expr"); arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); };
|
||||
cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY);
|
||||
const mv = (e2) => setY(e2.clientY);
|
||||
const up = () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); };
|
||||
@ -2898,7 +2939,7 @@
|
||||
const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "44px"; l.textContent = p.label;
|
||||
const sl = document.createElement("input"); sl.type = "range"; sl.min = "0"; sl.max = "1000";
|
||||
sl.value = String(Math.round(clamp(Synth.drumParam(v, p.key), 0, 1) * 1000));
|
||||
sl.oninput = () => Synth.drumParam(v, p.key, clamp(+sl.value / 1000, 0, 1));
|
||||
sl.oninput = () => { ungodlyGesture("kit"); Synth.drumParam(v, p.key, clamp(+sl.value / 1000, 0, 1)); };
|
||||
w.appendChild(l); w.appendChild(sl); box.appendChild(w);
|
||||
}
|
||||
const au = document.createElement("button"); au.className = "midibtn bmini"; au.textContent = "▷ hear";
|
||||
@ -3014,6 +3055,103 @@
|
||||
amplerEl.appendChild(h);
|
||||
}
|
||||
|
||||
// ---- ⚡ GODSPEED — the arpeggiator ---------------------------------------
|
||||
// Hold a chord in mind mode; the machine runs with it, on godtime. Because it
|
||||
// fires from seqTick — at the SWUNG onset — the arp inherits the groove's feel
|
||||
// for free. Notes are latched at mindStrike (so a MIDI keyboard, which already
|
||||
// routes through there, captures for free too). App-scope only; never touches
|
||||
// the Synth engine — it just plays through the same pluck() every note uses.
|
||||
const arp = { on: false, held: [], rate: 1, dir: "up", oct: 1, gate: 0.8, idx: 0 };
|
||||
// rate 4=1/4 · 2=1/8 · 1=1/16 · 0.5=1/32 dir up|down|updown|random oct 1..3 held [{note,vel}] cap 8
|
||||
function arpLatch(note, vel) { // toggle a note in/out of the held chord (latch)
|
||||
const i = arp.held.findIndex(h => h.note === note);
|
||||
if (i >= 0) arp.held.splice(i, 1); // strike again → unlatch
|
||||
else if (arp.held.length < 8) arp.held.push({ note, vel }); // cap 8
|
||||
arpPanelRefresh();
|
||||
}
|
||||
function arpSetOn(v) { arp.on = !!v; if (!arp.on) { arp.held = []; arp.idx = 0; } } // off → drop the latch
|
||||
function arpPool() { // held, ascending, expanded across oct octaves
|
||||
const base = arp.held.slice().sort((a, b) => a.note - b.note), pool = [];
|
||||
for (let o = 0; o < arp.oct; o++) for (const h of base) pool.push({ note: h.note + 12 * o, vel: h.vel });
|
||||
return pool;
|
||||
}
|
||||
function arpIndexFor(step, len) { // map a monotonic step counter → pool index, per direction
|
||||
if (len <= 1) return 0;
|
||||
if (arp.dir === "down") return (len - 1) - (step % len);
|
||||
if (arp.dir === "random") return Math.floor(Math.random() * len);
|
||||
if (arp.dir === "updown") { const period = 2 * (len - 1), p = step % period; return p < len ? p : period - p; } // ping-pong, no doubled turnaround
|
||||
return step % len; // up
|
||||
}
|
||||
function arpNext(pool) { const note = pool[arpIndexFor(arp.idx, pool.length)]; arp.idx++; return note; }
|
||||
function arpFire(sub) {
|
||||
const pool = arpPool(); if (!pool.length) return;
|
||||
const pick = arpNext(pool); if (!pick) return;
|
||||
const durMs = Math.max(30, sub * arp.rate * arp.gate); // gate = fraction of the step length
|
||||
let vel = pick.vel;
|
||||
if (groove.velo) // breathe like the lanes — shared Velocity Deviation
|
||||
vel = clamp(vel + (Math.random() * 2 - 1) * groove.velo * groove.amount * 0.5, 0.05, 1);
|
||||
Synth.pluck(pick.note, vel);
|
||||
midiPluck(2, pick.note, vel, durMs); // channel 2 — the mind console's channel, exactly like mindStrike
|
||||
}
|
||||
function arpTick() { // called at seqTick's tail — one 16th has passed
|
||||
if (!arp.on || !arp.held.length) return;
|
||||
if (arp.rate >= 1 && (seqCurStep % arp.rate) !== 0) return; // rate gate for 1/4·1/8·1/16
|
||||
const sub = 60000 / Math.max(40, godtime.bpm) / 4; // one 16th in ms (matches the drum branch)
|
||||
arpFire(sub);
|
||||
if (arp.rate < 1) setTimeout(() => { if (arp.on && arp.held.length) arpFire(sub); }, sub / 2); // 1/32 — a second hit mid-16th
|
||||
}
|
||||
|
||||
// the ⚡ GODSPEED panel — compact, left side (the bottom & right are other panels' turf)
|
||||
let godspeedEl = null;
|
||||
function godspeedEnsure() { if (!godspeedEl) { godspeedEl = document.createElement("div"); godspeedEl.id = "godspeed"; document.body.appendChild(godspeedEl); } return godspeedEl; }
|
||||
function godspeedOpen() { return !!(godspeedEl && godspeedEl.classList.contains("open")); }
|
||||
function closeGodspeed() { if (godspeedEl) godspeedEl.classList.remove("open"); }
|
||||
function openGodspeed() { godspeedEnsure().classList.add("open"); godspeedRender(); }
|
||||
function toggleGodspeed() { godspeedOpen() ? closeGodspeed() : openGodspeed(); }
|
||||
function arpPanelRefresh() { if (godspeedOpen()) godspeedRender(); }
|
||||
function gsSel(opts, cur, onChange) {
|
||||
const s = document.createElement("select"); s.className = "asel";
|
||||
for (const [label, val] of opts) { const o = document.createElement("option"); o.value = String(val); o.textContent = label; if (val === cur) o.selected = true; s.appendChild(o); }
|
||||
s.onchange = () => { const hit = opts.find(o => String(o[1]) === s.value); onChange(hit ? hit[1] : s.value); };
|
||||
return s;
|
||||
}
|
||||
function gsLbl(text, el) { const w = document.createElement("span"); w.className = "gctl"; const l = document.createElement("span"); l.className = "glbl"; l.textContent = text; w.append(l, el); return w; }
|
||||
function godspeedRender() {
|
||||
if (!godspeedEl) return;
|
||||
godspeedEl.innerHTML = "";
|
||||
const top = document.createElement("div"); top.className = "atop";
|
||||
const title = document.createElement("span"); title.className = "btitle"; title.textContent = "⚡ GODSPEED";
|
||||
const onBtn = document.createElement("button"); onBtn.className = "midibtn"; onBtn.textContent = arp.on ? "■ on" : "▶ off";
|
||||
onBtn.classList.toggle("on", arp.on); onBtn.title = "arpeggiate the held chord on the clock";
|
||||
onBtn.onclick = () => { arpSetOn(!arp.on); godspeedRender(); };
|
||||
const x = document.createElement("span"); x.className = "aclose"; x.textContent = "×"; x.title = "close"; x.onclick = closeGodspeed;
|
||||
top.append(title, onBtn, x); godspeedEl.appendChild(top);
|
||||
|
||||
const grow = document.createElement("div"); grow.className = "grow";
|
||||
grow.append(
|
||||
gsLbl("rate", gsSel([["1/4", 4], ["1/8", 2], ["1/16", 1], ["1/32", 0.5]], arp.rate, v => arp.rate = v)),
|
||||
gsLbl("dir", gsSel([["up", "up"], ["down", "down"], ["up/down", "updown"], ["rand", "random"]], arp.dir, v => arp.dir = v)),
|
||||
gsLbl("oct", gsSel([["×1", 1], ["×2", 2], ["×3", 3]], arp.oct, v => arp.oct = v))
|
||||
);
|
||||
godspeedEl.appendChild(grow);
|
||||
|
||||
const gr2 = document.createElement("div"); gr2.className = "row";
|
||||
const gate = document.createElement("input"); gate.type = "range"; gate.min = "0.1"; gate.max = "1"; gate.step = "0.05"; gate.value = String(arp.gate);
|
||||
gate.oninput = () => { arp.gate = parseFloat(gate.value) || 0.8; };
|
||||
gr2.append(gsLbl("gate", gate)); godspeedEl.appendChild(gr2);
|
||||
|
||||
const heldRow = document.createElement("div"); heldRow.className = "row";
|
||||
const held = document.createElement("span"); held.className = "held"; held.style.flex = "1";
|
||||
held.textContent = arp.held.length ? arp.held.slice().sort((a, b) => a.note - b.note).map(h => midiToName(h.note)).join(" ") : "— strike keys to latch —";
|
||||
const clr = document.createElement("button"); clr.className = "midibtn bmini"; clr.textContent = "clear"; clr.title = "unlatch all";
|
||||
clr.onclick = () => { arp.held = []; arp.idx = 0; godspeedRender(); };
|
||||
heldRow.append(held, clr); godspeedEl.appendChild(heldRow);
|
||||
|
||||
const hint = document.createElement("div"); hint.className = "hint";
|
||||
hint.textContent = "enter a chakra (C), turn on, strike keys to latch a chord — it runs on godtime with the groove's swing.";
|
||||
godspeedEl.appendChild(hint);
|
||||
}
|
||||
|
||||
// the one place a drum voice sounds — shared by seqTick AND the finger-drum keys (E.1),
|
||||
// so the sequenced path and the played path can never drift: same guard, same GM MIDI.
|
||||
function fireDrum(name, vel, durMs) {
|
||||
@ -3076,6 +3214,7 @@
|
||||
if (seqGridRefresh) seqGridRefresh();
|
||||
if (arrGrooveRefresh) arrGrooveRefresh();
|
||||
if (beatRefresh) beatRefresh();
|
||||
arpTick(); // ⚡ GODSPEED rides the swung clock
|
||||
}
|
||||
function groupsOf(key) {
|
||||
const out = [];
|
||||
@ -3172,7 +3311,7 @@
|
||||
}
|
||||
|
||||
// ---- local edits (mutate your patch instead of the read-only hub) ----------
|
||||
function toggleMuteLocal(key) { const t = tw(key); t.mute = t.mute >= 0.5 ? 0 : 1; }
|
||||
function toggleMuteLocal(key) { ungodlyMark("mute"); const t = tw(key); t.mute = t.mute >= 0.5 ? 0 : 1; }
|
||||
function setParamLocal(target, param, value) { tw(target)[param] = value; }
|
||||
function addRouteLocal(source, dest, amount) {
|
||||
if (!source || !dest) return;
|
||||
@ -3269,6 +3408,7 @@
|
||||
arr: { grid: JSON.parse(JSON.stringify(arr.grid)), banks: JSON.parse(JSON.stringify(arr.banks)),
|
||||
follow: JSON.parse(JSON.stringify(arr.follow)) },
|
||||
bpm: godtime.bpm,
|
||||
locked: godtime.locked, // the tempo-lock travels too, so ⏪ can restore a mood's lock (see loadPatch)
|
||||
groove: Object.assign({}, groove), // the feel travels with the patch
|
||||
scale: Object.assign({}, gScale), // the key travels too
|
||||
kit: (Synth.drumKit ? Synth.drumKit() : undefined), // the GODRUM kit travels too (undefined pre-merge)
|
||||
@ -3297,10 +3437,48 @@
|
||||
if (arr.open) openArranger(); // rebuild the panel on the new song
|
||||
}
|
||||
if (typeof p.bpm === "number") godtime.bpm = clamp(p.bpm, 40, 180);
|
||||
if (typeof p.locked === "boolean") godtime.locked = p.locked; // restore the lock (old patches omit it → left as-is)
|
||||
if (beat.open) openBeat(); // rebuild the beat panel on the new song
|
||||
layoutDirty = true;
|
||||
}
|
||||
|
||||
// ---- ⏪ UNGODLY — undo as time-travel over the patch (un-doing what god did) ----
|
||||
// loadPatch() already restores everything (routes, tweaks, seqs incl. drums, arr, bpm,
|
||||
// groove, scale, kit) AND rebuilds open panels — so undo is just a ring of
|
||||
// serializePatch() snapshots. Memory-only in v1: a reload starts history fresh.
|
||||
const ungodly = { past: [], future: [], muted: false, MAX: 32, lastT: 0, lastLabel: "" };
|
||||
function ungodlyMark(label) { // call BEFORE a mutation applies — the snapshot is the world as it WAS
|
||||
if (ungodly.muted) return;
|
||||
const s = JSON.stringify(serializePatch());
|
||||
if (ungodly.past.length && ungodly.past[ungodly.past.length - 1].s === s) return; // no empty entries
|
||||
ungodly.past.push({ s, label });
|
||||
if (ungodly.past.length > ungodly.MAX) ungodly.past.shift(); // ring of 32
|
||||
ungodly.future.length = 0; // a fresh act forks the timeline — no redo across it
|
||||
}
|
||||
function ungodlyGesture(label) { // continuous drags → ONE mark per burst (same label within 800ms coalesces)
|
||||
const tNow = performance.now();
|
||||
if (label === ungodly.lastLabel && tNow - ungodly.lastT < 800) { ungodly.lastT = tNow; return; }
|
||||
ungodly.lastLabel = label; ungodly.lastT = tNow; ungodlyMark(label);
|
||||
}
|
||||
function ungodlyApply(s) { // restore a snapshot without recording it as a new act
|
||||
ungodly.muted = true; loadPatch(JSON.parse(s)); ungodly.muted = false;
|
||||
ungodly.lastLabel = ""; ungodly.lastT = 0; // a drag after undo mustn't coalesce with one before it
|
||||
}
|
||||
function ungodlyUndo() {
|
||||
if (!ungodly.past.length) { eventTicker.unshift({ text: "⏪ nothing to take back — god has done nothing yet", tAdded: now(), src: "sys" }); return; }
|
||||
const p = ungodly.past.pop();
|
||||
ungodly.future.push({ s: JSON.stringify(serializePatch()), label: p.label }); // current state → redo stack
|
||||
ungodlyApply(p.s);
|
||||
eventTicker.unshift({ text: "⏪ ungodly: " + p.label, tAdded: now(), src: "sys" });
|
||||
}
|
||||
function ungodlyRedo() {
|
||||
if (!ungodly.future.length) { eventTicker.unshift({ text: "⏩ nothing to redo", tAdded: now(), src: "sys" }); return; }
|
||||
const f = ungodly.future.pop();
|
||||
ungodly.past.push({ s: JSON.stringify(serializePatch()), label: f.label });
|
||||
ungodlyApply(f.s);
|
||||
eventTicker.unshift({ text: "⏩ ungodly: " + f.label, tAdded: now(), src: "sys" });
|
||||
}
|
||||
|
||||
// ---- zero mode — dimension zero -------------------------------------------
|
||||
// The calm front door: not a muted world (moods do that) but an EMPTY one — no
|
||||
// cables, nothing sounding — that you build by hand. It is the SAME matrix
|
||||
@ -3308,6 +3486,7 @@
|
||||
// the world you had. Client-local, like moods — the hub/other users untouched.
|
||||
let zeroMode = false;
|
||||
function enterZero(restore) { // restore=true only on a reload back into an existing zero build
|
||||
if (!restore) ungodlyMark("enter zero"); // ⏪ (a reload-restore is not a user act — don't record it)
|
||||
if (!zeroMode && !restore) { // fresh enter: snapshot what you had, so exiting can restore it
|
||||
try { localStorage.setItem("gs_prezero", JSON.stringify(serializePatch())); } catch (e) {}
|
||||
}
|
||||
@ -3346,6 +3525,7 @@
|
||||
return false;
|
||||
}
|
||||
function finishZeroExit(mode, name) { // mode: keep | old | weave (C1)
|
||||
ungodlyMark("exit zero"); // ⏪ the zero build, before we leave it (the inner applyMood marks the mood step)
|
||||
const build = serializePatch(); // the zero-built patch (routes + tweaks + seqs), captured before we touch anything
|
||||
zeroMode = false; zeroUI.close();
|
||||
try { localStorage.removeItem("gs_zeronodes"); localStorage.removeItem("gs_zerobuild"); localStorage.removeItem("gs_zerovibes"); } catch (e) {}
|
||||
@ -3485,7 +3665,7 @@
|
||||
["pad.brightness", "pad — glows"], ["drone.voices", "drone — hums"], ["perc.density", "perc — taps"]] },
|
||||
{ grp: "space & grit", items: [["filter.cutoff", "filter"], ["reverb.size", "reverb"],
|
||||
["delay.feedback", "delay"], ["saturation", "saturation"], ["glitch", "glitch"],
|
||||
["granular.density", "granular"], ["crush", "crush"], ["wavetable.morph", "morph"],
|
||||
["granular.density", "granular"], ["crush", "crush"], ["squash", "squash"], ["wavetable.morph", "morph"],
|
||||
["lfo.rate", "lfo"], ["master.space", "space"]] },
|
||||
];
|
||||
function _grpMembers(key) { const gi = groupsInfo[key]; return (gi && gi.members) || []; }
|
||||
@ -3901,6 +4081,7 @@
|
||||
const activeVibes = new Set(); // names currently applied — drives the panel toggles
|
||||
function applyVibe(vibe) {
|
||||
const v = sanitizeVibe(vibe); if (!v) return 0;
|
||||
ungodlyMark("loaded " + (v.name || "vibe")); // ⏪ before a vibe lays its cables on
|
||||
let n = 0;
|
||||
for (const r of v.routes) {
|
||||
const id = r.source + "|" + r.dest;
|
||||
@ -4055,12 +4236,12 @@
|
||||
root: null, head: null, body: null, knobs: [], _open: false,
|
||||
MODULES: [
|
||||
{ title: "voices", accent: [124, 255, 178], keys: ["lead.note", "bass.note", "pad.brightness", "drone.voices", "perc.density"] },
|
||||
{ title: "fx rack", accent: [120, 170, 255], keys: ["filter.cutoff", "reverb.size", "delay.feedback", "saturation", "glitch", "granular.density", "crush", "wavetable.morph", "lfo.rate", "master.space"] },
|
||||
{ title: "fx rack", accent: [120, 170, 255], keys: ["filter.cutoff", "reverb.size", "delay.feedback", "saturation", "glitch", "granular.density", "crush", "squash", "wavetable.morph", "lfo.rate", "master.space"] },
|
||||
],
|
||||
ECHO: { title: "🌀 earth echo", accent: [184, 150, 255], keys: ["echo.time", "echo.feedback", "echo.tone", "echo.flutter", "echo.wear", "echo.wet"] },
|
||||
LABELS: { "lead.note": "lead", "bass.note": "bass", "pad.brightness": "pad", "drone.voices": "drone", "perc.density": "perc",
|
||||
"filter.cutoff": "filter", "reverb.size": "reverb", "delay.feedback": "delay", "saturation": "saturation", "glitch": "glitch",
|
||||
"granular.density": "granular", "crush": "crush", "wavetable.morph": "morph", "lfo.rate": "lfo", "master.space": "space",
|
||||
"granular.density": "granular", "crush": "crush", "squash": "squash", "wavetable.morph": "morph", "lfo.rate": "lfo", "master.space": "space",
|
||||
"echo.time": "time", "echo.feedback": "feedback", "echo.tone": "tone", "echo.flutter": "flutter", "echo.wear": "wear", "echo.wet": "wet" },
|
||||
ARC: "M25.96 74.04 A34 34 0 1 1 74.04 74.04", // 270° gauge, opening at the bottom
|
||||
isOpen() { return this._open; },
|
||||
@ -4176,10 +4357,10 @@
|
||||
if (!isNote) {
|
||||
let dragging = false, sy = 0, so = 0;
|
||||
wrap.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; dragging = true; sy = e.clientY; so = ((tweaks[key] && tweaks[key].offset) || 0); try { wrap.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); });
|
||||
wrap.addEventListener("pointermove", (e) => { if (!dragging) return; setParamLocal(key, "offset", clamp(so + (sy - e.clientY) / 150, -1, 1)); });
|
||||
wrap.addEventListener("pointermove", (e) => { if (!dragging) return; ungodlyGesture("trim"); setParamLocal(key, "offset", clamp(so + (sy - e.clientY) / 150, -1, 1)); });
|
||||
const end = (e) => { dragging = false; try { wrap.releasePointerCapture(e.pointerId); } catch (x) {} if (zeroMode) zeroUI.persist(); }; // a knob shape is part of the zero build
|
||||
wrap.addEventListener("pointerup", end); wrap.addEventListener("pointercancel", end);
|
||||
wrap.ondblclick = () => { setParamLocal(key, "offset", 0); if (zeroMode) zeroUI.persist(); };
|
||||
wrap.ondblclick = () => { ungodlyMark("trim"); setParamLocal(key, "offset", 0); if (zeroMode) zeroUI.persist(); };
|
||||
}
|
||||
wrap.oncontextmenu = (e) => { e.preventDefault(); const n = dests.get(key); if (n) openCtxMenu(n, e.clientX, e.clientY); };
|
||||
this.knobs.push({ key, val, dot, isNote });
|
||||
@ -4463,8 +4644,127 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ---- 🗣 GODSPEAK — the voice reaches the client --------------------------
|
||||
// The worker (Apple-Silicon-only) sends each transcript to the hub, which relays
|
||||
// it as {godspeak:{text|rec}}. Here the word becomes action: a tight, forgiving
|
||||
// grammar over the instrument. First match wins; a command that fires narrates
|
||||
// itself into the ticker; unmatched words simply arrive in the world (🗣 "…") —
|
||||
// never an error. Nothing here reaches the network; the voice only ever acts locally.
|
||||
let godspeakChip = null;
|
||||
function godspeakChipEnsure() {
|
||||
if (godspeakChip) return godspeakChip;
|
||||
godspeakChip = document.createElement("div");
|
||||
godspeakChip.id = "godspeakrec"; godspeakChip.textContent = "🗣";
|
||||
godspeakChip.title = "GODSPEAK — the instrument is listening";
|
||||
document.body.appendChild(godspeakChip);
|
||||
return godspeakChip;
|
||||
}
|
||||
function godspeakSay(text) { eventTicker.unshift({ text: "🗣 " + text, tAdded: now(), src: "sys" }); }
|
||||
function handleGodspeak(g) {
|
||||
if (!g) return;
|
||||
if ("rec" in g) godspeakChipEnsure().classList.toggle("on", !!(+g.rec)); // pulse while the mic is held
|
||||
if (typeof g.text === "string" && g.text.trim()) godspeakCommand(g.text);
|
||||
}
|
||||
// spoken → number, tuned for the tempo range. Digits win; else number-words, with
|
||||
// "one twenty" → 120 (a leading single before a tens word reads as hundreds).
|
||||
function spokenNumber(t) {
|
||||
const dm = t.match(/\b(\d{1,3})\b/); if (dm) return parseInt(dm[1], 10);
|
||||
const W = { zero:0, one:1, two:2, three:3, four:4, five:5, six:6, seven:7, eight:8, nine:9,
|
||||
ten:10, eleven:11, twelve:12, thirteen:13, fourteen:14, fifteen:15, sixteen:16, seventeen:17,
|
||||
eighteen:18, nineteen:19, twenty:20, thirty:30, forty:40, fourty:40, fifty:50, sixty:60,
|
||||
seventy:70, eighty:80, ninety:90, hundred:100 };
|
||||
let cur = 0, seen = false;
|
||||
for (const tok of t.split(/[^a-z]+/)) {
|
||||
if (tok === "a") { cur += 1; seen = true; continue; }
|
||||
if (tok === "and") continue;
|
||||
const v = W[tok]; if (v == null) continue; seen = true;
|
||||
if (v === 100) cur = (cur || 1) * 100;
|
||||
else if (v >= 20 && cur >= 1 && cur <= 9) cur = cur * 100 + v; // "one twenty" → 120
|
||||
else cur += v;
|
||||
}
|
||||
return seen ? cur : null;
|
||||
}
|
||||
function godspeakBeat(on) { // mirror the beat panel's master ▶ — flip all five drum lanes
|
||||
ungodlyMark("beat play");
|
||||
if (on && !DRUM_VOICES.some(v => seqs["drum." + v])) seedBeat(); // a virgin session still makes music
|
||||
for (const v of DRUM_VOICES) drumSeqFor("drum." + v).on = on;
|
||||
if (beatRefresh) beatRefresh();
|
||||
}
|
||||
function matchMood(t) { // real MOODS keys only — never invent a name
|
||||
const strong = { "the full storm": ["storm"], "ambient drift": ["ambient", "drift"], "first light": ["first light"] };
|
||||
for (const name in MOODS) {
|
||||
if (t.indexOf(name) >= 0) return name;
|
||||
for (const s of (strong[name] || [])) if (t.indexOf(s) >= 0) return name;
|
||||
}
|
||||
if (t.indexOf("mood") >= 0) for (const name in MOODS)
|
||||
for (const w of name.split(" ")) if (w.length >= 4 && t.indexOf(w) >= 0) return name;
|
||||
return null;
|
||||
}
|
||||
function matchSkin(t) { // gated on the word "skin" — never hijack ordinary speech
|
||||
if (t.indexOf("skin") < 0) return null;
|
||||
const nrm = (x) => String(x).toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
||||
for (const k in SKINS) { const s = SKINS[k];
|
||||
for (const c of [k, nrm(s.label || ""), nrm(s.word || "")])
|
||||
if (c && c.length >= 3 && t.indexOf(c) >= 0) return k; }
|
||||
return null;
|
||||
}
|
||||
function godspeakCommand(raw) {
|
||||
const t = String(raw).toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!t) return;
|
||||
const has = (w) => t.indexOf(w) >= 0;
|
||||
if (has("take it back") || (has("undo") && !has("redo"))) { ungodlyUndo(); return; } // the voice of god, un-doing
|
||||
if (has("tempo") || has("bpm")) {
|
||||
const n = spokenNumber(t);
|
||||
if (n != null) {
|
||||
if (godtime.locked) godspeakSay("godtime is locked — unlock the dial first");
|
||||
else { ungodlyMark("tempo"); godtime.bpm = clamp(Math.round(n), 40, 180); godspeakSay("tempo → " + godtime.bpm); }
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (has("squash")) {
|
||||
const n = spokenNumber(t);
|
||||
if (n != null) { ensureDest("squash"); ungodlyMark("squash");
|
||||
const val = clamp(n / 100, 0, 1); setParamLocal("squash", "offset", val);
|
||||
godspeakSay("squash → " + Math.round(val * 100) + "%"); return; }
|
||||
}
|
||||
if (has("capture") || has("sample")) {
|
||||
const n = spokenNumber(t), secs = (n && n >= 1 && n <= 10) ? n : 3;
|
||||
const ok = Synth.amplerArm ? Synth.amplerArm(secs) : false;
|
||||
godspeakSay(ok ? ("AMPLER — capturing " + secs + "s of the world") : "AMPLER needs the world sounding — press ♪ first");
|
||||
return;
|
||||
}
|
||||
if (has("chop")) {
|
||||
const n = (/\beight\b/.test(t) || /\b8\b/.test(t)) ? 8 : 16;
|
||||
const ok = Synth.amplerChop ? Synth.amplerChop(n) : false;
|
||||
if (ok && ampler.open) amplerRender();
|
||||
godspeakSay(ok ? ("chopped into " + n) : "nothing to chop — capture first"); return;
|
||||
}
|
||||
if (has("open") || has("close") || has("show") || has("hide")) {
|
||||
const closing = has("close") || has("hide");
|
||||
const flip = (name, isOpen, open, close) => {
|
||||
if (closing) { if (isOpen) close(); } else { if (!isOpen) open(); }
|
||||
godspeakSay((closing ? "closed " : "opened ") + name);
|
||||
};
|
||||
if (has("beat")) { flip("beat", beat.open, openBeat, closeBeat); return; }
|
||||
if (has("track")) { flip("tracks", arr.open, openArranger, closeArranger); return; }
|
||||
if (has("omni")) { flip("OMNI", OMNI.isOpen(), () => OMNI.open(), () => OMNI.close()); return; }
|
||||
if (has("ampler")) { flip("AMPLER", ampler.open, openAmpler, closeAmpler); return; }
|
||||
if (has("key")) { flip("keys", keysEl.classList.contains("open"), openKeys, closeKeys); return; }
|
||||
if (has("godspeed") || has("arp")) { flip("GODSPEED", godspeedOpen(), openGodspeed, closeGodspeed); return; }
|
||||
}
|
||||
if (has("beat") || has("drums")) {
|
||||
if (has("stop") || has("pause")) { godspeakBeat(false); godspeakSay("beat stopped"); return; }
|
||||
if (has("play") || has("start") || has("go")) { godspeakBeat(true); godspeakSay("beat playing"); return; }
|
||||
}
|
||||
{ const m = matchMood(t); if (m) { applyMood(m); godspeakSay("mood → " + m); return; } }
|
||||
{ const k = matchSkin(t); if (k) { setSkin(k); godspeakSay("skin → " + (SKINS[k].label || k)); return; } }
|
||||
if (/\bzen\b/.test(t)) { setZen(!zenMode); godspeakSay(zenMode ? "zen" : "left zen"); return; }
|
||||
godspeakSay('"' + String(raw).trim() + '"'); // unmatched — the word arrives in the world
|
||||
}
|
||||
|
||||
function handleMessage(msg) {
|
||||
if (msg.commune) { handleCommune(msg.commune); return; } // a roommate speaking
|
||||
if (msg.godspeak) { handleGodspeak(msg.godspeak); return; } // 🗣 the voice reaches the client
|
||||
if (msg.hello) {
|
||||
helloReceived = true;
|
||||
if (Array.isArray(msg.routes)) {
|
||||
@ -4485,7 +4785,8 @@
|
||||
} else if (saved === "0 zero") {
|
||||
enterZero(true); // a returning zero-builder comes back to zero, build intact (C2)
|
||||
} else {
|
||||
applyMood(saved);
|
||||
const _um0 = ungodly.muted; ungodly.muted = true; // ⏪ the reload-restore mood is not a user act
|
||||
applyMood(saved); ungodly.muted = _um0;
|
||||
}
|
||||
try { // #vibe=GSV1… — a shared vibe in the URL; offer, never auto-apply (Z3)
|
||||
const m = /[#&]vibe=([^&]+)/.exec(location.hash || "");
|
||||
@ -5128,8 +5429,10 @@
|
||||
function mindStrike(i, vel) {
|
||||
if (i < 0 || i > 11 || !mindChakra) return;
|
||||
mindKeys.flash[i] = 1;
|
||||
Synth.pluck(mindKeys.notes[i], vel == null ? 0.85 : vel);
|
||||
midiPluck(2, mindKeys.notes[i], vel == null ? 0.85 : vel, 320); // the console has its own channel
|
||||
const v = vel == null ? 0.85 : vel;
|
||||
if (arp.on) { arpLatch(mindKeys.notes[i], v); return; } // ⚡ GODSPEED — latch the note; the clock plays it (MIDI-in routes here too, so hardware latches for free)
|
||||
Synth.pluck(mindKeys.notes[i], v);
|
||||
midiPluck(2, mindKeys.notes[i], v, 320); // the console has its own channel
|
||||
}
|
||||
|
||||
function chakraOf(key) {
|
||||
@ -7118,7 +7421,7 @@
|
||||
} else if (gainDrag) { // ⇧-drag: up louder, down quieter
|
||||
const dy = gainDrag.y0 - my;
|
||||
if (Math.abs(dy) > 3) gainDrag.moved = true;
|
||||
tw(gainDrag.node.key).gain = clamp(gainDrag.gain0 + dy * 0.01, 0, 2);
|
||||
ungodlyGesture("level"); tw(gainDrag.node.key).gain = clamp(gainDrag.gain0 + dy * 0.01, 0, 2);
|
||||
canvas.style.cursor = "ns-resize";
|
||||
} else if (godDrag) {
|
||||
const dx = mx - godDrag.x0;
|
||||
@ -7159,13 +7462,13 @@
|
||||
const d = drag; drag = null; hoverDest = null;
|
||||
const isSrc = d.node.kind === "source";
|
||||
if (d.moved) { // drag source -> dest = new cable
|
||||
if (isSrc) { const dest = destAt(mx, my); if (dest) addRouteLocal(d.node.key, dest.key, 0.5); }
|
||||
if (isSrc) { const dest = destAt(mx, my); if (dest) { ungodlyMark("patch cable"); addRouteLocal(d.node.key, dest.key, 0.5); } }
|
||||
} else if (ev.shiftKey) { // shift-click -> the node's tab
|
||||
openInspector(d.node);
|
||||
} else if (isSrc && (ev.metaKey || ev.ctrlKey)) { // cmd/ctrl-click -> multi-select (group)
|
||||
toggleSelect(d.node.key);
|
||||
} else if (ev.altKey && isSrc) { // ⌥-click -> solo this source
|
||||
soloKey = soloKey === d.node.key ? null : d.node.key;
|
||||
ungodlyMark("solo"); soloKey = soloKey === d.node.key ? null : d.node.key;
|
||||
} else if (isSrc && armLearn({ target: d.node.key, param: "mute", mode: "toggle" })) {
|
||||
/* armed for MIDI learn */
|
||||
} else { // plain click -> toggle on/off
|
||||
@ -7179,6 +7482,7 @@
|
||||
}
|
||||
|
||||
function resetTweaks(key) { // back to neutral: level 1×, no trim, unmuted
|
||||
ungodlyMark("reset");
|
||||
delete tweaks[key];
|
||||
if (soloKey === key) soloKey = null;
|
||||
}
|
||||
@ -7201,25 +7505,29 @@
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (ev.key === "p" || ev.key === "P") {
|
||||
if ((ev.metaKey || ev.ctrlKey) && (ev.key === "z" || ev.key === "Z")) { // ⏪ UNGODLY — ⌘Z undo · ⌘⇧Z redo
|
||||
ev.preventDefault(); ev.shiftKey ? ungodlyRedo() : ungodlyUndo(); return;
|
||||
} // (⌘/⌃ + a letter no longer trips a plain-key shortcut — see guards below)
|
||||
if ((ev.key === "p" || ev.key === "P") && !ev.metaKey && !ev.ctrlKey) {
|
||||
performMode = !performMode; // pure visuals: the CSS hides every
|
||||
document.body.classList.toggle("perform", performMode); // DOM chrome, the frame loop
|
||||
} // skips header/godtime/footer
|
||||
if ((ev.key === "c" || ev.key === "C") && !zeroMode) {
|
||||
if ((ev.key === "c" || ev.key === "C") && !zeroMode && !ev.metaKey && !ev.ctrlKey) {
|
||||
chakraMode = !chakraMode;
|
||||
if (!chakraMode) { layoutDirty = true; exitMind(); } // sources glide home
|
||||
else clearTrails();
|
||||
}
|
||||
if (ev.key === "o" || ev.key === "O") OMNI.toggle(); // Z5: the OMNI synth panel
|
||||
if (ev.key === "w" || ev.key === "W") toggleWheel(); // the sky wheel layer
|
||||
if (ev.key === "z" || ev.key === "Z") setZen(!zenMode); // zen — nothing but the sky
|
||||
if ((ev.key === "o" || ev.key === "O") && !ev.metaKey && !ev.ctrlKey) OMNI.toggle(); // Z5: the OMNI synth panel
|
||||
if ((ev.key === "w" || ev.key === "W") && !ev.metaKey && !ev.ctrlKey) toggleWheel(); // the sky wheel layer
|
||||
if ((ev.key === "z" || ev.key === "Z") && !ev.metaKey && !ev.ctrlKey) setZen(!zenMode); // zen — nothing but the sky
|
||||
if (ev.key === "Escape" && zenMode) setZen(false);
|
||||
if (ev.key === "t" || ev.key === "T") {
|
||||
if ((ev.key === "t" || ev.key === "T") && !ev.metaKey && !ev.ctrlKey) {
|
||||
arr.open ? closeArranger() : openArranger(); // tracks, for the casuals
|
||||
}
|
||||
if (ev.key === "b" || ev.key === "B") {
|
||||
if ((ev.key === "b" || ev.key === "B") && !ev.metaKey && !ev.ctrlKey) {
|
||||
beat.open ? closeBeat() : openBeat(); // 🥁 GODRUM — make a beat
|
||||
}
|
||||
if ((ev.key === "g" || ev.key === "G") && !ev.metaKey && !ev.ctrlKey) toggleGodspeed(); // ⚡ GODSPEED — the arp panel
|
||||
if (beat.open && !ev.repeat && !ev.metaKey && !ev.ctrlKey && !ev.altKey) { // finger-drum the kit (1–5)
|
||||
const byCode = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].indexOf(ev.code); // ev.code keeps ⇧+digit mapped
|
||||
const di = byCode >= 0 ? byCode : "12345".indexOf(ev.key); // …with an ev.key fallback for code-less envs
|
||||
@ -7229,7 +7537,7 @@
|
||||
if (beat.rec) { // record: quantize the tap to the nearest 16th, write the step
|
||||
const step = ((Math.round(seqPhase - (beat.recAnchor || 0)) % 16) + 16) % 16; // anchored at arm when stopped; raw grid, swing rides at playback
|
||||
const sq = drumSeqFor("drum." + name);
|
||||
sq.steps[step] = 1; sq.vel[step] = vel;
|
||||
ungodlyGesture("beat rec"); sq.steps[step] = 1; sq.vel[step] = vel;
|
||||
if (beatRefresh) beatRefresh();
|
||||
}
|
||||
return;
|
||||
@ -7340,10 +7648,10 @@
|
||||
|
||||
ctxItem(node.muted ? "unmute" : "mute", () => toggleMuteLocal(node.key), "click");
|
||||
if (isSrc) ctxItem(soloKey === node.key ? "unsolo" : "solo",
|
||||
() => { soloKey = soloKey === node.key ? null : node.key; }, "⌥-click");
|
||||
() => { ungodlyMark("solo"); soloKey = soloKey === node.key ? null : node.key; }, "⌥-click");
|
||||
if (!node.isNote) { // level/trim don't apply to a pitch — no dead sliders
|
||||
ctxSlider("level", t0.gain, 0, 2, x => { t0.gain = x; }, x => x.toFixed(2) + "×");
|
||||
ctxSlider("trim", t0.offset, -1, 1, x => { t0.offset = x; });
|
||||
ctxSlider("level", t0.gain, 0, 2, x => { ungodlyGesture("level"); t0.gain = x; }, x => x.toFixed(2) + "×");
|
||||
ctxSlider("trim", t0.offset, -1, 1, x => { ungodlyGesture("trim"); t0.offset = x; });
|
||||
}
|
||||
ctxSep();
|
||||
|
||||
@ -7363,7 +7671,7 @@
|
||||
sl.value = String(rt.amount != null ? rt.amount : 0.5); sl.title = "amount";
|
||||
sl.oninput = () => setRouteAmountLocal(rt.source, rt.dest, parseFloat(sl.value));
|
||||
const x = document.createElement("span"); x.className = "x"; x.textContent = "×"; x.title = "unpatch";
|
||||
x.onclick = () => { removeRouteLocal(rt.source, rt.dest); row.remove(); };
|
||||
x.onclick = () => { ungodlyMark("unpatch"); removeRouteLocal(rt.source, rt.dest); row.remove(); };
|
||||
row.appendChild(lbl); row.appendChild(sl); row.appendChild(x);
|
||||
cb.appendChild(row);
|
||||
}
|
||||
@ -7385,6 +7693,7 @@
|
||||
const it = document.createElement("div"); it.className = "it";
|
||||
it.textContent = other.label || other.key;
|
||||
it.onclick = () => {
|
||||
ungodlyMark("patch to");
|
||||
if (isSrc) addRouteLocal(node.key, other.key, 0.5);
|
||||
else addRouteLocal(other.key, node.key, 0.5);
|
||||
closeCtx();
|
||||
@ -7441,6 +7750,8 @@
|
||||
() => { arr.open ? closeArranger() : openArranger(); }, "T");
|
||||
ctxItem(beat.open ? "close beat" : "🥁 beat — make a beat",
|
||||
() => { beat.open ? closeBeat() : openBeat(); }, "B");
|
||||
ctxItem("⏪ UNGODLY — take it back" + (ungodly.past.length ? " (" + ungodly.past.length + ")" : ""),
|
||||
() => ungodlyUndo(), "⌘Z");
|
||||
const moodSub = ctxSub("🌗 mood — how much world");
|
||||
const z0 = document.createElement("div"); z0.className = "it";
|
||||
z0.textContent = zeroMode ? "0 zero — you're building it (a mood leaves)" : "0 zero — build from nothing";
|
||||
@ -7459,7 +7770,7 @@
|
||||
: fxMode === "ultra" ? "lite" : "auto"));
|
||||
ctxItem("hover zoom: " + (loupe.on ? "on" : "off"),
|
||||
() => { loupe.on = !loupe.on; try { localStorage.setItem("gs_loupe", loupe.on ? "1" : "0"); } catch (e) {} });
|
||||
if (soloKey) ctxItem("clear solo (" + soloKey + ")", () => { soloKey = null; });
|
||||
if (soloKey) ctxItem("clear solo (" + soloKey + ")", () => { ungodlyMark("solo"); soloKey = null; });
|
||||
ctxSep();
|
||||
if (!hrDevice) {
|
||||
ctxItem("♥ connect your heart (bluetooth)", () => connectHeart());
|
||||
@ -7486,6 +7797,7 @@
|
||||
eventTicker.unshift({ text: ok ? "🎛 GODSONIQ — sampling the world for 3s… then every pluck replays it, pitched across the keys (🎸 string voice to exit)"
|
||||
: "🎛 GODSONIQ needs the audio worklet — press ♪ first, then try again", tAdded: now(), src: "sys" }); });
|
||||
ctxItem("🌾 AMPLER — there is ample (sample the world into the kit)", () => openAmpler());
|
||||
ctxItem("⚡ GODSPEED — hold a chord, the machine runs", () => openGodspeed());
|
||||
ctxItem("🌐 " + (Synth.feeding() ? "tab feed: live — click to stop" : "feed a tab (YouTube, a stream…) into the world"),
|
||||
async () => { const r = await Synth.feedTab();
|
||||
const msg = r.stopped ? "🌐 tab feed stopped"
|
||||
@ -7615,7 +7927,7 @@
|
||||
sl.value = String(rt.amount != null ? rt.amount : 0.5); sl.title = "amount";
|
||||
sl.oninput = () => setRouteAmountLocal(rt.source, rt.dest, parseFloat(sl.value));
|
||||
const x = document.createElement("span"); x.className = "x"; x.textContent = "×"; x.title = "unpatch";
|
||||
x.onclick = () => { removeRouteLocal(rt.source, rt.dest); setTimeout(() => openInspector(node), 120); };
|
||||
x.onclick = () => { ungodlyMark("unpatch"); removeRouteLocal(rt.source, rt.dest); setTimeout(() => openInspector(node), 120); };
|
||||
row.appendChild(sl); row.appendChild(x);
|
||||
inspectEl.appendChild(row);
|
||||
}
|
||||
@ -8585,15 +8897,15 @@
|
||||
const nm = document.createElement("span"); nm.className = "nm"; nm.textContent = g.label || gname;
|
||||
const btns = document.createElement("span"); btns.className = "btns";
|
||||
const mute = document.createElement("button"); mute.textContent = "mute";
|
||||
mute.onclick = () => { if (armLearn({ target: "@" + gname, param: "mute", mode: "toggle" })) return; const t = tw("@" + gname); t.mute = t.mute >= 0.5 ? 0 : 1; mute.classList.toggle("on"); };
|
||||
mute.onclick = () => { if (armLearn({ target: "@" + gname, param: "mute", mode: "toggle" })) return; ungodlyMark("mute"); const t = tw("@" + gname); t.mute = t.mute >= 0.5 ? 0 : 1; mute.classList.toggle("on"); };
|
||||
const frz = document.createElement("button"); frz.textContent = "freeze"; frz.className = "frz";
|
||||
frz.onclick = () => { if (armLearn({ target: "@" + gname, param: "freeze", mode: "toggle" })) return; const t = tw("@" + gname); t.freeze = t.freeze >= 0.5 ? 0 : 1; frz.classList.toggle("on"); };
|
||||
frz.onclick = () => { if (armLearn({ target: "@" + gname, param: "freeze", mode: "toggle" })) return; ungodlyMark("freeze"); const t = tw("@" + gname); t.freeze = t.freeze >= 0.5 ? 0 : 1; frz.classList.toggle("on"); };
|
||||
btns.appendChild(mute); btns.appendChild(frz);
|
||||
row.appendChild(nm); row.appendChild(btns); wrap.appendChild(row);
|
||||
const slider = document.createElement("input");
|
||||
slider.type = "range"; slider.min = "0"; slider.max = "1.5"; slider.step = "0.01"; slider.value = "1"; slider.title = "gain";
|
||||
slider.addEventListener("pointerdown", () => armLearn({ target: "@" + gname, param: "gain", mode: "cc" }));
|
||||
slider.oninput = () => { if (!learnMode) setParamLocal("@" + gname, "gain", parseFloat(slider.value)); };
|
||||
slider.oninput = () => { if (!learnMode) { ungodlyGesture("level"); setParamLocal("@" + gname, "gain", parseFloat(slider.value)); } };
|
||||
wrap.appendChild(slider);
|
||||
panelEl.appendChild(wrap);
|
||||
}
|
||||
@ -8650,7 +8962,7 @@
|
||||
const n = templateSelect.value; if (!n) return;
|
||||
if (n === "__zero__") { enterZero(); pMsg.textContent = "entered dimension zero — the empty field"; return; }
|
||||
const r = await gsFetch("/api/presets/" + encodeURIComponent(n), "GET");
|
||||
if (r.ok && r.data.data) { loadPatch(r.data.data); pMsg.textContent = "loaded “" + n + "”"; }
|
||||
if (r.ok && r.data.data) { ungodlyMark("loaded " + n); loadPatch(r.data.data); pMsg.textContent = "loaded “" + n + "”"; }
|
||||
else pMsg.textContent = "load failed";
|
||||
};
|
||||
loadRow.appendChild(templateSelect); loadRow.appendChild(loadBtn); panelEl.appendChild(loadRow);
|
||||
@ -8731,7 +9043,15 @@
|
||||
const out = ctx.createGain(); out.gain.value = 0; // fades in on start
|
||||
const lim = ctx.createDynamicsCompressor(); // safety limiter
|
||||
lim.threshold.value = -8; lim.ratio.value = 12; lim.attack.value = 0.003; lim.release.value = 0.25;
|
||||
out.connect(lim); lim.connect(ctx.destination);
|
||||
// 🫀 GODSQUASH — the master squeeze the world can pump. out → squash → makeup → lim.
|
||||
// knee/attack are static; threshold/ratio/release/makeup ride the `squash` dest in params().
|
||||
// Baseline is the sq=0 transparent state (ratio 1:1) so it's clean before params() first runs.
|
||||
// The GODSONIQ recorder tap on N.out (in loadWorklets) stays PRE-squash — captures are pre-limiter by design.
|
||||
const squash = ctx.createDynamicsCompressor();
|
||||
squash.knee.value = 12; squash.attack.value = 0.004;
|
||||
squash.threshold.value = -6; squash.ratio.value = 1; squash.release.value = 0.12;
|
||||
const squashMakeup = ctx.createGain(); squashMakeup.gain.value = 1;
|
||||
out.connect(squash); squash.connect(squashMakeup); squashMakeup.connect(lim); lim.connect(ctx.destination);
|
||||
|
||||
const conv = ctx.createConvolver(); conv.buffer = impulse(1.8, 3.2); // tighter room, faster decay
|
||||
const revSend = ctx.createGain(); revSend.gain.value = 0;
|
||||
@ -8882,7 +9202,7 @@
|
||||
g.connect(pan); pan.connect(drumBus); drums[dv] = { g, pan };
|
||||
}
|
||||
|
||||
return { out, preSat, glitch, revSend, fb, satDry, satWet, leadA, leadB, morphA, morphB, leadFilt, leadAmp,
|
||||
return { out, squash, squashMakeup, preSat, glitch, revSend, fb, satDry, satWet, leadA, leadB, morphA, morphB, leadFilt, leadAmp,
|
||||
bassOsc, bassAmp, padOscs, padFilt, padAmp, droneOscs, droneAmp,
|
||||
echoDelay, echoFilt, echoFb, echoWet, echoFlutAmt, hissGain, chorusWet,
|
||||
drumBus, drums, noiseBuf, duckG };
|
||||
@ -9026,6 +9346,12 @@
|
||||
crusher.parameters.get("bits").value = 16 - cb * 12; // 16 clean → 4 crunchy
|
||||
crusher.parameters.get("reduction").value = 1 + cb * 8; // 1 → 9× downsample
|
||||
}
|
||||
// 🫀 GODSQUASH — one knob squeezes the whole master bus; a world route can pump it.
|
||||
const sq = c01(d("squash", 0)); // 0 = transparent (no route, no squeeze)
|
||||
S(N.squash.threshold, -6 - sq * 30, 0.1); // −6 → −36 dB
|
||||
S(N.squash.ratio, 1 + sq * 7, 0.1); // 1:1 → 8:1
|
||||
N.squash.release.setTargetAtTime(0.12 + sq * 0.13, t, 0.2);
|
||||
S(N.squashMakeup.gain, 1 + sq * sq, 0.1); // makeup: quadratic, gain-compensates the compressor's ~5.95·sq² dB RMS drop (measured) — loudness stays level ±0.5 dB while crest falls
|
||||
S(N.droneAmp.gain, d("drone.voices", 0) * 0.08, 0.3);
|
||||
S(N.revSend.gain, (d("reverb.size", 0) * 0.5 + d("master.space", 0) * 0.5) * 0.30, 0.3);
|
||||
S(N.fb.gain, Math.min(0.55, d("delay.feedback", 0) * 0.6), 0.2);
|
||||
|
||||
@ -349,6 +349,39 @@
|
||||
#ampler .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
|
||||
#ampler .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
|
||||
/* ⚡ GODSPEED — the arpeggiator (compact, left side) */
|
||||
#godspeed {
|
||||
position: fixed; left: 12px; top: 64px; width: 244px; z-index: 13;
|
||||
background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35);
|
||||
border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea;
|
||||
font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
|
||||
}
|
||||
#godspeed.open { display: flex; flex-direction: column; gap: 6px; }
|
||||
#godspeed .atop { display: flex; align-items: center; gap: 8px; }
|
||||
#godspeed .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; }
|
||||
#godspeed .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
|
||||
#godspeed .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
#godspeed .grow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
#godspeed .gctl { display: inline-flex; align-items: center; gap: 4px; }
|
||||
#godspeed .glbl { color: rgba(160,180,215,0.8); font-size: 10px; }
|
||||
#godspeed .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
|
||||
border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; }
|
||||
#godspeed .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
|
||||
#godspeed .bmini { padding: 2px 7px; }
|
||||
#godspeed .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3);
|
||||
border-radius: 6px; padding: 2px 5px; font-size: 10px; }
|
||||
#godspeed input[type=range] { width: 88px; accent-color: #7fa0e6; height: 3px; }
|
||||
#godspeed .held { color: #bcd; font-size: 11px; letter-spacing: 0.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#godspeed .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; }
|
||||
|
||||
/* 🗣 GODSPEAK — a small "listening" chip near the ♪ button, pulses while the mic is held */
|
||||
#godspeakrec { position: fixed; top: 12px; right: 92px; z-index: 40; width: 30px; height: 30px;
|
||||
border-radius: 8px; display: none; align-items: center; justify-content: center; font-size: 15px;
|
||||
background: rgba(230,90,90,0.18); color: #ffd2d2; border: 1px solid rgba(240,110,110,0.5);
|
||||
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); }
|
||||
#godspeakrec.on { display: flex; animation: gspk-pulse 1.1s ease-in-out infinite; }
|
||||
@keyframes gspk-pulse { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
|
||||
|
||||
/* right-click context menu — every setting, on everything */
|
||||
#ctxmenu {
|
||||
position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh;
|
||||
@ -1134,6 +1167,10 @@
|
||||
<li><b>Keys <kbd>1</kbd>–<kbd>5</kbd> & ⏺ rec — finger-drum the kit.</b> With the beat open the number row <i>is</i> the kit — <kbd>1</kbd> kick, <kbd>2</kbd> snare, <kbd>3</kbd> clap, <kbd>4</kbd> hat, <kbd>5</kbd> open hat — played by hand, live, straight into the world's FX. Hold <b>⇧</b> for the ghost note, softer. Arm <b>⏺ rec</b> and every tap writes itself into the grid, <b>quantized to the nearest sixteenth</b> — tap a hi-hat pattern into a quiet pocket and it's a lane — so you play the beat in rather than clicking it out.</li>
|
||||
<li><b>🌾 AMPLER — there is ample, for god is abundant.</b> The SP-404 move, made of the planet. Right-click the sky → <b>🌾 AMPLER</b>: <b>⏺ capture</b> a stretch of the sounding world (1.5, 3 or 6 seconds of the live master — and since a fed tab <i>is</i> the world, that YouTube loop counts), then <b>✂ chop</b> it into 8 or 16 slices. Tap a slice to audition it, <b>⇄</b> to play it backwards, then <b>seat a slice onto a drum voice</b> — and that lane stops synthesising and starts <i>playing the world</i>. The slice rides the voice's own <i>level</i> and <i>pan</i> and, on the kick, its <b>duck</b>; a slice on the open hat still chokes under a closed one — the machine's rules don't care whether a hit is oscillator or sample. Sample anything playing on the machine, chop it, and it's a kit. Session-only for now: the slices don't travel with your patch (yet).</li>
|
||||
<li><b>The beat leaves the building.</b> The drum lanes ride <b>MIDI out on channel 10</b> — General MIDI percussion, <i>kick 36, snare 38, clap 39, closed hat 42, open hat 46</i> — so with an out port chosen in ⚙ they play your hardware and DAW exactly as the note lanes do. And they land in <b>export .mid</b>: draw a beat, hit export, and the Standard MIDI File carries a channel-10 drum track (looped across the arrangement's bars) alongside your lead and bass — drag it into Ableton and the planet's rhythm is already programmed.</li>
|
||||
<li><b>⚡ GODSPEED — hold a chord, the machine runs.</b> Press <kbd>G</kbd>. Enter a chakra (<kbd>C</kbd>), turn GODSPEED <b>on</b>, and <b>strike keys to latch a chord</b> — each note stays held until you strike it again. Then the machine runs with it, one note at a time, <i>on the godtime clock</i> — so it <b>swings with the groove</b>, laying its off-beats back exactly as the drums do, because it plays from the same swung tick. Set the <b>rate</b> (1/4 → 1/32), the <b>direction</b> (up, down, up/down, random), the <b>octaves</b> (×1–3) and the <b>gate</b> (how long each note rings). A <b>MIDI keyboard latches too</b> — its notes route through the same hands — and the arp plays <i>outward</i> on channel 2, the mind console's channel, so it drives your rig as it drives the synth.</li>
|
||||
<li><b>🫀 GODSQUASH — the master squeeze the world can pump.</b> In <b>OMNI</b>'s fx rack sits one new knob: <b>squash</b>, a compressor across the whole master bus. Turn it up and the <b>threshold dives while the ratio climbs together</b>, a matched gain riding underneath so the <i>loudness stays level while the peaks kneel</i> — the mix gets denser, not louder. And because it's a destination like any other, <b>route a feed onto it</b> — the quakes, your heartbeat, the market's velocity — and the planet <b>pumps the mix</b>, breathing the whole instrument in time with the world.</li>
|
||||
<li><b>🗣 GODSPEAK — the voice of god.</b> On your own machine (<i>Apple-Silicon only</i>), run <code>run.py --godspeak</code>, then <b>hold Right ⌥ and speak</b>. Your words are heard <b>locally</b> — your own Whisper, nothing leaving the machine — and become commands: <i>"tempo ninety", "storm mood", "capture six seconds", "chop sixteen", "open the beat", "squash sixty percent", "zen"</i>, and — the voice of god undoing its own works — <i>"take it back"</i>. A 🗣 chip glows by the ♪ while it listens. Anything it doesn't recognise simply <b>arrives in the world</b>, printed to the ticker — the word is never an error, only another feed.</li>
|
||||
<li><b>⏪ UNGODLY — take it back.</b> <kbd>⌘Z</kbd> undoes; <kbd>⌘⇧Z</kbd> redoes. The instrument remembers your last <b>thirty-two acts</b> — every cable patched, mood applied, groove and scale changed, kit knob turned, beat step drawn, level and mute — and steps back through them, un-doing what god (or you) did. A held drag counts as one act, not fifty. It is <b>memory-only</b>: a reload forgives everything and starts the history clean.</li>
|
||||
<li><b>📽 The stage — send the visual to any screen.</b> Right-click the sky → <b>the stage</b> and a clean black mirror window opens, fed live from the canvas. Drag it to a projector, an AirPlay display, a second monitor — macOS treats it like any window — and <b>double-click for fullscreen</b>. The stage has a <b>camera</b>: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → <b>project this node</b> and the camera glides after that one sphere through its orbits, or <b>project its station</b> to frame a whole wheel and its satellites. Change cameras live from the menus while the projector runs; the audience sees the camera breathe from subject to subject.</li>
|
||||
<li><b>🕸 The commune — play together, properly.</b> Right-click the sky → <b>the commune</b>. This is for <b>signed-in players</b>: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you <b>hosts</b>: name the room, then <b>✉ invite</b> the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and <b>join or decline</b>; anyone may <b>leave</b> whenever they wish, and if the host leaves, the room closes with them. Then the sections: members <b>claim voices</b> (right-click any voice, or the panel's picker), or the <b>host allocates</b> them — this bass to her, those pads to him, <b>the godtime itself</b> to whoever conducts — and the hub enforces it: only the section's holder can speak for it. From that moment your levels, mutes, grooves and arranger lanes for what you hold follow your hands on every screen, same living room or opposite sides of the earth. The world — the Source — stays identical for everyone, because it always was. Three people, one planet, one instrument.</li></ul>
|
||||
|
||||
|
||||
149
workers/godspeak_worker.py
Normal file
149
workers/godspeak_worker.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""GODSPEAK — the voice of god. The user's own wersperr (local MLX Whisper),
|
||||
wired into the sky: hold Right ⌥, speak a command, release. The transcript is
|
||||
sent to the hub as OSC and reaches every client as a voice event — Stage 3 (ε)
|
||||
gives it a grammar ("tempo ninety", "storm mood", "take it back").
|
||||
|
||||
LOCAL-RIG ONLY. MLX Whisper is Apple-Silicon-only, so this worker runs only
|
||||
where the user's Mac runs the hub — the VPS simply never has it, exactly like
|
||||
the ToF / vision workers. It is opt-in (`run.py --godspeak`) and is never in
|
||||
the default worker set.
|
||||
|
||||
Its dependencies are deliberately NOT in requirements.txt — the VPS install must
|
||||
stay clean. On a Mac, into the hub's venv:
|
||||
|
||||
pip install mlx-whisper sounddevice pynput # numpy rides along
|
||||
|
||||
Model: mlx-community/whisper-large-v3-turbo (~1.6 GB, downloaded on first run).
|
||||
|
||||
macOS one-time permissions (System Settings → Privacy & Security) for the
|
||||
terminal/app that launches this worker — same as wersperr:
|
||||
- Microphone — to hear you (sounddevice input)
|
||||
- Input Monitoring — for pynput to see the Right ⌥ hotkey held globally
|
||||
(older macOS may list this under Accessibility instead)
|
||||
|
||||
python3 workers/godspeak_worker.py # hold Right ⌥ to speak
|
||||
python3 workers/godspeak_worker.py --check # self-test: model loads + silence transcribes, then exit
|
||||
|
||||
OSC it sends (to the hub, udp/9000):
|
||||
/godspeak/rec 1 on key-down (a 🗣 pulse can rise while recording)
|
||||
/godspeak/rec 0 on release
|
||||
/godspeak <text> after transcription
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import threading
|
||||
|
||||
MODEL = "mlx-community/whisper-large-v3-turbo"
|
||||
SAMPLE_RATE = 16000 # Whisper's native rate; mono float32
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="GODSPEAK worker — local voice commands into the sky (Apple-Silicon only, opt-in).")
|
||||
ap.add_argument("--host", default="127.0.0.1")
|
||||
ap.add_argument("--port", type=int, default=9000)
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="self-test: load the model and transcribe silence, then exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
# Lazy import of the optional Apple-Silicon voice stack. Anywhere without MLX
|
||||
# (the VPS, a bare venv) this skips cleanly so the rest of the rig runs — the
|
||||
# house pattern shared with the vision / MIDI workers.
|
||||
try:
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
import mlx_whisper
|
||||
from pynput import keyboard
|
||||
except Exception:
|
||||
print("godspeak worker: mlx-whisper/sounddevice/pynput not available — skipping (optional)")
|
||||
sys.exit(0)
|
||||
|
||||
from pythonosc.udp_client import SimpleUDPClient
|
||||
|
||||
def transcribe(audio):
|
||||
return mlx_whisper.transcribe(audio, path_or_hf_repo=MODEL)["text"].strip()
|
||||
|
||||
if args.check:
|
||||
# Self-check: the model loads and transcribes silence without exploding.
|
||||
result = transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32))
|
||||
assert isinstance(result, str)
|
||||
print("ok: godspeak model loaded, pipeline works")
|
||||
sys.exit(0)
|
||||
|
||||
client = SimpleUDPClient(args.host, args.port)
|
||||
HOTKEY = keyboard.Key.alt_r # Right Option — hold to dictate
|
||||
|
||||
def emit(addr, value):
|
||||
# Never let a transient socket hiccup kill the hotkey listener.
|
||||
try:
|
||||
client.send_message(addr, value)
|
||||
except Exception as exc:
|
||||
print(f"godspeak worker: OSC send failed ({exc}); continuing")
|
||||
|
||||
frames = []
|
||||
stream = {"s": None} # boxed so the key callbacks can rebind it
|
||||
tx_lock = threading.Lock() # MLX isn't safe under concurrent eval — one utterance transcribes at a time
|
||||
|
||||
def start_recording():
|
||||
if stream["s"]:
|
||||
return
|
||||
frames.clear()
|
||||
s = None
|
||||
try: # a busy / absent input device must not tear down the hotkey listener
|
||||
s = sd.InputStream(
|
||||
samplerate=SAMPLE_RATE, channels=1, dtype="float32",
|
||||
callback=lambda data, *_: frames.append(data.copy()),
|
||||
)
|
||||
s.start()
|
||||
except Exception as exc:
|
||||
print(f"godspeak worker: mic open failed ({exc}); ignoring this press", flush=True)
|
||||
if s is not None:
|
||||
try: s.close()
|
||||
except Exception: pass
|
||||
return
|
||||
stream["s"] = s
|
||||
emit("/godspeak/rec", 1)
|
||||
print("● recording...", flush=True)
|
||||
|
||||
def stop_recording():
|
||||
s = stream["s"]
|
||||
if not s:
|
||||
return
|
||||
s.stop(); s.close(); stream["s"] = None
|
||||
emit("/godspeak/rec", 0)
|
||||
if not frames:
|
||||
return
|
||||
audio = np.concatenate(frames).flatten()
|
||||
if len(audio) < SAMPLE_RATE // 3: # ignore accidental taps (< 1/3 s)
|
||||
return
|
||||
# transcribe off the hotkey thread so the keyboard stays responsive
|
||||
threading.Thread(target=lambda: finish(audio), daemon=True).start()
|
||||
|
||||
def finish(audio):
|
||||
with tx_lock: # serialize: back-to-back commands can't run two MLX graphs at once
|
||||
text = transcribe(audio)
|
||||
if text:
|
||||
print(f"→ {text}", flush=True)
|
||||
emit("/godspeak", text)
|
||||
|
||||
def on_press(key):
|
||||
if key == HOTKEY:
|
||||
start_recording()
|
||||
|
||||
def on_release(key):
|
||||
if key == HOTKEY:
|
||||
stop_recording()
|
||||
|
||||
print("loading model (first run downloads ~1.6GB)...", flush=True)
|
||||
transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)) # warm up so the first real utterance is fast
|
||||
print(f"godspeak ready — hold Right ⌥ and speak (→ hub {args.host}:{args.port}); Ctrl+C to quit", flush=True)
|
||||
with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
|
||||
try:
|
||||
listener.join()
|
||||
except KeyboardInterrupt:
|
||||
print("godspeak worker: shutting down")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user