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