feat: tosc xml generator schema + fidelity gate
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
dist/_probe.tosc
|
||||
@@ -0,0 +1,157 @@
|
||||
"""TouchOSC v3 (.tosc) layout schema and serializer.
|
||||
|
||||
A ``.tosc`` file is a gzip-compressed XML document whose root is
|
||||
``<lexml version="3">`` containing a single root ``<node>``. This module
|
||||
emits that format with ``xml.etree.ElementTree``. The exact byte-shape
|
||||
(partials as child elements, frame ``r`` / color ``c`` value shapes, the
|
||||
``connections`` bit-string) was pinned against a file that the desktop
|
||||
TouchOSC app actually loads and re-saves on GrosMac. See
|
||||
``docs/superpowers/specs/2026-06-28-tosc-schema-reference.md``.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
# connections bit-string: one '0'/'1' flag per available OSC connection,
|
||||
# left-to-right = connection 1..N. TouchOSC writes 5 flags; a fresh
|
||||
# message targets connection 1 only. Confirmed from a TouchOSC re-save.
|
||||
CONN_DEFAULT = "00001"
|
||||
|
||||
|
||||
def tosc_id():
|
||||
"""Fresh UUID string for a node ID."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _set_text(parent, tag, text):
|
||||
e = ET.SubElement(parent, tag)
|
||||
e.text = str(text)
|
||||
return e
|
||||
|
||||
|
||||
def prop(key, value, typ):
|
||||
"""One ``<property type="..">`` with ``<key>``/``<value>`` children."""
|
||||
p = ET.Element("property", {"type": typ})
|
||||
_set_text(p, "key", key)
|
||||
_set_text(p, "value", value)
|
||||
return p
|
||||
|
||||
|
||||
def frame(x, y, w, h):
|
||||
"""The ``frame`` property (type ``r``) — value holds x/y/w/h children."""
|
||||
p = ET.Element("property", {"type": "r"})
|
||||
_set_text(p, "key", "frame")
|
||||
v = ET.SubElement(p, "value")
|
||||
for tag, n in (("x", x), ("y", y), ("w", w), ("h", h)):
|
||||
_set_text(v, tag, n)
|
||||
return p
|
||||
|
||||
|
||||
def color(r, g, b, a=1.0):
|
||||
"""The ``color`` property (type ``c``) — value holds r/g/b/a children."""
|
||||
p = ET.Element("property", {"type": "c"})
|
||||
_set_text(p, "key", "color")
|
||||
v = ET.SubElement(p, "value")
|
||||
for tag, n in (("r", r), ("g", g), ("b", b), ("a", a)):
|
||||
_set_text(v, tag, n)
|
||||
return p
|
||||
|
||||
|
||||
class Partial:
|
||||
"""One ``<partial>`` of an OSC path or argument list.
|
||||
|
||||
Fields are emitted as *child elements* (not attributes), matching the
|
||||
format TouchOSC writes.
|
||||
"""
|
||||
|
||||
def __init__(self, kind, conversion, value, scale_min=0, scale_max=1):
|
||||
self.kind = kind
|
||||
self.conversion = conversion
|
||||
self.value = value
|
||||
self.scale_min = scale_min
|
||||
self.scale_max = scale_max
|
||||
|
||||
def to_el(self):
|
||||
p = ET.Element("partial")
|
||||
_set_text(p, "type", self.kind)
|
||||
_set_text(p, "conversion", self.conversion)
|
||||
_set_text(p, "value", self.value)
|
||||
_set_text(p, "scaleMin", self.scale_min)
|
||||
_set_text(p, "scaleMax", self.scale_max)
|
||||
return p
|
||||
|
||||
|
||||
def const(s):
|
||||
"""A CONSTANT/STRING partial — a fixed address segment or argument."""
|
||||
return Partial("CONSTANT", "STRING", s)
|
||||
|
||||
|
||||
def val():
|
||||
"""A VALUE/FLOAT partial bound to the control's ``x`` value."""
|
||||
return Partial("VALUE", "FLOAT", "x")
|
||||
|
||||
|
||||
def _trigger(var="x", condition="ANY"):
|
||||
t = ET.Element("trigger")
|
||||
_set_text(t, "var", var)
|
||||
_set_text(t, "condition", condition)
|
||||
return t
|
||||
|
||||
|
||||
def osc(address, args, connections=CONN_DEFAULT, send=1, receive=0, feedback=0):
|
||||
"""One ``<osc>`` message: triggers + path + arguments."""
|
||||
m = ET.Element("osc", {
|
||||
"enabled": "1",
|
||||
"send": str(send),
|
||||
"receive": str(receive),
|
||||
"feedback": str(feedback),
|
||||
"connections": connections,
|
||||
})
|
||||
triggers = ET.SubElement(m, "triggers")
|
||||
triggers.append(_trigger())
|
||||
path = ET.SubElement(m, "path")
|
||||
path.append(const(address).to_el())
|
||||
arguments = ET.SubElement(m, "arguments")
|
||||
for p in args:
|
||||
arguments.append(p.to_el())
|
||||
return m
|
||||
|
||||
|
||||
def script(lua):
|
||||
"""The ``script`` string property (Lua source, type ``s``)."""
|
||||
return prop("script", lua, "s")
|
||||
|
||||
|
||||
def node(ntype, props, children=None, messages=None, values=None):
|
||||
"""A ``<node>`` with properties/values/messages/children sub-blocks."""
|
||||
n = ET.Element("node", {"ID": tosc_id(), "type": ntype})
|
||||
ps = ET.SubElement(n, "properties")
|
||||
for p in props:
|
||||
ps.append(p)
|
||||
vs = ET.SubElement(n, "values")
|
||||
for v in (values or []):
|
||||
vs.append(v)
|
||||
ms = ET.SubElement(n, "messages")
|
||||
for m in (messages or []):
|
||||
ms.append(m)
|
||||
cs = ET.SubElement(n, "children")
|
||||
for c in (children or []):
|
||||
cs.append(c)
|
||||
return n
|
||||
|
||||
|
||||
def write_tosc(root, path):
|
||||
"""Serialize ``root`` under ``<lexml version="3">`` and gzip to ``path``."""
|
||||
lexml = ET.Element("lexml", {"version": "3"})
|
||||
lexml.append(root)
|
||||
xml = ET.tostring(lexml, encoding="UTF-8", xml_declaration=True)
|
||||
with gzip.open(path, "wb") as f:
|
||||
f.write(xml)
|
||||
|
||||
|
||||
def read_tosc(path):
|
||||
"""Gunzip + parse a ``.tosc``; return its root ``<node>`` element."""
|
||||
with gzip.open(path, "rb") as f:
|
||||
lexml = ET.fromstring(f.read())
|
||||
return lexml.find("node")
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
name = "touchosc-gen"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
@@ -0,0 +1,27 @@
|
||||
from gen import schema
|
||||
|
||||
|
||||
def test_roundtrip_minimal_fader(tmp_path):
|
||||
fader = schema.node(
|
||||
"FADER",
|
||||
props=[
|
||||
schema.prop("name", "vol", "s"),
|
||||
schema.frame(10, 20, 60, 200),
|
||||
],
|
||||
messages=[
|
||||
schema.osc("/launch/vol", [schema.const("kick"), schema.val()]),
|
||||
],
|
||||
)
|
||||
root = schema.node("GROUP", props=[schema.prop("name", "root", "s")],
|
||||
children=[fader])
|
||||
out = tmp_path / "min.tosc"
|
||||
schema.write_tosc(root, str(out))
|
||||
parsed = schema.read_tosc(str(out))
|
||||
assert parsed.tag == "node"
|
||||
assert parsed.get("type") == "GROUP"
|
||||
child = parsed.find("children/node")
|
||||
assert child.get("type") == "FADER"
|
||||
# Partial fields are child elements in TouchOSC v3 (confirmed against a
|
||||
# real app re-save), so the address lives in <partial><value>, not an attr.
|
||||
addr = child.find("messages/osc/path/partial/value")
|
||||
assert addr.text == "/launch/vol"
|
||||
Generated
+79
@@ -0,0 +1,79 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "touchosc-gen"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pytest", specifier = ">=8" }]
|
||||
Reference in New Issue
Block a user