From e56e3bc28f1f65207ee9d5595d52111392108f57 Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Mon, 13 Jul 2026 16:57:37 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=A3=20pantheon:=20GODSPEAK=20worker=20?= =?UTF-8?q?=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()