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,143 @@
1
+ """Export the mix (TW-1204), with the user: Trackwright sets the range and opens Cubase's dialog, the user
2
+ chooses the format and exports, and the confirm phase finds the file and checks its duration.
3
+
4
+ Verified in Cubase Elements 15.0.30 (experiments/tw1204): the dialog has no range field and exports between
5
+ the locators; Select All + Locators to Selection put them around all the audio; it proposes
6
+ ``<project folder>/Mixdown`` and the project name.
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 operation
16
+ from trackwright.core.models import ExportedFileOut, ExportOut
17
+ from trackwright.core.project import format_seconds
18
+ from trackwright.core.ranges import TAG_RECORD, TAG_START, RangeMixin
19
+ from trackwright.errors import PreconditionFailedError, TrackwrightError
20
+
21
+ DURATION_TOLERANCE = 0.15
22
+ LOSSY_SUFFIXES = frozenset({".mp3", ".m4a", ".ogg"})
23
+
24
+
25
+ def duration_tolerance(path: str, expected: float) -> float:
26
+ """Lossy files report an *estimated* duration and lose or gain a few frames at the end: an MP3 of a
27
+ 139.976 s range measured 139.721 s (verified, TW-1204), a 28 s one 27.991 s."""
28
+ if Path(path).suffix.lower() in LOSSY_SUFFIXES:
29
+ return 0.5 + expected * 0.002
30
+ return DURATION_TOLERANCE
31
+
32
+
33
+ INSTRUCTIONS = (
34
+ "Cubase shows 'Export Audio Mixdown'. Check the channel (Stereo Out), the name, the folder and the format "
35
+ "(WAV for archiving, MP3 for publishing), then click 'Export Audio'. Afterwards call this tool with "
36
+ "phase 'confirm' and the same 'since' and 'expected_seconds'."
37
+ )
38
+
39
+
40
+ class ExportMixin(RangeMixin):
41
+ def _locators_around_all_audio(self, transport: int) -> tuple[float, float]:
42
+ """Select All + Locators to Selection (verified), then read both locators in seconds."""
43
+ self._range_cmd("tool.select")
44
+ self._range_cmd("edit.select_all")
45
+ self._range_cmd("transport.locators_to_selection")
46
+ self._clear_selection()
47
+ cursor = self._cursor_seconds(transport)
48
+ primary = self.call("transport.get").get("primary") or {}
49
+ bounds = []
50
+ for field in ("cycleLeft", "cycleRight"):
51
+ bars = str((primary.get(field) or {}).get("time", ""))
52
+ self.call("transport.setTime", {"which": "locator", "display": "primary", "time": bars})
53
+ self.wait_until(self._cursor_bars, bars.__eq__)
54
+ bounds.append(self._cursor_seconds(transport))
55
+ self._move_cursor_seconds(transport, cursor)
56
+ return bounds[0], bounds[1]
57
+
58
+ def _export_folder(self) -> Path | None:
59
+ folders = self.launcher.project_folders() if self.launcher is not None else []
60
+ return Path(folders[0]) / "Mixdown" if folders else None
61
+
62
+ @operation
63
+ def export_mix(
64
+ self,
65
+ phase: Literal["start", "confirm"],
66
+ *,
67
+ start: str | None = None,
68
+ end: str | None = None,
69
+ unit: str = "seconds",
70
+ since: float | None = None,
71
+ expected_seconds: float | None = None,
72
+ folder: str | None = None,
73
+ dry_run: bool = True,
74
+ ) -> ExportOut:
75
+ if phase == "confirm":
76
+ return self._confirm_export(since, expected_seconds, folder)
77
+ if (start is None) != (end is None):
78
+ raise PreconditionFailedError("give both start and end, or neither for the whole project",
79
+ code="BAD_REQUEST") # fmt: skip
80
+ transport = self._transport_id()
81
+ guess = self._export_folder()
82
+ out = ExportOut(phase="start", status="planned", dry_run=dry_run, start=None, end=None,
83
+ expected_seconds=None, folder=str(guess) if guess else None, since=None) # fmt: skip
84
+ if start is not None and end is not None:
85
+ cursor = self._cursor_seconds(transport)
86
+ span = (self._seconds_of(transport, start, unit), self._seconds_of(transport, end, unit))
87
+ self._move_cursor_seconds(transport, cursor)
88
+ if not 0 <= span[0] < span[1]:
89
+ raise PreconditionFailedError("the range must have 0 <= start < end", code="BAD_TIME")
90
+ out.start, out.end = format_seconds(span[0]), format_seconds(span[1])
91
+ out.expected_seconds = round(span[1] - span[0], 3)
92
+ else:
93
+ out.notes.append("whole project: the locators will go around all the audio")
94
+ if dry_run:
95
+ return out
96
+ self.policy.check_project_write()
97
+ if self._transport_flag(transport, TAG_START) or self._transport_flag(transport, TAG_RECORD):
98
+ raise PreconditionFailedError("stop the transport before exporting", code="TRANSPORT_RUNNING")
99
+ if out.expected_seconds is None:
100
+ span = self._locators_around_all_audio(transport)
101
+ out.start, out.end = format_seconds(span[0]), format_seconds(span[1])
102
+ out.expected_seconds = round(span[1] - span[0], 3)
103
+ else:
104
+ cursor = self._cursor_seconds(transport)
105
+ self._set_locator_seconds(transport, "left", self._seconds_of(transport, str(start), unit))
106
+ self._set_locator_seconds(transport, "right", self._seconds_of(transport, str(end), unit))
107
+ self._move_cursor_seconds(transport, cursor)
108
+ if not self._performable("file.export_audio_mixdown"):
109
+ raise TrackwrightError("Cubase cannot open the export dialog now (is another dialog open?)",
110
+ code="CANNOT_PERFORM") # fmt: skip
111
+ out.since = time.time()
112
+ self._range_cmd("file.export_audio_mixdown")
113
+ out.status = "awaiting_user"
114
+ out.instructions = INSTRUCTIONS
115
+ out.notes.append("the locators stay on the exported range")
116
+ return out
117
+
118
+ def _confirm_export(self, since: float | None, expected: float | None, folder: str | None) -> ExportOut:
119
+ if since is None:
120
+ raise PreconditionFailedError("give 'since' from the start phase", code="BAD_REQUEST")
121
+ where = Path(folder).expanduser() if folder else self._export_folder()
122
+ out = ExportOut(phase="confirm", status="not_found", dry_run=False, start=None, end=None,
123
+ expected_seconds=expected, folder=str(where) if where else None, since=since) # fmt: skip
124
+ if where is None:
125
+ out.instructions = "the export folder is unknown: give 'folder' (the path shown in Cubase's dialog)"
126
+ return out
127
+ for found in self.exports.find(where, since):
128
+ matches, difference = None, None
129
+ if expected is not None and found.duration_seconds is not None:
130
+ difference = round(found.duration_seconds - expected, 3)
131
+ matches = abs(difference) <= duration_tolerance(found.path, expected)
132
+ out.files.append(ExportedFileOut(path=found.path, size_bytes=found.size_bytes,
133
+ duration_seconds=found.duration_seconds,
134
+ difference_seconds=difference, duration_matches=matches)) # fmt: skip
135
+ if out.files:
136
+ out.status = "done"
137
+ out.verified = out.files[0].duration_matches
138
+ else:
139
+ out.instructions = (
140
+ "no audio file newer than the start phase in that folder: finish the export in Cubase, or give "
141
+ "'folder' if you chose another one"
142
+ )
143
+ return out
@@ -0,0 +1,127 @@
1
+ """Import an audio file, with the user (TW-1203).
2
+
3
+ Verified in Cubase Elements 15.0.30 (experiments/tw1203): ``File / Import Audio File`` opens the macOS file
4
+ selector, the file goes to the **selected track at the cursor**, and Cubase copies it to
5
+ ``<project>/Audio``. While the selector is open **Cubase does not answer the script**, so the start phase
6
+ makes no call after opening it. The confirm phase probes the track's event edges.
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 operation
16
+ from trackwright.core.exporting import ExportMixin
17
+ from trackwright.core.models import ImportOut
18
+ from trackwright.core.project import SECONDS_TOLERANCE, format_seconds
19
+ from trackwright.core.ranges import TAG_RECORD, TAG_START
20
+ from trackwright.core.refs import TrackSelector
21
+ from trackwright.errors import PreconditionFailedError, RequestTimeoutError, TrackwrightError
22
+ from trackwright.exports import AUDIO_SUFFIXES
23
+
24
+ EDGE_TOLERANCE = 0.01
25
+
26
+
27
+ class ImportMixin(ExportMixin):
28
+ @operation
29
+ def import_audio(
30
+ self,
31
+ phase: Literal["start", "confirm"],
32
+ track: str,
33
+ position: str,
34
+ *,
35
+ unit: str = "seconds",
36
+ file: str | None = None,
37
+ since: float | None = None,
38
+ dry_run: bool = True,
39
+ ) -> ImportOut:
40
+ path = Path(file).expanduser() if file else None
41
+ expected = None
42
+ if path is not None:
43
+ if not path.is_file() or path.suffix.lower() not in AUDIO_SUFFIXES:
44
+ raise PreconditionFailedError(f"not an audio file: {path}", code="NOT_FOUND")
45
+ expected = self.exports.duration(path)
46
+ if phase == "confirm":
47
+ return self._confirm_import(track, position, unit, path=path, expected=expected, since=since)
48
+ item = self.resolve_track(TrackSelector.parse(track))
49
+ transport = self._transport_id()
50
+ cursor = self._cursor_seconds(transport)
51
+ at = self._seconds_of(transport, position, unit)
52
+ self._move_cursor_seconds(transport, cursor)
53
+ out = ImportOut(phase="start", status="planned", dry_run=dry_run, track=self.track_info(item).name,
54
+ position=format_seconds(at), file=str(path) if path else None, expected_seconds=expected,
55
+ since=None) # fmt: skip
56
+ out.notes.append("the audio goes to this track at this position; Cubase copies the file into the project")
57
+ if dry_run:
58
+ return out
59
+ self.policy.check_track(out.track)
60
+ if self._transport_flag(transport, TAG_START) or self._transport_flag(transport, TAG_RECORD):
61
+ raise PreconditionFailedError("stop the transport before importing", code="TRANSPORT_RUNNING")
62
+ if not self._performable("file.import_audio"):
63
+ raise TrackwrightError("Cubase cannot open the import dialog now (is another dialog open?)",
64
+ code="CANNOT_PERFORM") # fmt: skip
65
+ self._select_channel(int(item["id"]))
66
+ self._move_cursor_seconds(transport, at)
67
+ out.since = time.time()
68
+ self._range_cmd("file.import_audio") # from here Cubase is blocked until the user closes the selector
69
+ out.status = "awaiting_user"
70
+ pick = f"press Cmd+Shift+G, paste {path} and open it" if path else "choose the audio file and open it"
71
+ out.instructions = (
72
+ f"Cubase shows the file selector: {pick}. Accept the import options (Cubase copies the file into "
73
+ "the project). Cubase does not answer until the selector is closed. Then call this tool with phase "
74
+ "'confirm', the same track, position and file, and 'since'."
75
+ )
76
+ return out
77
+
78
+ def _confirm_import(
79
+ self,
80
+ track: str,
81
+ position: str,
82
+ unit: str,
83
+ *,
84
+ path: Path | None,
85
+ expected: float | None,
86
+ since: float | None,
87
+ ) -> ImportOut:
88
+ if since is None:
89
+ raise PreconditionFailedError("give 'since' from the start phase", code="BAD_REQUEST")
90
+ out = ImportOut(phase="confirm", status="not_found", dry_run=False, track=track, position=position,
91
+ file=str(path) if path else None, expected_seconds=expected, since=since) # fmt: skip
92
+ try:
93
+ item = self.resolve_track(TrackSelector.parse(track))
94
+ transport = self._transport_id()
95
+ except RequestTimeoutError:
96
+ out.status = "awaiting_user"
97
+ out.instructions = "Cubase does not answer yet: the file selector is probably still open"
98
+ return out
99
+ cursor = self._cursor_seconds(transport)
100
+ at = self._seconds_of(transport, position, unit)
101
+ out.track, out.position = self.track_info(item).name, format_seconds(at)
102
+ selected = self.call("da.base").get("base")
103
+ self._select_channel(int(item["id"]))
104
+ edges, _ = self.probe_edges(transport, max(at - 1.0, 0.0), until=at + (expected or 0.0) + 1.0)
105
+ self._restore(transport, {}, cursor, selected)
106
+ wanted = [at] + ([at + expected] if expected is not None else [])
107
+ found = [e for e in edges if any(abs(e - w) <= EDGE_TOLERANCE + SECONDS_TOLERANCE for w in wanted)]
108
+ out.edges_found = [format_seconds(e) for e in found]
109
+ project = self._project_folder_for_import()
110
+ if project is not None:
111
+ out.copied_to_project = [Path(f.path).name for f in self.exports.find(project / "Audio", since)]
112
+ if len(found) == len(wanted):
113
+ out.status, out.verified = "done", True
114
+ else:
115
+ out.instructions = (
116
+ "the expected event edges are not on that track: the import was cancelled, went elsewhere, or "
117
+ "the file has another duration"
118
+ )
119
+ out.verified = False
120
+ if expected is None:
121
+ out.notes.append("only the start was checked: give 'file' to check the end too")
122
+ out.notes.append("Undo in Cubase removes the event, not the copy in the project's Audio folder")
123
+ return out
124
+
125
+ def _project_folder_for_import(self) -> Path | None:
126
+ folders = self.launcher.project_folders() if self.launcher is not None else []
127
+ return Path(folders[0]) if folders else None
@@ -0,0 +1,74 @@
1
+ """Opening Cubase (TW-1201). Trackwright never closes Cubase, forces it to quit or saves projects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from trackwright.core.base import ServiceBase, operation
9
+ from trackwright.core.models import LaunchOut
10
+ from trackwright.errors import PreconditionFailedError, TrackwrightError
11
+
12
+ POLL_SECONDS = 1.0
13
+
14
+
15
+ class LifecycleMixin(ServiceBase):
16
+ def _script_version(self) -> str | None:
17
+ """The script's version when it answers, else ``None`` (Cubase closed, loading or showing a dialog)."""
18
+ try:
19
+ return str(self.call("hello").get("scriptVersion") or "?")
20
+ except TrackwrightError:
21
+ return None
22
+
23
+ @operation
24
+ def launch_cubase(
25
+ self, project: str | None = None, *, dry_run: bool = True, timeout: float | None = None
26
+ ) -> LaunchOut:
27
+ if self.launcher is None:
28
+ raise TrackwrightError("this server cannot open applications", code="UNSUPPORTED")
29
+ app = self.launcher.app
30
+ project_path = self._project_path(project)
31
+ version = self._script_version()
32
+ running = self.launcher.is_running()
33
+ if version is not None:
34
+ return LaunchOut(status="already_running", app=app, dry_run=dry_run, process_running=True,
35
+ script_version=version, project=None) # fmt: skip
36
+ if running:
37
+ return LaunchOut(
38
+ status="running_without_script", app=app, dry_run=dry_run, process_running=True,
39
+ script_version=None, project=None,
40
+ instructions="Cubase is open but the Trackwright script does not answer. Open or create a project, "
41
+ "close any dialog, and check Studio > MIDI Remote Manager (run 'trackwright doctor'). "
42
+ "A second instance is not opened.",
43
+ ) # fmt: skip
44
+ plan = LaunchOut(status="planned", app=app, dry_run=dry_run, process_running=False, script_version=None,
45
+ project=str(project_path) if project_path else None) # fmt: skip
46
+ if dry_run:
47
+ return plan
48
+ self.policy.check_project_write()
49
+ self.launcher.launch(project_path)
50
+ deadline = time.monotonic() + (timeout if timeout is not None else self.launcher.launch_timeout)
51
+ while time.monotonic() < deadline:
52
+ self._sleep(POLL_SECONDS)
53
+ version = self._script_version()
54
+ if version is not None:
55
+ plan.status, plan.script_version, plan.process_running = "ready", version, True
56
+ return plan
57
+ plan.process_running = self.launcher.is_running()
58
+ plan.status = "awaiting_user"
59
+ plan.instructions = (
60
+ "Cubase is starting but the script has not answered yet. Look at Cubase: answer any dialog "
61
+ "(Steinberg Hub, project choice, audio device) and open a project; then call cubase_status."
62
+ )
63
+ return plan
64
+
65
+ @staticmethod
66
+ def _project_path(project: str | None) -> Path | None:
67
+ if project is None:
68
+ return None
69
+ path = Path(project).expanduser()
70
+ if path.suffix.lower() != ".cpr":
71
+ raise PreconditionFailedError("project must be a Cubase .cpr file", code="BAD_REQUEST")
72
+ if not path.is_file():
73
+ raise PreconditionFailedError(f"project not found: {path}", code="NOT_FOUND")
74
+ return path.resolve()