- 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>
215 lines
8.2 KiB
Python
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}
|