diff --git a/.gitignore b/.gitignore index 3425b1a..66b75a5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,8 @@ godstrument.db godstrument.db-wal godstrument.db-shm +# user-saved templates (runtime, per-machine) +patches/ + # os .DS_Store diff --git a/README.md b/README.md index bf8adc5..a955987 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.json b/config.json index 852621c..36896a1 100644 --- a/config.json +++ b/config.json @@ -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"} ] } diff --git a/hub.py b/hub.py index ed5076b..706fff9 100644 --- a/hub.py +++ b/hub.py @@ -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): diff --git a/run.py b/run.py index f768547..977ff8e 100644 --- a/run.py +++ b/run.py @@ -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 diff --git a/strudel/godstrument.md b/strudel/godstrument.md new file mode 100644 index 0000000..2cd5f9e --- /dev/null +++ b/strudel/godstrument.md @@ -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. +``` diff --git a/transform.py b/transform.py index dcd7b9f..b47f0d5 100644 --- a/transform.py +++ b/transform.py @@ -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), diff --git a/viz/index.html b/viz/index.html index 88598c2..67a9ca3 100644 --- a/viz/index.html +++ b/viz/index.html @@ -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); + } + } } // --------------------------------------------------------------------------- diff --git a/workers/world_debt.py b/workers/world_debt.py new file mode 100644 index 0000000..372942f --- /dev/null +++ b/workers/world_debt.py @@ -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() diff --git a/workers/world_econ.py b/workers/world_econ.py new file mode 100644 index 0000000..02c6929 --- /dev/null +++ b/workers/world_econ.py @@ -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()