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,410 @@
1
+ """Automation tools (TW-1207).
2
+
3
+ Mechanism verified in Cubase Elements 15.0.30 (experiments TW-1206 B2 and TW-1207 B3): the script has
4
+ surface faders bound to parameters of the *selected* track. Moving a *touched* control while the
5
+ track's automation write (W) is on and the transport plays records automation in Touch mode, the way a
6
+ hardware controller does. Writing the parameter through DirectAccess does **not** record (variant A).
7
+
8
+ Positions are bar strings ("9. 1. 1. 0"). They are converted to quarter notes with the project's time
9
+ signature at the cursor (TW-1209; constant signature assumed) and 120 ticks per 16th; the cursor position
10
+ reported by Cubase during playback drives the timing.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import itertools
16
+ import re
17
+ import time
18
+ from collections.abc import Iterator
19
+ from typing import Any
20
+
21
+ from trackwright.core.base import MIXER, TAG_SELECTED, TAG_VOLUME, operation
22
+ from trackwright.core.models import (
23
+ AutomationPoint,
24
+ AutomationRecordOut,
25
+ AutomationTarget,
26
+ AutomationTargetsOut,
27
+ ConvertOut,
28
+ ResolvedPoint,
29
+ WrittenPoint,
30
+ )
31
+ from trackwright.core.project import ProjectMixin
32
+ from trackwright.core.refs import TrackSelector
33
+ from trackwright.core.writing import bars_tuple, format_bars
34
+ from trackwright.errors import NotFoundError, PreconditionFailedError, TrackwrightError
35
+
36
+ TAG_WRITE, TAG_READ = 4013, 4014
37
+ MAX_POINTS = 64
38
+ VERIFY_TOLERANCE = 0.05
39
+ TICKS_PER_BEAT = 480
40
+ TAG_PAN, TAG_SEND_LEVEL = 4201, 4097
41
+ PREFILTER_TITLES = {"gain": "PreGain", "lowcut": "LC-Freq", "highcut": "HC-Freq"}
42
+ EQ_TITLES = {"gain": "Gain", "freq": "Freq", "q": "Q"}
43
+ SEND_KEY = re.compile(r"^send([1-8])\.level$")
44
+ EQ_KEY = re.compile(r"^eq([1-4])\.(gain|freq|q)$")
45
+ PREFILTER_KEY = re.compile(r"^prefilter\.(gain|lowcut|highcut)$")
46
+ INSERT_KEY = re.compile(r"^insert([1-8])\.p[1-8]$")
47
+ STALL_SECONDS = 2.0
48
+ JsonObject = dict[str, Any]
49
+
50
+
51
+ Meter = tuple[int, int]
52
+ COMMON_TIME: Meter = (4, 4)
53
+
54
+
55
+ def to_beats(position: tuple[int, int, int, int], meter: Meter = COMMON_TIME) -> float:
56
+ """Bars/beats/16ths/ticks to quarter notes from the project start, for a constant time signature."""
57
+ bar, beat, sixteenth, ticks = position
58
+ numerator, denominator = meter
59
+ beat_length = 4 / denominator
60
+ return (bar - 1) * numerator * beat_length + (beat - 1) * beat_length + (sixteenth - 1) / 4 + ticks / TICKS_PER_BEAT
61
+
62
+
63
+ def from_beats(beats: float, meter: Meter = COMMON_TIME) -> tuple[int, int, int, int]:
64
+ numerator, denominator = meter
65
+ beat_length = 4 / denominator
66
+ bar, rest = divmod(max(beats, 0.0), numerator * beat_length)
67
+ beat, rest = divmod(rest, beat_length)
68
+ sixteenth, rest = divmod(rest, 0.25)
69
+ return int(bar) + 1, int(beat) + 1, int(sixteenth) + 1, round(rest * TICKS_PER_BEAT)
70
+
71
+
72
+ def interpolate(points: list[tuple[float, float]], beat: float) -> float:
73
+ """Piecewise-linear value of the curve at ``beat`` (held before the first and after the last point)."""
74
+ if beat <= points[0][0]:
75
+ return points[0][1]
76
+ for (b0, v0), (b1, v1) in itertools.pairwise(points):
77
+ if beat <= b1:
78
+ return v0 + (v1 - v0) * (beat - b0) / (b1 - b0)
79
+ return points[-1][1]
80
+
81
+
82
+ class AutomationMixin(ProjectMixin):
83
+ # ------------------------------------------------------------------ helpers
84
+ def _auto_controls(self) -> dict[str, JsonObject]:
85
+ listed = self.call("auto.list")
86
+ if not listed.get("enabled"):
87
+ raise TrackwrightError(
88
+ "the installed Cubase script has no automation controls; install script 1.4.0 or later",
89
+ code="UNSUPPORTED",
90
+ )
91
+ return {str(c["key"]): c for c in listed.get("controls", [])}
92
+
93
+ def _select(self, item: JsonObject) -> None:
94
+ self.call("da.param.set", {"id": item["id"], "tag": TAG_SELECTED, "value": 1, "scope": "mixer"})
95
+ self.wait_until(lambda: self.call("da.base"), lambda b: b.get("base") == item["id"])
96
+
97
+ def _cursor_beats(self) -> float:
98
+ raw = (self.call("transport.get").get("primary") or {}).get("locator") or {}
99
+ position = bars_tuple(str(raw.get("time", "")))
100
+ if position is None:
101
+ raise TrackwrightError("the cursor position is not available", code="UNAVAILABLE")
102
+ return to_beats(position, self._meter())
103
+
104
+ def _is_playing(self) -> bool:
105
+ first = self._cursor_beats()
106
+ self._sleep(0.3)
107
+ return self._cursor_beats() != first
108
+
109
+ @staticmethod
110
+ def _target(key: str, control: JsonObject) -> AutomationTarget:
111
+ title = str(control.get("title") or "").strip()
112
+ bound = bool(title.strip(" /"))
113
+ value = control.get("value")
114
+ return AutomationTarget(
115
+ parameter=key,
116
+ title=title if bound else None,
117
+ value=value if isinstance(value, int | float) else None,
118
+ display=control.get("display") or None,
119
+ available=bound,
120
+ converts_units=bound and not key.startswith("qc"),
121
+ )
122
+
123
+ def _flag(self, item: JsonObject, tag: int, on: bool) -> None:
124
+ self.call("da.param.set", {"id": item["id"], "tag": tag, "value": 1.0 if on else 0.0, "scope": "mixer"})
125
+
126
+ def _flag_state(self, item: JsonObject, tag: int) -> bool:
127
+ return float(self.read_parameter(int(item["id"]), tag).get("process") or 0) >= 0.5
128
+
129
+ # ------------------------------------------------------------------ time signature (TW-1209)
130
+ def _meter(self) -> Meter:
131
+ meter: Meter = getattr(self._local, "meter", COMMON_TIME)
132
+ return meter
133
+
134
+ def _read_meter(self) -> tuple[Meter, str]:
135
+ signature = self.current_signature()
136
+ if signature is None or signature.numerator is None or signature.denominator is None:
137
+ return COMMON_TIME, "time signature unknown: positions assume 4/4"
138
+ meter = (signature.numerator, signature.denominator)
139
+ return meter, f"positions use the time signature at the cursor ({signature.text}); changes are not followed"
140
+
141
+ # ------------------------------------------------------------------ unit conversion (TW-1208)
142
+ def _child_of_type(self, item: JsonObject, kind: str) -> JsonObject:
143
+ for child in self.children(int(item["id"])):
144
+ if child.get("type") == kind:
145
+ return child
146
+ raise NotFoundError(f"the track has no {kind}", code="NO_UNIT_CONVERSION")
147
+
148
+ def _tag_by_title(self, obj: JsonObject, title: str) -> int:
149
+ offset = 0
150
+ while True:
151
+ page = self.call("da.params", {"id": obj["id"], "offset": offset, "limit": 25, **MIXER})
152
+ for param in page.get("items", []):
153
+ if param.get("title") == title:
154
+ return int(param["tag"])
155
+ offset += len(page.get("items", []))
156
+ if not page.get("items") or offset >= int(page.get("total", 0)):
157
+ raise NotFoundError(f"parameter {title!r} not found on {obj.get('title')}", code="NO_UNIT_CONVERSION")
158
+
159
+ def _da_parameter(self, item: JsonObject, key: str, control: JsonObject) -> tuple[JsonObject, int]:
160
+ """The DirectAccess object and tag behind an automation control (for exact unit conversion)."""
161
+ if key == "volume":
162
+ return item, TAG_VOLUME
163
+ if key == "pan":
164
+ return self._child_of_type(item, "Panner"), TAG_PAN
165
+ if match := SEND_KEY.match(key):
166
+ return self.children(int(self.section(item, "Sends")["id"]))[int(match.group(1)) - 1], TAG_SEND_LEVEL
167
+ if match := EQ_KEY.match(key):
168
+ eq = self.resolve_target(item, "strip:eq")
169
+ return eq, self._tag_by_title(eq, f"EQ {match.group(1)} {EQ_TITLES[match.group(2)]}")
170
+ if match := PREFILTER_KEY.match(key):
171
+ input_filter = self._child_of_type(item, "InputFilter")
172
+ return input_filter, self._tag_by_title(input_filter, PREFILTER_TITLES[match.group(1)])
173
+ if match := INSERT_KEY.match(key):
174
+ plugin = self.resolve_target(item, f"insert:{match.group(1)}")
175
+ title = str(control.get("title") or "").split(" / ", 1)[-1]
176
+ return plugin, self._tag_by_title(plugin, title)
177
+ raise PreconditionFailedError(
178
+ f"{key} has no known Cubase parameter for unit conversion: use normalized values",
179
+ code="NO_UNIT_CONVERSION",
180
+ )
181
+
182
+ def _to_value(self, target: tuple[JsonObject, int], plain: float) -> float:
183
+ obj, tag = target
184
+ value = float(
185
+ self.call("da.convert", {"id": obj["id"], "tag": tag, "plain": plain, **MIXER})["processFromPlain"]
186
+ )
187
+ if not 0.0 <= value <= 1.0:
188
+ raise PreconditionFailedError(
189
+ f"{plain} is outside the parameter's range", code="OUT_OF_RANGE", details={"normalized": value}
190
+ )
191
+ return value
192
+
193
+ def _to_plain(self, target: tuple[JsonObject, int], value: float) -> float:
194
+ obj, tag = target
195
+ return float(
196
+ self.call("da.convert", {"id": obj["id"], "tag": tag, "process": value, **MIXER})["plainFromProcess"]
197
+ )
198
+
199
+ def _bound_control(self, item: JsonObject, parameter: str) -> tuple[JsonObject, AutomationTarget]:
200
+ self._select(item)
201
+ controls = self._auto_controls()
202
+ if parameter not in controls:
203
+ raise NotFoundError(f"unknown automation parameter {parameter!r}", code="PARAM_NOT_FOUND",
204
+ details={"known": sorted(controls)}) # fmt: skip
205
+ target = self._target(parameter, controls[parameter])
206
+ if not target.available:
207
+ raise PreconditionFailedError(
208
+ f"{parameter} is not bound to any parameter on this track (empty slot or unassigned control)",
209
+ code="NOT_AUTOMATABLE",
210
+ )
211
+ return controls[parameter], target
212
+
213
+ # ------------------------------------------------------------------ tools
214
+ @operation
215
+ def list_automation_targets(self, track: str) -> AutomationTargetsOut:
216
+ """Parameters of ``track`` that can be automated. Selects the track (the controls follow the selection)."""
217
+ item = self.resolve_track(TrackSelector.parse(track))
218
+ self._select(item)
219
+ controls = self._auto_controls()
220
+ targets = [self._target(key, c) for key, c in controls.items()]
221
+ return AutomationTargetsOut(track=self.track_info(item).name, targets=targets)
222
+
223
+ @operation
224
+ def convert_value(
225
+ self, track: str, parameter: str, *, plain: float | None = None, value: float | None = None
226
+ ) -> ConvertOut:
227
+ """Exact conversion between real units and normalized values, done by Cubase. Selects the track."""
228
+ if (plain is None) == (value is None):
229
+ raise PreconditionFailedError("give exactly one of plain or value", code="BAD_REQUEST")
230
+ item = self.resolve_track(TrackSelector.parse(track))
231
+ control, target = self._bound_control(item, parameter)
232
+ da = self._da_parameter(item, parameter, control)
233
+ if plain is not None:
234
+ normalized = self._to_value(da, plain)
235
+ return ConvertOut(track=self.track_info(item).name, parameter=parameter, title=target.title,
236
+ plain=plain, value=normalized) # fmt: skip
237
+ assert value is not None
238
+ if not 0.0 <= value <= 1.0:
239
+ raise PreconditionFailedError("value must be within [0, 1]", code="OUT_OF_RANGE")
240
+ return ConvertOut(track=self.track_info(item).name, parameter=parameter, title=target.title,
241
+ plain=self._to_plain(da, value), value=value) # fmt: skip
242
+
243
+ @operation
244
+ def record_automation(
245
+ self,
246
+ track: str,
247
+ parameter: str,
248
+ points: list[AutomationPoint],
249
+ *,
250
+ dry_run: bool = True,
251
+ verify: bool = True,
252
+ pre_roll_bars: int = 1,
253
+ ) -> AutomationRecordOut:
254
+ if not 1 <= len(points) <= MAX_POINTS:
255
+ raise PreconditionFailedError(f"give 1 to {MAX_POINTS} points", code="BAD_REQUEST")
256
+ item = self.resolve_track(TrackSelector.parse(track))
257
+ name = self.track_info(item).name
258
+ control, target = self._bound_control(item, parameter)
259
+ resolved = self._resolve_points(item, parameter, control, points)
260
+ self._local.meter, signature_note = self._read_meter()
261
+ curve = self._validate_points(resolved, self._meter())
262
+ numerator, denominator = self._meter()
263
+ start_beats = max(curve[0][0] - numerator * 4 / denominator * pre_roll_bars, 0.0)
264
+ out = AutomationRecordOut(
265
+ dry_run=dry_run,
266
+ track=name,
267
+ parameter=parameter,
268
+ title=target.title,
269
+ value_before=target.value,
270
+ display_before=target.display,
271
+ start=format_bars(from_beats(start_beats, self._meter())),
272
+ points=resolved,
273
+ written=[],
274
+ verified=None,
275
+ notes=[signature_note, "playback is audible: lower the monitor level"],
276
+ )
277
+ if dry_run:
278
+ return out
279
+ self.policy.check_track(name)
280
+ if self._is_playing():
281
+ raise PreconditionFailedError("stop the transport before recording automation", code="TRANSPORT_RUNNING")
282
+ out.written = self._record(item, parameter, curve, start_beats)
283
+ if verify:
284
+ out.verified, out.mismatches = self._verify(item, parameter, curve, start_beats)
285
+ if out.verified is False:
286
+ out.notes.append("the read-back did not follow the curve: check the automation lane in Cubase")
287
+ out.notes.append("with read automation (R) on, the recorded curve controls the parameter")
288
+ return out
289
+
290
+ # ------------------------------------------------------------------ internals
291
+ def _resolve_points(
292
+ self, item: JsonObject, parameter: str, control: JsonObject, points: list[AutomationPoint]
293
+ ) -> list[ResolvedPoint]:
294
+ """Convert ``plain`` points with Cubase; keep normalized ones as given."""
295
+ da: tuple[JsonObject, int] | None = None
296
+ resolved = []
297
+ for point in points:
298
+ if point.plain is None:
299
+ assert point.value is not None
300
+ resolved.append(ResolvedPoint(position=point.position, value=point.value, plain=None))
301
+ continue
302
+ da = da or self._da_parameter(item, parameter, control)
303
+ resolved.append(ResolvedPoint(position=point.position, value=self._to_value(da, point.plain),
304
+ plain=point.plain)) # fmt: skip
305
+ return resolved
306
+
307
+ @staticmethod
308
+ def _validate_points(points: list[ResolvedPoint], meter: Meter) -> list[tuple[float, float]]:
309
+ curve: list[tuple[float, float]] = []
310
+ for point in points:
311
+ position = bars_tuple(point.position)
312
+ if position is None:
313
+ raise PreconditionFailedError(f"bad position {point.position!r}: use '9. 1. 1. 0'", code="BAD_TIME")
314
+ beats = to_beats(position, meter)
315
+ if curve and beats <= curve[-1][0]:
316
+ raise PreconditionFailedError("positions must be strictly increasing", code="BAD_TIME")
317
+ curve.append((beats, point.value))
318
+ if len(curve) == 1: # a single point is held for one beat
319
+ curve.append((curve[0][0] + 1, curve[0][1]))
320
+ return curve
321
+
322
+ def _play_from(self, start_beats: float) -> None:
323
+ self.call(
324
+ "transport.setTime",
325
+ {"which": "locator", "display": "primary", "time": format_bars(from_beats(start_beats, self._meter()))},
326
+ )
327
+ self.call("cmd.run", {"key": "transport.start"})
328
+
329
+ def _stop(self) -> None:
330
+ self.call("cmd.run", {"key": "transport.stop"})
331
+
332
+ @staticmethod
333
+ def _deadline(start_beats: float, end_beats: float) -> float:
334
+ """Upper bound for a pass: 2 s per beat (below 30 BPM) plus 10 s to start."""
335
+ return time.monotonic() + 10.0 + max(end_beats - start_beats, 1.0) * 2.0
336
+
337
+ def _positions(self, start: float, end: float) -> Iterator[float]:
338
+ """Yield cursor positions (beats) while the transport advances; stop if it stalls for STALL_SECONDS."""
339
+ deadline = self._deadline(start, end)
340
+ previous: float | None = None
341
+ last_progress = time.monotonic()
342
+ while time.monotonic() < deadline:
343
+ beat = self._cursor_beats()
344
+ now = time.monotonic()
345
+ if beat != previous:
346
+ last_progress = now
347
+ elif now - last_progress > STALL_SECONDS:
348
+ return # transport stopped (e.g. the user pressed Stop) or never started
349
+ previous = beat
350
+ yield beat
351
+
352
+ def _record(self, item: JsonObject, key: str, curve: list[tuple[float, float]], start: float) -> list[WrittenPoint]:
353
+ write_before = self._flag_state(item, TAG_WRITE)
354
+ read_before = self._flag_state(item, TAG_READ)
355
+ written: list[WrittenPoint] = []
356
+ touched = False
357
+ try:
358
+ self._flag(item, TAG_READ, False) # existing automation must not fight the new curve
359
+ self._flag(item, TAG_WRITE, True)
360
+ self._play_from(start)
361
+ for beat in self._positions(start, curve[-1][0]):
362
+ if beat < curve[0][0]:
363
+ self._sleep(0.02)
364
+ continue
365
+ value = interpolate(curve, beat)
366
+ result = self.call("auto.control", {"key": key, "value": value, **({} if touched else {"touch": True})})
367
+ touched = True
368
+ written.append(WrittenPoint(position=format_bars(from_beats(beat, self._meter())), value=value,
369
+ display=result.get("display"))) # fmt: skip
370
+ if beat >= curve[-1][0]:
371
+ break
372
+ finally:
373
+ if touched:
374
+ self.call("auto.control", {"key": key, "touch": False})
375
+ self._stop()
376
+ self._flag(item, TAG_WRITE, write_before)
377
+ self._flag(item, TAG_READ, read_before)
378
+ if not written:
379
+ raise TrackwrightError("playback did not reach the first point; nothing was recorded", code="NOT_RECORDED")
380
+ return written
381
+
382
+ def _verify(
383
+ self, item: JsonObject, key: str, curve: list[tuple[float, float]], start: float
384
+ ) -> tuple[bool, list[str]]:
385
+ read_before = self._flag_state(item, TAG_READ)
386
+ mismatches: list[str] = []
387
+ checked = 0
388
+ try:
389
+ self._flag(item, TAG_READ, True)
390
+ self._play_from(start)
391
+ pending = list(curve)
392
+ for beat in self._positions(start, curve[-1][0]):
393
+ if not pending:
394
+ break
395
+ if beat + 1e-9 < pending[0][0]:
396
+ self._sleep(0.02)
397
+ continue
398
+ expected = interpolate(curve, beat)
399
+ value = self._auto_controls()[key].get("value")
400
+ checked += 1
401
+ if not isinstance(value, int | float) or abs(value - expected) > VERIFY_TOLERANCE:
402
+ mismatches.append(
403
+ f"{format_bars(from_beats(beat, self._meter()))}: expected {expected:.3f}, read {value}"
404
+ )
405
+ while pending and pending[0][0] <= beat + 1e-9:
406
+ pending.pop(0)
407
+ finally:
408
+ self._stop()
409
+ self._flag(item, TAG_READ, read_before)
410
+ return checked > 0 and not mismatches, mismatches