Godstrument/hub.py
type-two e69b7e2ad6 Accounts, socio-economic feeds, and hub fixes (track deployed work)
Captures backend work already running on godstrument.pro but never committed:
- auth.py: invite-only accounts + per-user presets (SQLite, scrypt, signed
  stateless sessions), mounted by hub.py at /api/*
- hub.py: /api static+API handler, readonly public mode, full route spec in hello
- socio-economic / market / fire / debt / crypto workers + normalize/transform
- .gitignore: never commit godstrument_users.db* or auth_secret
- test_fixes.py: framework-free self-check for the review fixes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:18:35 +10:00

573 lines
24 KiB
Python

"""
hub.py — the traffic cop and the brain.
Everything (your hand, the room, a satellite, bitcoin) fires OSC at this hub on
127.0.0.1:9000. It doesn't care where a signal came from — a fingertip and a
plane over Tokyo are the same kind of thing here. The hub:
1. receives every OSC message into a thread-safe raw state,
2. on a fixed control-rate clock, normalizes + One-Euro-filters each signal
to a clean 0..1 (events become decaying impulses),
3. runs the modulation matrix (matrix.py) to turn signals into destinations,
4. emits results as OSC /out/<dest> (for Ableton / TouchDesigner), optional
MIDI CC/notes, and a live WebSocket feed for the browser visualizer.
Run: python hub.py (uses config.json)
python hub.py --config myset.json
Ports: OSC in 9000 | OSC out 9001 | WebSocket 8765 | HTTP viz 8088
"""
from __future__ import annotations
import argparse
import asyncio
import json
import math
import os
import queue
import threading
import time
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import websockets
from pythonosc.dispatcher import Dispatcher
from pythonosc.osc_server import ThreadingOSCUDPServer
from pythonosc.udp_client import SimpleUDPClient
from normalize import AdaptiveNormalizer, OneEuroFilter, ImpulseEnvelope
from matrix import ModMatrix
from transform import TweakBank
HERE = os.path.dirname(os.path.abspath(__file__))
START = time.monotonic()
# sidereal orbital periods in Earth-years — a group assigned a planet cycles at
# this rate relative to `orbit_base_seconds` (one Earth orbit = base seconds).
PLANET_PERIODS = {
"mercury": 0.2408, "venus": 0.6152, "earth": 1.0, "mars": 1.8808,
"jupiter": 11.862, "saturn": 29.457, "uranus": 84.02,
"neptune": 164.8, "pluto": 248.0,
}
def now() -> float:
return time.monotonic() - START
# ---------------------------------------------------------------------------
# Per-signal processing chain
# ---------------------------------------------------------------------------
class Signal:
def __init__(self, name: str, spec: dict):
self.name = name
self.type = spec.get("type", "continuous")
self.label = spec.get("label", name)
nrm = spec.get("norm", {})
mode = nrm.get("mode", "minmax")
fixed = None
if "lo" in nrm and "hi" in nrm:
fixed = (nrm["lo"], nrm["hi"])
mode = "fixed"
elif not nrm and (name.startswith(("midi.", "hand.", "tof.", "light."))
or self.type == "event"):
# Control inputs (knobs, hand, sensors) and bare events already
# speak 0..1 — adaptively rescaling a constant stream would pin it
# to 0.5 (a held knob) or halve every event. Pass them through.
# ponytail: prefix allowlist; give any exception an explicit norm.
fixed = (0.0, 1.0)
mode = "fixed"
self.norm = AdaptiveNormalizer(nrm.get("window", 600), mode, fixed)
flt = spec.get("filter", {})
self.filter = OneEuroFilter(flt.get("min_cutoff", 1.2),
flt.get("beta", 0.02))
self.env = ImpulseEnvelope(spec.get("attack", 0.01),
spec.get("decay", 0.6))
self.raw = 0.0
self.value = 0.0
def update_continuous(self, raw: float, t: float) -> float:
self.raw = raw
n = self.norm(raw)
self.value = self.filter(n, t)
return self.value
def trigger(self, magnitude: float, t: float):
self.raw = magnitude
self.env.trigger(self.norm(magnitude), t)
def event_value(self, t: float) -> float:
self.value = self.env.value(t)
return self.value
# ---------------------------------------------------------------------------
# The hub
# ---------------------------------------------------------------------------
class Hub:
def __init__(self, cfg: dict):
self.cfg = cfg
self.specs = cfg.get("signals", {})
self.matrix = ModMatrix.from_config(cfg)
self.tweaks = TweakBank(cfg)
self.rate = cfg.get("control_rate_hz", 60)
# read-only mode: broadcast to viewers but ignore every incoming command
# (the ws control channel has no auth — safe to expose publicly this way).
# Set via config "readonly": true or env GODSTRUMENT_READONLY=1.
self.readonly = bool(cfg.get("readonly")) or \
os.environ.get("GODSTRUMENT_READONLY", "") not in ("", "0")
self.signals: dict[str, Signal] = {}
self._raw_lock = threading.Lock()
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.out = SimpleUDPClient(cfg.get("osc_out_host", "127.0.0.1"),
cfg.get("osc_out_port", 9001))
self.warp = cfg.get("warp", {})
self._jog_clients: dict[int, SimpleUDPClient] = {}
self.orbit_base = cfg.get("orbit_base_seconds", 60)
self.patches_dir = os.path.join(HERE, "patches")
self._patches_dirty = False
self._scan_patches()
self.midi = _open_midi(cfg.get("midi", {}))
self.clients: set = set()
self.recent_events: list[dict] = []
self._last_notes: dict[str, int] = {}
self.route_meta = [
{"source": r.get("source"), "dest": r.get("dest"),
"label": r.get("label", "")}
for r in cfg.get("routes", [])
]
# ---- OSC ingest (runs in the OSC server thread) --------------------
def _osc_handler(self, address: str, *args):
if not args:
return
try:
val = float(args[0])
except (TypeError, ValueError):
return
# /gs/a/b -> "a.b" ; /gs/quake/event -> event on "quake.event"
parts = address.strip("/").split("/")
if parts and parts[0] == "gs":
parts = parts[1:]
key = ".".join(parts)
t = now()
if key.endswith(".event"):
self._event_q.put((key, val, t))
else:
with self._raw_lock:
self._raw_inbox[key] = (val, t)
def _ensure_signal(self, key: str) -> Signal:
sig = self.signals.get(key)
if sig is None:
spec = self.specs.get(key, {})
if key.endswith(".event") and "type" not in spec:
spec = {**spec, "type": "event"}
sig = Signal(key, spec)
self.signals[key] = sig
return sig
# ---- control-rate loop (runs in asyncio) --------------------------
async def control_loop(self):
period = 1.0 / self.rate
while True:
t = now()
# drain events -> triggers
while True:
try:
key, mag, et = self._event_q.get_nowait()
except queue.Empty:
break
sig = self._ensure_signal(key)
sig.trigger(mag, et)
ev = {"src": key.rsplit(".", 1)[0], "mag": round(mag, 3), "t": et}
self.recent_events.append(ev)
self.recent_events = self.recent_events[-40:]
# snapshot raw inbox
with self._raw_lock:
inbox = dict(self._raw_inbox)
sources: dict[str, float] = {}
wire_sources: dict[str, dict] = {}
for key, (raw, rt) in inbox.items():
sig = self._ensure_signal(key)
if self._last_raw.get(key) != raw:
# only feed the normalizer/filter when the value actually
# changed. Workers re-emit their last value on a cadence with
# a fresh timestamp each time, so gating on arrival time would
# still pad an adaptive window with duplicates; gating on the
# value means the window holds N distinct samples (not N ticks
# of polling). ponytail: windows now forget by sample, not
# wall-clock — the right call for this instrument.
v = sig.update_continuous(raw, t)
self._last_raw[key] = raw
else:
v = sig.value # unchanged datum; hold the last value
sources[key] = v
wire_sources[key] = {"raw": round(raw, 4), "norm": round(v, 4),
"label": sig.label,
"muted": self.tweaks.is_muted(key)}
# event envelopes decay every tick
for key, sig in self.signals.items():
if sig.type == "event":
v = sig.event_value(t)
sources[key] = v
wire_sources[key] = {"raw": round(sig.raw, 4),
"norm": round(v, 4),
"label": sig.label, "event": True,
"muted": self.tweaks.is_muted(key)}
# planetary orbits — each orbit-group's gain waxes/wanes at its
# planet's real relative rate (Mercury whips, Neptune barely drifts)
for g, planet in self.tweaks.group_orbit.items():
orb = PLANET_PERIODS.get(planet, 1.0) * self.orbit_base
phase = (t / orb) % 1.0
self.tweaks.set_orbit_lfo(
g, 0.25 + 0.75 * (0.5 + 0.5 * math.sin(2 * math.pi * phase)))
# scrub any time-warped replay workers from live controls
self._forward_jog(sources)
# performance layer: live controls -> tweaks -> groups -> matrix
self.tweaks.apply_controls(sources)
tweaked = self.tweaks.transform(sources)
dests, active = self.matrix.evaluate(tweaked)
self._emit(dests)
await self._broadcast(t, wire_sources, dests, active,
self.tweaks.macros())
await asyncio.sleep(period)
# ---- time-warp jog forwarding -------------------------------------
def _forward_jog(self, sources: dict[str, float]):
for w in self.warp.values():
port = w.get("port")
if not port:
continue
cl = self._jog_clients.get(port)
if cl is None:
cl = SimpleUDPClient("127.0.0.1", port)
self._jog_clients[port] = cl
rs, ns = w.get("rate_src"), w.get("nudge_src")
if rs and rs in sources:
cl.send_message("/jog/rate", float(sources[rs]))
if ns and ns in sources:
cl.send_message("/jog/nudge", float(sources[ns]))
# ---- outputs -------------------------------------------------------
def _emit(self, dests: dict[str, float]):
midi_map = self.cfg.get("midi", {}).get("map", {})
notes_live = set() # note dests sounding this frame
for dest, val in dests.items():
self.out.send_message(f"/out/{dest}", float(val))
m = midi_map.get(dest)
if not (m and self.midi):
continue
if m.get("note"): # note-mapped: val IS a note number
note = int(round(val))
if not 24 <= note <= 127: # matches the viz Web-MIDI guard
continue
notes_live.add(dest)
if self._last_notes.get(dest) != note:
if self._last_notes.get(dest) is not None:
self.midi.send_message([0x80 | m.get("channel", 0),
self._last_notes[dest], 0])
self.midi.send_message([0x90 | m.get("channel", 0),
note, 100])
self._last_notes[dest] = note
elif "cc" in m:
cc = int(_clamp01(val) * 127)
self.midi.send_message([0xB0 | m.get("channel", 0),
m["cc"], cc])
# release any held note whose source went silent (muted/gated) this frame
if self.midi:
for dest, note in list(self._last_notes.items()):
if note is not None and dest not in notes_live:
ch = midi_map.get(dest, {}).get("channel", 0)
self.midi.send_message([0x80 | ch, note, 0])
self._last_notes[dest] = None
async def _broadcast(self, t, sources, dests, active, macros=None):
if not self.clients:
return
routes = []
for i, r in enumerate(self.matrix.routes):
routes.append({"source": r.get("source"), "dest": r.get("dest"),
"label": r.get("label", ""),
"amount": round(r.get("amount", 0), 3),
"active": round(active.get(i, 0.0), 4)})
p = {
"t": round(t, 3),
"sources": sources,
"dests": {k: (round(v, 4) if isinstance(v, float) else v)
for k, v in dests.items()},
"routes": routes,
"macros": macros or {},
"events": self.recent_events[-12:],
}
if self._patches_dirty:
p["patches"] = self.patches
self._patches_dirty = False
payload = json.dumps(p)
websockets.broadcast(self.clients, payload)
async def ws_handler(self, ws):
self.clients.add(ws)
try:
await ws.send(json.dumps({
"hello": "godstrument",
"readonly": self.readonly,
# full route spec so a logged-in client can compute its own mix
"routes": [{"source": r.get("source"), "dest": r.get("dest"),
"label": r.get("label", ""),
"amount": round(r.get("amount", 0), 3),
"curve": r.get("curve", "lin"),
"gate": r.get("gate"), "quantize": r.get("quantize")}
for r in self.matrix.routes],
"groups": self.cfg.get("groups", {}),
"midi": self.cfg.get("midi", {}).get("map", {}),
"patches": self.patches}))
async for msg in ws: # browser -> hub commands
try:
self._handle_cmd(json.loads(msg))
except Exception: # noqa
pass
finally:
self.clients.discard(ws)
# ---- named templates (patches) ------------------------------------
def _scan_patches(self):
try:
self.patches = sorted(f[:-5] for f in os.listdir(self.patches_dir)
if f.endswith(".json"))
except OSError:
self.patches = []
def _save_patch(self, name):
if not name:
return
safe = _safe_name(name) or "patch"
os.makedirs(self.patches_dir, exist_ok=True)
data = {"routes": self.matrix.routes,
"groups": self.tweaks.dump_groups(),
"state": self.tweaks.dump_state()}
with open(os.path.join(self.patches_dir, safe + ".json"), "w") as f:
json.dump(data, f, indent=2)
self._scan_patches()
self._patches_dirty = True
print(f" saved template '{safe}'")
def _load_patch(self, name):
safe = _safe_name(name) # no path traversal / absolute paths
if not safe:
return
try:
with open(os.path.join(self.patches_dir, safe + ".json")) as f:
data = json.load(f)
except (OSError, ValueError):
return
if "routes" in data:
self.matrix.routes = [dict(r) for r in data["routes"]]
if "groups" in data:
self.tweaks.load_groups(data["groups"])
if "state" in data:
self.tweaks.load_state(data["state"])
print(f" loaded spell '{safe}'")
def _handle_cmd(self, m: dict):
if self.readonly: # public spectator: ignore all control commands
return
cmd = m.get("cmd")
if cmd == "mute_toggle":
self.tweaks.toggle(m.get("target"), "mute")
elif cmd == "toggle":
self.tweaks.toggle(m.get("target"), m.get("param", "mute"))
elif cmd == "set":
self.tweaks.set_param(m.get("target"), m.get("param", "gain"),
_ffloat(m.get("value"), 0.0))
elif cmd == "add_route":
self.matrix.add_route(m.get("source"), m.get("dest"),
_ffloat(m.get("amount"), 0.5))
elif cmd == "remove_route":
self.matrix.remove_route(m.get("source"), m.get("dest"))
elif cmd == "set_route":
self.matrix.set_route_amount(m.get("source"), m.get("dest"),
_ffloat(m.get("amount"), 0.5))
elif cmd == "make_group":
self.tweaks.add_group(m.get("name"), m.get("members", []))
elif cmd == "save_patch":
self._save_patch(m.get("name"))
elif cmd == "load_patch":
self._load_patch(m.get("name"))
# ---- run -----------------------------------------------------------
async def run(self):
# OSC server thread
disp = Dispatcher()
disp.set_default_handler(self._osc_handler)
osc_srv = ThreadingOSCUDPServer(
(self.cfg.get("osc_in_host", "127.0.0.1"),
self.cfg.get("osc_in_port", 9000)), disp)
threading.Thread(target=osc_srv.serve_forever, daemon=True).start()
# HTTP static server for the visualizer
_serve_http(os.path.join(HERE, "viz"),
self.cfg.get("http_port", 8080))
ws_port = self.cfg.get("ws_port", 8765)
print("╔══════════════════════════════════════════════╗")
print("║ GODSTRUMENT hub online ")
print(f"║ OSC in udp/{self.cfg.get('osc_in_port',9000)} "
f"OSC out udp/{self.cfg.get('osc_out_port',9001)}")
print(f"║ WebSocket ws/{ws_port}")
print(f"║ Visualizer -> http://localhost:"
f"{self.cfg.get('http_port',8080)}")
print(f"║ MIDI out: {'connected' if self.midi else 'off (optional)'}")
print(f"{len(self.route_meta)} routes patched, "
f"clock {self.rate}Hz")
print("╚══════════════════════════════════════════════╝")
async with websockets.serve(self.ws_handler, "127.0.0.1", ws_port):
await self.control_loop()
def _open_midi(midi_cfg: dict):
if not midi_cfg.get("enabled"):
return None
try:
import rtmidi
out = rtmidi.MidiOut()
ports = out.get_ports()
target = midi_cfg.get("port_name", "IAC")
idx = next((i for i, p in enumerate(ports) if target.lower() in p.lower()),
None)
if idx is None and ports:
idx = 0
if idx is None:
out.open_virtual_port("Godstrument")
else:
out.open_port(idx)
print(f" MIDI -> {ports[idx]}")
return out
except Exception as e: # noqa
print(f" MIDI unavailable ({e}); continuing without it.")
return None
def _serve_http(directory: str, port: int):
try:
import auth # invite-only accounts + presets, mounted at /api/*
except Exception as e: # noqa
auth = None
print(f" (accounts disabled: {e})")
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *a, **k):
super().__init__(*a, directory=directory, **k)
def log_message(self, *a, **k):
pass
def end_headers(self):
# never let a browser serve a stale console after a deploy
if not self.path.startswith("/api/"):
self.send_header("Cache-Control", "no-cache, must-revalidate")
super().end_headers()
def _api(self, method):
path = self.path.split("?", 1)[0]
if auth is None:
return self.send_error(503, "accounts unavailable")
length = int(self.headers.get("Content-Length", 0) or 0)
if length > 512 * 1024:
return self.send_error(413, "too large")
body = self.rfile.read(length) if length else b""
try:
status, resp, set_cookie = auth.handle_api(
method, path, body, self.headers.get("Cookie", ""))
except Exception: # never leak a stack trace to the client
status, resp, set_cookie = 500, {"error": "server error"}, None
payload = json.dumps(resp).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
if set_cookie:
self.send_header("Set-Cookie", set_cookie)
self.end_headers()
self.wfile.write(payload)
def _force_https(self):
# visitors on http can't keep a Secure session cookie -> send them to
# https (Cloudflare sets X-Forwarded-Proto to the visitor's scheme).
if self.headers.get("X-Forwarded-Proto", "").lower() == "http":
host = self.headers.get("Host", "")
if host:
self.send_response(301)
self.send_header("Location", f"https://{host}{self.path}")
self.send_header("Content-Length", "0")
self.end_headers()
return True
return False
def do_GET(self):
if self._force_https():
return
self._api("GET") if self.path.startswith("/api/") else super().do_GET()
def do_POST(self):
self._api("POST") if self.path.startswith("/api/") else self.send_error(501)
def do_PUT(self):
self._api("PUT") if self.path.startswith("/api/") else self.send_error(501)
def do_DELETE(self):
self._api("DELETE") if self.path.startswith("/api/") else self.send_error(501)
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
def _clamp01(x: float) -> float:
return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x
def _safe_name(name) -> str:
"""A patch name safe to use as a filename: basename only, alnum/-/_/space.
Defeats path traversal ('../x') and absolute paths from the ws command."""
base = os.path.basename(str(name or ""))
return "".join(c for c in base if c.isalnum() or c in "-_ ").strip()
def _ffloat(x, default: float = 0.0) -> float:
"""float() that rejects NaN/Infinity (json.loads accepts them) so a crafted
command can't poison the matrix or blind the console."""
try:
v = float(x)
except (TypeError, ValueError):
return default
return v if math.isfinite(v) else default
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default=os.path.join(HERE, "config.json"))
args = ap.parse_args()
with open(args.config) as f:
cfg = json.load(f)
hub = Hub(cfg)
try:
asyncio.run(hub.run())
except KeyboardInterrupt:
print("\n godstrument sleeps.")
if __name__ == "__main__":
main()