tonable 0.5.2__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.
- tonable/__init__.py +5 -0
- tonable/__main__.py +77 -0
- tonable/bridge.py +208 -0
- tonable/cli.py +353 -0
- tonable/presets.py +119 -0
- tonable/remote_script/Tonable/__init__.py +146 -0
- tonable/remote_script/Tonable/arrangement.py +236 -0
- tonable/remote_script/Tonable/clips.py +364 -0
- tonable/remote_script/Tonable/devices.py +188 -0
- tonable/remote_script/Tonable/dispatch.py +160 -0
- tonable/remote_script/Tonable/edit.py +281 -0
- tonable/remote_script/Tonable/envelopes.py +195 -0
- tonable/remote_script/Tonable/library.py +300 -0
- tonable/remote_script/Tonable/objects.py +134 -0
- tonable/remote_script/Tonable/params.py +170 -0
- tonable/remote_script/Tonable/reading.py +289 -0
- tonable/remote_script/Tonable/server.py +347 -0
- tonable/remote_script/Tonable/song.py +304 -0
- tonable/remote_script/Tonable/version.py +2 -0
- tonable/server.py +78 -0
- tonable/tools/__init__.py +144 -0
- tonable/tools/arrangement.py +62 -0
- tonable/tools/batch.py +53 -0
- tonable/tools/clips.py +179 -0
- tonable/tools/devices.py +207 -0
- tonable/tools/envelopes.py +76 -0
- tonable/tools/session.py +163 -0
- tonable/tools/tracks.py +129 -0
- tonable-0.5.2.dist-info/METADATA +229 -0
- tonable-0.5.2.dist-info/RECORD +34 -0
- tonable-0.5.2.dist-info/WHEEL +4 -0
- tonable-0.5.2.dist-info/entry_points.txt +2 -0
- tonable-0.5.2.dist-info/licenses/LICENSE +21 -0
- tonable-0.5.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +75 -0
tonable/__init__.py
ADDED
tonable/__main__.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""`tonable` serves MCP on stdio; setup commands never start the MCP server."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from tonable import __version__
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv=None) -> int:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="tonable",
|
|
13
|
+
description="Run the local MCP server or install its Live component.",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument("--version", action="version", version=f"tonable {__version__}")
|
|
16
|
+
commands = parser.add_subparsers(dest="command")
|
|
17
|
+
commands.add_parser("serve", help="Run the MCP server on stdio (also the default)")
|
|
18
|
+
commands.add_parser("libraries", help="List existing User Library candidates")
|
|
19
|
+
for name in ("install", "uninstall", "doctor"):
|
|
20
|
+
command = commands.add_parser(name)
|
|
21
|
+
command.add_argument(
|
|
22
|
+
"--user-library", type=Path, help="User Library root folder"
|
|
23
|
+
)
|
|
24
|
+
if name != "doctor":
|
|
25
|
+
command.add_argument(
|
|
26
|
+
"--yes", action="store_true", help="Confirm a unique path"
|
|
27
|
+
)
|
|
28
|
+
command.add_argument("--dry-run", action="store_true", help="Preview only")
|
|
29
|
+
if name == "install":
|
|
30
|
+
command.add_argument(
|
|
31
|
+
"--replace", action="store_true", help="Replace Tonable"
|
|
32
|
+
)
|
|
33
|
+
command.add_argument(
|
|
34
|
+
"--reload",
|
|
35
|
+
action="store_true",
|
|
36
|
+
help="Ask running Live to reload after copying",
|
|
37
|
+
)
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
|
|
40
|
+
if args.command in (None, "serve"):
|
|
41
|
+
from tonable.server import run
|
|
42
|
+
|
|
43
|
+
run()
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
from tonable import cli
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
if args.command == "libraries":
|
|
50
|
+
candidates = cli.discover_libraries()
|
|
51
|
+
for path, origin in candidates:
|
|
52
|
+
print(f"{path} [{origin}]")
|
|
53
|
+
if not candidates:
|
|
54
|
+
print("No existing User Library found; use --user-library PATH.")
|
|
55
|
+
return 0
|
|
56
|
+
if args.command == "doctor":
|
|
57
|
+
return cli.doctor(library=args.user_library)
|
|
58
|
+
library = cli.select_library(args.user_library, yes=args.yes or args.dry_run)
|
|
59
|
+
if args.command == "install":
|
|
60
|
+
cli.install(
|
|
61
|
+
library,
|
|
62
|
+
replace=args.replace,
|
|
63
|
+
dry_run=args.dry_run,
|
|
64
|
+
reload_live=args.reload,
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
cli.uninstall(library, yes=args.yes, dry_run=args.dry_run)
|
|
68
|
+
except (ValueError, OSError) as error:
|
|
69
|
+
parser.error(str(error))
|
|
70
|
+
except (KeyboardInterrupt, EOFError):
|
|
71
|
+
print("Cancelled.", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
sys.exit(main())
|
tonable/bridge.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""The MCP server's connection to the Tonable Remote Script inside Live.
|
|
2
|
+
|
|
3
|
+
The Remote Script writes its port and key to ~/.tonable/session.json. We connect to
|
|
4
|
+
127.0.0.1 only, send the key as the first message, then one JSON request per line and
|
|
5
|
+
read one JSON reply per line.
|
|
6
|
+
|
|
7
|
+
The protocol limits are documented in docs/plan.md and must match
|
|
8
|
+
remote_script/Tonable/server.py (tests/test_bridge.py checks it).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
HOST = "127.0.0.1"
|
|
18
|
+
CONNECT_TIMEOUT = 3.0
|
|
19
|
+
AUTH_TIMEOUT = 3.0
|
|
20
|
+
DEFAULT_TIMEOUT = 10.0
|
|
21
|
+
LONG_TIMEOUT = 30.0
|
|
22
|
+
MAX_MESSAGE_BYTES = 10 * 1024 * 1024
|
|
23
|
+
SESSION_PATH = Path("~/.tonable/session.json").expanduser()
|
|
24
|
+
|
|
25
|
+
NOT_RUNNING_HINT = (
|
|
26
|
+
"Open Live and choose Tonable in Settings -> Link, Tempo & MIDI -> Control "
|
|
27
|
+
"Surface. If Tonable is not in the list, run `tonable install` and restart Live."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BridgeError(Exception):
|
|
32
|
+
"""A failure between us and Live: a stable `code`, and a `hint` on what to do."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, code, message, hint=None):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.code = code
|
|
37
|
+
self.hint = hint
|
|
38
|
+
|
|
39
|
+
def as_dict(self):
|
|
40
|
+
out = {"code": self.code, "error": str(self)}
|
|
41
|
+
if self.hint:
|
|
42
|
+
out["hint"] = self.hint
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_session(path=None):
|
|
47
|
+
"""The Remote Script's session file as a dict. Raises BridgeError("not_running")."""
|
|
48
|
+
path = Path(path or SESSION_PATH)
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
raise BridgeError(
|
|
53
|
+
"not_running", "Tonable is not running in Live", NOT_RUNNING_HINT
|
|
54
|
+
) from None
|
|
55
|
+
except (OSError, ValueError) as err:
|
|
56
|
+
raise BridgeError("bad_session", f"cannot read {path}: {err}") from None
|
|
57
|
+
if not isinstance(data, dict) or not isinstance(data.get("port"), int):
|
|
58
|
+
raise BridgeError("bad_session", f"{path} has no port")
|
|
59
|
+
if not isinstance(data.get("key"), str):
|
|
60
|
+
raise BridgeError("bad_session", f"{path} has no key")
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Bridge:
|
|
65
|
+
"""One connection to Live, opened on first use and reopened when Live restarts.
|
|
66
|
+
|
|
67
|
+
Requests are sent one at a time; the lock keeps parallel tool calls in order.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, session_path=None):
|
|
71
|
+
self._session_path = session_path
|
|
72
|
+
self._sock = None
|
|
73
|
+
self._buffer = bytearray()
|
|
74
|
+
self._next_id = 1
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
self.hello = None
|
|
77
|
+
|
|
78
|
+
def close(self):
|
|
79
|
+
if self._sock is not None:
|
|
80
|
+
try:
|
|
81
|
+
self._sock.close()
|
|
82
|
+
except OSError:
|
|
83
|
+
pass
|
|
84
|
+
self._sock = None
|
|
85
|
+
self._buffer = bytearray()
|
|
86
|
+
self.hello = None
|
|
87
|
+
|
|
88
|
+
def request(self, command, params=None, long=False):
|
|
89
|
+
"""Run `command` in Live and return its result. Raises BridgeError."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
if self._sock is not None and not self._alive():
|
|
92
|
+
self.close()
|
|
93
|
+
if self._sock is None:
|
|
94
|
+
self._connect()
|
|
95
|
+
request_id = self._next_id
|
|
96
|
+
self._next_id += 1
|
|
97
|
+
message = {"id": request_id, "command": command, "params": params or {}}
|
|
98
|
+
if long:
|
|
99
|
+
message["long"] = True
|
|
100
|
+
# Live answers a timeout itself; we wait a connect timeout longer for it.
|
|
101
|
+
wait = (LONG_TIMEOUT if long else DEFAULT_TIMEOUT) + CONNECT_TIMEOUT
|
|
102
|
+
try:
|
|
103
|
+
self._send(message)
|
|
104
|
+
reply = self._receive(wait)
|
|
105
|
+
except ValueError:
|
|
106
|
+
self.close()
|
|
107
|
+
raise BridgeError(
|
|
108
|
+
"protocol", "Live sent a reply that is not JSON"
|
|
109
|
+
) from None
|
|
110
|
+
except (OSError, BridgeError) as err:
|
|
111
|
+
self.close()
|
|
112
|
+
if isinstance(err, BridgeError):
|
|
113
|
+
raise
|
|
114
|
+
raise BridgeError(
|
|
115
|
+
"connection_lost",
|
|
116
|
+
f"the connection to Live broke during {command!r}: {err}. "
|
|
117
|
+
"The command may or may not have run.",
|
|
118
|
+
"Read the state before repeating it.",
|
|
119
|
+
) from None
|
|
120
|
+
if reply.get("id") != request_id:
|
|
121
|
+
self.close()
|
|
122
|
+
raise BridgeError("protocol", "Live answered a different request")
|
|
123
|
+
if not reply.get("ok"):
|
|
124
|
+
raise BridgeError(
|
|
125
|
+
reply.get("code", "error"), reply.get("error", "unknown error")
|
|
126
|
+
)
|
|
127
|
+
return reply.get("result")
|
|
128
|
+
|
|
129
|
+
# --- internals -------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def _connect(self):
|
|
132
|
+
session = read_session(self._session_path)
|
|
133
|
+
try:
|
|
134
|
+
sock = socket.create_connection(
|
|
135
|
+
(HOST, session["port"]), timeout=CONNECT_TIMEOUT
|
|
136
|
+
)
|
|
137
|
+
except OSError:
|
|
138
|
+
raise BridgeError(
|
|
139
|
+
"not_running",
|
|
140
|
+
"Tonable is not answering in Live (the session file is left from "
|
|
141
|
+
"an earlier run)",
|
|
142
|
+
NOT_RUNNING_HINT,
|
|
143
|
+
) from None
|
|
144
|
+
self._sock = sock
|
|
145
|
+
try:
|
|
146
|
+
self._send({"key": session["key"]})
|
|
147
|
+
hello = self._receive(AUTH_TIMEOUT)
|
|
148
|
+
except (OSError, ValueError, BridgeError) as err:
|
|
149
|
+
self.close()
|
|
150
|
+
raise BridgeError(
|
|
151
|
+
"not_running", f"Live did not accept the connection: {err}"
|
|
152
|
+
) from None
|
|
153
|
+
if not hello.get("ok"):
|
|
154
|
+
self.close()
|
|
155
|
+
raise BridgeError(
|
|
156
|
+
hello.get("code", "bad_key"),
|
|
157
|
+
"Live refused the key from the session file",
|
|
158
|
+
"Choose None and then Tonable again in Live's Control Surface list.",
|
|
159
|
+
)
|
|
160
|
+
self.hello = hello
|
|
161
|
+
|
|
162
|
+
def _alive(self):
|
|
163
|
+
"""False when Live has closed our connection (e.g. after a restart)."""
|
|
164
|
+
try:
|
|
165
|
+
self._sock.setblocking(False)
|
|
166
|
+
try:
|
|
167
|
+
return self._sock.recv(1, socket.MSG_PEEK) != b""
|
|
168
|
+
finally:
|
|
169
|
+
self._sock.setblocking(True)
|
|
170
|
+
except BlockingIOError:
|
|
171
|
+
return True
|
|
172
|
+
except OSError:
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
def _send(self, message):
|
|
176
|
+
data = json.dumps(message, ensure_ascii=False).encode("utf-8") + b"\n"
|
|
177
|
+
if len(data) > MAX_MESSAGE_BYTES:
|
|
178
|
+
raise BridgeError("too_large", f"request over {MAX_MESSAGE_BYTES} bytes")
|
|
179
|
+
self._sock.sendall(data)
|
|
180
|
+
|
|
181
|
+
def _receive(self, timeout):
|
|
182
|
+
self._sock.settimeout(timeout)
|
|
183
|
+
while True:
|
|
184
|
+
end = self._buffer.find(b"\n")
|
|
185
|
+
if end >= 0:
|
|
186
|
+
line = bytes(self._buffer[:end])
|
|
187
|
+
del self._buffer[: end + 1]
|
|
188
|
+
return json.loads(line)
|
|
189
|
+
if len(self._buffer) > MAX_MESSAGE_BYTES:
|
|
190
|
+
raise BridgeError("too_large", "Live sent a reply over the size limit")
|
|
191
|
+
try:
|
|
192
|
+
chunk = self._sock.recv(65536)
|
|
193
|
+
except TimeoutError:
|
|
194
|
+
raise BridgeError(
|
|
195
|
+
"timeout",
|
|
196
|
+
f"Live did not answer within {timeout:g} s",
|
|
197
|
+
"Check Live for an open dialog, then read the state before "
|
|
198
|
+
"repeating the command.",
|
|
199
|
+
) from None
|
|
200
|
+
if not chunk:
|
|
201
|
+
raise OSError("Live closed the connection")
|
|
202
|
+
self._buffer += chunk
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def session_permissions_ok(path=None):
|
|
206
|
+
"""True when only the owner can read the session file (it holds the key)."""
|
|
207
|
+
mode = os.stat(path or SESSION_PATH).st_mode & 0o777
|
|
208
|
+
return mode == 0o600
|
tonable/cli.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""`tonable install`, `tonable uninstall` and `tonable doctor`.
|
|
2
|
+
|
|
3
|
+
Live keeps its User Library path in Library.cfg; Live 12.4.16 loads Remote Scripts
|
|
4
|
+
from that configured library, which may differ from the default location.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import xml.etree.ElementTree as ET
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import tonable
|
|
17
|
+
from tonable import bridge
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def platform_paths(platform=None, home=None, environ=None):
|
|
21
|
+
"""Known defaults for discovery; explicit paths work on any OS."""
|
|
22
|
+
platform = platform or sys.platform
|
|
23
|
+
home = Path(home) if home is not None else Path.home()
|
|
24
|
+
environ = os.environ if environ is None else environ
|
|
25
|
+
if platform == "darwin":
|
|
26
|
+
return home / "Library/Preferences/Ableton", home / "Music/Ableton/User Library"
|
|
27
|
+
if platform == "win32":
|
|
28
|
+
appdata = Path(environ.get("APPDATA", home / "AppData/Roaming"))
|
|
29
|
+
return appdata / "Ableton", home / "Documents/Ableton/User Library"
|
|
30
|
+
return None, None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
PREFERENCES, DEFAULT_USER_LIBRARY = platform_paths()
|
|
34
|
+
SCRIPT_NAME = "Tonable"
|
|
35
|
+
MIN_LIVE = (12, 4)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def preference_folders(root=None):
|
|
39
|
+
"""All versioned Live preferences, newest first."""
|
|
40
|
+
root = PREFERENCES if root is None else root
|
|
41
|
+
found = []
|
|
42
|
+
if root is None:
|
|
43
|
+
return found
|
|
44
|
+
for folder in Path(root).glob("Live *"):
|
|
45
|
+
match = re.fullmatch(r"Live (\d+)\.(\d+)(?:\.(\d+))?", folder.name)
|
|
46
|
+
if match and folder.is_dir():
|
|
47
|
+
version = tuple(int(part or 0) for part in match.groups())
|
|
48
|
+
found.append((version, folder))
|
|
49
|
+
return sorted(found, reverse=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def live_preferences(root=None):
|
|
53
|
+
"""The newest versioned Live preferences folder, or (None, None)."""
|
|
54
|
+
found = preference_folders(root)
|
|
55
|
+
if not found:
|
|
56
|
+
return None, None
|
|
57
|
+
version, folder = found[0]
|
|
58
|
+
return folder, version
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def configured_library(preferences_folder):
|
|
62
|
+
"""Read a valid absolute library location; never guess from partial XML."""
|
|
63
|
+
if preferences_folder is None:
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
project = (
|
|
67
|
+
ET.parse(Path(preferences_folder) / "Library.cfg")
|
|
68
|
+
.getroot()
|
|
69
|
+
.find("./ContentLibrary/UserLibrary/LibraryProject")
|
|
70
|
+
)
|
|
71
|
+
except (OSError, ET.ParseError):
|
|
72
|
+
return None
|
|
73
|
+
if project is None:
|
|
74
|
+
return None
|
|
75
|
+
path, name = project.find("ProjectPath"), project.find("ProjectName")
|
|
76
|
+
base = path.get("Value", "") if path is not None else ""
|
|
77
|
+
leaf = name.get("Value", "") if name is not None else ""
|
|
78
|
+
if (
|
|
79
|
+
not Path(base).is_absolute()
|
|
80
|
+
or not leaf
|
|
81
|
+
or leaf in (".", "..")
|
|
82
|
+
or "/" in leaf
|
|
83
|
+
or "\\" in leaf
|
|
84
|
+
):
|
|
85
|
+
return None
|
|
86
|
+
return Path(base) / leaf
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def user_library(preferences_folder):
|
|
90
|
+
"""Configured library or the OS default, for read-only library access."""
|
|
91
|
+
return configured_library(preferences_folder) or DEFAULT_USER_LIBRARY
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def discover_libraries(root=None, default=None):
|
|
95
|
+
"""Existing configured libraries, deduplicated; default only as a fallback."""
|
|
96
|
+
found = {}
|
|
97
|
+
configured = False
|
|
98
|
+
for _, folder in preference_folders(root):
|
|
99
|
+
library = configured_library(folder)
|
|
100
|
+
if library is not None:
|
|
101
|
+
configured = True
|
|
102
|
+
if library.is_dir():
|
|
103
|
+
found.setdefault(library.resolve(), f"{folder.name}/Library.cfg")
|
|
104
|
+
else:
|
|
105
|
+
print(f"Configured library unavailable: {library}", file=sys.stderr)
|
|
106
|
+
default = DEFAULT_USER_LIBRARY if default is None else default
|
|
107
|
+
if not configured and default is not None and Path(default).is_dir():
|
|
108
|
+
found[Path(default).resolve()] = (
|
|
109
|
+
"OS default (verify in Live Settings -> Library)"
|
|
110
|
+
)
|
|
111
|
+
return list(found.items())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def select_library(explicit=None, *, yes=False, interactive=None):
|
|
115
|
+
"""Select one library without silently choosing among Live installations."""
|
|
116
|
+
if explicit is not None:
|
|
117
|
+
library = Path(explicit).expanduser().resolve()
|
|
118
|
+
else:
|
|
119
|
+
candidates = discover_libraries()
|
|
120
|
+
if not candidates:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"No existing User Library found. Use --user-library PATH from "
|
|
123
|
+
"Live Settings -> Library (or an explicit staging folder)."
|
|
124
|
+
)
|
|
125
|
+
for i, (path, origin) in enumerate(candidates, 1):
|
|
126
|
+
print(f"{i}. {path} [{origin}]")
|
|
127
|
+
interactive = sys.stdin.isatty() if interactive is None else interactive
|
|
128
|
+
if len(candidates) > 1:
|
|
129
|
+
if not interactive:
|
|
130
|
+
raise ValueError("Multiple libraries found; use --user-library PATH.")
|
|
131
|
+
try:
|
|
132
|
+
choice = int(input("Select a User Library number (Ctrl-C to cancel): "))
|
|
133
|
+
except ValueError:
|
|
134
|
+
raise ValueError("Enter a listed library number.") from None
|
|
135
|
+
if not 1 <= choice <= len(candidates):
|
|
136
|
+
raise ValueError("Enter a listed library number.")
|
|
137
|
+
library = candidates[choice - 1][0]
|
|
138
|
+
else:
|
|
139
|
+
library = candidates[0][0]
|
|
140
|
+
if not yes:
|
|
141
|
+
if not interactive:
|
|
142
|
+
raise ValueError("Confirm this path with --yes or --user-library PATH.")
|
|
143
|
+
if input(f"Use {library}? [y/N] ").strip().lower() != "y":
|
|
144
|
+
raise ValueError("Cancelled; no files changed.")
|
|
145
|
+
if library == Path(library.anchor) or library.name in (
|
|
146
|
+
"Remote Scripts",
|
|
147
|
+
SCRIPT_NAME,
|
|
148
|
+
):
|
|
149
|
+
raise ValueError("Choose the User Library folder, not a root or script folder.")
|
|
150
|
+
if library.exists() and not library.is_dir():
|
|
151
|
+
raise ValueError(f"User Library is not a directory: {library}")
|
|
152
|
+
return library
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def script_source():
|
|
156
|
+
"""Production resources in a wheel, or in an editable source checkout."""
|
|
157
|
+
packaged = files("tonable").joinpath("remote_script", SCRIPT_NAME)
|
|
158
|
+
if packaged.is_dir():
|
|
159
|
+
return packaged
|
|
160
|
+
return Path(tonable.__file__).resolve().parents[2] / "remote_script" / SCRIPT_NAME
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _copy_resources(source, target):
|
|
164
|
+
target.mkdir()
|
|
165
|
+
for entry in source.iterdir():
|
|
166
|
+
if entry.name == "__pycache__" or entry.name.endswith((".pyc", ".pyo")):
|
|
167
|
+
continue
|
|
168
|
+
destination = target / entry.name
|
|
169
|
+
if entry.is_dir():
|
|
170
|
+
_copy_resources(entry, destination)
|
|
171
|
+
else:
|
|
172
|
+
destination.write_bytes(entry.read_bytes())
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _safe_target(library):
|
|
176
|
+
library = Path(library).expanduser().resolve()
|
|
177
|
+
target = script_target(library)
|
|
178
|
+
for path in (target.parent, target):
|
|
179
|
+
if path.is_symlink():
|
|
180
|
+
raise ValueError(f"Refusing symlink: {path}")
|
|
181
|
+
if path.exists() and not path.is_dir():
|
|
182
|
+
raise ValueError(f"Not a directory: {path}")
|
|
183
|
+
return target
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def script_target(library):
|
|
187
|
+
return Path(library) / "Remote Scripts" / SCRIPT_NAME
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def installed_version(target):
|
|
191
|
+
try:
|
|
192
|
+
text = (Path(target) / "version.py").read_text(encoding="utf-8")
|
|
193
|
+
except OSError:
|
|
194
|
+
return None
|
|
195
|
+
match = re.search(r'VERSION = "([^"]+)"', text)
|
|
196
|
+
return match.group(1) if match else None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def install(library, *, replace=False, dry_run=False, reload_live=False):
|
|
200
|
+
"""Stage resources before replacing only the selected Tonable directory."""
|
|
201
|
+
source = script_source()
|
|
202
|
+
target = _safe_target(library)
|
|
203
|
+
if not source.joinpath("__init__.py").is_file():
|
|
204
|
+
raise ValueError(f"Remote Script resources not found at {source}")
|
|
205
|
+
print(f"Target: {target}")
|
|
206
|
+
if target.exists() and not replace:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
"Tonable already exists; use --replace to replace this folder."
|
|
209
|
+
)
|
|
210
|
+
if dry_run:
|
|
211
|
+
print("Dry run: no files changed; Live was not contacted.")
|
|
212
|
+
return target
|
|
213
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
work = Path(tempfile.mkdtemp(prefix=".tonable-", dir=target.parent))
|
|
215
|
+
staged, previous = work / "Tonable", work / "previous"
|
|
216
|
+
committed = False
|
|
217
|
+
try:
|
|
218
|
+
_copy_resources(source, staged)
|
|
219
|
+
if target.exists():
|
|
220
|
+
target.rename(previous)
|
|
221
|
+
try:
|
|
222
|
+
staged.rename(target)
|
|
223
|
+
except OSError:
|
|
224
|
+
if previous.exists():
|
|
225
|
+
previous.rename(target)
|
|
226
|
+
raise
|
|
227
|
+
committed = True
|
|
228
|
+
finally:
|
|
229
|
+
if committed or not previous.exists():
|
|
230
|
+
shutil.rmtree(work)
|
|
231
|
+
else:
|
|
232
|
+
print(f"Previous installation preserved at {previous}", file=sys.stderr)
|
|
233
|
+
print(f"Installed Tonable {installed_version(target)} to {target}")
|
|
234
|
+
if reload_live and _reload_running_live():
|
|
235
|
+
return target
|
|
236
|
+
print(
|
|
237
|
+
"Next: restart Live, then choose Tonable in Settings -> Link, Tempo & MIDI ->"
|
|
238
|
+
)
|
|
239
|
+
print("Control Surface (Input and Output: None).")
|
|
240
|
+
return target
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _reload_running_live(session_path=None):
|
|
244
|
+
"""Ask a running Tonable to load the new code; True when it did."""
|
|
245
|
+
link = bridge.Bridge(session_path)
|
|
246
|
+
try:
|
|
247
|
+
result = link.request("reload")
|
|
248
|
+
except bridge.BridgeError as err:
|
|
249
|
+
if err.code == "unknown_command":
|
|
250
|
+
print("Live runs an older Tonable that cannot reload: restart Live once.")
|
|
251
|
+
return False
|
|
252
|
+
finally:
|
|
253
|
+
link.close()
|
|
254
|
+
print(f"Live reloaded Tonable {result['script_version']}: no restart needed.")
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def uninstall(library, *, yes=False, dry_run=False):
|
|
259
|
+
target = _safe_target(library)
|
|
260
|
+
print(f"Target: {target}")
|
|
261
|
+
if dry_run:
|
|
262
|
+
print("Dry run: no files changed.")
|
|
263
|
+
return
|
|
264
|
+
if not yes:
|
|
265
|
+
if not sys.stdin.isatty() or input("Remove this Tonable folder? [y/N] ") != "y":
|
|
266
|
+
raise ValueError("Removal needs confirmation; use --yes for this target.")
|
|
267
|
+
if target.exists():
|
|
268
|
+
shutil.rmtree(target)
|
|
269
|
+
print(f"Removed {target}")
|
|
270
|
+
else:
|
|
271
|
+
print(f"Nothing to remove: {target} does not exist")
|
|
272
|
+
print("In Live, set the Tonable Control Surface slot back to None.")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def doctor(session_path=None, library=None):
|
|
276
|
+
"""Print each check with OK or FAIL; return 0 when all pass."""
|
|
277
|
+
failures = 0
|
|
278
|
+
|
|
279
|
+
def report(ok, text, hint=None):
|
|
280
|
+
nonlocal failures
|
|
281
|
+
print(("OK " if ok else "FAIL ") + text)
|
|
282
|
+
if not ok:
|
|
283
|
+
failures += 1
|
|
284
|
+
if hint:
|
|
285
|
+
print(" " + hint)
|
|
286
|
+
|
|
287
|
+
prefs, version = live_preferences()
|
|
288
|
+
if prefs is None:
|
|
289
|
+
report(
|
|
290
|
+
False,
|
|
291
|
+
"Live preferences not found",
|
|
292
|
+
"Start Live once, then run doctor again.",
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
shown = ".".join(str(v) for v in version)
|
|
296
|
+
report(
|
|
297
|
+
version[:2] >= MIN_LIVE,
|
|
298
|
+
f"Live {shown} (preferences in {prefs})",
|
|
299
|
+
"Tonable needs Live 12.4 or newer.",
|
|
300
|
+
)
|
|
301
|
+
library = (
|
|
302
|
+
Path(library).expanduser().resolve()
|
|
303
|
+
if library is not None
|
|
304
|
+
else user_library(prefs)
|
|
305
|
+
)
|
|
306
|
+
if library is None:
|
|
307
|
+
report(False, "No User Library known; use --user-library PATH")
|
|
308
|
+
return 1
|
|
309
|
+
report(library.is_dir(), f"User Library: {library}")
|
|
310
|
+
target = script_target(library)
|
|
311
|
+
found = installed_version(target)
|
|
312
|
+
report(
|
|
313
|
+
found == tonable.__version__,
|
|
314
|
+
f"Remote Script in {target}: version {found or 'missing'}, "
|
|
315
|
+
f"server {tonable.__version__}",
|
|
316
|
+
"Run `tonable install` and restart Live.",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
path = Path(session_path or bridge.SESSION_PATH)
|
|
320
|
+
if not path.exists():
|
|
321
|
+
report(False, f"Session file {path}", bridge.NOT_RUNNING_HINT)
|
|
322
|
+
return 1
|
|
323
|
+
report(bridge.session_permissions_ok(path), f"Session file {path} is owner-only")
|
|
324
|
+
try:
|
|
325
|
+
session = bridge.read_session(path)
|
|
326
|
+
os.kill(session.get("pid", 0), 0)
|
|
327
|
+
alive = True
|
|
328
|
+
except (bridge.BridgeError, OSError, TypeError):
|
|
329
|
+
alive = False
|
|
330
|
+
report(
|
|
331
|
+
alive, "Live process from the session file is running", bridge.NOT_RUNNING_HINT
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
link = bridge.Bridge(path)
|
|
335
|
+
try:
|
|
336
|
+
status = link.request("status")
|
|
337
|
+
except bridge.BridgeError as err:
|
|
338
|
+
report(False, f"Connection to Live: {err}", err.hint)
|
|
339
|
+
else:
|
|
340
|
+
s = status["set"]
|
|
341
|
+
report(True, f"Connection to Live {status['live_version']}")
|
|
342
|
+
report(
|
|
343
|
+
status["script_version"] == tonable.__version__,
|
|
344
|
+
f"Remote Script running in Live: version {status['script_version']}",
|
|
345
|
+
"Restart Live so it loads the installed version.",
|
|
346
|
+
)
|
|
347
|
+
print(
|
|
348
|
+
f" Set: {s['tempo']:g} BPM, {s['time_signature']}, "
|
|
349
|
+
f"{s['tracks']} tracks, {s['return_tracks']} returns, {s['scenes']} scenes"
|
|
350
|
+
)
|
|
351
|
+
finally:
|
|
352
|
+
link.close()
|
|
353
|
+
return 1 if failures else 0
|