py2tosc 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- py2tosc/__init__.py +110 -0
- py2tosc/codec.py +512 -0
- py2tosc/control.py +391 -0
- py2tosc/defaults.py +207 -0
- py2tosc/document.py +238 -0
- py2tosc/enums.py +103 -0
- py2tosc/layout.py +194 -0
- py2tosc/messages.py +267 -0
- py2tosc/properties.py +345 -0
- py2tosc/py.typed +2 -0
- py2tosc/validate.py +199 -0
- py2tosc-0.1.0.dist-info/METADATA +119 -0
- py2tosc-0.1.0.dist-info/RECORD +15 -0
- py2tosc-0.1.0.dist-info/WHEEL +4 -0
- py2tosc-0.1.0.dist-info/licenses/LICENSE +10 -0
py2tosc/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Generate and edit TouchOSC layouts from Python.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
import py2tosc
|
|
5
|
+
|
|
6
|
+
doc = py2tosc.load("layout.tosc")
|
|
7
|
+
for fader in doc.find_all(type="FADER"):
|
|
8
|
+
fader.color = "#e76f51"
|
|
9
|
+
doc.save("out.tosc")
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
This project has no relation to Hexler, the developer of TouchOSC. Back up your
|
|
13
|
+
layouts before editing them with third party tools.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from . import layout
|
|
17
|
+
from .control import (
|
|
18
|
+
Control,
|
|
19
|
+
box,
|
|
20
|
+
button,
|
|
21
|
+
encoder,
|
|
22
|
+
fader,
|
|
23
|
+
grid,
|
|
24
|
+
group,
|
|
25
|
+
label,
|
|
26
|
+
pager,
|
|
27
|
+
radar,
|
|
28
|
+
radial,
|
|
29
|
+
radio,
|
|
30
|
+
text,
|
|
31
|
+
xy,
|
|
32
|
+
)
|
|
33
|
+
from .document import Document, dumps, load, loads, save
|
|
34
|
+
from .enums import (
|
|
35
|
+
ControlType,
|
|
36
|
+
Conversion,
|
|
37
|
+
MidiType,
|
|
38
|
+
PartialType,
|
|
39
|
+
PropertyType,
|
|
40
|
+
TriggerCondition,
|
|
41
|
+
)
|
|
42
|
+
from .messages import (
|
|
43
|
+
GamepadMessage,
|
|
44
|
+
LocalMessage,
|
|
45
|
+
Message,
|
|
46
|
+
MidiCommand,
|
|
47
|
+
MidiMessage,
|
|
48
|
+
MidiValue,
|
|
49
|
+
OscMessage,
|
|
50
|
+
Partial,
|
|
51
|
+
Trigger,
|
|
52
|
+
Value,
|
|
53
|
+
)
|
|
54
|
+
from .properties import Color, Frame, Property, to_color, to_frame
|
|
55
|
+
from .validate import Issue, ValidationError, validate
|
|
56
|
+
|
|
57
|
+
#: Keep in step with `version` in pyproject.toml, which the build backend reads
|
|
58
|
+
#: and which `uv_build` requires to be static. `test_version_is_declared_once`
|
|
59
|
+
#: fails if the two drift apart.
|
|
60
|
+
__version__ = "0.1.0"
|
|
61
|
+
|
|
62
|
+
# Sorted rather than grouped by topic, because RUF022 asks for it and the
|
|
63
|
+
# grouping lives in the API reference instead. See docs/api/.
|
|
64
|
+
__all__ = [
|
|
65
|
+
"Color",
|
|
66
|
+
"Control",
|
|
67
|
+
"ControlType",
|
|
68
|
+
"Conversion",
|
|
69
|
+
"Document",
|
|
70
|
+
"Frame",
|
|
71
|
+
"GamepadMessage",
|
|
72
|
+
"Issue",
|
|
73
|
+
"LocalMessage",
|
|
74
|
+
"Message",
|
|
75
|
+
"MidiCommand",
|
|
76
|
+
"MidiMessage",
|
|
77
|
+
"MidiType",
|
|
78
|
+
"MidiValue",
|
|
79
|
+
"OscMessage",
|
|
80
|
+
"Partial",
|
|
81
|
+
"PartialType",
|
|
82
|
+
"Property",
|
|
83
|
+
"PropertyType",
|
|
84
|
+
"Trigger",
|
|
85
|
+
"TriggerCondition",
|
|
86
|
+
"ValidationError",
|
|
87
|
+
"Value",
|
|
88
|
+
"__version__",
|
|
89
|
+
"box",
|
|
90
|
+
"button",
|
|
91
|
+
"dumps",
|
|
92
|
+
"encoder",
|
|
93
|
+
"fader",
|
|
94
|
+
"grid",
|
|
95
|
+
"group",
|
|
96
|
+
"label",
|
|
97
|
+
"layout",
|
|
98
|
+
"load",
|
|
99
|
+
"loads",
|
|
100
|
+
"pager",
|
|
101
|
+
"radar",
|
|
102
|
+
"radial",
|
|
103
|
+
"radio",
|
|
104
|
+
"save",
|
|
105
|
+
"text",
|
|
106
|
+
"to_color",
|
|
107
|
+
"to_frame",
|
|
108
|
+
"validate",
|
|
109
|
+
"xy",
|
|
110
|
+
]
|
py2tosc/codec.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Reading and writing the `.tosc` XML dialect.
|
|
2
|
+
|
|
3
|
+
Serialization is hand-rolled rather than delegated to `ElementTree` for one
|
|
4
|
+
reason: TouchOSC wraps keys and string values in CDATA sections, and
|
|
5
|
+
`ElementTree` cannot emit them. Writing the format directly also fixes element
|
|
6
|
+
order and the handling of the `<includes>` element, which together make output
|
|
7
|
+
byte-for-byte identical to what the TouchOSC editor itself produces.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .control import Control
|
|
16
|
+
from .enums import ControlType, PropertyType
|
|
17
|
+
from .messages import (
|
|
18
|
+
GamepadMessage,
|
|
19
|
+
LocalMessage,
|
|
20
|
+
Message,
|
|
21
|
+
MidiCommand,
|
|
22
|
+
MidiMessage,
|
|
23
|
+
MidiValue,
|
|
24
|
+
OscMessage,
|
|
25
|
+
Partial,
|
|
26
|
+
Trigger,
|
|
27
|
+
Value,
|
|
28
|
+
)
|
|
29
|
+
from .properties import Color, Frame, Property
|
|
30
|
+
|
|
31
|
+
__all__ = ["from_xml", "to_xml"]
|
|
32
|
+
|
|
33
|
+
_DECLARATION = "<?xml version='1.0' encoding='UTF-8'?>"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _num(value: Any) -> str:
|
|
37
|
+
"""Render a number the way TouchOSC does: no trailing `.0`."""
|
|
38
|
+
if isinstance(value, bool):
|
|
39
|
+
return "1" if value else "0"
|
|
40
|
+
if isinstance(value, float) and value.is_integer():
|
|
41
|
+
return str(int(value))
|
|
42
|
+
return str(value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _escape(text: str) -> str:
|
|
46
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Writer:
|
|
50
|
+
"""Collects the document as a list of lines, one element or tag per line."""
|
|
51
|
+
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self.lines: list[str] = []
|
|
54
|
+
|
|
55
|
+
def open(self, tag: str, **attrs: str) -> None:
|
|
56
|
+
rendered = "".join(f" {k}='{_escape(v)}'" for k, v in attrs.items())
|
|
57
|
+
self.lines.append(f"<{tag}{rendered}>")
|
|
58
|
+
|
|
59
|
+
def close(self, tag: str) -> None:
|
|
60
|
+
self.lines.append(f"</{tag}>")
|
|
61
|
+
|
|
62
|
+
def leaf(self, tag: str, text: Any, cdata: bool = False) -> None:
|
|
63
|
+
body = f"<![CDATA[{text}]]>" if cdata else _escape(str(text))
|
|
64
|
+
self.lines.append(f"<{tag}>{body}</{tag}>")
|
|
65
|
+
|
|
66
|
+
def empty(self, tag: str) -> None:
|
|
67
|
+
self.open(tag)
|
|
68
|
+
self.close(tag)
|
|
69
|
+
|
|
70
|
+
def render(self, pretty: bool) -> str:
|
|
71
|
+
if pretty:
|
|
72
|
+
return "\n".join(self.lines) + "\n"
|
|
73
|
+
return "".join(self.lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# -- writing ----------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _write_property(w: _Writer, prop: Property) -> None:
|
|
80
|
+
w.open("property", type=prop.type.value)
|
|
81
|
+
w.leaf("key", prop.key, cdata=True)
|
|
82
|
+
value = prop.value
|
|
83
|
+
if prop.type is PropertyType.FRAME:
|
|
84
|
+
w.open("value")
|
|
85
|
+
for field, item in zip(("x", "y", "w", "h"), value):
|
|
86
|
+
w.leaf(field, _num(item))
|
|
87
|
+
w.close("value")
|
|
88
|
+
elif prop.type is PropertyType.COLOR:
|
|
89
|
+
w.open("value")
|
|
90
|
+
for field, item in zip(("r", "g", "b", "a"), value):
|
|
91
|
+
w.leaf(field, _num(item))
|
|
92
|
+
w.close("value")
|
|
93
|
+
elif prop.type is PropertyType.STRING:
|
|
94
|
+
w.leaf("value", value, cdata=True)
|
|
95
|
+
else:
|
|
96
|
+
w.leaf("value", _num(value))
|
|
97
|
+
w.close("property")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _default_text(default: Any) -> str:
|
|
101
|
+
"""Render a value's default. Booleans are words here, not 1 and 0."""
|
|
102
|
+
if isinstance(default, bool):
|
|
103
|
+
return "true" if default else "false"
|
|
104
|
+
if isinstance(default, str):
|
|
105
|
+
return default
|
|
106
|
+
return _num(default)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _write_value(w: _Writer, value: Value) -> None:
|
|
110
|
+
w.open("value")
|
|
111
|
+
w.leaf("key", value.key, cdata=True)
|
|
112
|
+
w.leaf("locked", _num(value.locked))
|
|
113
|
+
w.leaf("lockedDefaultCurrent", _num(value.locked_default_current))
|
|
114
|
+
w.leaf("default", _default_text(value.default), cdata=True)
|
|
115
|
+
w.leaf("defaultPull", _num(value.default_pull))
|
|
116
|
+
w.close("value")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _write_triggers(w: _Writer, triggers: list[Trigger]) -> None:
|
|
120
|
+
# Omitted entirely when empty: across 4817 trigger-less messages in the
|
|
121
|
+
# bundled TouchOSC examples, the editor never writes <triggers></triggers>.
|
|
122
|
+
if not triggers:
|
|
123
|
+
return
|
|
124
|
+
w.open("triggers")
|
|
125
|
+
for trigger in triggers:
|
|
126
|
+
w.open("trigger")
|
|
127
|
+
w.leaf("var", trigger.var, cdata=True)
|
|
128
|
+
w.leaf("condition", str(trigger.condition))
|
|
129
|
+
w.close("trigger")
|
|
130
|
+
w.close("triggers")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _write_partials(w: _Writer, tag: str, partials: list[Partial]) -> None:
|
|
134
|
+
w.open(tag)
|
|
135
|
+
for partial in partials:
|
|
136
|
+
w.open("partial")
|
|
137
|
+
w.leaf("type", str(partial.type))
|
|
138
|
+
w.leaf("conversion", str(partial.conversion))
|
|
139
|
+
w.leaf("value", partial.value, cdata=True)
|
|
140
|
+
w.leaf("scaleMin", _num(partial.scale_min))
|
|
141
|
+
w.leaf("scaleMax", _num(partial.scale_max))
|
|
142
|
+
w.close("partial")
|
|
143
|
+
w.close(tag)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _write_message(w: _Writer, message: Message, modern: bool) -> None:
|
|
147
|
+
if isinstance(message, OscMessage):
|
|
148
|
+
w.open("osc")
|
|
149
|
+
w.leaf("enabled", _num(message.enabled))
|
|
150
|
+
w.leaf("send", _num(message.send))
|
|
151
|
+
w.leaf("receive", _num(message.receive))
|
|
152
|
+
w.leaf("feedback", _num(message.feedback))
|
|
153
|
+
if modern:
|
|
154
|
+
w.leaf("noDuplicates", _num(message.no_duplicates))
|
|
155
|
+
w.leaf("connections", message.connections)
|
|
156
|
+
_write_triggers(w, message.triggers)
|
|
157
|
+
_write_partials(w, "path", message.path)
|
|
158
|
+
_write_partials(w, "arguments", message.arguments)
|
|
159
|
+
w.close("osc")
|
|
160
|
+
|
|
161
|
+
elif isinstance(message, MidiMessage):
|
|
162
|
+
w.open("midi")
|
|
163
|
+
w.leaf("enabled", _num(message.enabled))
|
|
164
|
+
w.leaf("send", _num(message.send))
|
|
165
|
+
w.leaf("receive", _num(message.receive))
|
|
166
|
+
w.leaf("feedback", _num(message.feedback))
|
|
167
|
+
if modern:
|
|
168
|
+
w.leaf("noDuplicates", _num(message.no_duplicates))
|
|
169
|
+
w.leaf("connections", message.connections)
|
|
170
|
+
_write_triggers(w, message.triggers)
|
|
171
|
+
w.open("message")
|
|
172
|
+
w.leaf("type", str(message.message.type))
|
|
173
|
+
w.leaf("channel", _num(message.message.channel))
|
|
174
|
+
w.leaf("data1", _num(message.message.data1))
|
|
175
|
+
w.leaf("data2", _num(message.message.data2))
|
|
176
|
+
w.close("message")
|
|
177
|
+
w.open("values")
|
|
178
|
+
for item in message.values:
|
|
179
|
+
w.open("value")
|
|
180
|
+
w.leaf("type", str(item.type))
|
|
181
|
+
w.leaf("key", item.key, cdata=True)
|
|
182
|
+
w.leaf("scaleMin", _num(item.scale_min))
|
|
183
|
+
w.leaf("scaleMax", _num(item.scale_max))
|
|
184
|
+
w.close("value")
|
|
185
|
+
w.close("values")
|
|
186
|
+
w.close("midi")
|
|
187
|
+
|
|
188
|
+
elif isinstance(message, LocalMessage):
|
|
189
|
+
w.open("local")
|
|
190
|
+
w.leaf("enabled", _num(message.enabled))
|
|
191
|
+
_write_triggers(w, message.triggers)
|
|
192
|
+
w.leaf("type", str(message.type))
|
|
193
|
+
w.leaf("conversion", str(message.conversion))
|
|
194
|
+
w.leaf("value", message.value, cdata=True)
|
|
195
|
+
w.leaf("scaleMin", _num(message.scale_min))
|
|
196
|
+
w.leaf("scaleMax", _num(message.scale_max))
|
|
197
|
+
w.leaf("dstType", message.dst_type)
|
|
198
|
+
w.leaf("dstVar", message.dst_var, cdata=True)
|
|
199
|
+
w.leaf("dstID", message.dst_id, cdata=True)
|
|
200
|
+
w.close("local")
|
|
201
|
+
|
|
202
|
+
elif isinstance(message, GamepadMessage):
|
|
203
|
+
w.open("gamepad")
|
|
204
|
+
w.leaf("enabled", _num(message.enabled))
|
|
205
|
+
w.leaf("connections", message.connections)
|
|
206
|
+
w.leaf("type", str(message.type))
|
|
207
|
+
w.leaf("conversion", str(message.conversion))
|
|
208
|
+
w.leaf("scaleMin", _num(message.scale_min))
|
|
209
|
+
w.leaf("scaleMax", _num(message.scale_max))
|
|
210
|
+
w.leaf("targetType", str(message.target_type))
|
|
211
|
+
w.leaf("targetVar", message.target_var, cdata=True)
|
|
212
|
+
w.close("gamepad")
|
|
213
|
+
|
|
214
|
+
else:
|
|
215
|
+
raise TypeError(f"{type(message).__name__} is not a message type")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _is_modern(version: str) -> bool:
|
|
219
|
+
"""Whether the document uses lexml 6 conventions.
|
|
220
|
+
|
|
221
|
+
Two elements distinguish version 6 from the version 3 files older editors
|
|
222
|
+
and older tosclib releases produced: `<includes>` on the root node, and
|
|
223
|
+
`<noDuplicates>` on OSC and MIDI bindings. Writing either into a version 3
|
|
224
|
+
document would make it something the editor never wrote, so both are gated
|
|
225
|
+
on the version the document declares.
|
|
226
|
+
|
|
227
|
+
An unrecognised version is treated as modern, on the grounds that anything
|
|
228
|
+
newer than 6 will keep them.
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
return int(version) >= 6
|
|
232
|
+
except ValueError:
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _write_control(w: _Writer, control: Control, is_root: bool, includes: bool) -> None:
|
|
237
|
+
w.open("node", ID=control.id, type=control.control_type.value)
|
|
238
|
+
|
|
239
|
+
if (is_root and includes) or getattr(control, "_has_includes", False):
|
|
240
|
+
w.empty("includes")
|
|
241
|
+
|
|
242
|
+
w.open("properties")
|
|
243
|
+
for key in sorted(control.properties):
|
|
244
|
+
_write_property(w, control.properties[key])
|
|
245
|
+
w.close("properties")
|
|
246
|
+
|
|
247
|
+
if control.values:
|
|
248
|
+
w.open("values")
|
|
249
|
+
for value in control.values:
|
|
250
|
+
_write_value(w, value)
|
|
251
|
+
w.close("values")
|
|
252
|
+
|
|
253
|
+
if control.messages:
|
|
254
|
+
w.open("messages")
|
|
255
|
+
for message in control.messages:
|
|
256
|
+
_write_message(w, message, modern=includes)
|
|
257
|
+
w.close("messages")
|
|
258
|
+
|
|
259
|
+
if control.children:
|
|
260
|
+
w.open("children")
|
|
261
|
+
for child in control.children:
|
|
262
|
+
_write_control(w, child, is_root=False, includes=includes)
|
|
263
|
+
w.close("children")
|
|
264
|
+
|
|
265
|
+
w.close("node")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def to_xml(root: Control, version: str = "6", pretty: bool = False) -> str:
|
|
269
|
+
"""Serialize a control tree to the `.tosc` XML dialect.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
root: The root control of the layout.
|
|
273
|
+
version: The `lexml` format version to declare. Versions below 6 are
|
|
274
|
+
written without the `<includes>` element, which did not exist yet.
|
|
275
|
+
pretty: Emit one element per line, matching the editor's XML export.
|
|
276
|
+
The default single-line form matches what the editor saves inside a
|
|
277
|
+
`.tosc`.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
The complete XML document, including its declaration.
|
|
281
|
+
"""
|
|
282
|
+
w = _Writer()
|
|
283
|
+
w.lines.append(_DECLARATION)
|
|
284
|
+
w.open("lexml", version=version)
|
|
285
|
+
_write_control(w, root, is_root=True, includes=_is_modern(version))
|
|
286
|
+
w.close("lexml")
|
|
287
|
+
return w.render(pretty)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# -- reading ----------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _text(element: ET.Element | None, default: str = "") -> str:
|
|
294
|
+
if element is None or element.text is None:
|
|
295
|
+
return default
|
|
296
|
+
return element.text
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _flag(element: ET.Element | None, default: bool = False) -> bool:
|
|
300
|
+
raw = _text(element)
|
|
301
|
+
return default if raw == "" else raw not in ("0", "false")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _number(element: ET.Element | None, default: float = 0) -> float:
|
|
305
|
+
raw = _text(element)
|
|
306
|
+
try:
|
|
307
|
+
return float(raw)
|
|
308
|
+
except ValueError:
|
|
309
|
+
return default
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _read_property(element: ET.Element) -> Property:
|
|
313
|
+
key = _text(element.find("key"))
|
|
314
|
+
kind = PropertyType(element.get("type", "s"))
|
|
315
|
+
value_element = element.find("value")
|
|
316
|
+
|
|
317
|
+
def part(name: str) -> ET.Element | None:
|
|
318
|
+
"""A composite value's child, tolerating a property with no <value>."""
|
|
319
|
+
return None if value_element is None else value_element.find(name)
|
|
320
|
+
|
|
321
|
+
raw: Any
|
|
322
|
+
match kind:
|
|
323
|
+
case PropertyType.FRAME:
|
|
324
|
+
raw = Frame(*(_number(part(f)) for f in "xywh"))
|
|
325
|
+
case PropertyType.COLOR:
|
|
326
|
+
raw = Color(*(_number(part(f)) for f in "rgba"))
|
|
327
|
+
case PropertyType.BOOLEAN:
|
|
328
|
+
raw = _flag(value_element)
|
|
329
|
+
case PropertyType.INTEGER:
|
|
330
|
+
raw = int(_number(value_element))
|
|
331
|
+
case PropertyType.FLOAT:
|
|
332
|
+
raw = _number(value_element)
|
|
333
|
+
case _:
|
|
334
|
+
raw = _text(value_element)
|
|
335
|
+
|
|
336
|
+
return Property(key, raw, kind)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _read_default(key: str, raw: str) -> bool | float | str:
|
|
340
|
+
if key == "text":
|
|
341
|
+
return raw
|
|
342
|
+
if raw in ("true", "false"):
|
|
343
|
+
return raw == "true"
|
|
344
|
+
try:
|
|
345
|
+
return float(raw)
|
|
346
|
+
except ValueError:
|
|
347
|
+
return raw
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _read_value(element: ET.Element) -> Value:
|
|
351
|
+
key = _text(element.find("key"))
|
|
352
|
+
return Value(
|
|
353
|
+
key=key,
|
|
354
|
+
locked=_flag(element.find("locked")),
|
|
355
|
+
locked_default_current=_flag(element.find("lockedDefaultCurrent")),
|
|
356
|
+
default=_read_default(key, _text(element.find("default"))),
|
|
357
|
+
default_pull=int(_number(element.find("defaultPull"))),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _read_triggers(element: ET.Element | None) -> list[Trigger]:
|
|
362
|
+
if element is None:
|
|
363
|
+
return []
|
|
364
|
+
return [
|
|
365
|
+
Trigger(var=_text(t.find("var")), condition=_text(t.find("condition"), "ANY"))
|
|
366
|
+
for t in element.findall("trigger")
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _read_partials(element: ET.Element | None) -> list[Partial]:
|
|
371
|
+
if element is None:
|
|
372
|
+
return []
|
|
373
|
+
return [
|
|
374
|
+
Partial(
|
|
375
|
+
type=_text(p.find("type"), "CONSTANT"),
|
|
376
|
+
conversion=_text(p.find("conversion"), "STRING"),
|
|
377
|
+
value=_text(p.find("value")),
|
|
378
|
+
scale_min=_number(p.find("scaleMin")),
|
|
379
|
+
scale_max=_number(p.find("scaleMax"), 1),
|
|
380
|
+
)
|
|
381
|
+
for p in element.findall("partial")
|
|
382
|
+
]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _read_message(element: ET.Element) -> Message:
|
|
386
|
+
if element.tag == "osc":
|
|
387
|
+
return OscMessage(
|
|
388
|
+
enabled=_flag(element.find("enabled"), True),
|
|
389
|
+
send=_flag(element.find("send"), True),
|
|
390
|
+
receive=_flag(element.find("receive"), True),
|
|
391
|
+
feedback=_flag(element.find("feedback")),
|
|
392
|
+
no_duplicates=_flag(element.find("noDuplicates")),
|
|
393
|
+
connections=_text(element.find("connections")),
|
|
394
|
+
triggers=_read_triggers(element.find("triggers")),
|
|
395
|
+
path=_read_partials(element.find("path")),
|
|
396
|
+
arguments=_read_partials(element.find("arguments")),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if element.tag == "midi":
|
|
400
|
+
command = element.find("message")
|
|
401
|
+
values = element.find("values")
|
|
402
|
+
return MidiMessage(
|
|
403
|
+
enabled=_flag(element.find("enabled"), True),
|
|
404
|
+
send=_flag(element.find("send"), True),
|
|
405
|
+
receive=_flag(element.find("receive"), True),
|
|
406
|
+
feedback=_flag(element.find("feedback")),
|
|
407
|
+
no_duplicates=_flag(element.find("noDuplicates")),
|
|
408
|
+
connections=_text(element.find("connections")),
|
|
409
|
+
triggers=_read_triggers(element.find("triggers")),
|
|
410
|
+
message=MidiCommand(
|
|
411
|
+
type=_text(command.find("type"), "CONTROLCHANGE")
|
|
412
|
+
if command is not None
|
|
413
|
+
else "CONTROLCHANGE",
|
|
414
|
+
channel=int(_number(command.find("channel")))
|
|
415
|
+
if command is not None
|
|
416
|
+
else 0,
|
|
417
|
+
data1=int(_number(command.find("data1"))) if command is not None else 0,
|
|
418
|
+
data2=int(_number(command.find("data2"))) if command is not None else 0,
|
|
419
|
+
),
|
|
420
|
+
values=[
|
|
421
|
+
MidiValue(
|
|
422
|
+
type=_text(v.find("type"), "CONSTANT"),
|
|
423
|
+
key=_text(v.find("key")),
|
|
424
|
+
scale_min=_number(v.find("scaleMin")),
|
|
425
|
+
scale_max=_number(v.find("scaleMax")),
|
|
426
|
+
)
|
|
427
|
+
for v in (values.findall("value") if values is not None else [])
|
|
428
|
+
],
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if element.tag == "gamepad":
|
|
432
|
+
return GamepadMessage(
|
|
433
|
+
enabled=_flag(element.find("enabled"), True),
|
|
434
|
+
connections=_text(element.find("connections")),
|
|
435
|
+
type=_text(element.find("type"), "BUTTON_A"),
|
|
436
|
+
conversion=_text(element.find("conversion"), "FLOAT"),
|
|
437
|
+
scale_min=_number(element.find("scaleMin")),
|
|
438
|
+
scale_max=_number(element.find("scaleMax"), 1),
|
|
439
|
+
target_type=_text(element.find("targetType"), "VALUE"),
|
|
440
|
+
target_var=_text(element.find("targetVar")),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if element.tag == "local":
|
|
444
|
+
return LocalMessage(
|
|
445
|
+
enabled=_flag(element.find("enabled"), True),
|
|
446
|
+
triggers=_read_triggers(element.find("triggers")),
|
|
447
|
+
type=_text(element.find("type"), "VALUE"),
|
|
448
|
+
conversion=_text(element.find("conversion"), "FLOAT"),
|
|
449
|
+
value=_text(element.find("value")),
|
|
450
|
+
scale_min=_number(element.find("scaleMin")),
|
|
451
|
+
scale_max=_number(element.find("scaleMax"), 1),
|
|
452
|
+
dst_type=_text(element.find("dstType")),
|
|
453
|
+
dst_var=_text(element.find("dstVar")),
|
|
454
|
+
dst_id=_text(element.find("dstID")),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
raise ValueError(f"<{element.tag}> is not a known message type")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _read_control(element: ET.Element) -> Control:
|
|
461
|
+
control = Control(
|
|
462
|
+
ControlType(element.get("type", "GROUP")),
|
|
463
|
+
id=element.get("ID"),
|
|
464
|
+
properties={},
|
|
465
|
+
values=[],
|
|
466
|
+
messages=[],
|
|
467
|
+
children=[],
|
|
468
|
+
)
|
|
469
|
+
# Replace the type defaults wholesale: the file is the source of truth.
|
|
470
|
+
control.properties.clear()
|
|
471
|
+
|
|
472
|
+
for child in element.findall("./properties/property"):
|
|
473
|
+
prop = _read_property(child)
|
|
474
|
+
control.properties[prop.key] = prop
|
|
475
|
+
|
|
476
|
+
control.values.extend(_read_value(v) for v in element.findall("./values/value"))
|
|
477
|
+
|
|
478
|
+
messages = element.find("messages")
|
|
479
|
+
if messages is not None:
|
|
480
|
+
control.messages.extend(_read_message(m) for m in messages)
|
|
481
|
+
|
|
482
|
+
control.children.extend(
|
|
483
|
+
_read_control(node) for node in element.findall("./children/node")
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if element.find("includes") is not None:
|
|
487
|
+
object.__setattr__(control, "_has_includes", True)
|
|
488
|
+
|
|
489
|
+
return control
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def from_xml(source: str | bytes) -> tuple[Control, str]:
|
|
493
|
+
"""Parse the `.tosc` XML dialect into a control tree.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
source: The XML document, as text or bytes.
|
|
497
|
+
|
|
498
|
+
Returns:
|
|
499
|
+
The root control and the `lexml` version it declared.
|
|
500
|
+
|
|
501
|
+
Raises:
|
|
502
|
+
ValueError: If the document is not a `lexml` root holding one node.
|
|
503
|
+
"""
|
|
504
|
+
root = ET.fromstring(source)
|
|
505
|
+
if root.tag != "lexml":
|
|
506
|
+
raise ValueError(f"expected a <lexml> root, found <{root.tag}>")
|
|
507
|
+
|
|
508
|
+
node = root.find("node")
|
|
509
|
+
if node is None:
|
|
510
|
+
raise ValueError("<lexml> holds no <node>")
|
|
511
|
+
|
|
512
|
+
return _read_control(node), root.get("version", "6")
|