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.
- trackwright/__init__.py +13 -0
- trackwright/__main__.py +7 -0
- trackwright/bridge.py +323 -0
- trackwright/cli.py +203 -0
- trackwright/commands.py +397 -0
- trackwright/config.py +149 -0
- trackwright/core/__init__.py +1 -0
- trackwright/core/automation.py +410 -0
- trackwright/core/base.py +313 -0
- trackwright/core/commanding.py +300 -0
- trackwright/core/exporting.py +143 -0
- trackwright/core/importing.py +127 -0
- trackwright/core/lifecycle.py +74 -0
- trackwright/core/models.py +467 -0
- trackwright/core/policy.py +58 -0
- trackwright/core/presets.py +88 -0
- trackwright/core/project.py +221 -0
- trackwright/core/ranges.py +355 -0
- trackwright/core/reading.py +229 -0
- trackwright/core/refs.py +88 -0
- trackwright/core/saving.py +72 -0
- trackwright/core/service.py +27 -0
- trackwright/core/settings.py +80 -0
- trackwright/core/writing.py +390 -0
- trackwright/coremidi.py +307 -0
- trackwright/cubase/Trackwright_Bridge.js +1130 -0
- trackwright/cubase/__init__.py +83 -0
- trackwright/doctor.py +69 -0
- trackwright/errors.py +120 -0
- trackwright/exports.py +54 -0
- trackwright/fake.py +1112 -0
- trackwright/host.py +238 -0
- trackwright/hostport.py +74 -0
- trackwright/installer.py +305 -0
- trackwright/launcher.py +150 -0
- trackwright/logs.py +50 -0
- trackwright/ops.py +46 -0
- trackwright/protocol.py +182 -0
- trackwright/py.typed +0 -0
- trackwright/server.py +472 -0
- trackwright/transport.py +130 -0
- trackwright-0.3.0.dist-info/METADATA +190 -0
- trackwright-0.3.0.dist-info/RECORD +46 -0
- trackwright-0.3.0.dist-info/WHEEL +4 -0
- trackwright-0.3.0.dist-info/entry_points.txt +3 -0
- 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
|