Godstrument/transform.py
monsterrobotparty a039ee7d7c 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>
2026-07-05 02:25:19 +10:00

215 lines
8.2 KiB
Python

"""
transform.py — the performance layer. The human hands on the machine.
Raw sources (planet, room, markets) are honest but mechanical. This layer lets
you *play* them live: bend, offset, freeze, or smooth a signal on the fly — the
way you'd nudge a CDJ platter or lean on a pitch-bend wheel. And you can bind one
control to a whole GROUP of sources, so a single knob leans on four data streams
at once.
A Tweak sits between a source's normalized 0..1 value and the modulation matrix:
value ── group tweak ── source tweak ──► matrix
Parameters (each live-settable by a MIDI knob, a hand sensor, or the UI):
gain multiply (0..~2) — depth / "how much this source matters now"
offset add (-1..1) — bias / bend / lean
invert flip (0/1) — mirror the signal
freeze hold current value (0/1) — "stop the planet here" (CDJ pause)
smooth 0..1 extra lag — make it liquid, or snap it tight
"""
from __future__ import annotations
def _clamp01(x: float) -> float:
return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x
class Tweak:
"""A live-adjustable transform on one signal (or a whole group)."""
DEFAULTS = {"gain": 1.0, "offset": 0.0, "invert": 0.0,
"freeze": 0.0, "smooth": 0.0, "mute": 0.0}
def __init__(self, spec: dict | None = None):
self.p = dict(self.DEFAULTS)
if spec:
for k, v in spec.items():
if k in self.p:
self.p[k] = float(v) if not isinstance(v, bool) else float(v)
self._held: float | None = None
self._lag: float | None = None # exponential-smoothing state
self.orbit_lfo = 1.0 # external planetary-orbit gain (hub-set)
def set(self, param: str, value: float):
if param in self.p:
self.p[param] = float(value)
@property
def active(self) -> bool:
p = self.p
return (p["gain"] != 1.0 or p["offset"] != 0.0 or p["invert"] >= 0.5
or p["freeze"] >= 0.5 or p["smooth"] > 0.0)
def apply(self, x: float) -> float:
p = self.p
# mute: silence this source/group entirely
if p["mute"] >= 0.5:
return 0.0
# freeze: hold the last emitted value (a CDJ pause on the data)
if p["freeze"] >= 0.5:
if self._held is None:
self._held = x if self._lag is None else self._lag
return self._held
self._held = None
v = (1.0 - x) if p["invert"] >= 0.5 else x
v = v * p["gain"] * self.orbit_lfo + p["offset"]
v = _clamp01(v)
s = p["smooth"]
if s > 0.0:
alpha = 1.0 - min(0.985, s * 0.985) # smooth=0 -> 1 (pass), 1 -> ~0.015
self._lag = v if self._lag is None else self._lag + alpha * (v - self._lag)
v = self._lag
else:
self._lag = v
return _clamp01(v)
class TweakBank:
"""Owns every group + per-source tweak and resolves live control bindings.
config sections:
groups: { "planet": {"members": ["weather.temp", ...], "label": "..."} }
tweaks: { "crypto.vel": {gain,offset,...}, "@planet": {gain,...} }
controls: [ { "src": "hand.open", "target": "@planet",
"param": "gain", "min": 0.2, "max": 1.3 }, ... ]
(src is any signal name — a MIDI CC like "midi.cc.1", a sensor
like "hand.open", or another data source. target "@name" = group.)
"""
def __init__(self, cfg: dict):
self.groups: dict[str, Tweak] = {}
self.group_members: dict[str, list[str]] = {}
self.member_group: dict[str, str] = {}
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:
self.member_group.setdefault(m, gname)
for name, spec in tw.items():
if not name.startswith("@"):
self.source_tweaks[name] = Tweak(spec)
# ensure a tweak object exists for every control target
for b in self.controls:
self._resolve(b.get("target", ""))
def _resolve(self, target: str) -> Tweak | None:
if not target:
return None
if target.startswith("@"):
name = target[1:]
return self.groups.setdefault(name, Tweak())
return self.source_tweaks.setdefault(target, Tweak())
def apply_controls(self, sources: dict[str, float]):
"""Read live control signals and push them onto tweak params."""
for b in self.controls:
cv = sources.get(b.get("src"))
if cv is None:
continue
lo, hi = b.get("min", 0.0), b.get("max", 1.0)
mapped = lo + (hi - lo) * cv
tw = self._resolve(b.get("target", ""))
if tw:
tw.set(b.get("param", "gain"), mapped)
def transform(self, sources: dict[str, float]) -> dict[str, float]:
out: dict[str, float] = {}
for name, val in sources.items():
v = val
g = self.member_group.get(name)
if g:
v = self.groups[g].apply(v)
st = self.source_tweaks.get(name)
if st:
v = st.apply(v)
out[name] = v
return out
# ---- live control from the UI (browser -> hub) --------------------
def set_param(self, target: str, param: str, value: float):
tw = self._resolve(target)
if tw:
tw.set(param, value)
def toggle(self, target: str, param: str = "mute") -> float:
tw = self._resolve(target)
if not tw:
return 0.0
cur = tw.p.get(param, 0.0)
newv = 0.0 if cur >= 0.5 else 1.0
tw.set(param, newv)
return newv
def is_muted(self, name: str) -> bool:
st = self.source_tweaks.get(name)
if st and st.p.get("mute", 0.0) >= 0.5:
return True
g = self.member_group.get(name)
return bool(g and self.groups[g].p.get("mute", 0.0) >= 0.5)
def set_orbit_lfo(self, gname: str, v: float):
tw = self.groups.get(gname)
if tw:
tw.orbit_lfo = v
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),
"offset": round(t.p["offset"], 3),
"freeze": t.p["freeze"] >= 0.5,
"mute": t.p["mute"] >= 0.5,
"orbit": self.group_orbit.get(g),
"lfo": round(t.orbit_lfo, 3)}
for g, t in self.groups.items() if g in self.group_members}