Godstrument/recorder.py
monsterrobotparty 8deb65b4fe Godstrument: a multi-modal sensor-fusion instrument
Everything — your hand, the room, a satellite, the markets, the sky — becomes an
OSC control signal fused through a modulation matrix. Includes:
- hub + normalize (One-Euro) + matrix (curves/gates/quantize) + transform (tweaks/groups)
- 20+ workers: sim sensors, 8 keyless world feeds, ephemeris/almanac/clock (computed),
  time-warp replay (quakes/weather/db), and the dealgod market warehouse feed
- planetary orbital LFOs, SQLite recorder, city targeting
- live browser console: mute, group macros, drag-to-patch, inspector, Web MIDI

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

137 lines
5.3 KiB
Python

"""
recorder.py — the instrument's memory. Live and historical in one file.
Taps the hub's WebSocket firehose and writes every signal + event to a local
SQLite database (`godstrument.db`). Once it's recording, ANY source — including
your own hand, the room mic, the ToF sensor — can be replayed and time-warped
later by workers/replay_db.py, not just the feeds that happen to have a public
history API.
It's cheap: change-based logging (only write when a signal actually moves) plus a
decimation cap and a slow heartbeat. Slow world data costs almost nothing; only
fast local sensors add up, and even those are bounded.
python recorder.py # record whatever the hub is broadcasting
python recorder.py --stats # show size / signals / span, then exit
python recorder.py --decimate 20 # cap any one signal at 20 Hz on disk
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sqlite3
import time
import websockets
HERE = os.path.dirname(os.path.abspath(__file__))
SCHEMA = """
CREATE TABLE IF NOT EXISTS samples (signal TEXT, t REAL, v REAL);
CREATE INDEX IF NOT EXISTS idx_samples ON samples(signal, t);
CREATE TABLE IF NOT EXISTS events (signal TEXT, t REAL, mag REAL, meta TEXT);
CREATE INDEX IF NOT EXISTS idx_events ON events(signal, t);
"""
def open_db(path: str) -> sqlite3.Connection:
con = sqlite3.connect(path)
con.execute("PRAGMA journal_mode=WAL")
con.executescript(SCHEMA)
return con
def stats(path: str):
if not os.path.exists(path):
print(f"no database at {path} yet.")
return
con = open_db(path)
con.execute("PRAGMA wal_checkpoint(TRUNCATE)") # fold WAL in for a true size
con.commit()
size = os.path.getsize(path)
ns = con.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
ne = con.execute("SELECT COUNT(*) FROM events").fetchone()[0]
span = con.execute("SELECT MIN(t), MAX(t) FROM samples").fetchone()
sigs = con.execute("SELECT signal, COUNT(*) c FROM samples "
"GROUP BY signal ORDER BY c DESC").fetchall()
dur = (span[1] - span[0]) if span[0] else 0
print(f"db: {path}")
print(f"size: {size/1e6:.2f} MB samples: {ns:,} events: {ne:,}")
if dur:
print(f"span: {dur/3600:.2f} h -> {size/max(dur,1)*86400/1e6:.1f} MB/day"
f" ({size/max(ns,1):.1f} bytes/sample)")
print("busiest signals:")
for sig, c in sigs[:8]:
print(f" {sig:22} {c:,}")
async def record(url, path, decimate, heartbeat, eps):
con = open_db(path)
min_iv = 1.0 / decimate if decimate > 0 else 0.0
last, last_t, seen = {}, {}, set()
sbuf, ebuf, last_commit = [], [], time.time()
print(f"[recorder] -> {path} (decimate {decimate}Hz, heartbeat {heartbeat}s)")
while True:
try:
async with websockets.connect(url, max_size=None) as ws:
async for msg in ws:
d = json.loads(msg)
if "sources" not in d:
continue
now = time.time()
for sig, s in d["sources"].items():
raw = s.get("raw")
if raw is None:
continue
lv, lt = last.get(sig), last_t.get(sig, 0.0)
moved = lv is None or abs(raw - lv) > eps * max(1.0, abs(lv))
if (moved and now - lt >= min_iv) or now - lt >= heartbeat:
sbuf.append((sig, now, float(raw)))
last[sig], last_t[sig] = raw, now
for e in d.get("events", []):
k = (e.get("src"), round(e.get("t", 0), 3))
if k not in seen:
seen.add(k)
ebuf.append((e.get("src"), now, float(e.get("mag", 0)), None))
if len(seen) > 8000:
seen = set(list(seen)[-3000:])
if now - last_commit > 1.5:
if sbuf:
con.executemany("INSERT INTO samples VALUES (?,?,?)", sbuf)
sbuf = []
if ebuf:
con.executemany("INSERT INTO events VALUES (?,?,?,?)", ebuf)
ebuf = []
con.commit()
last_commit = now
except Exception as e: # noqa
print(f"[recorder] disconnected ({e}); retrying in 2s")
await asyncio.sleep(2)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--url", default="ws://127.0.0.1:8765")
ap.add_argument("--db", default=os.path.join(HERE, "godstrument.db"))
ap.add_argument("--decimate", type=float, default=20.0, help="max Hz per signal")
ap.add_argument("--heartbeat", type=float, default=10.0, help="force-write every Ns")
ap.add_argument("--eps", type=float, default=0.0005, help="relative change to log")
ap.add_argument("--stats", action="store_true")
args = ap.parse_args()
if args.stats:
stats(args.db)
return
try:
asyncio.run(record(args.url, args.db, args.decimate,
args.heartbeat, args.eps))
except KeyboardInterrupt:
print("\n[recorder] stopped.")
if __name__ == "__main__":
main()