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:
parent
8deb65b4fe
commit
a039ee7d7c
3
.gitignore
vendored
3
.gitignore
vendored
@ -8,5 +8,8 @@ godstrument.db
|
||||
godstrument.db-wal
|
||||
godstrument.db-shm
|
||||
|
||||
# user-saved templates (runtime, per-machine)
|
||||
patches/
|
||||
|
||||
# os
|
||||
.DS_Store
|
||||
|
||||
21
README.md
21
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
|
||||
|
||||
|
||||
16
config.json
16
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"}
|
||||
]
|
||||
}
|
||||
|
||||
50
hub.py
50
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):
|
||||
|
||||
2
run.py
2
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
|
||||
|
||||
75
strudel/godstrument.md
Normal file
75
strudel/godstrument.md
Normal 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.
|
||||
```
|
||||
21
transform.py
21
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),
|
||||
|
||||
@ -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
65
workers/world_debt.py
Normal 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
73
workers/world_econ.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user