Add dark/yang data, concept groups, named templates, editing console, Strudel kit

- world_debt.py + world_econ.py: US national debt + inflation (the dark half)
- concept groups: "the shadow" / "the bright" — group sources by feeling
- named templates: hub save_patch/load_patch + console TEMPLATES panel
- editing console: runtime add/remove/set route + make_group; drag-to-patch,
  shift-click inspector, cmd-select grouping in the viz
- planetary orbital LFOs per group; almanac + ephemeris workers
- Web MIDI (out + learn) in the console; strudel/godstrument.md starter kit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
monsterrobotparty 2026-07-05 02:25:19 +10:00
parent 8deb65b4fe
commit a039ee7d7c
10 changed files with 355 additions and 7 deletions

3
.gitignore vendored
View File

@ -8,5 +8,8 @@ godstrument.db
godstrument.db-wal
godstrument.db-shm
# user-saved templates (runtime, per-machine)
patches/
# os
.DS_Store

View File

@ -79,6 +79,27 @@ their optional libs are installed), `--profile minimal` (just the simulator).
| Wikipedia edits | Wikimedia SSE stream | human pulse / glitch |
| Local sky | computed (NOAA solar position) | day/night LFO, per-venue |
| Humanity | modelled vital rates | births/deaths/growth velocity |
| Ephemeris / almanac | computed (Swiss Ephemeris) | planets, moon, planting calendar |
| National debt | US Treasury (no key) | slow dark ballast + daily churn |
| Inflation | US BLS CPI (no key) | grinding pressure |
The **dark half** (debt, inflation, pollution, quakes, the death rate) balances
the **bright** (births, moon, harmony, fertility, daylight) — group your sources
by *feeling*, not category: a "shadow" group, a "sad" group, whatever you blend.
Congress-trade and conflict feeds are possible too but need keyed/curated sources.
## Templates
Save the whole wiring — routes, groups, orbits — as a named template from the ⚙
panel's TEMPLATES section (`save`, then `load` from the dropdown). They live in
`patches/*.json`. Build a set, name it, recall it.
## Strudel & MIDI voices
The ⚙ Web MIDI out already turns every destination into CC/notes, so any synth
becomes an output that "receives sources." `strudel/godstrument.md` is a starter
kit that drives a live **Strudel** track (free/open, no Ableton) from the hub's
CC map — plus a no-MIDI websocket path for a custom web-audio synth.
## Playing it live — the performance layer

View File

@ -33,7 +33,9 @@
"cosmos": {"label": "the shared sky", "orbit": "saturn", "members": ["sun.speed", "iss.vel", "planes.count", "crypto.vel"]},
"human": {"label": "humanity", "orbit": "mars", "members": ["wiki.rate", "clock.popvel", "clock.births"]},
"market": {"label": "your market", "orbit": "mercury", "members": ["crypto.vel", "market.velocity", "market.turnover"]},
"heavens": {"label": "the sky", "orbit": "neptune", "members": ["astro.moon", "astro.tension", "astro.harmony", "astro.saturn", "astro.mars"]}
"heavens": {"label": "the sky", "orbit": "neptune", "members": ["astro.moon", "astro.tension", "astro.harmony", "astro.saturn", "astro.mars"]},
"dark": {"label": "the shadow", "members": ["debt.vel", "econ.inflation", "air.pm25", "quake.event", "clock.deaths", "astro.tension"]},
"light": {"label": "the bright", "members": ["clock.births", "astro.moon", "astro.harmony", "almanac.fertile", "sky.day"]}
},
"tweaks": {
@ -108,7 +110,11 @@
"almanac.element": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.1, "beta": 0.001}, "label": "planting element (root/leaf/flower/fruit)"},
"almanac.waxing": {"norm": {"lo": 0, "hi": 1}, "label": "sow above ground (waxing)"},
"almanac.dec": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.1, "beta": 0.001}, "label": "moon ascending / descending"},
"almanac.fertile": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.12, "beta": 0.002}, "label": "biodynamic fertility"}
"almanac.fertile": {"norm": {"lo": 0, "hi": 1}, "filter": {"min_cutoff": 0.12, "beta": 0.002}, "label": "biodynamic fertility"},
"debt.total": {"norm": {"lo": 38000000000000, "hi": 42000000000000}, "filter": {"min_cutoff": 0.08, "beta": 0.001}, "label": "US national debt"},
"debt.vel": {"norm": {"lo": 0, "hi": 50000000000}, "label": "debt daily churn"},
"econ.inflation": {"norm": {"lo": 0, "hi": 10}, "filter": {"min_cutoff": 0.1, "beta": 0.002}, "label": "US inflation (YoY %)"},
"econ.cpi_mom": {"norm": {"lo": -1, "hi": 2}, "label": "CPI month-over-month %"}
},
"routes": [
@ -156,6 +162,10 @@
{"source": "astro.retro", "dest": "glitch", "amount": 0.3, "curve": "exp", "label": "the more retrogrades, the more glitch"},
{"source": "almanac.element", "dest": "wavetable.morph", "amount": 0.4, "curve": "lin", "label": "the moon's element steps the timbre (root/leaf/flower/fruit)"},
{"source": "almanac.fertile", "dest": "pad.brightness", "amount": 0.3, "curve": "scurve", "label": "fertile days brighten the pads"},
{"source": "almanac.dec", "dest": "lfo.rate", "amount": 0.25, "curve": "lin", "label": "the moon rising/falling sets the LFO"}
{"source": "almanac.dec", "dest": "lfo.rate", "amount": 0.25, "curve": "lin", "label": "the moon rising/falling sets the LFO"},
{"source": "debt.vel", "dest": "saturation", "amount": 0.4, "curve": "exp", "label": "the debt churn grinds the saturation"},
{"source": "debt.total", "dest": "drone.voices", "amount": 0.3, "curve": "lin", "label": "the weight of the debt as a drone"},
{"source": "econ.inflation", "dest": "tempo.nudge", "amount": 0.2, "curve": "scurve", "label": "inflation quietly presses the tempo"},
{"source": "econ.inflation", "dest": "master.space", "amount": 0.3, "curve": "lin", "label": "inflation erodes the space"}
]
}

50
hub.py
View File

@ -114,6 +114,9 @@ class Hub:
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] = []
@ -262,7 +265,7 @@ class Hub:
"label": r.get("label", ""),
"amount": round(r.get("amount", 0), 3),
"active": round(active.get(i, 0.0), 4)})
payload = json.dumps({
p = {
"t": round(t, 3),
"sources": sources,
"dests": {k: (round(v, 4) if isinstance(v, float) else v)
@ -270,7 +273,11 @@ class Hub:
"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):
@ -283,7 +290,8 @@ class Hub:
"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", {})}))
"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))
@ -292,6 +300,38 @@ class Hub:
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()}
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"])
print(f" loaded template '{name}'")
def _handle_cmd(self, m: dict):
cmd = m.get("cmd")
if cmd == "mute_toggle":
@ -311,6 +351,10 @@ class Hub:
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):

2
run.py
View File

@ -39,6 +39,8 @@ WORLD = [
"workers/world_clock.py",
"workers/world_ephemeris.py",
"workers/world_almanac.py",
"workers/world_debt.py",
"workers/world_econ.py",
]
# workers that retarget to the venue city

75
strudel/godstrument.md Normal file
View File

@ -0,0 +1,75 @@
# Godstrument → Strudel (and any MIDI synth)
The destinations aren't sound on their own — they're **control signals** looking
for a voice. The ⚙ panel's **Web MIDI out** already streams every destination to
a MIDI port as CC (and `lead.note`/`bass.note` as notes). So *any* synth that
speaks MIDI becomes an output that "receives sources": a hardware synth, a
software instrument, Ableton — or **Strudel**, which reads MIDI natively and is
free/open/self-hostable (fits the whole ethos).
## Setup (Strudel as the sound engine, no Ableton)
1. In `config.json` set `"midi": { "enabled": true }` (or just use the browser
Web MIDI out from the ⚙ panel — either sends the same CCs).
2. On macOS, open **Audio MIDI Setup → MIDI Studio**, double-click **IAC Driver**,
tick *Device is online*. That's your virtual cable.
3. In the ⚙ panel: **enable MIDI**, pick the IAC bus as **out**.
4. Open **strudel.cc** (or your self-hosted Strudel), allow MIDI, set its input to
the IAC bus, and paste the pattern below.
## The CC map (from `config.json``midi.map`)
| destination | CC | driven by (in the starter patch) |
|------------------|----|----------------------------------|
| filter.cutoff | 74 | the sun / your hand |
| pad.brightness | 71 | daylight, harmony, almanac fertility |
| wavetable.morph | 70 | your hand / Mars / almanac element |
| delay.feedback | 93 | your hand Y / Mercury retrograde |
| reverb.size | 91 | room light / quakes |
| saturation | 75 | Delhi's air / **debt churn** |
| granular.density | 76 | Wikipedia edit rate |
| perc.density | 77 | **your dealgod market** / crypto volatility |
| tempo.nudge | 78 | market activity / **inflation** |
| drone.voices | 79 | aircraft / population / **the debt's weight** |
| lfo.rate | 72 | wind / the Moon rising & falling |
| glitch | 73 | retrogrades / wiki edits / light flashes |
| master.space | 94 | the moon phase / quakes / inflation |
| lead.note | note, ch 0 | **your market**, quantized (G dorian) |
| bass.note | note, ch 1 | earthquake depth, quantized |
## A starter pattern
```javascript
// bind the hub's CCs (adjust the input-binding call to your Strudel version;
// ccn(n) returns 0..1 for controller n on the selected MIDI input)
setcps(0.5)
const cutoff = ccn(74), bright = ccn(71), morph = ccn(70)
const grit = ccn(75), perc = ccn(77), drone = ccn(79)
const space = ccn(94), lfo = ccn(72), glitch = ccn(73)
stack(
// LEAD — your market plays it (note on channel 0)
note(notein()).s("sawtooth")
.cutoff(cutoff.range(300, 5000)).lpq(morph.range(2, 12)).gain(0.5),
// PAD — light / almanac brightness
n("0 3 7 10").scale("C:dorian").s("gm_pad_warm")
.room(space.range(0, 0.9)).gain(bright.range(0.15, 0.6)),
// DRONE — the weight of the debt
n("0").scale("C:dorian").s("sawtooth").cutoff(180)
.gain(drone.range(0, 0.45)).room(0.7),
// HATS — dealgod market activity drives density
s("hh*8").gain(perc.range(0, 0.9)).pan(sine.fast(lfo.range(0.2, 4)))
).distort(grit.range(0, 2.5)).sometimesBy(glitch.range(0, 0.4), x => x.crush(4))
```
## No-MIDI alternative (custom web-audio synth)
If you'd rather build your own voices, the hub also broadcasts every value on
`ws://localhost:8765` as JSON (`dests`, `sources`, `macros`). A ~30-line page can
open that socket and feed the numbers straight into Web Audio nodes — no MIDI, no
IAC. Ask and I'll scaffold it.
```

View File

@ -97,12 +97,14 @@ class TweakBank:
self.source_tweaks: dict[str, Tweak] = {}
self.controls: list[dict] = list(cfg.get("controls", []))
self.group_orbit: dict[str, str] = {} # group -> planet name
self.group_label: dict[str, str] = {} # group -> display label
tw = cfg.get("tweaks", {})
for gname, g in cfg.get("groups", {}).items():
members = g.get("members", [])
self.group_members[gname] = members
self.groups[gname] = Tweak(tw.get("@" + gname))
self.group_label[gname] = g.get("label", gname)
if g.get("orbit"):
self.group_orbit[gname] = g["orbit"]
for m in members:
@ -175,15 +177,32 @@ class TweakBank:
if tw:
tw.orbit_lfo = v
def add_group(self, name: str, members: list):
def add_group(self, name: str, members: list, label: str = None,
orbit: str = None):
"""Create/replace a group at runtime (browser multi-select -> group)."""
if not name or not members:
return
self.group_members[name] = list(members)
self.groups.setdefault(name, Tweak())
self.group_label[name] = label or name
if orbit:
self.group_orbit[name] = orbit
for m in members:
self.member_group[m] = name
def dump_groups(self) -> dict:
"""Serialize current groups for saving a template."""
return {g: {"members": self.group_members[g],
"label": self.group_label.get(g, g),
"orbit": self.group_orbit.get(g)}
for g in self.group_members}
def load_groups(self, groups: dict):
"""Rebuild groups from a saved template."""
for name, gd in (groups or {}).items():
self.add_group(name, gd.get("members", []),
gd.get("label"), gd.get("orbit"))
def macros(self) -> dict:
"""Summary of group tweak state for the UI."""
return {g: {"gain": round(t.p["gain"], 3),

View File

@ -49,6 +49,10 @@
background: rgba(20,28,44,0.9); color: #c7d4ea; border: 1px solid rgba(120,150,200,0.25);
border-radius: 6px; font-size: 11px; max-width: 152px; padding: 2px;
}
#panel .tplname {
background: rgba(20,28,44,0.9); color: #c7d4ea; border: 1px solid rgba(120,150,200,0.25);
border-radius: 6px; font-size: 11px; padding: 3px 6px; flex: 1; max-width: 120px; margin-right: 6px;
}
#inspect {
position: fixed; top: 96px; left: 14px; z-index: 11;
width: 262px; max-height: 74vh; overflow-y: auto;
@ -310,6 +314,7 @@
if (Array.isArray(msg.routes)) {
preLayoutRoutes(msg.routes);
}
if (Array.isArray(msg.patches)) patchList = msg.patches;
if (msg.groups) { groupsInfo = msg.groups; buildPanel(); }
if (msg.midi) midiMap = msg.midi;
return;
@ -355,6 +360,7 @@
for (const g in msg.macros) if (!(g in groupsInfo)) { groupsInfo[g] = { label: g }; fresh = true; }
if (fresh) { buildPanel(); if (typeof midiAccess !== "undefined" && midiAccess) populatePorts(); }
}
if (msg.patches) { patchList = msg.patches; refreshTemplates(); }
if (msg.events && Array.isArray(msg.events)) {
for (const e of msg.events) {
ingestEvent(e);
@ -1075,6 +1081,7 @@
// ---- Web MIDI state ----
let midiAccess = null, midiOut = null, midiIn = null;
let learnMode = false, learnTarget = null;
let patchList = [], templateSelect = null;
const midiBindings = loadBindings(); // "cc:17" / "note:36" -> {target,param,mode}
const lastCCSent = {}, lastNoteFor = {};
let outSel = null, inSel = null, midiStatusEl = null, learnBtn = null, bindCountEl = null;
@ -1367,6 +1374,35 @@
const clr = document.createElement("button"); clr.textContent = "clear"; clr.className = "midibtn"; clr.style.marginLeft = "8px";
clr.onclick = () => { for (const k in midiBindings) delete midiBindings[k]; saveBindings(); midiStatusEl.textContent = "mappings cleared."; };
panelEl.appendChild(clr);
// Templates — save / load named patches
const th = document.createElement("h3"); th.textContent = "TEMPLATES"; th.style.marginTop = "8px";
panelEl.appendChild(th);
const nameRow = document.createElement("div"); nameRow.className = "row"; nameRow.style.margin = "4px 0";
const nameIn = document.createElement("input"); nameIn.type = "text"; nameIn.placeholder = "name…"; nameIn.className = "tplname";
const saveBtn = document.createElement("button"); saveBtn.textContent = "save"; saveBtn.className = "midibtn";
saveBtn.onclick = () => { const n = nameIn.value.trim(); if (n) { sendCmd({ cmd: "save_patch", name: n }); nameIn.value = ""; } };
nameRow.appendChild(nameIn); nameRow.appendChild(saveBtn); panelEl.appendChild(nameRow);
const loadRow = document.createElement("div"); loadRow.className = "row"; loadRow.style.margin = "4px 0";
templateSelect = document.createElement("select"); templateSelect.className = "midisel";
const loadBtn = document.createElement("button"); loadBtn.textContent = "load"; loadBtn.className = "midibtn"; loadBtn.style.marginLeft = "6px";
loadBtn.onclick = () => { if (templateSelect.value) sendCmd({ cmd: "load_patch", name: templateSelect.value }); };
loadRow.appendChild(templateSelect); loadRow.appendChild(loadBtn); panelEl.appendChild(loadRow);
refreshTemplates();
}
function refreshTemplates() {
if (!templateSelect) return;
templateSelect.innerHTML = "";
if (!patchList.length) {
const o = document.createElement("option"); o.value = ""; o.textContent = "— none saved —";
templateSelect.appendChild(o);
} else {
for (const p of patchList) {
const o = document.createElement("option"); o.value = p; o.textContent = p;
templateSelect.appendChild(o);
}
}
}
// ---------------------------------------------------------------------------

65
workers/world_debt.py Normal file
View File

@ -0,0 +1,65 @@
"""
world_debt.py the weight of the world's money. The dark ballast.
Pulls the US national debt straight from the Treasury (no key). It's ~$39
trillion and grinding a vast, slow, heavy number. Its day-to-day *change* is
the churn; the total is a near-DC drone that only creeps. Yang ballast for a set
that's otherwise all moon and weather.
Emits:
/gs/debt/total total public debt in dollars (a slow, heavy drift)
/gs/debt/vel magnitude of the daily change ($/day) the churn
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.request
from pythonosc.udp_client import SimpleUDPClient
URL = ("https://api.fiscaldata.treasury.gov/services/api/fiscal_service"
"/v2/accounting/od/debt_to_penny?sort=-record_date&page[size]=2")
UA = "Godstrument/1.0 (live instrument)"
def fetch():
req = urllib.request.Request(URL, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
d = json.load(r)["data"]
total = float(d[0]["tot_pub_debt_out_amt"])
prev = float(d[1]["tot_pub_debt_out_amt"]) if len(d) > 1 else total
return total, abs(total - prev), d[0]["record_date"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=9000)
ap.add_argument("--poll", type=float, default=3600.0, help="re-query every Ns")
args = ap.parse_args()
client = SimpleUDPClient(args.host, args.port)
print(f"[debt] emitting /gs/debt/total|vel (US national debt) "
f"(re-query every {args.poll:g}s)")
total, vel, last = 0.0, 0.0, None
last_poll = 0.0
while True:
try:
if time.time() - last_poll > args.poll or total == 0.0:
total, vel, date = fetch()
last_poll = time.time()
if date != last:
print(f" 💰 debt ${total/1e12:.3f}T (daily churn ${vel/1e9:.1f}B)")
last = date
client.send_message("/gs/debt/total", total)
client.send_message("/gs/debt/vel", vel)
except Exception as e: # noqa
print(f"[debt] warn: {e}")
time.sleep(5.0)
if __name__ == "__main__":
main()

73
workers/world_econ.py Normal file
View File

@ -0,0 +1,73 @@
"""
world_econ.py the cost of living. Inflation as pressure.
Pulls US CPI (consumer price index) from the Bureau of Labor Statistics (no key,
but rate-limited so it polls rarely; CPI is monthly data anyway) and computes
the year-over-year inflation rate. Inflation is a quiet, grinding pressure it
never trends toward zero, it just erodes. Good as a slow "tension" source in the
dark half of a set.
Emits:
/gs/econ/inflation year-over-year CPI change, percent (~0..10)
/gs/econ/cpi_mom latest month-over-month change, percent
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.request
from pythonosc.udp_client import SimpleUDPClient
URL = "https://api.bls.gov/publicAPI/v2/timeseries/data/CUUR0000SA0" # CPI-U all
UA = "Godstrument/1.0 (live instrument)"
def fetch():
req = urllib.request.Request(URL, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=30) as r:
rows = json.load(r)["Results"]["series"][0]["data"]
latest = rows[0]
idx = float(latest["value"])
# month-over-month (previous data point)
mom = ((idx / float(rows[1]["value"])) - 1) * 100 if len(rows) > 1 else 0.0
# year-over-year: same period one year earlier
yoy = 0.0
for row in rows:
if row["year"] == str(int(latest["year"]) - 1) and row["period"] == latest["period"]:
yoy = (idx / float(row["value"]) - 1) * 100
break
return yoy, mom, latest["periodName"] + " " + latest["year"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=9000)
ap.add_argument("--poll", type=float, default=21600.0, help="re-query every Ns (BLS is rate-limited)")
args = ap.parse_args()
client = SimpleUDPClient(args.host, args.port)
print(f"[econ] emitting /gs/econ/inflation|cpi_mom (US CPI) "
f"(re-query every {args.poll:g}s)")
yoy, mom, last = 0.0, 0.0, None
last_poll = 0.0
while True:
try:
if time.time() - last_poll > args.poll or last is None:
yoy, mom, label = fetch()
last_poll = time.time()
if label != last:
print(f" 📈 inflation {yoy:.2f}% YoY ({label}, {mom:+.2f}% MoM)")
last = label
client.send_message("/gs/econ/inflation", yoy)
client.send_message("/gs/econ/cpi_mom", mom)
except Exception as e: # noqa
print(f"[econ] warn: {e}")
time.sleep(10.0)
if __name__ == "__main__":
main()