Godstrument/hub.py
monsterrobotparty 60173d44cb Add socio-economic data, performance mode, spells (full-state templates)
- world_bank.py: poverty, food security, fertility, life expectancy (World Bank)
  folded into the "shadow" / "bright" concept groups
- spells: save/load now captures the full live mix (gains/mutes/freezes), not
  just the wiring; TEMPLATES panel renamed SPELLS
- performance mode (press P): clean projection view + rotating "dispatches from
  the world" text readout for live shows

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 02:37:03 +10:00

443 lines
17 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"
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)
self.signals: dict[str, Signal] = {}
self._raw_lock = threading.Lock()
self._raw_inbox: dict[str, tuple[float, float]] = {} # key -> (val,t)
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)
v = sig.update_continuous(raw, t)
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", {})
for dest, val in dests.items():
self.out.send_message(f"/out/{dest}", float(val))
m = midi_map.get(dest)
if m and self.midi:
if m.get("note") and val >= 40: # quantized note number
note = int(val)
if self._last_notes.get(dest) != note:
if self._last_notes.get(dest):
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])
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",
"routes": [{"source": r.get("source"), "dest": r.get("dest"),
"label": r.get("label", ""),
"amount": round(r.get("amount", 0), 3)}
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 = "".join(c for c in name if c.isalnum() or c in "-_ ").strip() 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):
try:
with open(os.path.join(self.patches_dir, name + ".json")) as f:
data = json.load(f)
except (OSError, TypeError):
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 '{name}'")
def _handle_cmd(self, m: dict):
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"),
float(m.get("value", 0.0)))
elif cmd == "add_route":
self.matrix.add_route(m.get("source"), m.get("dest"),
float(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"),
float(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):
handler = partial(SimpleHTTPRequestHandler, directory=directory)
httpd = ThreadingHTTPServer(("127.0.0.1", port), handler)
httpd.RequestHandlerClass.log_message = lambda *a, **k: None
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 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()