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,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
+ )