diff --git a/README.md b/README.md index a955987..7cf07f1 100644 --- a/README.md +++ b/README.md @@ -82,17 +82,27 @@ their optional libs are installed), `--profile minimal` (just the simulator). | 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 | +| Human condition | World Bank (no key) | poverty, hunger, fertility, life expectancy | 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 +## Spells -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. +Save a whole configuration — routes, groups, orbits, **and the live mix** (gains, +mutes, freezes) — as a named **spell** from the ⚙ panel's SPELLS section (`save`, +then `load` from the dropdown). Casting a saved spell restores the exact state. +They live in `patches/*.json`. + +## Performance mode + +Press **P** for a clean projection view: the editing chrome vanishes, the source +readouts declutter, and a rotating line of "dispatches from the world" (*bitcoin +at $62,902 · the moon is 81% lit · mercury is retrograde · M4.2 earthquake*) +appears for the audience. Open the console on a second monitor / projector and +hit P. (Curated selections + MIDI-mappable display controls are the next layer.) ## Strudel & MIDI voices diff --git a/config.json b/config.json index 36896a1..0e4edf0 100644 --- a/config.json +++ b/config.json @@ -34,8 +34,8 @@ "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"]}, - "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"]} + "dark": {"label": "the shadow", "members": ["debt.vel", "econ.inflation", "air.pm25", "quake.event", "clock.deaths", "astro.tension", "world.poverty", "world.hunger"]}, + "light": {"label": "the bright", "members": ["clock.births", "astro.moon", "astro.harmony", "almanac.fertile", "sky.day", "world.lifeexp", "world.fertility"]} }, "tweaks": { @@ -114,7 +114,11 @@ "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 %"} + "econ.cpi_mom": {"norm": {"lo": -1, "hi": 2}, "label": "CPI month-over-month %"}, + "world.poverty": {"norm": {"lo": 0, "hi": 20}, "filter": {"min_cutoff": 0.05, "beta": 0.001}, "label": "extreme poverty (world %)"}, + "world.hunger": {"norm": {"lo": 0, "hi": 15}, "filter": {"min_cutoff": 0.05, "beta": 0.001}, "label": "undernourishment (world %)"}, + "world.fertility": {"norm": {"lo": 0, "hi": 4}, "label": "world fertility rate"}, + "world.lifeexp": {"norm": {"lo": 50, "hi": 85}, "label": "world life expectancy"} }, "routes": [ @@ -166,6 +170,9 @@ {"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"} + {"source": "econ.inflation", "dest": "master.space", "amount": 0.3, "curve": "lin", "label": "inflation erodes the space"}, + {"source": "world.poverty", "dest": "drone.voices", "amount": 0.35, "curve": "lin", "label": "the world's poverty weights the drone"}, + {"source": "world.hunger", "dest": "saturation", "amount": 0.25, "curve": "lin", "label": "hunger grinds under everything"}, + {"source": "world.lifeexp", "dest": "pad.brightness", "amount": 0.2, "curve": "lin", "label": "longer lives, brighter pads"} ] } diff --git a/hub.py b/hub.py index 706fff9..4cf7937 100644 --- a/hub.py +++ b/hub.py @@ -313,7 +313,9 @@ class Hub: 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()} + 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() @@ -330,7 +332,9 @@ class Hub: 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}'") + 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") diff --git a/run.py b/run.py index 977ff8e..d0515e6 100644 --- a/run.py +++ b/run.py @@ -41,6 +41,7 @@ WORLD = [ "workers/world_almanac.py", "workers/world_debt.py", "workers/world_econ.py", + "workers/world_bank.py", ] # workers that retarget to the venue city diff --git a/transform.py b/transform.py index b47f0d5..c38b108 100644 --- a/transform.py +++ b/transform.py @@ -203,6 +203,25 @@ class TweakBank: self.add_group(name, gd.get("members", []), gd.get("label"), gd.get("orbit")) + def dump_state(self) -> dict: + """Serialize live tweak settings (gains/mutes/freezes) — a spell's mix.""" + return { + "groups": {g: dict(self.groups[g].p) for g in self.group_members}, + "sources": {n: dict(t.p) for n, t in self.source_tweaks.items() + if t.active}, + } + + def load_state(self, state: dict): + for g, params in (state.get("groups") or {}).items(): + tw = self.groups.get(g) + if tw: + for k, v in params.items(): + tw.set(k, v) + for n, params in (state.get("sources") or {}).items(): + tw = self.source_tweaks.setdefault(n, Tweak()) + for k, v in params.items(): + tw.set(k, v) + 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 67a9ca3..2959453 100644 --- a/viz/index.html +++ b/viz/index.html @@ -529,6 +529,7 @@ for (const n of sources.values()) drawSourceNode(n, t); for (const n of dests.values()) drawDestNode(n, t); drawEditing(t); + if (performMode) drawPerform(t, dt); drawHeader(t); drawFooter(t); if (!connected) drawConnecting(t); @@ -707,9 +708,11 @@ ctx.font = "600 13px 'Helvetica Neue', Arial, sans-serif"; ctx.fillStyle = rgba(rgbMix(col, 0.55), 0.95); ctx.fillText(truncate(n.label, 22), x - NODE_R - 14, y - 1); - ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif"; - ctx.fillStyle = "rgba(150,165,190,0.7)"; - ctx.fillText(fmtRaw(n.rawDisp), x - NODE_R - 14, y + 13); + if (!performMode) { + ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif"; + ctx.fillStyle = "rgba(150,165,190,0.7)"; + ctx.fillText(fmtRaw(n.rawDisp), x - NODE_R - 14, y + 13); + } // multi-select highlight ring if (selection.has(n.key)) { @@ -1082,6 +1085,7 @@ let midiAccess = null, midiOut = null, midiIn = null; let learnMode = false, learnTarget = null; let patchList = [], templateSelect = null; + let performMode = false, dispatchIdx = 0, dispatchT = 0; const midiBindings = loadBindings(); // "cc:17" / "note:36" -> {target,param,mode} const lastCCSent = {}, lastNoteFor = {}; let outSel = null, inSel = null, midiStatusEl = null, learnBtn = null, bindCountEl = null; @@ -1158,6 +1162,52 @@ updateGroupBtn(); } + // performance mode — clean projection view for the audience (press P) + window.addEventListener("keydown", (ev) => { + const tag = (ev.target && ev.target.tagName) || ""; + if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return; + if (ev.key === "p" || ev.key === "P") { + performMode = !performMode; + if (performMode) { + gearEl.style.display = "none"; panelEl.style.display = "none"; + inspectEl.style.display = "none"; + } else { + gearEl.style.display = ""; panelEl.style.display = ""; inspectEl.style.display = ""; + } + } + }); + + // rotating "dispatches from the world" — data as readable poetry + function worldPhrases() { + const p = [], get = (k) => sources.get(k); + const moon = get("astro.moon"); if (moon) p.push("the moon is " + Math.round(moon.raw * 100) + "% lit"); + const merc = get("astro.mercury_retro"); if (merc && merc.raw > 0.5) p.push("mercury is retrograde"); + const btc = get("crypto.price"); if (btc && btc.raw > 0) p.push("bitcoin at $" + Math.round(btc.raw).toLocaleString()); + const inf = get("econ.inflation"); if (inf && inf.raw > 0) p.push("inflation runs at " + inf.raw.toFixed(1) + "%"); + const debt = get("debt.total"); if (debt && debt.raw > 0) p.push("$" + (debt.raw / 1e12).toFixed(1) + " trillion owed"); + const pov = get("world.poverty"); if (pov && pov.raw > 0) p.push(pov.raw.toFixed(1) + "% of the world in extreme poverty"); + const ev = eventTicker[0]; if (ev) p.push(ev.text.toLowerCase()); + if (!p.length) p.push("the world is playing"); + return p; + } + + function drawPerform(t, dt) { + dispatchT += dt; + const phrases = worldPhrases(); + if (dispatchT > 5.0) { dispatchT = 0; dispatchIdx++; } + const phrase = phrases[dispatchIdx % phrases.length]; + let a = 1; + if (dispatchT < 0.6) a = dispatchT / 0.6; + else if (dispatchT > 4.4) a = (5.0 - dispatchT) / 0.6; + a = clamp(a, 0, 1); + ctx.save(); + ctx.textAlign = "center"; ctx.textBaseline = "middle"; + ctx.font = "300 30px 'Helvetica Neue', Arial, sans-serif"; + ctx.fillStyle = "rgba(224,234,255," + (0.12 + a * 0.72).toFixed(3) + ")"; + ctx.fillText(phrase, W / 2, H - 118); + ctx.restore(); + } + function openInspector(node) { inspectEl.innerHTML = ""; const close = document.createElement("span"); @@ -1376,10 +1426,10 @@ panelEl.appendChild(clr); // Templates — save / load named patches - const th = document.createElement("h3"); th.textContent = "TEMPLATES"; th.style.marginTop = "8px"; + const th = document.createElement("h3"); th.textContent = "SPELLS"; 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 nameIn = document.createElement("input"); nameIn.type = "text"; nameIn.placeholder = "name your spell…"; 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); diff --git a/workers/world_bank.py b/workers/world_bank.py new file mode 100644 index 0000000..21d7bff --- /dev/null +++ b/workers/world_bank.py @@ -0,0 +1,79 @@ +""" +world_bank.py — the human condition, slow and heavy. + +Pulls World Bank development indicators (no key) for the whole world: extreme +poverty, undernourishment (food insecurity), fertility, life expectancy. These +are annual figures — they barely move — so they're near-DC ballast, a moral +weight under the set rather than a rhythm. (The API is annual and occasionally +throttles rapid requests, so this polls slowly and holds the last value.) + +Emits: + /gs/world/poverty extreme poverty headcount, % of population + /gs/world/hunger prevalence of undernourishment, % + /gs/world/fertility births per woman + /gs/world/lifeexp life expectancy at birth, years +""" + +from __future__ import annotations +import argparse +import json +import time +import urllib.request + +from pythonosc.udp_client import SimpleUDPClient + +BASE = "https://api.worldbank.org/v2/country/WLD/indicator/{ind}?format=json&mrnev=1" +UA = "Godstrument/1.0 (live instrument)" + +INDICATORS = { + "SI.POV.DDAY": ("world/poverty", "extreme poverty %"), + "SN.ITK.DEFC.ZS": ("world/hunger", "undernourishment %"), + "SP.DYN.TFRT.IN": ("world/fertility", "fertility"), + "SP.DYN.LE00.IN": ("world/lifeexp", "life expectancy"), +} + + +def fetch(ind: str): + req = urllib.request.Request(BASE.format(ind=ind), headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=30) as r: + raw = r.read().decode("utf-8-sig").strip() # World Bank prepends a BOM + d = json.loads(raw) + if isinstance(d, list) and len(d) > 1 and d[1]: + row = d[1][0] + if row.get("value") is not None: + return float(row["value"]), row["date"] + return None, None + + +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") + args = ap.parse_args() + + client = SimpleUDPClient(args.host, args.port) + print("[worldbank] emitting /gs/world/poverty|hunger|fertility|lifeexp " + f"(re-query every {args.poll:g}s)") + + values: dict[str, float] = {} + last_poll = 0.0 + while True: + if time.time() - last_poll > args.poll or not values: + for ind, (addr, name) in INDICATORS.items(): + try: + v, year = fetch(ind) + if v is not None: + values[addr] = v + print(f" 🌍 {name}: {v} ({year})") + time.sleep(1.0) # be gentle with the API + except Exception as e: # noqa + print(f"[worldbank] {ind} warn: {e}") + last_poll = time.time() + for addr, v in values.items(): + client.send_message("/gs/" + addr, v) + time.sleep(10.0) + + +if __name__ == "__main__": + main()