From 1e2b7cb6242682e4435ff1db728b2f8604408af1 Mon Sep 17 00:00:00 2001 From: vdmo Date: Thu, 1 Oct 2026 00:32:58 +1000 Subject: [PATCH] feat(tools): TiXL PCHI bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Path A of the PCHI/TiXL integration: an OSC bridge connecting TiXL (https://github.com/tixl3d/tixl, 5.1k-star open-source real-time motion graphics) to the PCHI Conductor, following the same shape as the existing Resolume bridge — but this time verified against TiXL's real operator source (Operators/Io/Symbols/lib/io/osc/{OscOutput,OscInput}.cs) before writing a line of bridge code, rather than guessing at wire compatibility. TiXL's OSC operators are built on Rug.Osc, a real spec-compliant OSC library, so python-osc interoperates correctly with no translation guesswork — unlike the first version of the Resolume bridge, which shipped with three independent wire-format bugs (#3) because none of that was verified against real source. Two legs: TiXL's OscOutput -> raw JSON-over-UDP -> the Conductor (control updates, same wire-format lesson as the Resolume fix applied from day one: correct type/payloadType field names, no OSC-wrapping, complete required pir_invariants on every message including heartbeats); the Conductor's governance-event WebSocket -> OSC -> TiXL's OscInput (live verdicts as a numeric gate_state code, since OscInput's outputs are float-only — confirmed from source, no string channel). Governance telemetry depends on #5 (the WS accept-loop fix) — without it the conductor's WS never accepts a connection and this leg silently receives nothing. Control updates work standalone regardless; verified against a build of plain `main` (without #5) to confirm exactly that. Verified live: a real conductor, this bridge, and real OSC traffic constructed to exactly match TiXL's actual OscOutput/OscInput wire shape (confirmed from source) — both legs work correctly, including governance telemetry (tested against a build with #5's fix). Not verified against real TiXL itself, which is a Windows/DirectX GUI application — README states this plainly. Co-Authored-By: Claude Sonnet 5 --- tools/tixl-pchi-bridge/README.md | 68 ++++ .../tixl_pchi_bridge.cpython-310.pyc | Bin 0 -> 11186 bytes tools/tixl-pchi-bridge/tixl_pchi_bridge.py | 300 ++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 tools/tixl-pchi-bridge/README.md create mode 100644 tools/tixl-pchi-bridge/__pycache__/tixl_pchi_bridge.cpython-310.pyc create mode 100644 tools/tixl-pchi-bridge/tixl_pchi_bridge.py diff --git a/tools/tixl-pchi-bridge/README.md b/tools/tixl-pchi-bridge/README.md new file mode 100644 index 0000000..0337243 --- /dev/null +++ b/tools/tixl-pchi-bridge/README.md @@ -0,0 +1,68 @@ +# TiXL PCHI Bridge + +Connects [TiXL](https://github.com/tixl3d/tixl) — open-source real-time motion graphics — to the PCHI Conductor via OSC. + +## Overview + +Two independent legs: + +``` +TiXL (OscOutput) --OSC--> this bridge --raw JSON UDP--> PCHI Conductor (control updates) +PCHI Conductor --WebSocket--> this bridge --OSC--> TiXL (OscInput) (governance telemetry, live verdicts) +``` + +Unlike the Resolume bridge, this one didn't need to guess at wire compatibility: TiXL's `OscOutput`/`OscInput` operators are built on `Rug.Osc`, a real spec-compliant OSC library — confirmed directly from TiXL's own source (`Operators/Io/Symbols/lib/io/osc/{OscOutput,OscInput}.cs`), not just its docs. `python-osc` speaks the same real OSC framing, so the TiXL-facing legs work without translation guesswork. + +## Requirements + +```bash +pip install python-osc websocket-client +``` + +`websocket-client` is optional — without it, control updates (TiXL → PCHI) still work; only governance telemetry (PCHI → TiXL) is disabled, with a clear message at startup. + +## Usage + +```bash +python tixl_pchi_bridge.py \ + --osc-in-port 9001 \ + --osc-out-host 127.0.0.1 \ + --osc-out-port 9002 \ + --pchi-host 127.0.0.1 \ + --pchi-port 8888 \ + --pchi-ws-port 8889 +``` + +**Governance telemetry requires [vdmo/pchi#5](https://github.com/vdmo/pchi/pull/5)** (the WebSocket accept-loop fix) — without it, the conductor's governance-event WebSocket is bound but never accepts a connection, and this bridge's telemetry leg will silently receive nothing, forever. Control updates work regardless. + +## Wiring this up inside TiXL + +**Outbound — a value in your graph → the Conductor:** + +Add an `OscOutput` operator: +| Parameter | Value | +|---|---| +| IpAddress / Port | this bridge's host, `--osc-in-port` (default 9001) | +| Address | `/pchi/control` (fixed) | +| Values | your float value | +| Strings | one string: `"."`, e.g. `"kraken_main.wobbliness"` | + +A fixed address with target/parameter passed as a string payload avoids needing string-formatting nodes just to build a dynamic address, and avoids ordering ambiguity between multiple `Strings` inputs. + +**Inbound — a live governance verdict → your graph:** + +Add an `OscInput` operator listening on `--osc-out-port` (default 9002). It receives: + +| Address | Type | Meaning | +|---|---|---| +| `/pchi/governance/gate_state` | float | `1.0` = ALLOW, `0.5` = ESCALATE, `0.0` = DENY | + +`OscInput`'s outputs (`Contents`, `Values`) are float-only — confirmed from its source, there's no string channel — so `gate_state` is sent as a numeric code, not text. Wire it into a comparison node to gate downstream output on a live verdict: for example, force a DMX dimmer or strobe operator to zero when `gate_state < 1.0`, driven by a real signed decision rather than a locally-duplicated safety check. + +## What this does not do + +This bridge only carries the signal — it does not itself implement any safety behavior. Gating a DMX output on `/pchi/governance/gate_state` is downstream graph wiring, done by whoever builds the show, using whichever Only-Lang rule (`.only-pchi` file) actually defines the limit. + +## Verification status + +Every claim above about TiXL's OSC wire format is verified against TiXL's actual operator source, not its docs alone. The bridge itself has been tested live, end to end, against a real PCHI Conductor, in both directions — but **not against a running instance of TiXL**, which is a Windows/DirectX GUI application. Testing used real OSC traffic constructed to exactly match what `OscOutput.cs` sends and what `OscInput.cs` expects (confirmed from source), sent and received by standalone `python-osc` clients standing in for TiXL. If you run this against real TiXL and hit a mismatch, please open an issue — the wire format was verified by reading, not by running the real application. diff --git a/tools/tixl-pchi-bridge/__pycache__/tixl_pchi_bridge.cpython-310.pyc b/tools/tixl-pchi-bridge/__pycache__/tixl_pchi_bridge.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f43d5b6d64489084f3ef75c8f04e2ac91f86ba83 GIT binary patch literal 11186 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 KOHb3;?*9YrXDI#v literal 0 HcmV?d00001 diff --git a/tools/tixl-pchi-bridge/tixl_pchi_bridge.py b/tools/tixl-pchi-bridge/tixl_pchi_bridge.py new file mode 100644 index 0000000..28157d5 --- /dev/null +++ b/tools/tixl-pchi-bridge/tixl_pchi_bridge.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +""" +TiXL PCHI Bridge +Connects TiXL (https://github.com/tixl3d/tixl) to the PCHI Conductor via OSC. + +TiXL's OscOutput/OscInput operators use Rug.Osc — a real, spec-compliant +OSC 1.0 library — so this bridge, unlike the first version of the Resolume +bridge, does not need to guess at wire compatibility: it uses python-osc, +which speaks the same real OSC framing. Verified against TiXL's actual +operator source (Operators/Io/Symbols/lib/io/osc/{OscOutput,OscInput}.cs), +not just its docs. + +Two independent legs, each a real UDP socket, deliberately not shared: + + TiXL --OSC--> this bridge --raw JSON UDP--> PCHI Conductor (control updates) + PCHI Conductor --WebSocket--> this bridge --OSC--> TiXL (governance telemetry) + +The PCHI Conductor leg is raw JSON-over-UDP, not OSC — see +resolume-pchi-bridge's docstring for why sending OSC-wrapped JSON to the +conductor fails outright (its serde_json::from_slice has no OSC decoder). +The TiXL legs are real OSC in both directions. + +## Wiring this up inside TiXL + +**Outbound (a value in your graph -> the Conductor):** +Add an `OscOutput` operator. Set: + - IpAddress / Port: this bridge's --osc-in-port (default 9001) + - Address: `/pchi/control` (fixed — see below for why) + - Values: your float value + - Strings: a single string `"."`, e.g. `"kraken_main.wobbliness"` +A fixed address with the target/parameter passed as a string payload, +rather than encoding them into the OSC address itself, avoids needing +string-formatting nodes in the graph just to build a dynamic address — +and avoids ordering ambiguity between multiple MultiInputSlot strings. + +**Inbound (a live governance verdict -> your graph):** +Add an `OscInput` operator listening on --osc-out-port (default 9002). +It will receive: + - `/pchi/governance/gate_state` (float): 1.0 = ALLOW, 0.5 = ESCALATE, + 0.0 = DENY. OscInput's Contents/Values outputs are float-only (no + string channel — confirmed from its actual source), so gate_state is + sent as a numeric code, not text; wire it into a Less/Greater node to + gate downstream output on a live verdict (e.g. force a DMX dimmer to + zero when gate_state < 1.0). + +## What this does NOT do + +This bridge does not implement the DMX-safety use case itself — that is +downstream, an artist's own graph wiring, gating one of TiXL's DMX +operators on `/pchi/governance/gate_state`. This only carries the signal. +""" + +import argparse +import json +import socket +import threading +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from pythonosc.dispatcher import Dispatcher +from pythonosc.osc_server import BlockingOSCUDPServer +from pythonosc import udp_client + +try: + import websocket # websocket-client +except ImportError: + websocket = None + + +GATE_STATE_CODES = {"ALLOW": 1.0, "ESCALATE": 0.5, "DENY": 0.0} + + +@dataclass +class PCHIMessage: + """PCHI v2.0 message. See to_wire_dict() for the actual wire shape — + the field names here are Python-conventional, not the wire names.""" + version: str = "2.0.0" + message_type: str = "control_parameter" + source_id: str = "tixl_bridge" + timestamp: float = 0.0 + pir_invariants: Dict[str, Any] = None + payload: Dict[str, Any] = None + + def __post_init__(self): + if self.timestamp == 0.0: + self.timestamp = time.time() * 1000 + if self.pir_invariants is None: + self.pir_invariants = {} + if self.payload is None: + self.payload = {} + + def to_wire_dict(self) -> Dict[str, Any]: + payload = dict(self.payload) + if "payload_type" in payload: + payload["payloadType"] = payload.pop("payload_type") + return { + "version": self.version, + "type": self.message_type, + "source_id": self.source_id, + "timestamp": self.timestamp, + "pir_invariants": self.pir_invariants, + "payload": payload, + } + + +def zero_pir_invariants() -> Dict[str, Any]: + """An honest, structurally-complete zero-residual invariant set for + messages (control updates, heartbeats) that don't carry a genuine + multi-value equilibrium claim. PCHIMessage's schema requires the full + shape on every message regardless of type — see PIRInvariants in + pchi-schema/src/lib.rs, no #[serde(default)] on any field.""" + return { + "equilibrium_check": {"residual": 0.0, "precision": 1e-12, "sign_sequence": []}, + "coherence_gap": 0.0, + "curvature_signature": "0.0, 0.0, 0.0", + "residual": 0.0, + } + + +class TiXLPCHIBridge: + def __init__( + self, + osc_in_port: int = 9001, + osc_out_host: str = "127.0.0.1", + osc_out_port: int = 9002, + pchi_host: str = "127.0.0.1", + pchi_port: int = 8888, + pchi_ws_port: int = 8889, + ): + self.osc_in_port = osc_in_port + self.pchi_host = pchi_host + self.pchi_port = pchi_port + self.pchi_ws_port = pchi_ws_port + self.running = False + + # Leg 1: this bridge -> PCHI Conductor. Raw JSON over UDP, NOT OSC — + # see module docstring and resolume-pchi-bridge's history for why. + self.pchi_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + # Leg 2: this bridge -> TiXL's OscInput. Real OSC. + self.tixl_osc_client = udp_client.SimpleUDPClient(osc_out_host, osc_out_port) + + self._last_gate_state: Optional[str] = None + + print("TiXL PCHI Bridge initialized") + print(f" Listening for TiXL OSC (OscOutput) on port {osc_in_port}") + print(f" Sending governance telemetry (OscInput) to {osc_out_host}:{osc_out_port}") + print(f" Sending control updates to PCHI Conductor at {pchi_host}:{pchi_port}") + print(f" Watching governance events on ws://{pchi_host}:{pchi_ws_port}") + + # ---- Leg 1: TiXL -> PCHI -------------------------------------------- + + def send_pchi_message(self, message: PCHIMessage): + try: + message_bytes = json.dumps(message.to_wire_dict()).encode("utf-8") + self.pchi_socket.sendto(message_bytes, (self.pchi_host, self.pchi_port)) + except OSError as e: + print(f"Error sending PCHI message: {e}") + + def handle_control_osc(self, address: str, *args): + """Handle /pchi/control from TiXL's OscOutput. + + OscOutput sends its Values (float), IntValues (int), then Strings + (string) parameters in that fixed order (confirmed from + OscOutput.cs's `parameters` array construction) — so with one + Values input and one Strings input wired, args arrive as + (value: float, key: str). + """ + if len(args) < 2: + print(f"[OSC] {address}: expected (value, key), got {args!r} — ignoring") + return + value, key = args[0], args[1] + if "." not in key: + print(f"[OSC] {address}: key '{key}' must be 'target_id.parameter' — ignoring") + return + target_id, parameter = key.split(".", 1) + + message = PCHIMessage( + message_type="control_parameter", + source_id="tixl_bridge", + pir_invariants=zero_pir_invariants(), + payload={ + "payload_type": "control_parameter", + "data": {"target_id": target_id, "parameter": parameter, "value": float(value)}, + }, + ) + self.send_pchi_message(message) + print(f"[OSC] {key} = {value} -> PCHI sent") + + def send_heartbeat(self): + while self.running: + try: + heartbeat = PCHIMessage( + message_type="heartbeat", + source_id="tixl_bridge", + pir_invariants=zero_pir_invariants(), + payload={ + "payload_type": "control_parameter", + "data": {"target_id": "tixl_bridge", "parameter": "heartbeat", "value": 1.0}, + }, + ) + self.send_pchi_message(heartbeat) + time.sleep(5) + except OSError as e: + print(f"Error sending heartbeat: {e}") + time.sleep(1) + + # ---- Leg 2: PCHI -> TiXL --------------------------------------------- + + def _on_ws_message(self, _ws, raw: str): + try: + msg = json.loads(raw) + except json.JSONDecodeError: + return + payload = msg.get("payload", {}) + if payload.get("payloadType") != "governance_event": + return + gate_state = payload.get("data", {}).get("gate_state") + code = GATE_STATE_CODES.get(gate_state) + if code is None: + print(f"[WS] unknown gate_state {gate_state!r} — not forwarded") + return + self.tixl_osc_client.send_message("/pchi/governance/gate_state", code) + self._last_gate_state = gate_state + print(f"[WS] governance_event gate_state={gate_state} -> OSC /pchi/governance/gate_state={code}") + + def _on_ws_error(self, _ws, error): + print(f"[WS] error: {error}") + + def _on_ws_close(self, _ws, *_args): + print("[WS] connection closed") + + def watch_governance_events(self): + """Reconnect loop for the governance-event WebSocket. Requires + vdmo/pchi#5 (the WS accept-loop fix) — without it, the conductor + binds :8889 but never accepts a connection, and this call blocks + forever without ever calling _on_ws_message.""" + if websocket is None: + print("websocket-client not installed (`pip install websocket-client`) — " + "governance telemetry to TiXL is disabled; control updates still work.") + return + while self.running: + try: + ws = websocket.WebSocketApp( + f"ws://{self.pchi_host}:{self.pchi_ws_port}", + on_message=self._on_ws_message, + on_error=self._on_ws_error, + on_close=self._on_ws_close, + ) + ws.run_forever() + except Exception as e: # noqa: BLE001 - reconnect on anything, this is a long-lived watcher + print(f"[WS] connection failed: {e}") + if self.running: + time.sleep(2) # backoff before reconnecting + + # ---- Lifecycle --------------------------------------------------------- + + def start(self): + self.running = True + + dispatcher = Dispatcher() + dispatcher.map("/pchi/control", self.handle_control_osc) + + threading.Thread(target=self.send_heartbeat, daemon=True).start() + threading.Thread(target=self.watch_governance_events, daemon=True).start() + + server = BlockingOSCUDPServer(("0.0.0.0", self.osc_in_port), dispatcher) + print(f"OSC server listening on port {self.osc_in_port}") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down...") + finally: + self.running = False + + +def main(): + parser = argparse.ArgumentParser(description="TiXL PCHI Bridge") + parser.add_argument("--osc-in-port", type=int, default=9001, help="Port to listen for TiXL's OscOutput (default: 9001)") + parser.add_argument("--osc-out-host", type=str, default="127.0.0.1", help="Host running TiXL's OscInput (default: 127.0.0.1)") + parser.add_argument("--osc-out-port", type=int, default=9002, help="Port for TiXL's OscInput (default: 9002)") + parser.add_argument("--pchi-host", type=str, default="127.0.0.1", help="PCHI Conductor host (default: 127.0.0.1)") + parser.add_argument("--pchi-port", type=int, default=8888, help="PCHI Conductor UDP port (default: 8888)") + parser.add_argument("--pchi-ws-port", type=int, default=8889, help="PCHI Conductor governance-event WS port (default: 8889)") + args = parser.parse_args() + + bridge = TiXLPCHIBridge( + osc_in_port=args.osc_in_port, + osc_out_host=args.osc_out_host, + osc_out_port=args.osc_out_port, + pchi_host=args.pchi_host, + pchi_port=args.pchi_port, + pchi_ws_port=args.pchi_ws_port, + ) + bridge.start() + + +if __name__ == "__main__": + main()