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 ADDED
@@ -0,0 +1,5 @@
1
+ """Tonable: MCP server for Ableton Live 12."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("tonable")
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