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,229 @@
|
|
|
1
|
+
"""Read-only tools (epic E05). None of them modifies the project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from trackwright.core.base import MIXER, JsonObject, ServiceBase, operation
|
|
6
|
+
from trackwright.core.models import (
|
|
7
|
+
ChangeEvent,
|
|
8
|
+
ChangesOut,
|
|
9
|
+
InsertsOut,
|
|
10
|
+
ParameterInfo,
|
|
11
|
+
ParameterOut,
|
|
12
|
+
ParametersOut,
|
|
13
|
+
PluginCatalogOut,
|
|
14
|
+
PluginEntry,
|
|
15
|
+
PluginInSlot,
|
|
16
|
+
Section,
|
|
17
|
+
SelectedChannelOut,
|
|
18
|
+
SlotInfo,
|
|
19
|
+
StatusOut,
|
|
20
|
+
TimePosition,
|
|
21
|
+
TrackListOut,
|
|
22
|
+
TransportOut,
|
|
23
|
+
)
|
|
24
|
+
from trackwright.core.refs import TrackSelector
|
|
25
|
+
from trackwright.errors import NotFoundError, TrackwrightError
|
|
26
|
+
|
|
27
|
+
MAX_FILTER_SCAN = 500
|
|
28
|
+
MAX_EVENT_LOOKUPS = 20
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ReadingMixin(ServiceBase):
|
|
32
|
+
@operation
|
|
33
|
+
def status(self) -> StatusOut:
|
|
34
|
+
hello = self.call("hello")
|
|
35
|
+
caps = self.call("capabilities")
|
|
36
|
+
capabilities: JsonObject = caps.get("capabilities") or {}
|
|
37
|
+
host: JsonObject = caps.get("host") or hello.get("host") or {}
|
|
38
|
+
return StatusOut(
|
|
39
|
+
connected=True,
|
|
40
|
+
simulated=bool(self.host.simulated or capabilities.get("simulated")),
|
|
41
|
+
session=self.host.session_id or hello.get("sessionId"),
|
|
42
|
+
script_version=hello.get("scriptVersion"),
|
|
43
|
+
host_app=host.get("appName"),
|
|
44
|
+
host_version=host.get("appVersion"),
|
|
45
|
+
insert_slots=host.get("insertSlots"),
|
|
46
|
+
mixer_access=bool(
|
|
47
|
+
capabilities.get("mixerDirectAccessActive", capabilities.get("mixerDirectAccessCreated"))
|
|
48
|
+
),
|
|
49
|
+
transport_access=bool(capabilities.get("transportTimeDisplay")),
|
|
50
|
+
commands_bound=capabilities.get("commandsBound"),
|
|
51
|
+
writes_enabled=self.policy.enabled,
|
|
52
|
+
allowed_tracks=list(self.policy.allowed_tracks),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@operation
|
|
56
|
+
def list_tracks(self, offset: int = 0, limit: int = 32) -> TrackListOut:
|
|
57
|
+
channels = self.channels()
|
|
58
|
+
page = channels[offset : offset + limit]
|
|
59
|
+
return TrackListOut(total=len(channels), tracks=[self.track_info(c) for c in page])
|
|
60
|
+
|
|
61
|
+
@operation
|
|
62
|
+
def get_selected_channel(self) -> SelectedChannelOut:
|
|
63
|
+
base = self.call("da.base")
|
|
64
|
+
if int(base.get("base", -1)) < 0:
|
|
65
|
+
return SelectedChannelOut(track=None, sections=[])
|
|
66
|
+
track = self.resolve_track(TrackSelector("selected"))
|
|
67
|
+
sections = [
|
|
68
|
+
Section(kind=str(c.get("type")), title=str(c.get("title")), ref=self.ref_for(c))
|
|
69
|
+
for c in self.children(int(track["id"]))
|
|
70
|
+
]
|
|
71
|
+
return SelectedChannelOut(track=self.track_info(track), sections=sections)
|
|
72
|
+
|
|
73
|
+
@operation
|
|
74
|
+
def list_inserts(self, track: str) -> InsertsOut:
|
|
75
|
+
item = self.resolve_track(TrackSelector.parse(track))
|
|
76
|
+
slots: list[SlotInfo] = []
|
|
77
|
+
for index, slot in enumerate(self.children(int(self.section(item, "Inserts")["id"])), start=1):
|
|
78
|
+
plugin = None
|
|
79
|
+
if slot.get("children"):
|
|
80
|
+
child = self.children(int(slot["id"]))[0]
|
|
81
|
+
plugin = PluginInSlot(
|
|
82
|
+
name=str(child.get("title")),
|
|
83
|
+
plugin_uid=self.plugin_uid(str(child.get("uniqueName"))),
|
|
84
|
+
ref=self.ref_for(child),
|
|
85
|
+
)
|
|
86
|
+
slots.append(SlotInfo(index=index, empty=plugin is None, ref=self.ref_for(slot), plugin=plugin))
|
|
87
|
+
return InsertsOut(track=self.track_info(item), slots=slots)
|
|
88
|
+
|
|
89
|
+
@operation
|
|
90
|
+
def list_available_plugins(
|
|
91
|
+
self, track: str, slot: int = 1, filter_text: str | None = None, offset: int = 0, limit: int = 20
|
|
92
|
+
) -> PluginCatalogOut:
|
|
93
|
+
slot_obj = self.insert_slot(self.resolve_track(TrackSelector.parse(track)), slot)
|
|
94
|
+
collections = self.call("pm.collections", {"slot": slot_obj["id"], **MIXER})
|
|
95
|
+
index = int(collections.get("activeIndex", -1))
|
|
96
|
+
if int(collections.get("total", 0)) == 0 or index < 0:
|
|
97
|
+
raise NotFoundError("this slot offers no plugin collection", code="NO_COLLECTION")
|
|
98
|
+
entries = self.call(
|
|
99
|
+
"pm.entries",
|
|
100
|
+
{
|
|
101
|
+
"slot": slot_obj["id"],
|
|
102
|
+
"collection": index,
|
|
103
|
+
"offset": offset,
|
|
104
|
+
"limit": min(limit, 40),
|
|
105
|
+
"filter": filter_text or "",
|
|
106
|
+
**MIXER,
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
items = [
|
|
110
|
+
PluginEntry(
|
|
111
|
+
name=str(e["name"]),
|
|
112
|
+
vendor=str(e["vendor"]),
|
|
113
|
+
version=str(e["version"]),
|
|
114
|
+
uid=str(e["uid"]),
|
|
115
|
+
sub_categories=str(e["subCategories"]),
|
|
116
|
+
)
|
|
117
|
+
for e in entries.get("items", [])
|
|
118
|
+
]
|
|
119
|
+
return PluginCatalogOut(
|
|
120
|
+
collection=entries.get("collectionName"),
|
|
121
|
+
total=int(entries.get("total", 0)),
|
|
122
|
+
matched=int(entries.get("matched", 0)),
|
|
123
|
+
offset=offset,
|
|
124
|
+
items=items,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
@operation
|
|
128
|
+
def list_parameters(
|
|
129
|
+
self, track: str, target: str, offset: int = 0, limit: int = 25, filter_text: str | None = None
|
|
130
|
+
) -> ParametersOut:
|
|
131
|
+
obj = self.resolve_target(self.resolve_track(TrackSelector.parse(track)), target)
|
|
132
|
+
needle = (filter_text or "").lower()
|
|
133
|
+
items: list[ParameterInfo] = []
|
|
134
|
+
next_offset, scanned, total = offset, 0, 0
|
|
135
|
+
while len(items) < limit and scanned < MAX_FILTER_SCAN:
|
|
136
|
+
page_size = 25 if needle else min(25, limit - len(items))
|
|
137
|
+
page = self.call("da.params", {"id": obj["id"], "offset": next_offset, "limit": page_size, **MIXER})
|
|
138
|
+
total = int(page.get("total", 0))
|
|
139
|
+
batch = page.get("items", [])
|
|
140
|
+
if not batch:
|
|
141
|
+
break
|
|
142
|
+
for raw in batch:
|
|
143
|
+
# Consume parameter by parameter so that no match is skipped when the limit is reached.
|
|
144
|
+
next_offset = int(raw.get("index", next_offset)) + 1
|
|
145
|
+
scanned += 1
|
|
146
|
+
if needle in str(raw.get("title", "")).lower():
|
|
147
|
+
items.append(self.parameter_info(raw))
|
|
148
|
+
if len(items) >= limit:
|
|
149
|
+
break
|
|
150
|
+
if next_offset >= total:
|
|
151
|
+
break
|
|
152
|
+
return ParametersOut(
|
|
153
|
+
target=target,
|
|
154
|
+
target_ref=self.ref_for(obj),
|
|
155
|
+
target_name=str(obj.get("title", "")),
|
|
156
|
+
total=total,
|
|
157
|
+
offset=offset,
|
|
158
|
+
scanned=scanned,
|
|
159
|
+
items=items,
|
|
160
|
+
next_offset=next_offset,
|
|
161
|
+
truncated=next_offset < total,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@operation
|
|
165
|
+
def get_parameter(self, track: str, target: str, tag: int) -> ParameterOut:
|
|
166
|
+
obj = self.resolve_target(self.resolve_track(TrackSelector.parse(track)), target)
|
|
167
|
+
raw = self.read_parameter(int(obj["id"]), tag)
|
|
168
|
+
return ParameterOut(
|
|
169
|
+
target=target,
|
|
170
|
+
target_ref=self.ref_for(obj),
|
|
171
|
+
target_name=str(obj.get("title", "")),
|
|
172
|
+
parameter=self.parameter_info(raw),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@operation
|
|
176
|
+
def get_transport(self) -> TransportOut:
|
|
177
|
+
data = self.call("transport.get")
|
|
178
|
+
primary: JsonObject = data.get("primary") or {}
|
|
179
|
+
if not data.get("available") or not primary.get("locator"):
|
|
180
|
+
raise TrackwrightError(
|
|
181
|
+
"transport values have not arrived yet; move the cursor once in Cubase and retry", code="UNAVAILABLE"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def position(key: str) -> TimePosition | None:
|
|
185
|
+
raw = primary.get(key)
|
|
186
|
+
return TimePosition(time=str(raw["time"]), format=raw.get("format")) if isinstance(raw, dict) else None
|
|
187
|
+
|
|
188
|
+
cursor = position("locator")
|
|
189
|
+
assert cursor is not None
|
|
190
|
+
return TransportOut(
|
|
191
|
+
cursor=cursor,
|
|
192
|
+
cycle_left=position("cycleLeft"),
|
|
193
|
+
cycle_right=position("cycleRight"),
|
|
194
|
+
tempo_bpm=data.get("tempoBPM"),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@operation
|
|
198
|
+
def poll_changes(self) -> ChangesOut:
|
|
199
|
+
data = self.call("events")
|
|
200
|
+
events: list[ChangeEvent] = []
|
|
201
|
+
described: dict[int, JsonObject | None] = {}
|
|
202
|
+
unchecked = 0
|
|
203
|
+
for raw in data.get("events", []):
|
|
204
|
+
oid = int(raw.get("id", -1))
|
|
205
|
+
if oid not in described and len(described) < MAX_EVENT_LOOKUPS:
|
|
206
|
+
try:
|
|
207
|
+
described[oid] = self.call("da.object", {"id": oid, **MIXER})
|
|
208
|
+
except TrackwrightError:
|
|
209
|
+
described[oid] = None
|
|
210
|
+
checked = oid in described
|
|
211
|
+
obj = described.get(oid)
|
|
212
|
+
unchecked += not checked
|
|
213
|
+
tag = int(raw.get("t", -1))
|
|
214
|
+
events.append(
|
|
215
|
+
ChangeEvent(
|
|
216
|
+
kind=str(raw.get("k")),
|
|
217
|
+
tag=tag if tag >= 0 else None,
|
|
218
|
+
ref=self.ref_for(obj) if obj else None,
|
|
219
|
+
title=obj.get("title") if obj else None,
|
|
220
|
+
still_valid=(obj is not None) if checked else None,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
dropped = int(data.get("dropped", 0))
|
|
224
|
+
advice = (
|
|
225
|
+
"events were dropped: re-list tracks and objects"
|
|
226
|
+
if dropped
|
|
227
|
+
else "a 'removed' event is a hint to re-validate, not proof of removal (it also fires on selection changes)"
|
|
228
|
+
)
|
|
229
|
+
return ChangesOut(events=events, dropped=dropped, unchecked=unchecked, advice=advice)
|
trackwright/core/refs.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Track selectors and opaque object references.
|
|
2
|
+
|
|
3
|
+
Runtime object ids are only valid inside one script session and are **reused** for different
|
|
4
|
+
objects after a reload (verified: id 550 went from Compressor to Input Filter). They are therefore
|
|
5
|
+
never exposed alone: tools return an opaque ``ref`` that embeds the session and a fingerprint of
|
|
6
|
+
the object, and validate both before every use.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from trackwright.errors import ProtocolError, StaleReferenceError
|
|
17
|
+
|
|
18
|
+
REF_PREFIX = "tw1."
|
|
19
|
+
SELECTED = "selected"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class TrackSelector:
|
|
24
|
+
"""How a tool input designates a track.
|
|
25
|
+
|
|
26
|
+
Accepted forms: ``"selected"``; ``"uid:<UID>"`` (stable across Cubase restarts); ``"name:<exact name>"``;
|
|
27
|
+
or any other text, taken as an exact track name.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
kind: Literal["selected", "uid", "name"]
|
|
31
|
+
value: str = ""
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def parse(cls, text: str | None) -> TrackSelector:
|
|
35
|
+
raw = (text or SELECTED).strip()
|
|
36
|
+
if not raw or raw == SELECTED:
|
|
37
|
+
return cls("selected")
|
|
38
|
+
if raw.startswith("uid:") and raw[4:].strip():
|
|
39
|
+
return cls("uid", raw[4:].strip())
|
|
40
|
+
if raw.startswith("name:") and raw[5:]:
|
|
41
|
+
return cls("name", raw[5:])
|
|
42
|
+
return cls("name", raw)
|
|
43
|
+
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
return SELECTED if self.kind == "selected" else f"{self.kind}:{self.value}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class ObjectRef:
|
|
50
|
+
"""Opaque handle on a Cubase object: session + runtime id + fingerprint."""
|
|
51
|
+
|
|
52
|
+
session: str
|
|
53
|
+
object_id: int
|
|
54
|
+
type_name: str
|
|
55
|
+
unique_name: str
|
|
56
|
+
|
|
57
|
+
def encode(self) -> str:
|
|
58
|
+
payload = {"s": self.session, "i": self.object_id, "t": self.type_name, "u": self.unique_name}
|
|
59
|
+
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode()
|
|
60
|
+
return REF_PREFIX + base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def decode(cls, text: str) -> ObjectRef:
|
|
64
|
+
if not text.startswith(REF_PREFIX):
|
|
65
|
+
raise ProtocolError("not a Trackwright object reference", details={"ref": text[:40]})
|
|
66
|
+
body = text[len(REF_PREFIX) :]
|
|
67
|
+
try:
|
|
68
|
+
data: Any = json.loads(base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)))
|
|
69
|
+
return cls(str(data["s"]), int(data["i"]), str(data["t"]), str(data["u"]))
|
|
70
|
+
except (ValueError, KeyError, TypeError) as exc:
|
|
71
|
+
raise ProtocolError("malformed object reference", details={"ref": text[:40]}) from exc
|
|
72
|
+
|
|
73
|
+
def check_session(self, current: str | None) -> None:
|
|
74
|
+
if current is None or current != self.session:
|
|
75
|
+
raise StaleReferenceError(
|
|
76
|
+
"the reference belongs to a previous Cubase script session; list the objects again",
|
|
77
|
+
details={"ref_session": self.session, "current_session": current},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def check_fingerprint(self, described: dict[str, Any]) -> None:
|
|
81
|
+
if described.get("type") != self.type_name or described.get("uniqueName") != self.unique_name:
|
|
82
|
+
raise StaleReferenceError(
|
|
83
|
+
"the reference now points at a different object; list the objects again",
|
|
84
|
+
details={
|
|
85
|
+
"expected": {"type": self.type_name, "uniqueName": self.unique_name},
|
|
86
|
+
"found": {"type": described.get("type"), "uniqueName": described.get("uniqueName")},
|
|
87
|
+
},
|
|
88
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Saving the project (TW-1202). Only on an explicit request: separate policy switch, dry run by default.
|
|
2
|
+
|
|
3
|
+
Verified in Cubase Elements 15.0.30 (experiments/tw1202): ``File / Save`` rewrites the project's .cpr and
|
|
4
|
+
``File / Save New Version`` writes ``<name>-01.cpr`` (and Cubase then works on it), both without a dialog
|
|
5
|
+
in a project that was saved before. The result is checked by the .cpr files' dates; nothing is opened.
|
|
6
|
+
Close, Revert and Quit are not offered.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
from trackwright.core.base import ServiceBase, operation
|
|
16
|
+
from trackwright.core.models import ProjectFileOut, SaveOut
|
|
17
|
+
from trackwright.exports import PROJECT_SUFFIXES, ExportedFile
|
|
18
|
+
|
|
19
|
+
SAVE_KEYS = {"save": "file.save", "new_version": "file.save_new_version"}
|
|
20
|
+
SAVE_WAIT_SECONDS = 10.0
|
|
21
|
+
EFFECTS = {
|
|
22
|
+
"save": "overwrites the project file Cubase has open with the current state",
|
|
23
|
+
"new_version": "writes a new numbered project file (<name>-NN.cpr); Cubase then works on that file",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _file(found: ExportedFile) -> ProjectFileOut:
|
|
28
|
+
return ProjectFileOut(name=Path(found.path).name, size_bytes=found.size_bytes,
|
|
29
|
+
modified=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(found.modified))) # fmt: skip
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SavingMixin(ServiceBase):
|
|
33
|
+
def _project_folder(self) -> Path | None:
|
|
34
|
+
folders = self.launcher.project_folders() if self.launcher is not None else []
|
|
35
|
+
return Path(folders[0]) if folders else None
|
|
36
|
+
|
|
37
|
+
def _project_files(self, folder: Path | None, since: float = 0.0) -> list[ExportedFile]:
|
|
38
|
+
return self.exports.find(folder, since, PROJECT_SUFFIXES) if folder else []
|
|
39
|
+
|
|
40
|
+
@operation
|
|
41
|
+
def save_project(self, mode: Literal["save", "new_version"] = "save", *, dry_run: bool = True) -> SaveOut:
|
|
42
|
+
folder = self._project_folder()
|
|
43
|
+
before = self._project_files(folder)
|
|
44
|
+
out = SaveOut(mode=mode, dry_run=dry_run, status="planned", folder=str(folder) if folder else None,
|
|
45
|
+
files_before=[_file(f) for f in before], notes=[EFFECTS[mode]]) # fmt: skip
|
|
46
|
+
if folder is None:
|
|
47
|
+
out.notes.append("the project folder is unknown (no audio file open): the save cannot be verified")
|
|
48
|
+
if dry_run:
|
|
49
|
+
return out
|
|
50
|
+
self.policy.check_save()
|
|
51
|
+
started = time.time()
|
|
52
|
+
self.call("cmd.run", {"key": SAVE_KEYS[mode]})
|
|
53
|
+
if folder is None:
|
|
54
|
+
out.status = "saved"
|
|
55
|
+
return out
|
|
56
|
+
names_before = {Path(f.path).name for f in before}
|
|
57
|
+
deadline = time.monotonic() + SAVE_WAIT_SECONDS
|
|
58
|
+
while True:
|
|
59
|
+
written = [f for f in self._project_files(folder, started)
|
|
60
|
+
if mode == "save" or Path(f.path).name not in names_before] # fmt: skip
|
|
61
|
+
if written or time.monotonic() >= deadline:
|
|
62
|
+
break
|
|
63
|
+
self._sleep(0.5)
|
|
64
|
+
if written:
|
|
65
|
+
out.status, out.saved, out.verified = "saved", _file(written[0]), True
|
|
66
|
+
else:
|
|
67
|
+
out.status, out.verified = "awaiting_user", False
|
|
68
|
+
out.instructions = (
|
|
69
|
+
"no project file was written: Cubase may be showing a dialog (for example 'Save As' for a "
|
|
70
|
+
"project that was never saved). Answer it in Cubase."
|
|
71
|
+
)
|
|
72
|
+
return out
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The Cubase service: every tool's logic, independent of MCP so it can be tested directly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from trackwright.core.automation import AutomationMixin
|
|
6
|
+
from trackwright.core.commanding import CommandingMixin
|
|
7
|
+
from trackwright.core.importing import ImportMixin
|
|
8
|
+
from trackwright.core.lifecycle import LifecycleMixin
|
|
9
|
+
from trackwright.core.presets import PresetMixin
|
|
10
|
+
from trackwright.core.reading import ReadingMixin
|
|
11
|
+
from trackwright.core.saving import SavingMixin
|
|
12
|
+
from trackwright.core.settings import SettingsMixin
|
|
13
|
+
from trackwright.core.writing import WritingMixin
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CubaseService(
|
|
17
|
+
PresetMixin,
|
|
18
|
+
SettingsMixin,
|
|
19
|
+
SavingMixin,
|
|
20
|
+
ImportMixin,
|
|
21
|
+
LifecycleMixin,
|
|
22
|
+
AutomationMixin,
|
|
23
|
+
CommandingMixin,
|
|
24
|
+
WritingMixin,
|
|
25
|
+
ReadingMixin,
|
|
26
|
+
):
|
|
27
|
+
"""Every tool: read, write, commands, automation, launch, project, ranges, export, import and save (E05-E12)."""
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Change project and transport settings (TW-1210) through Cubase's Transport and Metronome objects.
|
|
2
|
+
|
|
3
|
+
Verified in Cubase Elements 15.0.30 (experiments/tw1210): every switch below and the click level accept a
|
|
4
|
+
DirectAccess write and read back; the time signature does not (its value is outside [0, 1]).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from trackwright.core.base import MIXER, operation
|
|
12
|
+
from trackwright.core.models import ProjectSettingsOut, SettingChange
|
|
13
|
+
from trackwright.core.project import ProjectMixin
|
|
14
|
+
from trackwright.errors import NotFoundError, PreconditionFailedError
|
|
15
|
+
|
|
16
|
+
JsonObject = dict[str, Any]
|
|
17
|
+
SWITCHES: dict[str, tuple[str, str]] = { # setting -> (object type, parameter title)
|
|
18
|
+
"cycle": ("Transport", "cycle"),
|
|
19
|
+
"metronome": ("Transport", "metronomeOn"),
|
|
20
|
+
"precount": ("Transport", "precountOn"),
|
|
21
|
+
"punch_in": ("Transport", "punchIn"),
|
|
22
|
+
"punch_out": ("Transport", "punchOut"),
|
|
23
|
+
"pre_roll": ("Transport", "usePreRoll"),
|
|
24
|
+
"post_roll": ("Transport", "usePostRoll"),
|
|
25
|
+
"click_in_playback": ("Metronome", "tickInPlayback"),
|
|
26
|
+
"click_in_record": ("Metronome", "tickInRecord"),
|
|
27
|
+
}
|
|
28
|
+
CLICK_LEVEL = ("Metronome", "clickLevel")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SettingsMixin(ProjectMixin):
|
|
32
|
+
@operation
|
|
33
|
+
def set_project(self, settings: dict[str, bool | int], *, dry_run: bool = True) -> ProjectSettingsOut:
|
|
34
|
+
if not settings:
|
|
35
|
+
raise PreconditionFailedError("give at least one setting to change", code="BAD_REQUEST")
|
|
36
|
+
unknown = sorted(set(settings) - set(SWITCHES) - {"click_level"})
|
|
37
|
+
if unknown:
|
|
38
|
+
raise PreconditionFailedError(f"unknown settings {unknown}", code="BAD_REQUEST",
|
|
39
|
+
details={"known": [*SWITCHES, "click_level"]}) # fmt: skip
|
|
40
|
+
level = settings.get("click_level")
|
|
41
|
+
if level is not None and (isinstance(level, bool) or not 0 <= int(level) <= 127):
|
|
42
|
+
raise PreconditionFailedError("click_level must be 0..127", code="OUT_OF_RANGE")
|
|
43
|
+
objects = self._studio_objects()
|
|
44
|
+
params = {kind: self._params_by_title(objects.get(kind)) for kind in ("Transport", "Metronome")}
|
|
45
|
+
plan: list[tuple[str, int, int, float, bool | int | None]] = [] # name, object, tag, value, before
|
|
46
|
+
for name, requested in settings.items():
|
|
47
|
+
kind, title = SWITCHES.get(name, CLICK_LEVEL)
|
|
48
|
+
param = params[kind].get(title)
|
|
49
|
+
if param is None or objects.get(kind) is None:
|
|
50
|
+
raise NotFoundError(f"Cubase does not expose {kind}/{title}", code="UNAVAILABLE")
|
|
51
|
+
value = int(requested) / 127 if name == "click_level" else (1.0 if requested else 0.0)
|
|
52
|
+
plan.append((name, int(objects[kind]), int(param["tag"]), value, self._setting_value(name, param)))
|
|
53
|
+
out = ProjectSettingsOut(
|
|
54
|
+
dry_run=dry_run,
|
|
55
|
+
changes=[
|
|
56
|
+
SettingChange(setting=n, before=b, requested=settings[n], after=None, verified=None)
|
|
57
|
+
for n, _o, _t, _v, b in plan
|
|
58
|
+
],
|
|
59
|
+
verified=None,
|
|
60
|
+
)
|
|
61
|
+
if dry_run:
|
|
62
|
+
return out
|
|
63
|
+
self.policy.check_project_write()
|
|
64
|
+
for change, (name, oid, tag, value, _before) in zip(out.changes, plan, strict=True):
|
|
65
|
+
self.call("da.param.set", {"id": oid, "tag": tag, "value": value, **MIXER})
|
|
66
|
+
change.after = self._read_setting(name, oid, tag, change.requested)
|
|
67
|
+
change.verified = change.after == change.requested
|
|
68
|
+
out.verified = all(c.verified for c in out.changes)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
def _read_setting(self, name: str, oid: int, tag: int, wanted: bool | int) -> bool | int:
|
|
72
|
+
def read() -> bool | int:
|
|
73
|
+
return self._setting_value(name, self.call("da.param.get", {"id": oid, "tag": tag, **MIXER}))
|
|
74
|
+
|
|
75
|
+
return self.wait_until(read, wanted.__eq__)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _setting_value(name: str, param: JsonObject) -> bool | int:
|
|
79
|
+
process = float(param.get("process") or 0)
|
|
80
|
+
return round(process * 127) if name == "click_level" else process >= 0.5
|