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,221 @@
|
|
|
1
|
+
"""Project properties (TW-1209), read from Cubase's own DirectAccess objects.
|
|
2
|
+
|
|
3
|
+
Verified in Cubase Elements 15.0.30 (experiments/tw1209): besides the mixer, DirectAccess reaches the studio
|
|
4
|
+
objects under "Device Root". The "Transport" object has the time signature, cycle, punch, pre/post-roll,
|
|
5
|
+
metronome, precount and record modes; "Metronome" has the click level. Their ids change between sessions,
|
|
6
|
+
so they are found by type. Parameters are matched by their (English, internal) title.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from trackwright.core.base import MIXER, ServiceBase, operation
|
|
15
|
+
from trackwright.core.models import Metronome, ProjectOut, Roll, Signature, Span
|
|
16
|
+
from trackwright.errors import NotFoundError, TrackwrightError
|
|
17
|
+
|
|
18
|
+
JsonObject = dict[str, Any]
|
|
19
|
+
DEVICE_ROOT_SCAN = 64
|
|
20
|
+
TAG_SIGNATURE = 4027 # Transport "signature" (verified in Cubase 15)
|
|
21
|
+
TAG_TIME_BARS, TAG_TIME_SECONDS = 4023, 4101 # Transport "time" / "time2": the cursor (verified, TW-1211)
|
|
22
|
+
SECONDS_TOLERANCE = 0.0015
|
|
23
|
+
CLOCK = re.compile(r"^\s*(?:(\d+):)?(?:(\d+):)?(\d+(?:\.\d+)?)\s*$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_seconds(text: str) -> float | None:
|
|
27
|
+
"""'2', '2.5', '1:30', '0:01:30.250' → seconds; ``None`` when it is not a time."""
|
|
28
|
+
match = CLOCK.match(text)
|
|
29
|
+
if match is None:
|
|
30
|
+
return None
|
|
31
|
+
first, second, seconds = match.groups()
|
|
32
|
+
hours, minutes = (first, second) if second is not None else (None, first)
|
|
33
|
+
return int(hours or 0) * 3600 + int(minutes or 0) * 60 + float(seconds)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_seconds(seconds: float) -> str:
|
|
37
|
+
"""Seconds → 'h:mm:ss.mmm', the format of Cubase's seconds display."""
|
|
38
|
+
millis = round(max(seconds, 0.0) * 1000)
|
|
39
|
+
hours, rest = divmod(millis, 3_600_000)
|
|
40
|
+
minutes, rest = divmod(rest, 60_000)
|
|
41
|
+
return f"{hours}:{minutes:02d}:{rest // 1000:02d}.{rest % 1000:03d}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
UNAVAILABLE = {
|
|
45
|
+
"sample_rate": "not exposed by the MIDI Remote API (Project Setup dialog only)",
|
|
46
|
+
"bit_depth": "not exposed by the MIDI Remote API (Project Setup dialog only)",
|
|
47
|
+
"project_length": "not exposed by the MIDI Remote API (Project Setup dialog only)",
|
|
48
|
+
"project_name": "not exposed by the MIDI Remote API; see project_folders",
|
|
49
|
+
}
|
|
50
|
+
MODE_TITLES = {
|
|
51
|
+
"record_button": "recordButtonMode",
|
|
52
|
+
"audio_record": "audioRecordMode",
|
|
53
|
+
"midi_record": "midiRecordMode",
|
|
54
|
+
"midi_cycle_record": "midiCycleRecordMode",
|
|
55
|
+
"record_start": "recordStartMode",
|
|
56
|
+
"play_start": "startPlayModes",
|
|
57
|
+
"return_on_stop": "returnOnStop",
|
|
58
|
+
}
|
|
59
|
+
SIGNATURE = re.compile(r"^\s*(\d+)\s*/\s*(\d+)\s*$")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_signature(text: str | None) -> Signature | None:
|
|
63
|
+
if not text:
|
|
64
|
+
return None
|
|
65
|
+
match = SIGNATURE.match(text)
|
|
66
|
+
if match is None:
|
|
67
|
+
return Signature(text=text, numerator=None, denominator=None)
|
|
68
|
+
return Signature(text=text, numerator=int(match.group(1)), denominator=int(match.group(2)))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def click_level(metronome: dict[str, JsonObject]) -> str | None:
|
|
72
|
+
"""The click level 0-127 (verified: value x 127, TW-1210)."""
|
|
73
|
+
param = metronome.get("clickLevel")
|
|
74
|
+
return None if param is None else str(round(float(param.get("process") or 0) * 127))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ProjectMixin(ServiceBase):
|
|
78
|
+
def _device_root(self) -> int:
|
|
79
|
+
for oid in (1, *range(DEVICE_ROOT_SCAN)): # id 1 in Cubase 15; scan in case that changes
|
|
80
|
+
try:
|
|
81
|
+
if self.call("da.object", {"id": oid, **MIXER}).get("type") == "Device Root":
|
|
82
|
+
return int(oid)
|
|
83
|
+
except TrackwrightError:
|
|
84
|
+
continue
|
|
85
|
+
raise NotFoundError("Cubase's Device Root object was not found", code="UNAVAILABLE")
|
|
86
|
+
|
|
87
|
+
def _studio_objects(self) -> dict[str, int]:
|
|
88
|
+
root: int = self._device_root()
|
|
89
|
+
found: dict[str, int] = {}
|
|
90
|
+
offset = 0
|
|
91
|
+
while True:
|
|
92
|
+
page = self.call("da.children", {"id": root, "offset": offset, "limit": 50, **MIXER})
|
|
93
|
+
for child in page.get("items", []):
|
|
94
|
+
found.setdefault(str(child.get("type")), int(child["id"]))
|
|
95
|
+
offset += len(page.get("items", []))
|
|
96
|
+
if not page.get("items") or offset >= int(page.get("total", 0)):
|
|
97
|
+
return found
|
|
98
|
+
|
|
99
|
+
def _params_by_title(self, oid: int | None) -> dict[str, JsonObject]:
|
|
100
|
+
if oid is None:
|
|
101
|
+
return {}
|
|
102
|
+
out: dict[str, JsonObject] = {}
|
|
103
|
+
offset = 0
|
|
104
|
+
while True:
|
|
105
|
+
page = self.call("da.params", {"id": oid, "offset": offset, "limit": 25, **MIXER})
|
|
106
|
+
for param in page.get("items", []):
|
|
107
|
+
out.setdefault(str(param.get("title")), param)
|
|
108
|
+
offset += len(page.get("items", []))
|
|
109
|
+
if not page.get("items") or offset >= int(page.get("total", 0)):
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------ time in seconds (TW-1211)
|
|
113
|
+
def _transport_id(self) -> int:
|
|
114
|
+
transport = self._studio_objects().get("Transport")
|
|
115
|
+
if transport is None:
|
|
116
|
+
raise NotFoundError("Cubase's Transport object was not found", code="UNAVAILABLE")
|
|
117
|
+
return transport
|
|
118
|
+
|
|
119
|
+
def _cursor_seconds(self, transport: int) -> float:
|
|
120
|
+
display = str(self.call("da.param.get", {"id": transport, "tag": TAG_TIME_SECONDS, **MIXER}).get("display"))
|
|
121
|
+
seconds = parse_seconds(display)
|
|
122
|
+
if seconds is None:
|
|
123
|
+
raise TrackwrightError(f"unexpected cursor time {display!r}", code="UNAVAILABLE")
|
|
124
|
+
return seconds
|
|
125
|
+
|
|
126
|
+
def _cursor_bars(self) -> str:
|
|
127
|
+
raw = (self.call("transport.get").get("primary") or {}).get("locator") or {}
|
|
128
|
+
return str(raw.get("time", ""))
|
|
129
|
+
|
|
130
|
+
def _move_cursor_seconds(self, transport: int, seconds: float) -> float:
|
|
131
|
+
"""Put the cursor at ``seconds`` through the seconds display (verified) and return where it landed."""
|
|
132
|
+
self.call("transport.setTime", {"which": "locator", "display": "secondary", "time": format_seconds(seconds)})
|
|
133
|
+
return self.wait_until(
|
|
134
|
+
lambda: self._cursor_seconds(transport), lambda now: abs(now - seconds) <= SECONDS_TOLERANCE
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _set_locator_seconds(self, transport: int, which: str, seconds: float) -> str:
|
|
138
|
+
"""Set the left or right locator at ``seconds``: cursor there, then Set Left/Right Locator.
|
|
139
|
+
|
|
140
|
+
Setting the locators through the seconds display does not work (verified, TW-1211). Returns the
|
|
141
|
+
locator's bar position, which must equal the cursor's.
|
|
142
|
+
"""
|
|
143
|
+
self._move_cursor_seconds(transport, seconds)
|
|
144
|
+
target = self._cursor_bars()
|
|
145
|
+
field = "cycleLeft" if which == "left" else "cycleRight"
|
|
146
|
+
self.call("cmd.run", {"key": f"transport.set_{which}_locator"})
|
|
147
|
+
|
|
148
|
+
def read() -> str:
|
|
149
|
+
raw = (self.call("transport.get").get("primary") or {}).get(field) or {}
|
|
150
|
+
return str(raw.get("time", ""))
|
|
151
|
+
|
|
152
|
+
return self.wait_until(read, lambda now: now == target)
|
|
153
|
+
|
|
154
|
+
def current_signature(self) -> Signature | None:
|
|
155
|
+
"""The time signature at the cursor, or ``None`` when Cubase does not report it."""
|
|
156
|
+
try:
|
|
157
|
+
transport = self._studio_objects().get("Transport")
|
|
158
|
+
if transport is None:
|
|
159
|
+
return None
|
|
160
|
+
param = self.call("da.param.get", {"id": transport, "tag": TAG_SIGNATURE, **MIXER})
|
|
161
|
+
except TrackwrightError:
|
|
162
|
+
return None
|
|
163
|
+
if param.get("title") != "signature":
|
|
164
|
+
return None
|
|
165
|
+
return parse_signature(str(param.get("display") or ""))
|
|
166
|
+
|
|
167
|
+
@operation
|
|
168
|
+
def get_project(self, *, include_folders: bool = True) -> ProjectOut:
|
|
169
|
+
objects = self._studio_objects()
|
|
170
|
+
transport = self._params_by_title(objects.get("Transport"))
|
|
171
|
+
metronome = self._params_by_title(objects.get("Metronome"))
|
|
172
|
+
if not transport:
|
|
173
|
+
raise NotFoundError("Cubase's Transport object was not found", code="UNAVAILABLE")
|
|
174
|
+
|
|
175
|
+
def flag(params: dict[str, JsonObject], title: str) -> bool | None:
|
|
176
|
+
param = params.get(title)
|
|
177
|
+
return None if param is None else float(param.get("process") or 0) >= 0.5
|
|
178
|
+
|
|
179
|
+
def text(params: dict[str, JsonObject], title: str) -> str | None:
|
|
180
|
+
param = params.get(title)
|
|
181
|
+
return None if param is None else str(param.get("display") or "").strip() or None
|
|
182
|
+
|
|
183
|
+
state: Literal["stopped", "playing", "recording", "unknown"]
|
|
184
|
+
if flag(transport, "record"):
|
|
185
|
+
state = "recording"
|
|
186
|
+
elif flag(transport, "start"):
|
|
187
|
+
state = "playing"
|
|
188
|
+
elif flag(transport, "stop"):
|
|
189
|
+
state = "stopped"
|
|
190
|
+
else:
|
|
191
|
+
state = "unknown"
|
|
192
|
+
tempo = self.call("transport.get").get("tempoBPM")
|
|
193
|
+
folders = None
|
|
194
|
+
if include_folders and self.launcher is not None:
|
|
195
|
+
folders = self.launcher.project_folders()
|
|
196
|
+
return ProjectOut(
|
|
197
|
+
tempo_bpm=float(tempo) if isinstance(tempo, int | float) else None,
|
|
198
|
+
signature=parse_signature(text(transport, "signature")),
|
|
199
|
+
cycle=Span(
|
|
200
|
+
active=flag(transport, "cycle"),
|
|
201
|
+
start=text(transport, "cycleLeftTime"),
|
|
202
|
+
end=text(transport, "cycleRightTime"),
|
|
203
|
+
),
|
|
204
|
+
punch_in=flag(transport, "punchIn"),
|
|
205
|
+
punch_out=flag(transport, "punchOut"),
|
|
206
|
+
punch_range=Span(active=None, start=text(transport, "punchInTime"), end=text(transport, "punchOutTime")),
|
|
207
|
+
pre_roll=Roll(active=flag(transport, "usePreRoll"), length=text(transport, "preRoll")),
|
|
208
|
+
post_roll=Roll(active=flag(transport, "usePostRoll"), length=text(transport, "postRoll")),
|
|
209
|
+
metronome=Metronome(
|
|
210
|
+
active=flag(transport, "metronomeOn"),
|
|
211
|
+
click_level=click_level(metronome),
|
|
212
|
+
in_record=flag(metronome, "tickInRecord"),
|
|
213
|
+
in_playback=flag(metronome, "tickInPlayback"),
|
|
214
|
+
),
|
|
215
|
+
precount=flag(transport, "precountOn"),
|
|
216
|
+
transport=state,
|
|
217
|
+
time_formats=[text(transport, "timeFormat"), text(transport, "timeFormat2")],
|
|
218
|
+
modes={key: text(transport, title) for key, title in MODE_TITLES.items()},
|
|
219
|
+
project_folders=folders,
|
|
220
|
+
unavailable=dict(UNAVAILABLE),
|
|
221
|
+
)
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Range editing in seconds (TW-1211), for podcast-style editing where the time signature does not matter.
|
|
2
|
+
|
|
3
|
+
Verified in Cubase Elements 15.0.30 (experiments/tw1211):
|
|
4
|
+
|
|
5
|
+
* the locators cannot be set through the seconds display, but the cursor can; ``Set Left/Right Locator``
|
|
6
|
+
then puts a locator at the cursor;
|
|
7
|
+
* with the Range tool, ``Select in Loop`` selects the range between the locators **on every track**;
|
|
8
|
+
* ``Delete``, ``Delete Time``, ``Cut Time``, ``Insert Silence`` and ``Crop Range`` act on it without a
|
|
9
|
+
dialog, and ``Undo`` reverts each exactly;
|
|
10
|
+
* ``Locate Next Event`` walks the event edges (starts and ends, unlabelled) of the selected tracks. The
|
|
11
|
+
API cannot read events, so this probe is what verifies the edit.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import itertools
|
|
18
|
+
import math
|
|
19
|
+
from typing import Literal
|
|
20
|
+
|
|
21
|
+
from trackwright.core.base import MIXER, TAG_SELECTED, operation
|
|
22
|
+
from trackwright.core.models import RangeEditOut
|
|
23
|
+
from trackwright.core.project import SECONDS_TOLERANCE, ProjectMixin, format_seconds, parse_seconds
|
|
24
|
+
from trackwright.core.refs import TrackSelector
|
|
25
|
+
from trackwright.core.writing import bars_tuple, format_bars
|
|
26
|
+
from trackwright.errors import PreconditionFailedError, TrackwrightError
|
|
27
|
+
|
|
28
|
+
RangeAction = Literal["delete", "delete_time", "cut_time", "insert_silence", "crop"]
|
|
29
|
+
ACTION_KEYS: dict[str, str] = {
|
|
30
|
+
"delete": "edit.delete",
|
|
31
|
+
"delete_time": "edit.delete_time",
|
|
32
|
+
"cut_time": "edit.cut_time",
|
|
33
|
+
"insert_silence": "edit.insert_silence",
|
|
34
|
+
"crop": "edit.crop_range",
|
|
35
|
+
}
|
|
36
|
+
EFFECTS: dict[str, str] = {
|
|
37
|
+
"delete": "removes the audio in the range and leaves a gap; nothing moves",
|
|
38
|
+
"delete_time": "removes the range and moves everything after it earlier, closing the gap",
|
|
39
|
+
"cut_time": "like delete_time, and copies the range to the clipboard",
|
|
40
|
+
"insert_silence": "inserts silence as long as the range at its start, moving everything after it later",
|
|
41
|
+
"crop": "keeps only the range and removes everything outside it",
|
|
42
|
+
}
|
|
43
|
+
TAG_START, TAG_RECORD = 4015, 4019
|
|
44
|
+
MAX_EDGES = 48
|
|
45
|
+
MAX_ROUNDS = 6 # single-track deletion: events hidden under others only show up after those are deleted
|
|
46
|
+
PROBE_MARGIN = 1.0 # seconds before the range where the probe starts
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RangeMixin(ProjectMixin):
|
|
50
|
+
# ------------------------------------------------------------------ helpers
|
|
51
|
+
def _seconds_of(self, transport: int, value: str, unit: str) -> float:
|
|
52
|
+
if unit == "seconds":
|
|
53
|
+
seconds = parse_seconds(value)
|
|
54
|
+
if seconds is None:
|
|
55
|
+
raise PreconditionFailedError(f"bad time {value!r}: use '2.5', '1:30' or '0:01:30.000'",
|
|
56
|
+
code="BAD_TIME") # fmt: skip
|
|
57
|
+
return seconds
|
|
58
|
+
if unit == "bars":
|
|
59
|
+
position = bars_tuple(value)
|
|
60
|
+
if position is None:
|
|
61
|
+
raise PreconditionFailedError(f"bad position {value!r}: use '9. 1. 1. 0'", code="BAD_TIME")
|
|
62
|
+
self.call("transport.setTime", {"which": "locator", "display": "primary", "time": format_bars(position)})
|
|
63
|
+
self.wait_until(self._cursor_bars, lambda now: bars_tuple(now) == position)
|
|
64
|
+
return self._cursor_seconds(transport)
|
|
65
|
+
raise PreconditionFailedError("unit must be 'seconds' or 'bars'", code="BAD_REQUEST")
|
|
66
|
+
|
|
67
|
+
def _transport_flag(self, transport: int, tag: int) -> bool:
|
|
68
|
+
return float(self.call("da.param.get", {"id": transport, "tag": tag, **MIXER}).get("process") or 0) >= 0.5
|
|
69
|
+
|
|
70
|
+
def _select_channel(self, channel_id: int) -> None:
|
|
71
|
+
self.call("da.param.set", {"id": channel_id, "tag": TAG_SELECTED, "value": 1, "scope": "mixer"})
|
|
72
|
+
self.wait_until(lambda: self.call("da.base"), lambda base: base.get("base") == channel_id)
|
|
73
|
+
|
|
74
|
+
def _range_cmd(self, key: str) -> None:
|
|
75
|
+
self.call("cmd.run", {"key": key})
|
|
76
|
+
|
|
77
|
+
def _performable(self, key: str) -> bool:
|
|
78
|
+
return self.call("cmd.canPerform", {"key": key}).get("canPerform", {}).get(key) is True
|
|
79
|
+
|
|
80
|
+
def probe_edges(self, transport: int, start: float, until: float = math.inf) -> tuple[list[float], bool]:
|
|
81
|
+
"""Event edges after ``start`` on the selected tracks (up to the first one past ``until``), and
|
|
82
|
+
whether the walk reached the last edge."""
|
|
83
|
+
self._move_cursor_seconds(transport, start)
|
|
84
|
+
edges: list[float] = []
|
|
85
|
+
previous = self._cursor_seconds(transport)
|
|
86
|
+
for _ in range(MAX_EDGES):
|
|
87
|
+
self._range_cmd("transport.locate_next_event")
|
|
88
|
+
last = previous
|
|
89
|
+
|
|
90
|
+
def moved(value: float, last: float = last) -> bool:
|
|
91
|
+
return value != last
|
|
92
|
+
|
|
93
|
+
now = self.wait_until(lambda: self._cursor_seconds(transport), moved)
|
|
94
|
+
if abs(now - previous) <= SECONDS_TOLERANCE / 2:
|
|
95
|
+
return edges, True
|
|
96
|
+
edges.append(now)
|
|
97
|
+
previous = now
|
|
98
|
+
if now > until:
|
|
99
|
+
return edges, False
|
|
100
|
+
return edges, False
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------ tool
|
|
103
|
+
@operation
|
|
104
|
+
def edit_range(
|
|
105
|
+
self,
|
|
106
|
+
start: str,
|
|
107
|
+
end: str,
|
|
108
|
+
action: RangeAction,
|
|
109
|
+
*,
|
|
110
|
+
unit: str = "seconds",
|
|
111
|
+
verify_track: str | None = None,
|
|
112
|
+
track: str | None = None,
|
|
113
|
+
dry_run: bool = True,
|
|
114
|
+
) -> RangeEditOut:
|
|
115
|
+
if action not in ACTION_KEYS:
|
|
116
|
+
raise PreconditionFailedError(f"action must be one of {sorted(ACTION_KEYS)}", code="BAD_REQUEST")
|
|
117
|
+
transport = self._transport_id()
|
|
118
|
+
cursor_before = self._cursor_seconds(transport)
|
|
119
|
+
start_s, end_s = self._seconds_of(transport, start, unit), self._seconds_of(transport, end, unit)
|
|
120
|
+
if unit == "bars":
|
|
121
|
+
self._move_cursor_seconds(transport, cursor_before)
|
|
122
|
+
if not 0 <= start_s < end_s:
|
|
123
|
+
raise PreconditionFailedError("the range must have 0 <= start < end", code="BAD_TIME")
|
|
124
|
+
if track is not None and action != "delete":
|
|
125
|
+
raise PreconditionFailedError(
|
|
126
|
+
"on a single track only 'delete' is possible: closing or opening time on one track would "
|
|
127
|
+
"put it out of sync with the others",
|
|
128
|
+
code="BAD_REQUEST",
|
|
129
|
+
)
|
|
130
|
+
target = self.resolve_track(TrackSelector.parse(track)) if track else None
|
|
131
|
+
probe_item = target or (self.resolve_track(TrackSelector.parse(verify_track)) if verify_track else None)
|
|
132
|
+
out = RangeEditOut(
|
|
133
|
+
dry_run=dry_run,
|
|
134
|
+
action=action,
|
|
135
|
+
start=format_seconds(start_s),
|
|
136
|
+
end=format_seconds(end_s),
|
|
137
|
+
length_seconds=round(end_s - start_s, 3),
|
|
138
|
+
effect=EFFECTS[action],
|
|
139
|
+
scope=(
|
|
140
|
+
f"only the track {self.track_info(target).name}"
|
|
141
|
+
if target
|
|
142
|
+
else "every track with events in the range (Cubase's Select in Loop selects all tracks)"
|
|
143
|
+
),
|
|
144
|
+
track=self.track_info(target).name if target else None,
|
|
145
|
+
verify_track=self.track_info(probe_item).name if probe_item else None,
|
|
146
|
+
notes=["Edit > Undo in Cubase reverts it", "the project is not saved"],
|
|
147
|
+
)
|
|
148
|
+
if dry_run:
|
|
149
|
+
return out
|
|
150
|
+
self.policy.check_project_write()
|
|
151
|
+
if self._transport_flag(transport, TAG_START) or self._transport_flag(transport, TAG_RECORD):
|
|
152
|
+
raise PreconditionFailedError("stop the transport before editing", code="TRANSPORT_RUNNING")
|
|
153
|
+
if target is not None:
|
|
154
|
+
self._delete_on_track(out, transport, (start_s, end_s), int(target["id"]), cursor_before)
|
|
155
|
+
else:
|
|
156
|
+
self._apply_range(out, transport, (start_s, end_s), probe_item=probe_item, cursor_before=cursor_before)
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------ one track (TW-1212)
|
|
160
|
+
def _clear_selection(self) -> None:
|
|
161
|
+
if self._performable("edit.select_none"):
|
|
162
|
+
self._range_cmd("edit.select_none")
|
|
163
|
+
|
|
164
|
+
def _selection_bounds(self, transport: int) -> tuple[float, float] | None:
|
|
165
|
+
bounds = []
|
|
166
|
+
for key in ("transport.locate_selection", "transport.locate_selection_end"):
|
|
167
|
+
if not self._performable(key):
|
|
168
|
+
return None
|
|
169
|
+
previous = self._cursor_seconds(transport)
|
|
170
|
+
self._range_cmd(key)
|
|
171
|
+
|
|
172
|
+
def moved(value: float, last: float = previous) -> bool:
|
|
173
|
+
return value != last
|
|
174
|
+
|
|
175
|
+
bounds.append(self.wait_until(lambda: self._cursor_seconds(transport), moved))
|
|
176
|
+
return bounds[0], bounds[1]
|
|
177
|
+
|
|
178
|
+
def _delete_on_track(
|
|
179
|
+
self, out: RangeEditOut, transport: int, span: tuple[float, float], channel: int, cursor_before: float
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Verified recipe (experiments/tw1212): split this track's events at both ends, then delete the
|
|
182
|
+
pieces inside one by one with Select Events under Cursor, which only looks at the selected track."""
|
|
183
|
+
start, end = span
|
|
184
|
+
selected = self.call("da.base").get("base")
|
|
185
|
+
probe_from = max(start - PROBE_MARGIN, 0.0)
|
|
186
|
+
steps = 0
|
|
187
|
+
try:
|
|
188
|
+
self._range_cmd("tool.select")
|
|
189
|
+
self._select_channel(channel)
|
|
190
|
+
before = self.probe_edges(transport, probe_from)
|
|
191
|
+
for cut in (start, end):
|
|
192
|
+
self._clear_selection()
|
|
193
|
+
self._move_cursor_seconds(transport, cut)
|
|
194
|
+
if self._performable("edit.select_events_under_cursor"):
|
|
195
|
+
self._range_cmd("edit.select_events_under_cursor")
|
|
196
|
+
if self._performable("edit.split_at_cursor"):
|
|
197
|
+
self._range_cmd("edit.split_at_cursor")
|
|
198
|
+
steps += 1
|
|
199
|
+
for _ in range(MAX_ROUNDS):
|
|
200
|
+
deleted = self._delete_round(transport, start, end)
|
|
201
|
+
steps += deleted
|
|
202
|
+
if not deleted:
|
|
203
|
+
break
|
|
204
|
+
out.applied = True
|
|
205
|
+
finally:
|
|
206
|
+
out.undo_steps = steps
|
|
207
|
+
self._clear_selection()
|
|
208
|
+
out.notes.append("the Object Selection tool is active")
|
|
209
|
+
self._restore(transport, {}, cursor_before, selected)
|
|
210
|
+
self._select_channel(channel)
|
|
211
|
+
after = self.probe_edges(transport, probe_from)
|
|
212
|
+
self._restore(transport, {}, cursor_before, selected)
|
|
213
|
+
out.edges_before = [format_seconds(e) for e in before[0]]
|
|
214
|
+
out.edges_after = [format_seconds(e) for e in after[0]]
|
|
215
|
+
out.mismatches = compare_edges("delete", start, end, before, after)
|
|
216
|
+
out.verified = not out.mismatches
|
|
217
|
+
if steps > 1:
|
|
218
|
+
out.notes.append(f"undoing it in Cubase takes {steps} Undo steps")
|
|
219
|
+
|
|
220
|
+
def _delete_round(self, transport: int, start: float, end: float) -> int:
|
|
221
|
+
"""Delete every event piece found between consecutive edges inside the range; return how many."""
|
|
222
|
+
edges, _ = self.probe_edges(transport, start, until=end)
|
|
223
|
+
points = [start, *(e for e in edges if e < end - SECONDS_TOLERANCE), end]
|
|
224
|
+
deleted = 0
|
|
225
|
+
for a, b in itertools.pairwise(points):
|
|
226
|
+
if b - a <= SECONDS_TOLERANCE:
|
|
227
|
+
continue
|
|
228
|
+
self._clear_selection()
|
|
229
|
+
self._move_cursor_seconds(transport, (a + b) / 2)
|
|
230
|
+
if not self._performable("edit.select_events_under_cursor"):
|
|
231
|
+
continue
|
|
232
|
+
self._range_cmd("edit.select_events_under_cursor")
|
|
233
|
+
bounds = self._selection_bounds(transport)
|
|
234
|
+
if bounds is None or bounds[0] < start - SECONDS_TOLERANCE or bounds[1] > end + SECONDS_TOLERANCE:
|
|
235
|
+
raise PreconditionFailedError(
|
|
236
|
+
"an event reaches outside the range (the split did not happen); nothing more was deleted",
|
|
237
|
+
code="SPLIT_FAILED",
|
|
238
|
+
details={"selection": [format_seconds(x) for x in bounds] if bounds else None},
|
|
239
|
+
)
|
|
240
|
+
# Measuring moved the cursor to the selection end, and with Cubase's "auto-select events under
|
|
241
|
+
# cursor" preference that changes the selection (verified, TW-1212): select the piece again.
|
|
242
|
+
self._clear_selection()
|
|
243
|
+
self._move_cursor_seconds(transport, (a + b) / 2)
|
|
244
|
+
self._range_cmd("edit.select_events_under_cursor")
|
|
245
|
+
if self._performable("edit.delete"):
|
|
246
|
+
self._range_cmd("edit.delete")
|
|
247
|
+
deleted += 1
|
|
248
|
+
return deleted
|
|
249
|
+
|
|
250
|
+
def _apply_range(
|
|
251
|
+
self,
|
|
252
|
+
out: RangeEditOut,
|
|
253
|
+
transport: int,
|
|
254
|
+
span: tuple[float, float],
|
|
255
|
+
*,
|
|
256
|
+
probe_item: dict[str, object] | None,
|
|
257
|
+
cursor_before: float,
|
|
258
|
+
) -> None:
|
|
259
|
+
start, end = span
|
|
260
|
+
primary = self.call("transport.get").get("primary") or {}
|
|
261
|
+
locators = {k: str((primary.get(k) or {}).get("time", "")) for k in ("cycleLeft", "cycleRight")}
|
|
262
|
+
selected = self.call("da.base").get("base")
|
|
263
|
+
probe_from = max(start - PROBE_MARGIN, 0.0)
|
|
264
|
+
before: tuple[list[float], bool] | None = None
|
|
265
|
+
try:
|
|
266
|
+
if probe_item is not None:
|
|
267
|
+
self._select_channel(int(str(probe_item["id"])))
|
|
268
|
+
before = self.probe_edges(transport, probe_from)
|
|
269
|
+
left = self._set_locator_seconds(transport, "left", start)
|
|
270
|
+
right = self._set_locator_seconds(transport, "right", end)
|
|
271
|
+
out.locators = [left, right]
|
|
272
|
+
self._range_cmd("tool.range")
|
|
273
|
+
self._range_cmd("edit.select_in_loop")
|
|
274
|
+
key = ACTION_KEYS[out.action]
|
|
275
|
+
if not self._performable(key):
|
|
276
|
+
raise PreconditionFailedError(
|
|
277
|
+
"Cubase did not create the range selection (is the project window in front?)",
|
|
278
|
+
code="NO_RANGE_SELECTION",
|
|
279
|
+
)
|
|
280
|
+
self._range_cmd(key)
|
|
281
|
+
out.applied = True
|
|
282
|
+
out.undo_steps = 1
|
|
283
|
+
if not self._performable("edit.undo"):
|
|
284
|
+
out.notes.append("Undo is not available: Cubase may be showing a dialog; answer it")
|
|
285
|
+
finally:
|
|
286
|
+
self._range_cmd("tool.select")
|
|
287
|
+
out.notes.append("the Object Selection tool is active again")
|
|
288
|
+
self._restore(transport, locators, cursor_before, selected)
|
|
289
|
+
if probe_item is not None and before is not None and out.applied:
|
|
290
|
+
self._select_channel(int(str(probe_item["id"])))
|
|
291
|
+
after = self.probe_edges(transport, probe_from)
|
|
292
|
+
self._restore(transport, locators, cursor_before, selected)
|
|
293
|
+
out.edges_before = [format_seconds(e) for e in before[0]]
|
|
294
|
+
out.edges_after = [format_seconds(e) for e in after[0]]
|
|
295
|
+
out.mismatches = compare_edges(out.action, start, end, before, after)
|
|
296
|
+
out.verified = not out.mismatches
|
|
297
|
+
else:
|
|
298
|
+
out.notes.append("not verified: give verify_track (a track with audio) to check the result")
|
|
299
|
+
|
|
300
|
+
def _restore(self, transport: int, locators: dict[str, str], cursor: float, selected: object) -> None:
|
|
301
|
+
for field, time in locators.items():
|
|
302
|
+
if bars_tuple(time) is not None:
|
|
303
|
+
self.call("transport.setTime", {"which": field, "display": "primary", "time": time})
|
|
304
|
+
with contextlib.suppress(TrackwrightError): # the project may now be shorter than the old cursor
|
|
305
|
+
self._move_cursor_seconds(transport, cursor)
|
|
306
|
+
if isinstance(selected, int) and selected >= 0:
|
|
307
|
+
with contextlib.suppress(TrackwrightError):
|
|
308
|
+
self._select_channel(selected)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def expected_edge(action: str, start: float, end: float, edge: float) -> float | None:
|
|
312
|
+
"""Where an edge should be after the action (``None`` = removed)."""
|
|
313
|
+
gap = end - start
|
|
314
|
+
inside = start + SECONDS_TOLERANCE < edge < end - SECONDS_TOLERANCE
|
|
315
|
+
if action == "crop":
|
|
316
|
+
return edge if start - SECONDS_TOLERANCE <= edge <= end + SECONDS_TOLERANCE else None
|
|
317
|
+
if action == "insert_silence":
|
|
318
|
+
return edge if edge < start - SECONDS_TOLERANCE else edge + gap
|
|
319
|
+
if inside:
|
|
320
|
+
return None
|
|
321
|
+
if action == "delete" or edge <= start + SECONDS_TOLERANCE:
|
|
322
|
+
return edge
|
|
323
|
+
return edge - gap # delete_time / cut_time
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def compare_edges(
|
|
327
|
+
action: str, start: float, end: float, before: tuple[list[float], bool], after: tuple[list[float], bool]
|
|
328
|
+
) -> list[str]:
|
|
329
|
+
"""Differences between the probed edges after the edit and what the action should leave."""
|
|
330
|
+
gap = end - start
|
|
331
|
+
mapped = [m for m in (expected_edge(action, start, end, e) for e in before[0]) if m is not None]
|
|
332
|
+
new_edges = {"delete": [start, end], "crop": [start, end], "insert_silence": [start, start + gap]}.get(
|
|
333
|
+
action, [start]
|
|
334
|
+
)
|
|
335
|
+
horizon = math.inf
|
|
336
|
+
if not before[1] and before[0]:
|
|
337
|
+
last = expected_edge(action, start, end, before[0][-1])
|
|
338
|
+
horizon = min(horizon, last if last is not None else start)
|
|
339
|
+
if not after[1] and after[0]:
|
|
340
|
+
horizon = min(horizon, after[0][-1])
|
|
341
|
+
|
|
342
|
+
def near(value: float, pool: list[float]) -> bool:
|
|
343
|
+
return any(abs(value - other) <= SECONDS_TOLERANCE * 2 for other in pool)
|
|
344
|
+
|
|
345
|
+
problems = [
|
|
346
|
+
f"unexpected edge at {format_seconds(e)}"
|
|
347
|
+
for e in after[0]
|
|
348
|
+
if e <= horizon + SECONDS_TOLERANCE and not near(e, mapped + new_edges)
|
|
349
|
+
]
|
|
350
|
+
problems += [
|
|
351
|
+
f"missing edge at {format_seconds(m)}"
|
|
352
|
+
for m in mapped
|
|
353
|
+
if m <= horizon + SECONDS_TOLERANCE and not near(m, after[0])
|
|
354
|
+
]
|
|
355
|
+
return problems
|