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