From 0e17dbf793c6e5041650ca535fc18d9e83de430e Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Mon, 13 Jul 2026 13:27:17 +1000 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9A=A1=20pantheon:=20GODSPEED=20?= =?UTF-8?q?=E2=80=94=20the=20arpeggiator=20(Stage=201=CE=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold a chord in mind mode, the machine runs with it — on godtime, with the groove's swing (it fires from seqTick's tail, so it inherits the swung onset for free; measured: off-beats land 0.31 late under mpc 8-4). Notes latch at mindStrike when the arp is on (a strike toggles the note in/out; MIDI-in routes through there too, so hardware latches for free); mindStrike is byte-identical when the arp is off. rate 1/4→1/32 (1/32 double-fires mid-16th), up/down/updown/random, oct ×1–3, gate → note length. Fires through the same pluck() + midiPluck(ch 2) every mind note uses. A compact #godspeed panel on G and one sky-menu entry. App-scope only — never touches the Synth engine (β's turf), OMNI, or squash. seqTick/mindStrike/keydown edits are additive one-liners. Co-Authored-By: Claude Opus 4.8 --- viz/index.html | 128 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/viz/index.html b/viz/index.html index 5a4e74e..e48cf42 100644 --- a/viz/index.html +++ b/viz/index.html @@ -334,6 +334,31 @@ #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; } + /* right-click context menu — every setting, on everything */ #ctxmenu { position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh; @@ -2912,6 +2937,100 @@ 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, 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 + 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; arp.up = true; } } // 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 + Synth.pluck(pick.note, pick.vel); + midiPluck(2, pick.note, pick.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) { @@ -2970,6 +3089,7 @@ if (seqGridRefresh) seqGridRefresh(); if (arrGrooveRefresh) arrGrooveRefresh(); if (beatRefresh) beatRefresh(); + arpTick(); // ⚡ GODSPEED rides the swung clock } function groupsOf(key) { const out = []; @@ -5019,8 +5139,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) { @@ -6906,6 +7028,7 @@ if (ev.key === "b" || ev.key === "B") { beat.open ? closeBeat() : openBeat(); // 🥁 GODRUM — make a beat } + if (ev.key === "g" || ev.key === "G") 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 @@ -7172,6 +7295,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" From ea13b2efd67bd424fe917f74e4ca56116f3b0707 Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Mon, 13 Jul 2026 13:36:03 +1000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=AB=80=20godsquash:=20master-bus=20co?= =?UTF-8?q?mpressor=20the=20world=20can=20pump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1β. A DynamicsCompressor + makeup between the master fade and the safety limiter, driven by one normalized `squash` dest. - build(): out → squash → makeup → lim (knee 12, attack 0.004; baseline is the sq=0 transparent state so it's clean before params() first runs). The GODSONIQ N.out tap stays pre-squash. squash/squashMakeup returned on N. - params(): sq=c01(d("squash",0)); threshold −6→−36, ratio 1:1→8:1, release 0.12→0.25; makeup = 1 + sq·sq — a QUADRATIC curve tuned by offline measurement (the compressor's RMS drop is ~5.95·sq² dB, so 1+sq² holds loudness level ±0.5 dB while crest factor falls monotonically). - Routable exactly like crush: added to OMNI.MODULES fx-rack keys, OMNI.LABELS, and ZERO_RACK — nothing else outside the IIFE. Co-Authored-By: Claude Opus 4.8 --- viz/index.html | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/viz/index.html b/viz/index.html index 5a4e74e..79fce30 100644 --- a/viz/index.html +++ b/viz/index.html @@ -3376,7 +3376,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) || []; } @@ -3946,12 +3946,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; }, @@ -8399,7 +8399,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; @@ -8550,7 +8558,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 }; @@ -8694,6 +8702,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); From e56e3bc28f1f65207ee9d5595d52111392108f57 Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Mon, 13 Jul 2026 16:57:37 +1000 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=97=A3=20pantheon:=20GODSPEAK=20worke?= =?UTF-8?q?r=20=E2=80=94=20the=20voice=20of=20god=20(Stage=202=CE=B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user's wersperr, wired into the sky: hold Right ⌥, speak, release — the transcript flows to the hub as OSC and reaches every client as a voice event (Stage 3 gives it the grammar). - workers/godspeak_worker.py: wersperr-derived (MLX Whisper, 16kHz mono, Right ⌥ via pynput). Sends /godspeak/rec 1|0 on key edges and /godspeak after transcription instead of pbcopy/paste. House worker pattern: argparse --host/--port, lazy imports with graceful "not available — skipping (optional)" exit, --check self-test, warm-up. MLX is Apple-Silicon-only so it's local-rig-only + opt-in; deps stay OUT of requirements.txt (documented pip install in the docstring). Transcription serialized (one MLX graph at a time); a busy mic can't kill the hotkey listener. - hub.py: _osc_handler special-cases /godspeak (text/state, not a float signal), queues it, and control_loop relays {"godspeak":{...}} to ws clients — dropped entirely in read-only mode (it's a command channel). Bridged OSC-thread → asyncio via a queue, like every other ingest. - run.py: --godspeak opt-in flag mirroring --sports; never a default. Python-only — zero viz/index.html, zero requirements.txt/config.json. Co-Authored-By: Claude Opus 4.8 --- hub.py | 29 ++++++++ run.py | 6 ++ workers/godspeak_worker.py | 143 +++++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 workers/godspeak_worker.py diff --git a/hub.py b/hub.py index f6502d0..c9a1691 100644 --- a/hub.py +++ b/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) diff --git a/run.py b/run.py index a2f4f0d..cd5c792 100644 --- a/run.py +++ b/run.py @@ -131,6 +131,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() @@ -170,6 +172,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") diff --git a/workers/godspeak_worker.py b/workers/godspeak_worker.py new file mode 100644 index 0000000..b8736b7 --- /dev/null +++ b/workers/godspeak_worker.py @@ -0,0 +1,143 @@ +"""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). + + 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 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() From d2c8ad6a6a92b5eb842b04404edc5757d21ff720 Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Mon, 13 Jul 2026 17:14:41 +1000 Subject: [PATCH 4/6] =?UTF-8?q?=E2=8F=AA=20pantheon:=20UNGODLY=20=E2=80=94?= =?UTF-8?q?=20undo,=20un-doing=20what=20god=20did=20(Stage=202=CE=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Undo/redo as time-travel over serializePatch(): loadPatch already restores everything and rebuilds open panels, so undo is a 32-deep ring of snapshots. - ungodlyMark(label) snapshots BEFORE a mutation (dedup identical, cap 32, clears the redo future); ungodlyGesture(label) coalesces a drag burst (same label within 800ms) into one entry; undo/redo loadPatch under `muted`. - ~47 mark sites at every patch mutation: mood, zero enter/exit, dimension + vibe load, route patch/unpatch, the beat panel (steps/groove/euclid/expr/ kit/mute/play), the arranger (grooves/clips/scale/euclid/follow/expr/p-lock), and the mix (mute/solo/reset/level/trim/group). Boot restore is suppressed. - ⌘Z undo · ⌘⇧Z redo, added before the letter shortcuts; every single-letter key (p/c/o/w/z/t/b/g) now guards !metaKey && !ctrlKey — so ⌘Z no longer toggles zen (the pre-existing quirk), while plain z still does. - One ctxItem with a live depth count. Memory-only in v1 (reload clears it). App scope only; nothing inside the Synth IIFE. Co-Authored-By: Claude Opus 4.8 --- viz/index.html | 141 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 96 insertions(+), 45 deletions(-) diff --git a/viz/index.html b/viz/index.html index 6d31521..0d99d97 100644 --- a/viz/index.html +++ b/viz/index.html @@ -1993,11 +1993,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)"; @@ -2013,7 +2013,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; }; @@ -2024,7 +2024,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); @@ -2039,7 +2039,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"; @@ -2047,7 +2047,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); } @@ -2066,7 +2066,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; @@ -2075,7 +2075,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"; @@ -2094,10 +2094,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); @@ -2151,7 +2151,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); }; @@ -2186,7 +2186,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); @@ -2234,7 +2234,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(); }; @@ -2553,6 +2553,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) { @@ -2670,7 +2671,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)); @@ -2681,7 +2682,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)); @@ -2699,6 +2700,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 = []; @@ -2712,7 +2714,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(); }; @@ -2724,7 +2726,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); @@ -2749,6 +2751,7 @@ play.onclick = () => { // master ▶ — flip all five lanes at once const turnOn = !anyOn(); + ungodlyMark("beat play"); lanes.forEach(L => { L.sq.on = turnOn; L.paintMute(); }); paintGrid(); }; @@ -2791,7 +2794,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); }; @@ -2817,7 +2820,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"; @@ -3186,7 +3189,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; @@ -3312,6 +3315,43 @@ 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 @@ -3319,6 +3359,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) {} } @@ -3357,6 +3398,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) {} @@ -3912,6 +3954,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; @@ -4187,10 +4230,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 }); @@ -4496,7 +4539,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 || ""); @@ -6926,7 +6970,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; @@ -6967,13 +7011,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 @@ -6987,6 +7031,7 @@ } function resetTweaks(key) { // back to neutral: level 1×, no trim, unmuted + ungodlyMark("reset"); delete tweaks[key]; if (soloKey === key) soloKey = null; } @@ -7009,26 +7054,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") toggleGodspeed(); // ⚡ GODSPEED — the arp panel + 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 @@ -7038,7 +7086,7 @@ if (beat.rec) { // record: quantize the tap to the nearest 16th, write the step const step = Math.round(seqPhase) % 16; // frac > .5 rounds to the upcoming 16th (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; @@ -7149,10 +7197,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(); @@ -7172,7 +7220,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); } @@ -7194,6 +7242,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(); @@ -7250,6 +7299,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"; @@ -7268,7 +7319,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()); @@ -7425,7 +7476,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); } @@ -8377,15 +8428,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); } @@ -8442,7 +8493,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); From 18fd8364569b7dde75776b8df9cdabbefccd6a43 Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Tue, 14 Jul 2026 13:21:24 +1000 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=97=A3=20pantheon:=20=CE=B5=20the=20c?= =?UTF-8?q?loser=20=E2=80=94=20the=20voice=20becomes=20command=20(Stage=20?= =?UTF-8?q?3=CE=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GODSPEAK grammar: tempo/bpm with spoken numbers (one twenty → 120), squash N percent, AMPLER capture/chop, open/close every panel, play/stop the beat, mood, skin, zen — and take it back, the voice of god un-doing. First match wins; unmatched words arrive in the ticker, never an error. A 🗣 chip pulses while the mic is held. Polish: arp.up pruned, the arp breathes with groove.velo, tempo edits mark undo, serializePatch carries godtime.locked so undoing a mood restores the lock. Grimoire chapter for all four gods + regenerated manual + worker permission notes. Reviewed by fable: two fixes folded in — matchSkin normalizes candidate names (so god-s-eye matches post-convergence) and a virgin session saying play-the-beat gets the seed pattern first. Co-Authored-By: Claude Fable 5 --- viz/index.html | 147 +++++++++++++++++++++++++++++++++++-- viz/manual.html | 37 ++++++++++ workers/godspeak_worker.py | 6 ++ 3 files changed, 185 insertions(+), 5 deletions(-) diff --git a/viz/index.html b/viz/index.html index 0d99d97..68fa77d 100644 --- a/viz/index.html +++ b/viz/index.html @@ -359,6 +359,14 @@ #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; @@ -1166,6 +1174,10 @@
  • Keys 15 & ⏺ rec — finger-drum the kit. With the beat open the number row is the kit — 1 kick, 2 snare, 3 clap, 4 hat, 5 open hat — played by hand, live, straight into the world's FX. Hold for the ghost note, softer. Arm ⏺ rec and every tap writes itself into the grid, quantized to the nearest sixteenth — 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.
  • 🌾 AMPLER — there is ample, for god is abundant. The SP-404 move, made of the planet. Right-click the sky → 🌾 AMPLER: ⏺ capture a stretch of the sounding world (1.5, 3 or 6 seconds of the live master — and since a fed tab is the world, that YouTube loop counts), then ✂ chop it into 8 or 16 slices. Tap a slice to audition it, to play it backwards, then seat a slice onto a drum voice — and that lane stops synthesising and starts playing the world. The slice rides the voice's own level and pan and, on the kick, its duck; 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).
  • The beat leaves the building. The drum lanes ride MIDI out on channel 10 — General MIDI percussion, kick 36, snare 38, clap 39, closed hat 42, open hat 46 — so with an out port chosen in ⚙ they play your hardware and DAW exactly as the note lanes do. And they land in export .mid: 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.
  • +
  • ⚡ GODSPEED — hold a chord, the machine runs. Press G. Enter a chakra (C), turn GODSPEED on, and strike keys to latch a chord — each note stays held until you strike it again. Then the machine runs with it, one note at a time, on the godtime clock — so it swings with the groove, laying its off-beats back exactly as the drums do, because it plays from the same swung tick. Set the rate (1/4 → 1/32), the direction (up, down, up/down, random), the octaves (×1–3) and the gate (how long each note rings). A MIDI keyboard latches too — its notes route through the same hands — and the arp plays outward on channel 2, the mind console's channel, so it drives your rig as it drives the synth.
  • +
  • 🫀 GODSQUASH — the master squeeze the world can pump. In OMNI's fx rack sits one new knob: squash, a compressor across the whole master bus. Turn it up and the threshold dives while the ratio climbs together, a matched gain riding underneath so the loudness stays level while the peaks kneel — the mix gets denser, not louder. And because it's a destination like any other, route a feed onto it — the quakes, your heartbeat, the market's velocity — and the planet pumps the mix, breathing the whole instrument in time with the world.
  • +
  • 🗣 GODSPEAK — the voice of god. On your own machine (Apple-Silicon only), run run.py --godspeak, then hold Right ⌥ and speak. Your words are heard locally — your own Whisper, nothing leaving the machine — and become commands: "tempo ninety", "storm mood", "capture six seconds", "chop sixteen", "open the beat", "squash sixty percent", "zen", and — the voice of god undoing its own works — "take it back". A 🗣 chip glows by the ♪ while it listens. Anything it doesn't recognise simply arrives in the world, printed to the ticker — the word is never an error, only another feed.
  • +
  • ⏪ UNGODLY — take it back. ⌘Z undoes; ⌘⇧Z redoes. The instrument remembers your last thirty-two acts — 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 memory-only: a reload forgives everything and starts the history clean.
  • 📽 The stage — send the visual to any screen. Right-click the sky → the stage 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 double-click for fullscreen. The stage has a camera: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → project this node and the camera glides after that one sphere through its orbits, or project its station 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.
  • 🕸 The commune — play together, properly. Right-click the sky → the commune. This is for signed-in players: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you hosts: name the room, then ✉ invite the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and join or decline; anyone may leave whenever they wish, and if the host leaves, the room closes with them. Then the sections: members claim voices (right-click any voice, or the panel's picker), or the host allocates them — this bass to her, those pads to him, the godtime itself 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.
  • @@ -2650,7 +2662,8 @@ bpm.value = String(Math.round(godtime.bpm)); bpm.disabled = godtime.locked; bpm.title = godtime.locked ? "godtime is locked (🔒) — unlock it on the dial" : "beats per minute (40–180)"; - bpm.onchange = () => { if (!godtime.locked) 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 (!godtime.locked && 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"; @@ -2946,7 +2959,7 @@ // 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, up: true }; + 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); @@ -2954,7 +2967,7 @@ 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; arp.up = true; } } // off → drop the latch + 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 }); @@ -2972,8 +2985,11 @@ 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 - Synth.pluck(pick.note, pick.vel); - midiPluck(2, pick.note, pick.vel, durMs); // channel 2 — the mind console's channel, exactly like mindStrike + 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; @@ -3283,6 +3299,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) @@ -3311,6 +3328,7 @@ 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; } @@ -4517,8 +4535,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)) { diff --git a/viz/manual.html b/viz/manual.html index 9cad154..0e6da70 100644 --- a/viz/manual.html +++ b/viz/manual.html @@ -338,6 +338,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; @@ -1121,6 +1154,10 @@
  • Keys 15 & ⏺ rec — finger-drum the kit. With the beat open the number row is the kit — 1 kick, 2 snare, 3 clap, 4 hat, 5 open hat — played by hand, live, straight into the world's FX. Hold for the ghost note, softer. Arm ⏺ rec and every tap writes itself into the grid, quantized to the nearest sixteenth — 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.
  • 🌾 AMPLER — there is ample, for god is abundant. The SP-404 move, made of the planet. Right-click the sky → 🌾 AMPLER: ⏺ capture a stretch of the sounding world (1.5, 3 or 6 seconds of the live master — and since a fed tab is the world, that YouTube loop counts), then ✂ chop it into 8 or 16 slices. Tap a slice to audition it, to play it backwards, then seat a slice onto a drum voice — and that lane stops synthesising and starts playing the world. The slice rides the voice's own level and pan and, on the kick, its duck; 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).
  • The beat leaves the building. The drum lanes ride MIDI out on channel 10 — General MIDI percussion, kick 36, snare 38, clap 39, closed hat 42, open hat 46 — so with an out port chosen in ⚙ they play your hardware and DAW exactly as the note lanes do. And they land in export .mid: 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.
  • +
  • ⚡ GODSPEED — hold a chord, the machine runs. Press G. Enter a chakra (C), turn GODSPEED on, and strike keys to latch a chord — each note stays held until you strike it again. Then the machine runs with it, one note at a time, on the godtime clock — so it swings with the groove, laying its off-beats back exactly as the drums do, because it plays from the same swung tick. Set the rate (1/4 → 1/32), the direction (up, down, up/down, random), the octaves (×1–3) and the gate (how long each note rings). A MIDI keyboard latches too — its notes route through the same hands — and the arp plays outward on channel 2, the mind console's channel, so it drives your rig as it drives the synth.
  • +
  • 🫀 GODSQUASH — the master squeeze the world can pump. In OMNI's fx rack sits one new knob: squash, a compressor across the whole master bus. Turn it up and the threshold dives while the ratio climbs together, a matched gain riding underneath so the loudness stays level while the peaks kneel — the mix gets denser, not louder. And because it's a destination like any other, route a feed onto it — the quakes, your heartbeat, the market's velocity — and the planet pumps the mix, breathing the whole instrument in time with the world.
  • +
  • 🗣 GODSPEAK — the voice of god. On your own machine (Apple-Silicon only), run run.py --godspeak, then hold Right ⌥ and speak. Your words are heard locally — your own Whisper, nothing leaving the machine — and become commands: "tempo ninety", "storm mood", "capture six seconds", "chop sixteen", "open the beat", "squash sixty percent", "zen", and — the voice of god undoing its own works — "take it back". A 🗣 chip glows by the ♪ while it listens. Anything it doesn't recognise simply arrives in the world, printed to the ticker — the word is never an error, only another feed.
  • +
  • ⏪ UNGODLY — take it back. ⌘Z undoes; ⌘⇧Z redoes. The instrument remembers your last thirty-two acts — 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 memory-only: a reload forgives everything and starts the history clean.
  • 📽 The stage — send the visual to any screen. Right-click the sky → the stage 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 double-click for fullscreen. The stage has a camera: the whole view by default (whatever you're in — a skin, the wheel, mind mode's POV), or right-click any node → project this node and the camera glides after that one sphere through its orbits, or project its station 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.
  • 🕸 The commune — play together, properly. Right-click the sky → the commune. This is for signed-in players: your session rides the connection, so the hub knows exactly who everyone is — no impostors, no setup, no Bluetooth. One of you hosts: name the room, then ✉ invite the others by their username (they must be on the site — invitations reach the present). The invited see the invitation arrive live and join or decline; anyone may leave whenever they wish, and if the host leaves, the room closes with them. Then the sections: members claim voices (right-click any voice, or the panel's picker), or the host allocates them — this bass to her, those pads to him, the godtime itself 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.
  • diff --git a/workers/godspeak_worker.py b/workers/godspeak_worker.py index b8736b7..2c06b6e 100644 --- a/workers/godspeak_worker.py +++ b/workers/godspeak_worker.py @@ -15,6 +15,12 @@ stay clean. On a Mac, into the hub's venv: 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 From 9adbafe34282b9328cc52197bb2b900ed8e8222a Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Tue, 14 Jul 2026 13:21:57 +1000 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=93=8B=20pantheon=20+=20revelation=20?= =?UTF-8?q?briefs=20=E2=80=94=20the=20process=20docs=20travel=20with=20the?= =?UTF-8?q?=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- PANTHEON_BRIEF.md | 189 ++++++++++++++++++++++++++++++++++++++++++++ REVELATION_BRIEF.md | 130 ++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 PANTHEON_BRIEF.md create mode 100644 REVELATION_BRIEF.md diff --git a/PANTHEON_BRIEF.md b/PANTHEON_BRIEF.md new file mode 100644 index 0000000..a8e11b8 --- /dev/null +++ b/PANTHEON_BRIEF.md @@ -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- -b pantheon/ 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 ` 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": }}` 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 "`) · 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 `🗣 `; unmatched text falls through to the ticker as `🗣 ""` — 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 `
  • ` 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. diff --git a/REVELATION_BRIEF.md b/REVELATION_BRIEF.md new file mode 100644 index 0000000..3222bf9 --- /dev/null +++ b/REVELATION_BRIEF.md @@ -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-.` (derive the timestamp from `new Date()` at stop time), trigger the download (the `a[download]` + `URL.createObjectURL` idiom), and ticker: `📼 testament: — 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 `