feat: tosc xml generator schema + fidelity gate

This commit is contained in:
L'électron rare
2026-06-28 12:49:20 +02:00
parent 048403e92b
commit 9e32e3afd8
6 changed files with 280 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.venv/
__pycache__/
.pytest_cache/
*.pyc
dist/_probe.tosc
View File
+157
View File
@@ -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")
+12
View File
@@ -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 = ["."]
+27
View File
@@ -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"
+79
View File
@@ -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" }]