🗣 pantheon: GODSPEAK worker — the voice of god (Stage 2γ)
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 <text> 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 <noreply@anthropic.com>
This commit is contained in:
parent
3d913b97fd
commit
e56e3bc28f
29
hub.py
29
hub.py
@ -122,6 +122,7 @@ class Hub:
|
||||
self._raw_inbox: dict[str, tuple[float, float]] = {} # key -> (val,t)
|
||||
self._last_raw: dict[str, float] = {} # last value fed to the normalizer
|
||||
self._event_q: "queue.Queue[tuple[str,float,float]]" = queue.Queue()
|
||||
self._godspeak_q: "queue.Queue[dict]" = queue.Queue() # 🗣 GODSPEAK text/state → clients (a voice command channel, not a signal)
|
||||
|
||||
self.out = SimpleUDPClient(cfg.get("osc_out_host", "127.0.0.1"),
|
||||
cfg.get("osc_out_port", 9001))
|
||||
@ -146,6 +147,24 @@ class Hub:
|
||||
|
||||
# ---- OSC ingest (runs in the OSC server thread) --------------------
|
||||
def _osc_handler(self, address: str, *args):
|
||||
# 🗣 GODSPEAK — text/state, not a norm signal: relay it straight to clients.
|
||||
# Muted entirely in read-only mode (voice is a command channel, and the
|
||||
# ws control path has no auth). Queued here (OSC thread) → drained and
|
||||
# broadcast in control_loop (asyncio thread), like every other OSC ingest.
|
||||
if address == "/godspeak/rec" or address == "/godspeak":
|
||||
if self.readonly:
|
||||
return
|
||||
if address == "/godspeak/rec":
|
||||
try:
|
||||
rec = 1 if (args and float(args[0]) >= 0.5) else 0
|
||||
except (TypeError, ValueError):
|
||||
rec = 0
|
||||
self._godspeak_q.put({"rec": rec})
|
||||
elif args:
|
||||
text = str(args[0]).strip()
|
||||
if text:
|
||||
self._godspeak_q.put({"text": text})
|
||||
return
|
||||
if not args:
|
||||
return
|
||||
try:
|
||||
@ -191,6 +210,16 @@ class Hub:
|
||||
self.recent_events.append(ev)
|
||||
self.recent_events = self.recent_events[-40:]
|
||||
|
||||
# 🗣 GODSPEAK — relay queued voice text/state to clients immediately.
|
||||
# Always drain (so it can't grow unbounded); broadcast only if anyone's listening.
|
||||
while True:
|
||||
try:
|
||||
obj = self._godspeak_q.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if self.clients:
|
||||
websockets.broadcast(self.clients, json.dumps({"godspeak": obj}))
|
||||
|
||||
# snapshot raw inbox
|
||||
with self._raw_lock:
|
||||
inbox = dict(self._raw_inbox)
|
||||
|
||||
6
run.py
6
run.py
@ -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")
|
||||
|
||||
143
workers/godspeak_worker.py
Normal file
143
workers/godspeak_worker.py
Normal file
@ -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 <text> after transcription
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import threading
|
||||
|
||||
MODEL = "mlx-community/whisper-large-v3-turbo"
|
||||
SAMPLE_RATE = 16000 # Whisper's native rate; mono float32
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="GODSPEAK worker — local voice commands into the sky (Apple-Silicon only, opt-in).")
|
||||
ap.add_argument("--host", default="127.0.0.1")
|
||||
ap.add_argument("--port", type=int, default=9000)
|
||||
ap.add_argument("--check", action="store_true",
|
||||
help="self-test: load the model and transcribe silence, then exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
# Lazy import of the optional Apple-Silicon voice stack. Anywhere without MLX
|
||||
# (the VPS, a bare venv) this skips cleanly so the rest of the rig runs — the
|
||||
# house pattern shared with the vision / MIDI workers.
|
||||
try:
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
import mlx_whisper
|
||||
from pynput import keyboard
|
||||
except Exception:
|
||||
print("godspeak worker: mlx-whisper/sounddevice/pynput not available — skipping (optional)")
|
||||
sys.exit(0)
|
||||
|
||||
from pythonosc.udp_client import SimpleUDPClient
|
||||
|
||||
def transcribe(audio):
|
||||
return mlx_whisper.transcribe(audio, path_or_hf_repo=MODEL)["text"].strip()
|
||||
|
||||
if args.check:
|
||||
# Self-check: the model loads and transcribes silence without exploding.
|
||||
result = transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32))
|
||||
assert isinstance(result, str)
|
||||
print("ok: godspeak model loaded, pipeline works")
|
||||
sys.exit(0)
|
||||
|
||||
client = SimpleUDPClient(args.host, args.port)
|
||||
HOTKEY = keyboard.Key.alt_r # Right Option — hold to dictate
|
||||
|
||||
def emit(addr, value):
|
||||
# Never let a transient socket hiccup kill the hotkey listener.
|
||||
try:
|
||||
client.send_message(addr, value)
|
||||
except Exception as exc:
|
||||
print(f"godspeak worker: OSC send failed ({exc}); continuing")
|
||||
|
||||
frames = []
|
||||
stream = {"s": None} # boxed so the key callbacks can rebind it
|
||||
tx_lock = threading.Lock() # MLX isn't safe under concurrent eval — one utterance transcribes at a time
|
||||
|
||||
def start_recording():
|
||||
if stream["s"]:
|
||||
return
|
||||
frames.clear()
|
||||
s = None
|
||||
try: # a busy / absent input device must not tear down the hotkey listener
|
||||
s = sd.InputStream(
|
||||
samplerate=SAMPLE_RATE, channels=1, dtype="float32",
|
||||
callback=lambda data, *_: frames.append(data.copy()),
|
||||
)
|
||||
s.start()
|
||||
except Exception as exc:
|
||||
print(f"godspeak worker: mic open failed ({exc}); ignoring this press", flush=True)
|
||||
if s is not None:
|
||||
try: s.close()
|
||||
except Exception: pass
|
||||
return
|
||||
stream["s"] = s
|
||||
emit("/godspeak/rec", 1)
|
||||
print("● recording...", flush=True)
|
||||
|
||||
def stop_recording():
|
||||
s = stream["s"]
|
||||
if not s:
|
||||
return
|
||||
s.stop(); s.close(); stream["s"] = None
|
||||
emit("/godspeak/rec", 0)
|
||||
if not frames:
|
||||
return
|
||||
audio = np.concatenate(frames).flatten()
|
||||
if len(audio) < SAMPLE_RATE // 3: # ignore accidental taps (< 1/3 s)
|
||||
return
|
||||
# transcribe off the hotkey thread so the keyboard stays responsive
|
||||
threading.Thread(target=lambda: finish(audio), daemon=True).start()
|
||||
|
||||
def finish(audio):
|
||||
with tx_lock: # serialize: back-to-back commands can't run two MLX graphs at once
|
||||
text = transcribe(audio)
|
||||
if text:
|
||||
print(f"→ {text}", flush=True)
|
||||
emit("/godspeak", text)
|
||||
|
||||
def on_press(key):
|
||||
if key == HOTKEY:
|
||||
start_recording()
|
||||
|
||||
def on_release(key):
|
||||
if key == HOTKEY:
|
||||
stop_recording()
|
||||
|
||||
print("loading model (first run downloads ~1.6GB)...", flush=True)
|
||||
transcribe(np.zeros(SAMPLE_RATE, dtype=np.float32)) # warm up so the first real utterance is fast
|
||||
print(f"godspeak ready — hold Right ⌥ and speak (→ hub {args.host}:{args.port}); Ctrl+C to quit", flush=True)
|
||||
with keyboard.Listener(on_press=on_press, on_release=on_release) as listener:
|
||||
try:
|
||||
listener.join()
|
||||
except KeyboardInterrupt:
|
||||
print("godspeak worker: shutting down")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Reference in New Issue
Block a user