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
trackwright/core/base.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Shared machinery of the Cubase service: session, references, track and target resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Concatenate, Protocol, TypeVar
|
|
13
|
+
|
|
14
|
+
from trackwright.core.models import ParameterInfo, TrackInfo, Value
|
|
15
|
+
from trackwright.core.policy import WritePolicy
|
|
16
|
+
from trackwright.core.refs import ObjectRef, TrackSelector
|
|
17
|
+
from trackwright.errors import (
|
|
18
|
+
HostError,
|
|
19
|
+
NotFoundError,
|
|
20
|
+
PreconditionFailedError,
|
|
21
|
+
StaleSessionError,
|
|
22
|
+
TrackwrightError,
|
|
23
|
+
)
|
|
24
|
+
from trackwright.exports import ExportedFile, ExportInspector
|
|
25
|
+
from trackwright.hostport import HostPort
|
|
26
|
+
from trackwright.ops import WRITE_OPS
|
|
27
|
+
|
|
28
|
+
JsonObject = dict[str, Any]
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ExportFinder(Protocol):
|
|
33
|
+
def find(self, folder: Path, since: float, suffixes: frozenset[str] = ...) -> list[ExportedFile]: ...
|
|
34
|
+
|
|
35
|
+
def duration(self, path: Path) -> float | None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AppLauncher(Protocol):
|
|
39
|
+
"""Opens Cubase (:class:`trackwright.launcher.CubaseLauncher`, or a fake in tests)."""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def app(self) -> str: ...
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def launch_timeout(self) -> float: ...
|
|
46
|
+
|
|
47
|
+
def is_running(self) -> bool: ...
|
|
48
|
+
|
|
49
|
+
def launch(self, project: Path | None = None) -> None: ...
|
|
50
|
+
|
|
51
|
+
def project_folders(self) -> list[str]: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
MIXER: JsonObject = {"scope": "mixer"}
|
|
55
|
+
CHANNEL_TAGS = (1024, 4000, 1025, 1027, 1028, 4101, 4102)
|
|
56
|
+
TAG_NAME, TAG_SELECTED, TAG_VOLUME, TAG_MUTE, TAG_SOLO, TAG_RECORD, TAG_INPUT, TAG_OUTPUT = (
|
|
57
|
+
1024,
|
|
58
|
+
4000,
|
|
59
|
+
1025,
|
|
60
|
+
1027,
|
|
61
|
+
1028,
|
|
62
|
+
4002,
|
|
63
|
+
4101,
|
|
64
|
+
4102,
|
|
65
|
+
)
|
|
66
|
+
_SLOT_SUFFIX = re.compile(r"-\d+$")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_INACTIVE = object()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def operation[S: "ServiceBase", **P, R](fn: Callable[Concatenate[S, P], R]) -> Callable[Concatenate[S, P], R]:
|
|
73
|
+
"""Mark a public tool method: the script session is pinned for its whole duration.
|
|
74
|
+
|
|
75
|
+
Object ids are only meaningful inside one script session. A tool gathers ids over several
|
|
76
|
+
calls; if Cubase reloads the script in between, the bridge renegotiates transparently and a
|
|
77
|
+
later write could carry an id that now names another object. Pinning makes the tool stop with
|
|
78
|
+
``STALE_SESSION`` before any further write.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def wrapper(self: S, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
82
|
+
with self.pinned_session():
|
|
83
|
+
return fn(self, *args, **kwargs)
|
|
84
|
+
|
|
85
|
+
functools.update_wrapper(wrapper, fn)
|
|
86
|
+
return wrapper
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ServiceBase:
|
|
90
|
+
"""Base of :class:`trackwright.core.service.CubaseService`; tool groups are mixins on top of it."""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
host: HostPort,
|
|
95
|
+
policy: WritePolicy,
|
|
96
|
+
*,
|
|
97
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
98
|
+
settle_timeout: float = 1.0,
|
|
99
|
+
launcher: AppLauncher | None = None,
|
|
100
|
+
exports: ExportFinder | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
self.host = host
|
|
103
|
+
self.launcher = launcher
|
|
104
|
+
self.exports: ExportFinder = exports if exports is not None else ExportInspector()
|
|
105
|
+
self.policy = policy
|
|
106
|
+
self._sleep = sleep
|
|
107
|
+
self._settle_timeout = settle_timeout
|
|
108
|
+
self._local = threading.local()
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------ host access
|
|
111
|
+
@contextmanager
|
|
112
|
+
def pinned_session(self) -> Iterator[None]:
|
|
113
|
+
"""Pin the session for the current thread (nested uses keep the outer pin)."""
|
|
114
|
+
if getattr(self._local, "session", _INACTIVE) is not _INACTIVE:
|
|
115
|
+
yield
|
|
116
|
+
return
|
|
117
|
+
self._local.session = None
|
|
118
|
+
try:
|
|
119
|
+
yield
|
|
120
|
+
finally:
|
|
121
|
+
self._local.session = _INACTIVE
|
|
122
|
+
|
|
123
|
+
def call(self, op: str, params: JsonObject | None = None) -> JsonObject:
|
|
124
|
+
pinned = getattr(self._local, "session", _INACTIVE)
|
|
125
|
+
if op in WRITE_OPS and pinned not in (_INACTIVE, None) and self.host.session_id != pinned:
|
|
126
|
+
raise self._session_changed(op)
|
|
127
|
+
result = self.host.call(op, params)
|
|
128
|
+
if pinned is not _INACTIVE:
|
|
129
|
+
current = self.host.session_id
|
|
130
|
+
if pinned is None:
|
|
131
|
+
self._local.session = current
|
|
132
|
+
elif current != pinned:
|
|
133
|
+
raise self._session_changed(op)
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _session_changed(op: str) -> StaleSessionError:
|
|
138
|
+
return StaleSessionError(
|
|
139
|
+
"Cubase reloaded the script during this operation; it was stopped before any further write. "
|
|
140
|
+
"Run the tool again.",
|
|
141
|
+
details={"op": op},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def session(self) -> str:
|
|
145
|
+
if self.host.session_id is None:
|
|
146
|
+
self.call("hello")
|
|
147
|
+
session = self.host.session_id
|
|
148
|
+
if session is None:
|
|
149
|
+
raise TrackwrightError("the Cubase script did not report a session", code="NO_SESSION")
|
|
150
|
+
return session
|
|
151
|
+
|
|
152
|
+
def wait_until(self, read: Callable[[], T], done: Callable[[T], bool]) -> T:
|
|
153
|
+
"""Re-read until ``done`` or the settle timeout (values that arrive through callbacks lag a little)."""
|
|
154
|
+
deadline = time.monotonic() + self._settle_timeout
|
|
155
|
+
value = read()
|
|
156
|
+
while not done(value) and time.monotonic() < deadline:
|
|
157
|
+
self._sleep(0.1)
|
|
158
|
+
value = read()
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------ references
|
|
162
|
+
def ref_for(self, described: JsonObject) -> str:
|
|
163
|
+
return ObjectRef(
|
|
164
|
+
self.session(), int(described["id"]), str(described["type"]), str(described["uniqueName"])
|
|
165
|
+
).encode()
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------ tracks
|
|
168
|
+
def channels(self) -> list[JsonObject]:
|
|
169
|
+
"""Every mixer channel, deduplicated by id (the mixer's last child is a duplicate)."""
|
|
170
|
+
items: list[JsonObject] = []
|
|
171
|
+
offset = 0
|
|
172
|
+
while True:
|
|
173
|
+
page = self.call("da.channels", {"offset": offset, "limit": 32, "tags": list(CHANNEL_TAGS), **MIXER})
|
|
174
|
+
items.extend(page.get("items", []))
|
|
175
|
+
indexes = [i["index"] for i in page.get("items", [])] + [d["index"] for d in page.get("duplicates", [])]
|
|
176
|
+
if not indexes:
|
|
177
|
+
break
|
|
178
|
+
offset = max(indexes) + 1
|
|
179
|
+
if offset >= int(page.get("total", 0)):
|
|
180
|
+
break
|
|
181
|
+
unique: dict[int, JsonObject] = {}
|
|
182
|
+
for item in items:
|
|
183
|
+
unique.setdefault(int(item["id"]), item)
|
|
184
|
+
return list(unique.values())
|
|
185
|
+
|
|
186
|
+
def track_info(self, item: JsonObject) -> TrackInfo:
|
|
187
|
+
values: JsonObject = item.get("values") or {}
|
|
188
|
+
|
|
189
|
+
def value(tag: int) -> JsonObject | None:
|
|
190
|
+
v = values.get(str(tag))
|
|
191
|
+
return v if isinstance(v, dict) else None
|
|
192
|
+
|
|
193
|
+
def flag(tag: int) -> bool | None:
|
|
194
|
+
v = value(tag)
|
|
195
|
+
return None if v is None else float(v.get("process") or 0) >= 0.5
|
|
196
|
+
|
|
197
|
+
def text(tag: int) -> str | None:
|
|
198
|
+
v = value(tag)
|
|
199
|
+
return None if v is None else str(v.get("display"))
|
|
200
|
+
|
|
201
|
+
volume = value(TAG_VOLUME)
|
|
202
|
+
return TrackInfo(
|
|
203
|
+
track=f"uid:{item['uid']}",
|
|
204
|
+
uid=str(item["uid"]),
|
|
205
|
+
name=text(TAG_NAME) or str(item.get("title", "")),
|
|
206
|
+
type=str(item.get("type", "")),
|
|
207
|
+
ref=self.ref_for(item),
|
|
208
|
+
mixer_index=item.get("mixerIndex"),
|
|
209
|
+
selected=bool(flag(TAG_SELECTED)),
|
|
210
|
+
volume=Value(process=volume.get("process"), display=volume.get("display")) if volume else None,
|
|
211
|
+
mute=flag(TAG_MUTE),
|
|
212
|
+
solo=flag(TAG_SOLO),
|
|
213
|
+
input=text(TAG_INPUT),
|
|
214
|
+
output=text(TAG_OUTPUT),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def resolve_track(self, selector: TrackSelector) -> JsonObject:
|
|
218
|
+
"""Return the mixer item of the designated track (with ``values``)."""
|
|
219
|
+
channels = self.channels()
|
|
220
|
+
if selector.kind == "selected":
|
|
221
|
+
base = int(self.call("da.base").get("base", -1))
|
|
222
|
+
if base < 0:
|
|
223
|
+
raise NotFoundError(
|
|
224
|
+
"no track with a mixer channel is selected (marker and folder tracks have none)",
|
|
225
|
+
code="NO_SELECTION",
|
|
226
|
+
)
|
|
227
|
+
matches = [c for c in channels if c["id"] == base]
|
|
228
|
+
elif selector.kind == "uid":
|
|
229
|
+
matches = [c for c in channels if c.get("uid") == selector.value]
|
|
230
|
+
else:
|
|
231
|
+
matches = [c for c in channels if self.track_info(c).name == selector.value]
|
|
232
|
+
if not matches:
|
|
233
|
+
raise NotFoundError(
|
|
234
|
+
f"track {selector} not found",
|
|
235
|
+
code="TRACK_NOT_FOUND",
|
|
236
|
+
details={"available": [self.track_info(c).name for c in channels]},
|
|
237
|
+
)
|
|
238
|
+
if len(matches) > 1:
|
|
239
|
+
raise PreconditionFailedError(
|
|
240
|
+
f"{len(matches)} tracks are called {selector.value!r}; use the uid selector",
|
|
241
|
+
code="TRACK_AMBIGUOUS",
|
|
242
|
+
details={"candidates": [f"uid:{m['uid']}" for m in matches]},
|
|
243
|
+
)
|
|
244
|
+
return matches[0]
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------ targets inside a track
|
|
247
|
+
def children(self, object_id: int) -> list[JsonObject]:
|
|
248
|
+
items: list[JsonObject] = []
|
|
249
|
+
while True:
|
|
250
|
+
page = self.call("da.children", {"id": object_id, "offset": len(items), "limit": 50, **MIXER})
|
|
251
|
+
items.extend(page.get("items", []))
|
|
252
|
+
if not page.get("items") or len(items) >= int(page.get("total", 0)):
|
|
253
|
+
return items
|
|
254
|
+
|
|
255
|
+
def section(self, track: JsonObject, kind: str) -> JsonObject:
|
|
256
|
+
for child in self.children(int(track["id"])):
|
|
257
|
+
if child.get("type") == kind:
|
|
258
|
+
return child
|
|
259
|
+
raise NotFoundError(f"the track has no {kind} section", details={"track": track.get("title")})
|
|
260
|
+
|
|
261
|
+
def insert_slot(self, track: JsonObject, slot: int) -> JsonObject:
|
|
262
|
+
slots = self.children(int(self.section(track, "Inserts")["id"]))
|
|
263
|
+
if not 1 <= slot <= len(slots):
|
|
264
|
+
raise NotFoundError(f"insert slot {slot} does not exist (1..{len(slots)})", code="SLOT_NOT_FOUND")
|
|
265
|
+
return slots[slot - 1]
|
|
266
|
+
|
|
267
|
+
def resolve_target(self, track: JsonObject, target: str) -> JsonObject:
|
|
268
|
+
"""``channel`` | ``insert:N`` (the plugin in slot N) | ``strip:eq``."""
|
|
269
|
+
spec = target.strip().lower()
|
|
270
|
+
if spec == "channel":
|
|
271
|
+
return track
|
|
272
|
+
if spec.startswith("insert:") and spec[7:].isdigit():
|
|
273
|
+
slot = self.insert_slot(track, int(spec[7:]))
|
|
274
|
+
if not slot.get("children"):
|
|
275
|
+
raise PreconditionFailedError(f"insert slot {spec[7:]} is empty", code="EMPTY_SLOT")
|
|
276
|
+
return self.children(int(slot["id"]))[0]
|
|
277
|
+
if spec == "strip:eq":
|
|
278
|
+
for strip_slot in self.children(int(self.section(track, "Strips")["id"])):
|
|
279
|
+
for child in self.children(int(strip_slot["id"])) if strip_slot.get("children") else []:
|
|
280
|
+
if child.get("type") == "EQ":
|
|
281
|
+
return child
|
|
282
|
+
raise NotFoundError("the channel strip has no EQ module", code="EQ_NOT_FOUND")
|
|
283
|
+
raise NotFoundError(f"unknown target {target!r}: use 'channel', 'insert:N' or 'strip:eq'", code="BAD_TARGET")
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------ parameters
|
|
286
|
+
@staticmethod
|
|
287
|
+
def parameter_info(raw: JsonObject) -> ParameterInfo:
|
|
288
|
+
return ParameterInfo(
|
|
289
|
+
tag=int(raw["tag"]),
|
|
290
|
+
title=str(raw.get("title", "")),
|
|
291
|
+
process=raw.get("process"),
|
|
292
|
+
display=raw.get("display"),
|
|
293
|
+
units=raw.get("units"),
|
|
294
|
+
plain=raw.get("plain"),
|
|
295
|
+
value_type=raw.get("valueType"),
|
|
296
|
+
automatable=raw.get("automatable"),
|
|
297
|
+
edit_locked=raw.get("editLock"),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def read_parameter(self, object_id: int, tag: int) -> JsonObject:
|
|
301
|
+
raw = self.call("da.param.get", {"id": object_id, "tag": tag, **MIXER})
|
|
302
|
+
if not raw.get("title") and raw.get("process") == -1:
|
|
303
|
+
raise NotFoundError(f"parameter {tag} does not exist on this object", code="PARAM_NOT_FOUND")
|
|
304
|
+
return raw
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def plugin_uid(unique_name: str) -> str:
|
|
308
|
+
"""The plugin's type UID: its unique name without the ``-N`` instance suffix."""
|
|
309
|
+
return _SLOT_SUFFIX.sub("", unique_name)
|
|
310
|
+
|
|
311
|
+
@staticmethod
|
|
312
|
+
def is_host_code(exc: TrackwrightError, code: str) -> bool:
|
|
313
|
+
return isinstance(exc, HostError) and exc.code == code
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Tools built on Cubase commands (epic E07).
|
|
2
|
+
|
|
3
|
+
Commands give no result back. The tools therefore:
|
|
4
|
+
- check ``canPerform`` first (it depends on context);
|
|
5
|
+
- report the observed class of the command (``direct``, ``dialog``, ``confirm``, ``ui_selection``,
|
|
6
|
+
``unknown``) and never report a ``dialog`` command as done;
|
|
7
|
+
- verify the effect by reading state when it is observable, or say ``not_observable``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Literal
|
|
13
|
+
|
|
14
|
+
from trackwright.commands import COMMANDS, CommandSpec
|
|
15
|
+
from trackwright.core.base import JsonObject, ServiceBase, operation
|
|
16
|
+
from trackwright.core.models import CommandOut, CommandStatus
|
|
17
|
+
from trackwright.core.refs import TrackSelector
|
|
18
|
+
from trackwright.core.writing import bars_tuple, format_bars
|
|
19
|
+
from trackwright.errors import NotFoundError, PreconditionFailedError
|
|
20
|
+
|
|
21
|
+
TRACK_KINDS = {"audio": "addtrack.audio", "group": "addtrack.group", "folder": "addtrack.folder"}
|
|
22
|
+
MARKER_ACTIONS = {
|
|
23
|
+
"add_marker_track": "addtrack.marker",
|
|
24
|
+
"insert_at_cursor": "marker.insert",
|
|
25
|
+
"locate_prev": "transport.locate_prev_marker",
|
|
26
|
+
"locate_next": "transport.locate_next_marker",
|
|
27
|
+
}
|
|
28
|
+
NAVIGATION_COMMANDS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"transport.locate_prev_marker",
|
|
31
|
+
"transport.locate_next_marker",
|
|
32
|
+
"project.select_track_next",
|
|
33
|
+
"project.select_track_prev",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CommandingMixin(ServiceBase):
|
|
39
|
+
# ------------------------------------------------------------------ core
|
|
40
|
+
def _spec(self, key: str) -> CommandSpec:
|
|
41
|
+
spec = COMMANDS.get(key)
|
|
42
|
+
if spec is None:
|
|
43
|
+
raise NotFoundError(
|
|
44
|
+
f"unknown command {key!r}", code="COMMAND_NOT_FOUND", details={"known": sorted(COMMANDS)}
|
|
45
|
+
)
|
|
46
|
+
return spec
|
|
47
|
+
|
|
48
|
+
def _can_perform(self, key: str) -> bool | None:
|
|
49
|
+
value = self.call("cmd.canPerform", {"key": key}).get("canPerform", {}).get(key)
|
|
50
|
+
return value if isinstance(value, bool) else None
|
|
51
|
+
|
|
52
|
+
def _out(
|
|
53
|
+
self,
|
|
54
|
+
spec: CommandSpec,
|
|
55
|
+
*,
|
|
56
|
+
dry_run: bool,
|
|
57
|
+
can: bool | None,
|
|
58
|
+
status: CommandStatus,
|
|
59
|
+
verified: bool | Literal["not_observable"] | None,
|
|
60
|
+
instructions: str | None = None,
|
|
61
|
+
details: dict[str, str | int | float | bool | None] | None = None,
|
|
62
|
+
) -> CommandOut:
|
|
63
|
+
return CommandOut(
|
|
64
|
+
dry_run=dry_run,
|
|
65
|
+
key=spec.key,
|
|
66
|
+
category=spec.category,
|
|
67
|
+
name=spec.name,
|
|
68
|
+
command_class=spec.command_class,
|
|
69
|
+
can_perform=can,
|
|
70
|
+
status=status,
|
|
71
|
+
verified=verified,
|
|
72
|
+
instructions=instructions,
|
|
73
|
+
details=details or {},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _fire(self, spec: CommandSpec, *, force: bool = False) -> None:
|
|
77
|
+
params: JsonObject = {"key": spec.key}
|
|
78
|
+
if force:
|
|
79
|
+
params["force"] = True
|
|
80
|
+
self.call("cmd.run", params)
|
|
81
|
+
|
|
82
|
+
def _status_for(self, spec: CommandSpec) -> tuple[CommandStatus, str | None]:
|
|
83
|
+
if spec.command_class == "dialog":
|
|
84
|
+
return "awaiting_user", f"Cubase opened a dialog for '{spec.name}': ask the user to complete or cancel it"
|
|
85
|
+
if spec.command_class == "confirm":
|
|
86
|
+
return "fired", "Cubase may ask the user to confirm"
|
|
87
|
+
if spec.command_class == "ui_selection":
|
|
88
|
+
return "fired", "acts on the selection in the Cubase window, which Trackwright cannot see: ask the user"
|
|
89
|
+
return "fired", None
|
|
90
|
+
|
|
91
|
+
@operation
|
|
92
|
+
def run_command(self, key: str, *, dry_run: bool = True, force: bool = False) -> CommandOut:
|
|
93
|
+
spec = self._spec(key)
|
|
94
|
+
can = self._can_perform(key)
|
|
95
|
+
if can is False and not force:
|
|
96
|
+
raise PreconditionFailedError(
|
|
97
|
+
f"Cubase reports that '{spec.name}' cannot be performed now", code="CANNOT_PERFORM"
|
|
98
|
+
)
|
|
99
|
+
if dry_run:
|
|
100
|
+
return self._out(spec, dry_run=True, can=can, status="planned", verified=None)
|
|
101
|
+
if key not in NAVIGATION_COMMANDS:
|
|
102
|
+
self.policy.check_project_write()
|
|
103
|
+
self._fire(spec, force=force)
|
|
104
|
+
status, instructions = self._status_for(spec)
|
|
105
|
+
return self._out(
|
|
106
|
+
spec, dry_run=False, can=can, status=status, verified="not_observable", instructions=instructions
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------ markers
|
|
110
|
+
@operation
|
|
111
|
+
def markers(self, action: str, *, dry_run: bool = True) -> CommandOut:
|
|
112
|
+
key = MARKER_ACTIONS.get(action)
|
|
113
|
+
if key is None:
|
|
114
|
+
raise PreconditionFailedError(f"action must be one of {sorted(MARKER_ACTIONS)}", code="BAD_REQUEST")
|
|
115
|
+
spec = self._spec(key)
|
|
116
|
+
can = self._can_perform(key)
|
|
117
|
+
if can is False:
|
|
118
|
+
raise PreconditionFailedError(
|
|
119
|
+
f"Cubase reports that '{spec.name}' cannot be performed now", code="CANNOT_PERFORM"
|
|
120
|
+
)
|
|
121
|
+
if dry_run:
|
|
122
|
+
return self._out(spec, dry_run=True, can=can, status="planned", verified=None)
|
|
123
|
+
if key not in NAVIGATION_COMMANDS:
|
|
124
|
+
self.policy.check_project_write()
|
|
125
|
+
before = self._cursor()
|
|
126
|
+
self._fire(spec)
|
|
127
|
+
if key in NAVIGATION_COMMANDS:
|
|
128
|
+
after = self._cursor()
|
|
129
|
+
return self._out(
|
|
130
|
+
spec,
|
|
131
|
+
dry_run=False,
|
|
132
|
+
can=can,
|
|
133
|
+
status="done",
|
|
134
|
+
verified=after is not None,
|
|
135
|
+
details={"cursor_before": before, "cursor_after": after},
|
|
136
|
+
)
|
|
137
|
+
return self._out(
|
|
138
|
+
spec,
|
|
139
|
+
dry_run=False,
|
|
140
|
+
can=can,
|
|
141
|
+
status="done",
|
|
142
|
+
verified="not_observable",
|
|
143
|
+
instructions="marker tracks are not visible through the API: check in Cubase",
|
|
144
|
+
details={"cursor": before},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _cursor(self) -> str | None:
|
|
148
|
+
raw = (self.call("transport.get").get("primary") or {}).get("locator")
|
|
149
|
+
return str(raw["time"]) if isinstance(raw, dict) else None
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------ removing tracks
|
|
152
|
+
@operation
|
|
153
|
+
def remove_selected_tracks(self, expect_selected: str, *, dry_run: bool = True) -> CommandOut:
|
|
154
|
+
spec = self._spec("project.remove_selected_tracks")
|
|
155
|
+
expected = self.resolve_track(TrackSelector.parse(expect_selected))
|
|
156
|
+
base = int(self.call("da.base").get("base", -1))
|
|
157
|
+
# "Remove Selected Tracks" removes *every* selected track: require exactly one, the expected one.
|
|
158
|
+
selected = [self.track_info(c).name for c in self.channels() if self.track_info(c).selected]
|
|
159
|
+
if base != expected["id"] or selected != [self.track_info(expected).name]:
|
|
160
|
+
raise PreconditionFailedError(
|
|
161
|
+
"the selection must be exactly the expected track; select it alone first (cubase_select_track)",
|
|
162
|
+
code="SELECTION_MISMATCH",
|
|
163
|
+
details={"expected": self.track_info(expected).name, "selected": selected},
|
|
164
|
+
)
|
|
165
|
+
name = self.track_info(expected).name
|
|
166
|
+
can = self._can_perform(spec.key)
|
|
167
|
+
if dry_run:
|
|
168
|
+
return self._out(spec, dry_run=True, can=can, status="planned", verified=None, details={"track": name})
|
|
169
|
+
self.policy.check_track(name)
|
|
170
|
+
self._fire(spec)
|
|
171
|
+
listing = self.wait_until(
|
|
172
|
+
lambda: {"ids": [c["id"] for c in self.channels()]}, lambda d: expected["id"] not in d["ids"]
|
|
173
|
+
)
|
|
174
|
+
if expected["id"] in listing["ids"]:
|
|
175
|
+
return self._out(
|
|
176
|
+
spec,
|
|
177
|
+
dry_run=False,
|
|
178
|
+
can=can,
|
|
179
|
+
status="pending_confirmation",
|
|
180
|
+
verified=False,
|
|
181
|
+
instructions="Cubase is asking for confirmation (the track has events): ask the user",
|
|
182
|
+
details={"track": name},
|
|
183
|
+
)
|
|
184
|
+
return self._out(spec, dry_run=False, can=can, status="done", verified=True, details={"track": name})
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------ split at locators
|
|
187
|
+
@operation
|
|
188
|
+
def split_at_locators(self, left: str, right: str, *, dry_run: bool = True) -> CommandOut:
|
|
189
|
+
spec = self._spec("edit.split_loop")
|
|
190
|
+
left_pos, right_pos = bars_tuple(left), bars_tuple(right)
|
|
191
|
+
if left_pos is None or right_pos is None:
|
|
192
|
+
raise PreconditionFailedError("locators look like '2. 1. 1. 0'", code="BAD_TIME")
|
|
193
|
+
if left_pos >= right_pos:
|
|
194
|
+
raise PreconditionFailedError("the left locator must be before the right one", code="BAD_TIME")
|
|
195
|
+
previous = dict(self.call("transport.get").get("primary") or {})
|
|
196
|
+
details: dict[str, str | int | float | bool | None] = {
|
|
197
|
+
"left": format_bars(left_pos),
|
|
198
|
+
"right": format_bars(right_pos),
|
|
199
|
+
# The locators are left at the split points: restoring them right away could race with the
|
|
200
|
+
# command, which Cubase runs in its own idle tick. Report them so they can be restored.
|
|
201
|
+
"previous_left": (previous.get("cycleLeft") or {}).get("time"),
|
|
202
|
+
"previous_right": (previous.get("cycleRight") or {}).get("time"),
|
|
203
|
+
"warning": "splits every track with events crossing the locators; the locators stay at the split points",
|
|
204
|
+
}
|
|
205
|
+
if dry_run:
|
|
206
|
+
return self._out(
|
|
207
|
+
spec, dry_run=True, can=self._can_perform(spec.key), status="planned", verified=None, details=details
|
|
208
|
+
)
|
|
209
|
+
self.policy.check_project_write()
|
|
210
|
+
for which, text in (("cycleLeft", format_bars(left_pos)), ("cycleRight", format_bars(right_pos))):
|
|
211
|
+
self.call("transport.setTime", {"which": which, "display": "primary", "time": text})
|
|
212
|
+
locators = self.wait_until(
|
|
213
|
+
lambda: dict(self.call("transport.get").get("primary") or {}),
|
|
214
|
+
lambda p: (
|
|
215
|
+
bars_tuple(str((p.get("cycleLeft") or {}).get("time", ""))) == left_pos
|
|
216
|
+
and bars_tuple(str((p.get("cycleRight") or {}).get("time", ""))) == right_pos
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
if (
|
|
220
|
+
bars_tuple(str((locators.get("cycleLeft") or {}).get("time", ""))) != left_pos
|
|
221
|
+
or bars_tuple(str((locators.get("cycleRight") or {}).get("time", ""))) != right_pos
|
|
222
|
+
):
|
|
223
|
+
raise PreconditionFailedError("the locators could not be set; nothing was split", code="VERIFY_FAILED")
|
|
224
|
+
can = self._can_perform(spec.key)
|
|
225
|
+
if can is False:
|
|
226
|
+
raise PreconditionFailedError(
|
|
227
|
+
"Cubase reports that 'Split Loop' cannot be performed now", code="CANNOT_PERFORM"
|
|
228
|
+
)
|
|
229
|
+
self._fire(spec)
|
|
230
|
+
return self._out(
|
|
231
|
+
spec,
|
|
232
|
+
dry_run=False,
|
|
233
|
+
can=can,
|
|
234
|
+
status="done",
|
|
235
|
+
verified="not_observable",
|
|
236
|
+
instructions="events are not visible through the API: check the result in Cubase",
|
|
237
|
+
details=details,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# ------------------------------------------------------------------ add a track with the user's help
|
|
241
|
+
@operation
|
|
242
|
+
def add_track_interactive(self, phase: Literal["start", "confirm"], kind: str, suggested_name: str) -> CommandOut:
|
|
243
|
+
key = TRACK_KINDS.get(kind)
|
|
244
|
+
if key is None:
|
|
245
|
+
raise PreconditionFailedError(f"kind must be one of {sorted(TRACK_KINDS)}", code="BAD_REQUEST")
|
|
246
|
+
spec = self._spec(key)
|
|
247
|
+
if phase == "start":
|
|
248
|
+
self.policy.check_project_write()
|
|
249
|
+
if any(self.track_info(c).name == suggested_name for c in self.channels()):
|
|
250
|
+
# Otherwise 'confirm' could report an old track as the new one.
|
|
251
|
+
raise PreconditionFailedError(
|
|
252
|
+
f"a track called {suggested_name!r} already exists; suggest another name", code="NAME_CONFLICT"
|
|
253
|
+
)
|
|
254
|
+
can = self._can_perform(key)
|
|
255
|
+
if can is False:
|
|
256
|
+
raise PreconditionFailedError(
|
|
257
|
+
f"Cubase reports that '{spec.name}' cannot be performed now", code="CANNOT_PERFORM"
|
|
258
|
+
)
|
|
259
|
+
self._fire(spec)
|
|
260
|
+
return self._out(
|
|
261
|
+
spec,
|
|
262
|
+
dry_run=False,
|
|
263
|
+
can=can,
|
|
264
|
+
status="awaiting_user",
|
|
265
|
+
verified=None,
|
|
266
|
+
instructions=f"In Cubase's Add Track dialog, name the track '{suggested_name}' and accept it "
|
|
267
|
+
f"(or cancel). Then call this tool again with phase='confirm'.",
|
|
268
|
+
details={"suggested_name": suggested_name},
|
|
269
|
+
)
|
|
270
|
+
if phase != "confirm":
|
|
271
|
+
raise PreconditionFailedError("phase must be 'start' or 'confirm'", code="BAD_REQUEST")
|
|
272
|
+
if kind == "folder":
|
|
273
|
+
return self._out(
|
|
274
|
+
spec,
|
|
275
|
+
dry_run=False,
|
|
276
|
+
can=None,
|
|
277
|
+
status="done",
|
|
278
|
+
verified="not_observable",
|
|
279
|
+
instructions="folder tracks have no mixer channel: confirm visually in Cubase",
|
|
280
|
+
details={"suggested_name": suggested_name},
|
|
281
|
+
)
|
|
282
|
+
found = [c for c in self.channels() if self.track_info(c).name == suggested_name]
|
|
283
|
+
if not found:
|
|
284
|
+
return self._out(
|
|
285
|
+
spec,
|
|
286
|
+
dry_run=False,
|
|
287
|
+
can=None,
|
|
288
|
+
status="not_found",
|
|
289
|
+
verified=False,
|
|
290
|
+
instructions="no new track with that name: the dialog may have been cancelled or the name changed",
|
|
291
|
+
details={"suggested_name": suggested_name},
|
|
292
|
+
)
|
|
293
|
+
return self._out(
|
|
294
|
+
spec,
|
|
295
|
+
dry_run=False,
|
|
296
|
+
can=None,
|
|
297
|
+
status="done",
|
|
298
|
+
verified=True,
|
|
299
|
+
details={"suggested_name": suggested_name, "track": f"uid:{found[0]['uid']}"},
|
|
300
|
+
)
|