trackwright 0.3.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.
Files changed (46) hide show
  1. trackwright/__init__.py +13 -0
  2. trackwright/__main__.py +7 -0
  3. trackwright/bridge.py +323 -0
  4. trackwright/cli.py +203 -0
  5. trackwright/commands.py +397 -0
  6. trackwright/config.py +149 -0
  7. trackwright/core/__init__.py +1 -0
  8. trackwright/core/automation.py +410 -0
  9. trackwright/core/base.py +313 -0
  10. trackwright/core/commanding.py +300 -0
  11. trackwright/core/exporting.py +143 -0
  12. trackwright/core/importing.py +127 -0
  13. trackwright/core/lifecycle.py +74 -0
  14. trackwright/core/models.py +467 -0
  15. trackwright/core/policy.py +58 -0
  16. trackwright/core/presets.py +88 -0
  17. trackwright/core/project.py +221 -0
  18. trackwright/core/ranges.py +355 -0
  19. trackwright/core/reading.py +229 -0
  20. trackwright/core/refs.py +88 -0
  21. trackwright/core/saving.py +72 -0
  22. trackwright/core/service.py +27 -0
  23. trackwright/core/settings.py +80 -0
  24. trackwright/core/writing.py +390 -0
  25. trackwright/coremidi.py +307 -0
  26. trackwright/cubase/Trackwright_Bridge.js +1130 -0
  27. trackwright/cubase/__init__.py +83 -0
  28. trackwright/doctor.py +69 -0
  29. trackwright/errors.py +120 -0
  30. trackwright/exports.py +54 -0
  31. trackwright/fake.py +1112 -0
  32. trackwright/host.py +238 -0
  33. trackwright/hostport.py +74 -0
  34. trackwright/installer.py +305 -0
  35. trackwright/launcher.py +150 -0
  36. trackwright/logs.py +50 -0
  37. trackwright/ops.py +46 -0
  38. trackwright/protocol.py +182 -0
  39. trackwright/py.typed +0 -0
  40. trackwright/server.py +472 -0
  41. trackwright/transport.py +130 -0
  42. trackwright-0.3.0.dist-info/METADATA +190 -0
  43. trackwright-0.3.0.dist-info/RECORD +46 -0
  44. trackwright-0.3.0.dist-info/WHEEL +4 -0
  45. trackwright-0.3.0.dist-info/entry_points.txt +3 -0
  46. trackwright-0.3.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,390 @@
1
+ """Write and navigation tools (epic E06).
2
+
3
+ Every project modification follows the same rules:
4
+ - the write policy is checked (dry runs are always allowed);
5
+ - ``dry_run`` defaults to true and returns the plan without touching Cubase;
6
+ - writes are **never retried**: after a timeout the outcome is unknown, so the value is read back
7
+ and reported with the error;
8
+ - the result is verified by reading the state back.
9
+
10
+ Navigation (selecting a track, moving the cursor) changes no project data and is always allowed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ from trackwright.core.base import (
18
+ MIXER,
19
+ TAG_MUTE,
20
+ TAG_NAME,
21
+ TAG_RECORD,
22
+ TAG_SELECTED,
23
+ TAG_SOLO,
24
+ TAG_VOLUME,
25
+ JsonObject,
26
+ operation,
27
+ )
28
+ from trackwright.core.models import (
29
+ Change,
30
+ InsertPluginOut,
31
+ PositionOut,
32
+ SelectOut,
33
+ TempoOut,
34
+ TimePosition,
35
+ Value,
36
+ WriteOut,
37
+ )
38
+ from trackwright.core.project import SECONDS_TOLERANCE, ProjectMixin, format_seconds, parse_seconds
39
+ from trackwright.core.refs import TrackSelector
40
+ from trackwright.errors import NotFoundError, PreconditionFailedError, RequestTimeoutError, TrackwrightError
41
+
42
+ BARS_RE = re.compile(r"^\s*(\d+)\s*\.\s*(\d+)\s*\.\s*(\d+)\s*\.\s*(\d+)\s*$")
43
+ TEMPO_TOLERANCE = 0.01
44
+
45
+
46
+ def bars_tuple(text: str) -> tuple[int, int, int, int] | None:
47
+ match = BARS_RE.match(text)
48
+ if not match:
49
+ return None
50
+ bar, beat, sixteenth, ticks = (int(g) for g in match.groups())
51
+ return bar, beat, sixteenth, ticks
52
+
53
+
54
+ def format_bars(position: tuple[int, int, int, int]) -> str:
55
+ """Cubase's own rendering, e.g. ``"3. 1. 1. 0"`` (ticks right-aligned on three characters)."""
56
+ bar, beat, sixteenth, ticks = position
57
+ return f"{bar}. {beat}. {sixteenth}.{ticks:>3}"
58
+
59
+
60
+ class WritingMixin(ProjectMixin):
61
+ # ------------------------------------------------------------------ helpers
62
+ def _value(self, raw: JsonObject | None) -> Value | None:
63
+ return None if raw is None else Value(process=raw.get("process"), display=raw.get("display"))
64
+
65
+ def _write(self, op: str, params: JsonObject, object_id: int, tag: int) -> JsonObject:
66
+ """Run one write; on timeout read the value back and report it (never retry)."""
67
+ try:
68
+ return self.call(op, params)
69
+ except RequestTimeoutError as exc:
70
+ try:
71
+ exc.details["read_back"] = self.read_parameter(object_id, tag)
72
+ except TrackwrightError as read_error:
73
+ exc.details["read_back_error"] = str(read_error)
74
+ raise
75
+
76
+ def _change(self, before: JsonObject, requested: float | str, after: JsonObject | None, ok: bool | None) -> Change:
77
+ before_value = self._value(before)
78
+ assert before_value is not None
79
+ return Change(
80
+ tag=int(before["tag"]),
81
+ title=str(before.get("title", "")),
82
+ before=before_value,
83
+ requested=requested,
84
+ after=self._value(after),
85
+ verified=ok,
86
+ )
87
+
88
+ # ------------------------------------------------------------------ parameters
89
+ @operation
90
+ def set_parameter(
91
+ self,
92
+ track: str,
93
+ target: str,
94
+ tag: int,
95
+ value: float,
96
+ *,
97
+ unit: str = "process",
98
+ dry_run: bool = True,
99
+ tolerance: float = 1e-3,
100
+ expect_title: str | None = None,
101
+ ) -> WriteOut:
102
+ item = self.resolve_track(TrackSelector.parse(track))
103
+ obj = self.resolve_target(item, target)
104
+ before = self.read_parameter(int(obj["id"]), tag)
105
+ if before.get("editLock"):
106
+ raise PreconditionFailedError("the parameter is edit-locked in Cubase", code="PARAM_LOCKED")
107
+ if expect_title is not None and before.get("title") != expect_title:
108
+ raise PreconditionFailedError(
109
+ f"parameter {tag} is {before.get('title')!r}, expected {expect_title!r}", code="PRECONDITION_FAILED"
110
+ )
111
+ if unit == "plain":
112
+ converted = self.call("da.convert", {"id": obj["id"], "tag": tag, "plain": value, **MIXER})
113
+ process = float(converted["processFromPlain"])
114
+ elif unit == "process":
115
+ process = value
116
+ else:
117
+ raise PreconditionFailedError("unit must be 'process' or 'plain'", code="BAD_REQUEST")
118
+ if not 0.0 <= process <= 1.0:
119
+ raise PreconditionFailedError(
120
+ f"process value {process:.6g} is outside [0, 1]", code="OUT_OF_RANGE", details={"unit": unit}
121
+ )
122
+ name = self.track_info(item).name
123
+ if dry_run:
124
+ return WriteOut(
125
+ dry_run=True,
126
+ target=f"{name} / {target}",
127
+ changes=[self._change(before, process, None, None)],
128
+ verified=None,
129
+ )
130
+ self.policy.check_track(name)
131
+ params = {"id": obj["id"], "tag": tag, "value": process, **MIXER}
132
+ if expect_title is not None:
133
+ params["expectTitle"] = expect_title # re-checked by the script in the same tick as the write
134
+ result = self._write("da.param.set", params, int(obj["id"]), tag)
135
+ after = result.get("after") or self.read_parameter(int(obj["id"]), tag)
136
+ ok = after.get("process") is not None and abs(float(after["process"]) - process) <= tolerance
137
+ return WriteOut(
138
+ dry_run=False,
139
+ target=f"{name} / {target}",
140
+ changes=[self._change(before, process, after, ok)],
141
+ verified=ok,
142
+ note=None if ok else "the value read back differs from the request",
143
+ )
144
+
145
+ @operation
146
+ def set_track_mix(
147
+ self,
148
+ track: str,
149
+ *,
150
+ volume: float | None = None,
151
+ mute: bool | None = None,
152
+ solo: bool | None = None,
153
+ record_arm: bool | None = None,
154
+ dry_run: bool = True,
155
+ ) -> WriteOut:
156
+ wanted: list[tuple[int, float]] = []
157
+ if volume is not None:
158
+ if not 0.0 <= volume <= 1.0:
159
+ raise PreconditionFailedError("volume is a normalized value in [0, 1]", code="OUT_OF_RANGE")
160
+ wanted.append((TAG_VOLUME, volume))
161
+ wanted += [
162
+ (tag, 1.0 if flag else 0.0)
163
+ for tag, flag in ((TAG_MUTE, mute), (TAG_SOLO, solo), (TAG_RECORD, record_arm))
164
+ if flag is not None
165
+ ]
166
+ if not wanted:
167
+ raise PreconditionFailedError("give at least one of volume, mute, solo, record_arm", code="BAD_REQUEST")
168
+ item = self.resolve_track(TrackSelector.parse(track))
169
+ name = self.track_info(item).name
170
+ oid = int(item["id"])
171
+ befores = [(self.read_parameter(oid, tag), value) for tag, value in wanted]
172
+ if dry_run:
173
+ return WriteOut(
174
+ dry_run=True, target=name, verified=None, changes=[self._change(b, v, None, None) for b, v in befores]
175
+ )
176
+ self.policy.check_track(name)
177
+ changes: list[Change] = []
178
+ for before, value in befores:
179
+ tag = int(before["tag"])
180
+ try:
181
+ result = self._write("da.param.set", {"id": oid, "tag": tag, "value": value, **MIXER}, oid, tag)
182
+ except TrackwrightError as exc:
183
+ # Earlier changes did happen: say so, so that nobody assumes the track is untouched.
184
+ exc.details["applied"] = [c.model_dump() for c in changes]
185
+ raise
186
+ after = result.get("after") or self.read_parameter(oid, tag)
187
+ ok = after.get("process") is not None and abs(float(after["process"]) - value) <= 1e-3
188
+ changes.append(self._change(before, value, after, ok))
189
+ return WriteOut(dry_run=False, target=name, changes=changes, verified=all(c.verified for c in changes))
190
+
191
+ @operation
192
+ def rename_track(self, track: str, new_name: str, *, dry_run: bool = True) -> WriteOut:
193
+ new_name = new_name.strip()
194
+ if not 1 <= len(new_name) <= 64:
195
+ raise PreconditionFailedError("the new name must have 1 to 64 characters", code="BAD_REQUEST")
196
+ item = self.resolve_track(TrackSelector.parse(track))
197
+ current = self.track_info(item).name
198
+ others = [self.track_info(c).name for c in self.channels() if c["id"] != item["id"]]
199
+ if new_name in others:
200
+ raise PreconditionFailedError(f"another track is already called {new_name!r}", code="NAME_CONFLICT")
201
+ oid = int(item["id"])
202
+ before = self.read_parameter(oid, TAG_NAME)
203
+ if dry_run:
204
+ return WriteOut(
205
+ dry_run=True, target=current, verified=None, changes=[self._change(before, new_name, None, None)]
206
+ )
207
+ self.policy.check_track(current)
208
+ result = self._write(
209
+ "da.param.setDisplay", {"id": oid, "tag": TAG_NAME, "value": new_name, **MIXER}, oid, TAG_NAME
210
+ )
211
+ after = result.get("after") or self.read_parameter(oid, TAG_NAME)
212
+ title = self.call("da.object", {"id": oid, **MIXER}).get("title")
213
+ ok = after.get("display") == new_name and title == new_name
214
+ return WriteOut(
215
+ dry_run=False,
216
+ target=current,
217
+ changes=[self._change(before, new_name, after, ok)],
218
+ verified=ok,
219
+ note=f"use the selector 'uid:{item['uid']}': name selectors change with the name",
220
+ )
221
+
222
+ # ------------------------------------------------------------------ plugins
223
+ @operation
224
+ def insert_plugin(self, track: str, slot: int, plugin_uid: str, *, dry_run: bool = True) -> InsertPluginOut:
225
+ item = self.resolve_track(TrackSelector.parse(track))
226
+ name = self.track_info(item).name
227
+ slot_obj = self.insert_slot(item, slot)
228
+ if slot_obj.get("children"):
229
+ raise PreconditionFailedError(f"insert slot {slot} is occupied", code="SLOT_OCCUPIED")
230
+ entry = self._find_plugin(int(slot_obj["id"]), plugin_uid)
231
+ plugin_name = str(entry["name"])
232
+ if dry_run:
233
+ return InsertPluginOut(
234
+ dry_run=True,
235
+ track=name,
236
+ slot=slot,
237
+ plugin_uid=plugin_uid,
238
+ plugin_name=plugin_name,
239
+ inserted=None,
240
+ verified=None,
241
+ plugin_ref=None,
242
+ )
243
+ self.policy.check_track(name)
244
+ try:
245
+ result = self.call("pm.set", {"slot": slot_obj["id"], "uid": plugin_uid, **MIXER})
246
+ except RequestTimeoutError as exc:
247
+ try:
248
+ exc.details["read_back"] = self.call("da.object", {"id": slot_obj["id"], **MIXER})
249
+ except TrackwrightError as read_error:
250
+ exc.details["read_back_error"] = str(read_error)
251
+ raise
252
+ children = self.children(int(slot_obj["id"])) if result.get("result") else []
253
+ plugin = children[0] if children else None
254
+ ok = plugin is not None and self.plugin_uid(str(plugin.get("uniqueName"))) == plugin_uid
255
+ return InsertPluginOut(
256
+ dry_run=False,
257
+ inserted=bool(result.get("result")),
258
+ verified=ok,
259
+ plugin_ref=self.ref_for(plugin) if plugin else None,
260
+ track=name,
261
+ slot=slot,
262
+ plugin_uid=plugin_uid,
263
+ plugin_name=plugin_name,
264
+ )
265
+
266
+ def _find_plugin(self, slot_id: int, plugin_uid: str) -> JsonObject:
267
+ collections = self.call("pm.collections", {"slot": slot_id, **MIXER})
268
+ index = int(collections.get("activeIndex", -1))
269
+ offset = 0
270
+ while index >= 0:
271
+ page = self.call(
272
+ "pm.entries", {"slot": slot_id, "collection": index, "offset": offset, "limit": 40, **MIXER}
273
+ )
274
+ for entry in page.get("items", []):
275
+ if entry.get("uid") == plugin_uid:
276
+ return dict(entry)
277
+ offset += len(page.get("items", []))
278
+ if not page.get("items") or offset >= int(page.get("matched", 0)):
279
+ break
280
+ raise NotFoundError(f"plugin {plugin_uid} is not in the catalog of this slot", code="PLUGIN_NOT_FOUND")
281
+
282
+ # ------------------------------------------------------------------ navigation
283
+ def _selected_name(self) -> tuple[int, str | None]:
284
+ base = int(self.call("da.base").get("base", -1))
285
+ if base < 0:
286
+ return base, None
287
+ match = [c for c in self.channels() if c["id"] == base]
288
+ return base, self.track_info(match[0]).name if match else None
289
+
290
+ @operation
291
+ def select_track(self, track: str | None = None, step: str | None = None) -> SelectOut:
292
+ if (track is None) == (step is None):
293
+ raise PreconditionFailedError("give exactly one of track or step", code="BAD_REQUEST")
294
+ before_id, before_name = self._selected_name()
295
+
296
+ def base() -> JsonObject:
297
+ return self.call("da.base")
298
+
299
+ if step is not None:
300
+ if step not in ("next", "prev"):
301
+ raise PreconditionFailedError("step must be 'next' or 'prev'", code="BAD_REQUEST")
302
+ self.call("sel.step", {"direction": step})
303
+ self.wait_until(base, lambda b: int(b.get("base", -1)) != before_id)
304
+ after_id, after_name = self._selected_name()
305
+ return SelectOut(selected_before=before_name, selected_after=after_name, verified=after_id >= 0)
306
+ item = self.resolve_track(TrackSelector.parse(track))
307
+ self.call("da.param.set", {"id": item["id"], "tag": TAG_SELECTED, "value": 1, **MIXER})
308
+ self.wait_until(base, lambda b: b.get("base") == item["id"])
309
+ after_id, after_name = self._selected_name()
310
+ return SelectOut(selected_before=before_name, selected_after=after_name, verified=after_id == item["id"])
311
+
312
+ @operation
313
+ def set_position(self, which: str, time: str, *, unit: str = "bars", dry_run: bool = False) -> PositionOut:
314
+ field = {"cursor": "locator", "cycle_left": "cycleLeft", "cycle_right": "cycleRight"}.get(which)
315
+ if field is None:
316
+ raise PreconditionFailedError("which must be cursor, cycle_left or cycle_right", code="BAD_REQUEST")
317
+ seconds: float | None = None
318
+ if unit == "bars":
319
+ position = bars_tuple(time)
320
+ if position is None:
321
+ raise PreconditionFailedError("bars look like '3. 1. 1. 0' (bar. beat. 16th. tick)", code="BAD_TIME")
322
+ text = format_bars(position)
323
+ elif unit == "seconds":
324
+ seconds = parse_seconds(time)
325
+ if seconds is None:
326
+ raise PreconditionFailedError("seconds look like '2.5', '1:30' or '0:01:30.000'", code="BAD_TIME")
327
+ text = format_seconds(seconds)
328
+ else:
329
+ raise PreconditionFailedError("unit must be 'bars' or 'seconds'", code="BAD_REQUEST")
330
+ transport = self.call("transport.get")
331
+ before_raw = (transport.get("primary") or {}).get(field)
332
+ before = TimePosition(time=before_raw["time"], format=before_raw.get("format")) if before_raw else None
333
+ if dry_run:
334
+ return PositionOut(dry_run=True, which=which, requested=text, before=before, after=None, verified=None)
335
+ if seconds is not None:
336
+ return self._set_position_seconds(which, field, text, seconds, before)
337
+ self.call("transport.setTime", {"which": field, "display": "primary", "time": text})
338
+ expected = bars_tuple(text)
339
+
340
+ def read() -> JsonObject:
341
+ raw = (self.call("transport.get").get("primary") or {}).get(field) or {}
342
+ return dict(raw)
343
+
344
+ after_raw = self.wait_until(read, lambda raw: bars_tuple(str(raw.get("time", ""))) == expected)
345
+ after = TimePosition(time=str(after_raw.get("time", "")), format=after_raw.get("format"))
346
+ return PositionOut(
347
+ dry_run=False, which=which, requested=text, before=before, after=after,
348
+ verified=bars_tuple(after.time) == expected,
349
+ ) # fmt: skip
350
+
351
+ def _set_position_seconds(
352
+ self, which: str, field: str, text: str, seconds: float, before: TimePosition | None
353
+ ) -> PositionOut:
354
+ """Seconds, verified with the Transport's own seconds display (TW-1211): no tempo or meter assumed."""
355
+ transport = self._transport_id()
356
+ if which == "cursor":
357
+ landed = self._move_cursor_seconds(transport, seconds)
358
+ after = TimePosition(time=self._cursor_bars(), format="Compases+Tiempos")
359
+ return PositionOut(dry_run=False, which=which, requested=text, before=before, after=after,
360
+ verified=abs(landed - seconds) <= SECONDS_TOLERANCE,
361
+ note=f"cursor at {format_seconds(landed)} (read back in seconds)") # fmt: skip
362
+ cursor = self._cursor_seconds(transport)
363
+ try:
364
+ locator = self._set_locator_seconds(transport, "left" if which == "cycle_left" else "right", seconds)
365
+ finally:
366
+ self._move_cursor_seconds(transport, cursor) # the cursor was only a means: put it back
367
+ expected = bars_tuple(locator)
368
+ raw = (self.call("transport.get").get("primary") or {}).get(field) or {}
369
+ after = TimePosition(time=str(raw.get("time", "")), format=raw.get("format"))
370
+ return PositionOut(
371
+ dry_run=False, which=which, requested=text, before=before, after=after,
372
+ verified=expected is not None and bars_tuple(after.time) == expected,
373
+ note="set at the cursor with Set Left/Right Locator (the seconds display cannot move the locators)",
374
+ ) # fmt: skip
375
+
376
+ @operation
377
+ def set_tempo(self, bpm: float, *, dry_run: bool = True) -> TempoOut:
378
+ if not 20 <= bpm <= 300:
379
+ raise PreconditionFailedError("tempo must be within 20..300 BPM", code="OUT_OF_RANGE")
380
+ before = self.call("transport.get").get("tempoBPM")
381
+ if dry_run:
382
+ return TempoOut(dry_run=True, before=before, requested=bpm, after=None, verified=None)
383
+ self.policy.check_project_write()
384
+ self.call("transport.setTempo", {"bpm": bpm})
385
+ after = self.wait_until(
386
+ lambda: self.call("transport.get"),
387
+ lambda data: abs(float(data.get("tempoBPM") or 0) - bpm) < TEMPO_TOLERANCE,
388
+ ).get("tempoBPM")
389
+ ok = after is not None and abs(float(after) - bpm) < TEMPO_TOLERANCE
390
+ return TempoOut(dry_run=False, before=before, requested=bpm, after=after, verified=ok)