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.
@@ -0,0 +1,289 @@
1
+ """read_set, read_track and read_device. Main thread only; nothing here changes Live."""
2
+
3
+ import difflib
4
+
5
+ from . import objects, params
6
+ from .server import CommandError
7
+
8
+ # Device.type
9
+ DEVICE_KINDS = {1: "instrument", 2: "audio_effect", 4: "midi_effect"}
10
+
11
+
12
+ def read_set(song):
13
+ """The Set at a glance: transport, tracks, returns, scenes and their clips."""
14
+ tracks = list(song.tracks)
15
+ scenes = []
16
+ for s, scene in enumerate(song.scenes):
17
+ entry = {"index": s, "name": scene.name}
18
+ if _get(scene, "tempo_enabled", False):
19
+ entry["tempo"] = scene.tempo
20
+ clips = {}
21
+ for t, track in enumerate(tracks):
22
+ slots = list(track.clip_slots)
23
+ if s < len(slots) and slots[s].has_clip:
24
+ clips[f"t{t}"] = slots[s].clip.name
25
+ entry["clips"] = clips
26
+ scenes.append(entry)
27
+ return {
28
+ "tempo": song.tempo,
29
+ "time_signature": f"{song.signature_numerator}/{song.signature_denominator}",
30
+ "is_playing": bool(song.is_playing),
31
+ "loop": {
32
+ "on": bool(song.loop),
33
+ "start_beats": song.loop_start,
34
+ "length_beats": song.loop_length,
35
+ },
36
+ "tracks": [_track_summary(song, track) for track in tracks],
37
+ "returns": [
38
+ {"path": "r" + objects.return_letter(i), "name": track.name}
39
+ for i, track in enumerate(song.return_tracks)
40
+ ],
41
+ "master": {"path": "m", "name": song.master_track.name},
42
+ "scenes": scenes,
43
+ "locators": [
44
+ {"name": cue.name, "time_beats": cue.time} for cue in song.cue_points
45
+ ],
46
+ }
47
+
48
+
49
+ def read_track(song, ref):
50
+ """One track: mixer, routing, the device tree and its clips."""
51
+ track = objects.track(song, ref)
52
+ path = objects.track_path(song, track) if ref != "m" else "m"
53
+ out = _track_summary(song, track)
54
+ out["path"] = path
55
+ out["mixer"] = _mixer(song, track)
56
+ routing = _routing(track)
57
+ if routing:
58
+ out["routing"] = routing
59
+ out["devices"] = _devices(track, path)
60
+ clips = [
61
+ _clip(i, slot.clip)
62
+ for i, slot in enumerate(_get(track, "clip_slots", []) or [])
63
+ if slot.has_clip
64
+ ]
65
+ if path.startswith("t"):
66
+ out["session_clips"] = clips
67
+ out["arrangement_clips"] = [
68
+ {"name": c.name, "start_beats": c.start_time, "end_beats": c.end_time}
69
+ for c in _get(track, "arrangement_clips", []) or []
70
+ ]
71
+ return out
72
+
73
+
74
+ def read_device(song, path, wanted=None, all_params=False):
75
+ """A device, chain or pad. Racks show their macros; other devices all parameters.
76
+
77
+ `wanted` lists parameter names to show (any device); `all_params` shows every
78
+ parameter of a rack too.
79
+ """
80
+ kind, obj, path = objects.resolve(song, path)
81
+ if kind == "track":
82
+ raise CommandError(
83
+ "bad_address",
84
+ f"{path} is a track: use read_track, or {path}/d0 for a device",
85
+ )
86
+ if kind in ("chain", "pad"):
87
+ out = {"path": path, "kind": kind, "name": obj.name}
88
+ if kind == "pad":
89
+ out["note"] = objects.note_name(obj.note)
90
+ for flag in ("mute", "solo"):
91
+ if _get(obj, flag, False):
92
+ out[flag] = True
93
+ if kind == "chain":
94
+ out["devices"] = _devices(obj, path)
95
+ else:
96
+ out["chains"] = _chains(obj, path)
97
+ return out
98
+
99
+ device = obj
100
+ plist = list(device.parameters)
101
+ out = {"path": path, **_device_head(device)}
102
+ if plist:
103
+ out["on"] = _is_on(plist[0])
104
+ is_rack = bool(_get(device, "can_have_chains", False))
105
+ if is_rack:
106
+ out["macros"] = _macros(device, plist)
107
+ out["chains"] = _chains(device, path)
108
+ if wanted:
109
+ out["parameters"] = _pick(plist, wanted)
110
+ elif all_params or not is_rack:
111
+ # Parameter 0 is Device On, already shown as "on".
112
+ out["parameters"] = [params.describe(p, i) for i, p in enumerate(plist) if i]
113
+ return out
114
+
115
+
116
+ # --- pieces ------------------------------------------------------------------
117
+
118
+
119
+ def _track_summary(song, track):
120
+ out = {"path": objects.track_path(song, track), "name": track.name}
121
+ out["kind"] = _track_kind(track)
122
+ for flag in ("mute", "solo", "arm"):
123
+ if _get(track, flag, False):
124
+ out[flag] = True
125
+ if _get(track, "is_grouped", False) and track.group_track is not None:
126
+ out["group"] = objects.track_path(song, track.group_track)
127
+ devices = list(track.devices)
128
+ if devices:
129
+ out["device_names"] = [d.name for d in devices]
130
+ return out
131
+
132
+
133
+ def _track_kind(track):
134
+ if _get(track, "is_foldable", False):
135
+ return "group"
136
+ if _get(track, "has_midi_input", False):
137
+ return "midi"
138
+ if _get(track, "has_audio_input", False):
139
+ return "audio"
140
+ return "return_or_master"
141
+
142
+
143
+ def _mixer(song, track):
144
+ mixer = track.mixer_device
145
+ out = {
146
+ "volume": params.display(mixer.volume),
147
+ "pan": params.display(mixer.panning),
148
+ }
149
+ sends = list(_get(mixer, "sends", []) or [])
150
+ if sends:
151
+ out["sends"] = [
152
+ {"return": objects.return_letter(i), "level": params.display(send)}
153
+ for i, send in enumerate(sends)
154
+ ]
155
+ return out
156
+
157
+
158
+ def _routing(track):
159
+ out = {}
160
+ for key, attr in (
161
+ ("input", "input_routing_type"),
162
+ ("output", "output_routing_type"),
163
+ ):
164
+ value = _get(track, attr, None)
165
+ name = _get(value, "display_name", None)
166
+ if name:
167
+ out[key] = name
168
+ return out
169
+
170
+
171
+ def _clip(slot, clip):
172
+ out = {"slot": slot, "name": clip.name, "length_beats": clip.length}
173
+ out["type"] = "midi" if clip.is_midi_clip else "audio"
174
+ if clip.looping:
175
+ out["looping"] = True
176
+ if clip.is_playing:
177
+ out["playing"] = True
178
+ return out
179
+
180
+
181
+ def _device_head(device):
182
+ out = {"name": device.name, "class": device.class_name}
183
+ kind = DEVICE_KINDS.get(int(_get(device, "type", 0)))
184
+ if kind:
185
+ out["kind"] = kind
186
+ if _get(device, "can_have_drum_pads", False):
187
+ out["rack"] = "drum_rack"
188
+ elif _get(device, "can_have_chains", False):
189
+ out["rack"] = "rack"
190
+ return out
191
+
192
+
193
+ def _devices(holder, prefix):
194
+ """The device tree below a track or chain, with racks opened up."""
195
+ out = []
196
+ for i, device in enumerate(holder.devices):
197
+ path = f"{prefix}/d{i}"
198
+ entry = {"path": path, **_device_head(device)}
199
+ plist = list(device.parameters)
200
+ if plist and not _is_on(plist[0]):
201
+ entry["on"] = False
202
+ if _get(device, "can_have_chains", False):
203
+ entry["chains"] = _chains(device, path)
204
+ out.append(entry)
205
+ return out
206
+
207
+
208
+ def _chains(device, path):
209
+ """Chains of a rack, or the filled pads of a Drum Rack, each with its devices."""
210
+ if _get(device, "can_have_drum_pads", False):
211
+ pads = []
212
+ for pad in device.drum_pads:
213
+ chains = list(pad.chains)
214
+ if not chains:
215
+ continue
216
+ pad_path = f"{path}/p{objects.note_name(pad.note)}"
217
+ entry = {
218
+ "path": pad_path,
219
+ "name": pad.name,
220
+ "note": objects.note_name(pad.note),
221
+ "devices": _devices(chains[0], pad_path + "/c0"),
222
+ }
223
+ if len(chains) > 1:
224
+ entry["chain_count"] = len(chains)
225
+ pads.append(entry)
226
+ return pads
227
+ out = []
228
+ for c, chain in enumerate(_get(device, "chains", []) or []):
229
+ chain_path = f"{path}/c{c}"
230
+ out.append(
231
+ {
232
+ "path": chain_path,
233
+ "name": chain.name,
234
+ "devices": _devices(chain, chain_path),
235
+ }
236
+ )
237
+ return out
238
+
239
+
240
+ def _macros(rack, plist):
241
+ """The rack's visible, mapped macro knobs (Live 12 racks have up to 16)."""
242
+ macros = plist[1:]
243
+ visible = _get(rack, "visible_macro_count", len(macros))
244
+ mapped = list(_get(rack, "macros_mapped", []) or [True] * len(macros))
245
+ out = []
246
+ for i, param in enumerate(macros[:visible]):
247
+ if i < len(mapped) and mapped[i]:
248
+ out.append(params.describe(param, i + 1))
249
+ return out
250
+
251
+
252
+ def _pick(plist, wanted):
253
+ """Parameters by name (case-insensitive), with close matches for typos."""
254
+ by_name = {}
255
+ for i, p in enumerate(plist):
256
+ by_name.setdefault(p.name.casefold(), (i, p))
257
+ out = []
258
+ for name in wanted:
259
+ hit = by_name.get(str(name).strip().casefold())
260
+ if hit:
261
+ out.append(params.describe(hit[1], hit[0]))
262
+ else:
263
+ out.append(
264
+ {
265
+ "name": name,
266
+ "error": "no such parameter",
267
+ "similar": _similar(name, plist),
268
+ }
269
+ )
270
+ return out
271
+
272
+
273
+ def _similar(name, plist):
274
+ # difflib's defaults: up to 3 names, similarity 0.6 or more.
275
+ return difflib.get_close_matches(str(name), [p.name for p in plist])
276
+
277
+
278
+ def _is_on(device_on):
279
+ """Parameter 0 of every device is Device On, quantized Off/On."""
280
+ return device_on.value > device_on.min
281
+
282
+
283
+ def _get(obj, name, default=None):
284
+ """An optional Live property. Live raises RuntimeError, not AttributeError, for
285
+ some that a track type lacks (the master's mute, a return's arm)."""
286
+ try:
287
+ return getattr(obj, name)
288
+ except Exception:
289
+ return default
@@ -0,0 +1,347 @@
1
+ """The Remote Script's socket side: loopback only, a key, one JSON message per line.
2
+
3
+ Socket threads only read and write. Every command runs on Live's main thread, which
4
+ calls `run_pending` from `update_display`. A command that Live hasn't started when its
5
+ time is up is cancelled, so it can never run late and reorder edits (an idea from
6
+ ableton-live-mcp).
7
+
8
+ The protocol limits are documented in docs/plan.md. The MCP server keeps the same
9
+ values in src/tonable/bridge.py; tests/test_bridge.py checks that they match.
10
+ """
11
+
12
+ import hmac
13
+ import json
14
+ import os
15
+ import queue
16
+ import secrets
17
+ import socket
18
+ import threading
19
+ import time
20
+ import traceback
21
+
22
+ # Fixed: never read from settings or the environment.
23
+ HOST = "127.0.0.1"
24
+ AUTH_TIMEOUT = 3.0
25
+ DEFAULT_TIMEOUT = 10.0
26
+ LONG_TIMEOUT = 30.0
27
+ MAX_MESSAGE_BYTES = 10 * 1024 * 1024
28
+ KEY_BYTES = 32
29
+ SESSION_PATH = os.path.expanduser("~/.tonable/session.json")
30
+
31
+ # A web page can make a browser send these to a loopback port; we never answer them.
32
+ HTTP_METHODS = (
33
+ b"GET ",
34
+ b"POST ",
35
+ b"PUT ",
36
+ b"HEAD ",
37
+ b"DELETE ",
38
+ b"OPTIONS ",
39
+ b"PATCH ",
40
+ b"CONNECT ",
41
+ b"TRACE ",
42
+ )
43
+
44
+
45
+ class CommandError(Exception):
46
+ """An error the client should see as is: `code` for programs, message for people."""
47
+
48
+ def __init__(self, code, message):
49
+ super().__init__(message)
50
+ self.code = code
51
+
52
+
53
+ class MessageTooLarge(Exception):
54
+ pass
55
+
56
+
57
+ class LineReader:
58
+ """Reads newline-terminated messages, each at most MAX_MESSAGE_BYTES."""
59
+
60
+ def __init__(self, sock):
61
+ self._sock = sock
62
+ self._buffer = bytearray()
63
+
64
+ def peek_start(self, size):
65
+ """Up to `size` first bytes, without consuming them."""
66
+ while len(self._buffer) < size and b"\n" not in self._buffer:
67
+ chunk = self._sock.recv(65536)
68
+ if not chunk:
69
+ break
70
+ self._buffer += chunk
71
+ return bytes(self._buffer[:size])
72
+
73
+ def read_line(self):
74
+ """The next message without its newline, or None when the peer closed."""
75
+ while True:
76
+ end = self._buffer.find(b"\n")
77
+ if end >= 0:
78
+ line = bytes(self._buffer[:end])
79
+ del self._buffer[: end + 1]
80
+ return line
81
+ if len(self._buffer) > MAX_MESSAGE_BYTES:
82
+ raise MessageTooLarge()
83
+ chunk = self._sock.recv(65536)
84
+ if not chunk:
85
+ return None
86
+ self._buffer += chunk
87
+
88
+
89
+ def encode(message):
90
+ return json.dumps(message, ensure_ascii=False, default=str).encode("utf-8") + b"\n"
91
+
92
+
93
+ class Job:
94
+ """One command waiting for Live's main thread."""
95
+
96
+ def __init__(self, handler, params):
97
+ self.handler = handler
98
+ self.params = params
99
+ self.state = "queued" # queued -> running -> done, or queued -> cancelled
100
+ self.lock = threading.Lock()
101
+ self.done = threading.Event()
102
+ self.reply = None
103
+
104
+ def run(self, log):
105
+ with self.lock:
106
+ if self.state != "queued":
107
+ return
108
+ self.state = "running"
109
+ try:
110
+ self.reply = {"ok": True, "result": self.handler(self.params)}
111
+ except CommandError as err:
112
+ self.reply = {"ok": False, "code": err.code, "error": str(err)}
113
+ except Exception as err:
114
+ log("command failed:\n" + traceback.format_exc())
115
+ self.reply = {
116
+ "ok": False,
117
+ "code": "internal",
118
+ "error": f"{type(err).__name__}: {err}",
119
+ }
120
+ with self.lock:
121
+ self.state = "done"
122
+ self.done.set()
123
+
124
+ def wait(self, timeout):
125
+ """The reply, or a timeout error that says whether Live changed anything."""
126
+ if self.done.wait(timeout):
127
+ return self.reply
128
+ with self.lock:
129
+ if self.state == "queued":
130
+ self.state = "cancelled"
131
+ return {
132
+ "ok": False,
133
+ "code": "timeout_not_started",
134
+ "error": (
135
+ f"Live did not start the command within {timeout:g} s, "
136
+ "so nothing changed. A dialog may be open in Live."
137
+ ),
138
+ }
139
+ return {
140
+ "ok": False,
141
+ "code": "timeout_running",
142
+ "error": (
143
+ f"The command is still running in Live after {timeout:g} s. It may "
144
+ "still finish: read the state before repeating it."
145
+ ),
146
+ }
147
+
148
+
149
+ class BridgeServer:
150
+ """Listens on a free loopback port and hands commands to Live's main thread.
151
+
152
+ `commands` maps a command name to a function of `params`; `hello` is extra data
153
+ for the reply to a good key.
154
+ """
155
+
156
+ def __init__(self, commands, hello, log, session_path=None):
157
+ self._commands = commands
158
+ self._hello = hello
159
+ self._log = log
160
+ self._session_path = session_path or SESSION_PATH
161
+ self._jobs = queue.Queue()
162
+ self._clients = set()
163
+ self._clients_lock = threading.Lock()
164
+ self._listener = None
165
+ self._running = False
166
+ self.port = None
167
+ self.key = None
168
+
169
+ # --- lifecycle (main thread) ---------------------------------------
170
+
171
+ def start(self):
172
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
173
+ listener.bind((HOST, 0))
174
+ listener.listen()
175
+ self._listener = listener
176
+ self.port = listener.getsockname()[1]
177
+ self.key = secrets.token_hex(KEY_BYTES)
178
+ self._write_session()
179
+ self._running = True
180
+ threading.Thread(
181
+ target=self._accept_loop, name="Tonable accept", daemon=True
182
+ ).start()
183
+
184
+ def stop(self):
185
+ self._running = False
186
+ if self._listener is not None:
187
+ try:
188
+ self._listener.close()
189
+ except OSError:
190
+ pass
191
+ with self._clients_lock:
192
+ clients = list(self._clients)
193
+ for conn in clients:
194
+ try:
195
+ conn.shutdown(socket.SHUT_RDWR)
196
+ conn.close()
197
+ except OSError:
198
+ pass
199
+ self._remove_session()
200
+ # Wake any socket thread still waiting on a job.
201
+ self.run_pending(cancel=True)
202
+
203
+ def run_pending(self, cancel=False):
204
+ """Run every queued command. Live's main thread calls this on each tick."""
205
+ while True:
206
+ try:
207
+ job = self._jobs.get_nowait()
208
+ except queue.Empty:
209
+ return
210
+ if cancel:
211
+ with job.lock:
212
+ if job.state == "queued":
213
+ job.state = "cancelled"
214
+ job.reply = {
215
+ "ok": False,
216
+ "code": "stopped",
217
+ "error": "Tonable was switched off in Live",
218
+ }
219
+ job.done.set()
220
+ continue
221
+ job.run(self._log)
222
+
223
+ # --- session file ---------------------------------------------------
224
+
225
+ def _write_session(self):
226
+ folder = os.path.dirname(self._session_path)
227
+ os.makedirs(folder, mode=0o700, exist_ok=True)
228
+ os.chmod(folder, 0o700)
229
+ data = {
230
+ "port": self.port,
231
+ "key": self.key,
232
+ "pid": os.getpid(),
233
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
234
+ }
235
+ data.update(self._hello)
236
+ tmp = self._session_path + ".tmp"
237
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
238
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
239
+ json.dump(data, f)
240
+ os.replace(tmp, self._session_path)
241
+
242
+ def _remove_session(self):
243
+ """Remove the session file, unless another Live has written its own since."""
244
+ try:
245
+ with open(self._session_path, encoding="utf-8") as f:
246
+ data = json.load(f)
247
+ if data.get("port") == self.port and data.get("key") == self.key:
248
+ os.remove(self._session_path)
249
+ except (OSError, ValueError):
250
+ pass
251
+
252
+ # --- socket threads -------------------------------------------------
253
+
254
+ def _accept_loop(self):
255
+ while self._running:
256
+ try:
257
+ conn, _ = self._listener.accept()
258
+ except OSError:
259
+ return
260
+ threading.Thread(
261
+ target=self._serve, args=(conn,), name="Tonable client", daemon=True
262
+ ).start()
263
+
264
+ def _serve(self, conn):
265
+ with self._clients_lock:
266
+ self._clients.add(conn)
267
+ try:
268
+ reader = LineReader(conn)
269
+ if self._authenticate(conn, reader):
270
+ self._command_loop(conn, reader)
271
+ except (OSError, MessageTooLarge, ValueError):
272
+ pass
273
+ finally:
274
+ with self._clients_lock:
275
+ self._clients.discard(conn)
276
+ try:
277
+ conn.close()
278
+ except OSError:
279
+ pass
280
+
281
+ def _authenticate(self, conn, reader):
282
+ conn.settimeout(AUTH_TIMEOUT)
283
+ start = reader.peek_start(max(len(m) for m in HTTP_METHODS))
284
+ if any(start.startswith(m) for m in HTTP_METHODS):
285
+ self._log("closed an HTTP request on the bridge port")
286
+ return False
287
+ line = reader.read_line()
288
+ if line is None:
289
+ return False
290
+ message = json.loads(line)
291
+ key = message.get("key") if isinstance(message, dict) else None
292
+ if not isinstance(key, str) or not hmac.compare_digest(key, self.key):
293
+ conn.sendall(encode({"ok": False, "code": "bad_key", "error": "wrong key"}))
294
+ return False
295
+ conn.sendall(encode(dict(self._hello, ok=True)))
296
+ conn.settimeout(None)
297
+ return True
298
+
299
+ def _command_loop(self, conn, reader):
300
+ while self._running:
301
+ try:
302
+ line = reader.read_line()
303
+ except MessageTooLarge:
304
+ conn.sendall(
305
+ encode(
306
+ {
307
+ "ok": False,
308
+ "code": "too_large",
309
+ "error": f"message over {MAX_MESSAGE_BYTES} bytes",
310
+ }
311
+ )
312
+ )
313
+ return
314
+ if line is None:
315
+ return
316
+ conn.sendall(encode(self._handle(line)))
317
+
318
+ def _handle(self, line):
319
+ try:
320
+ message = json.loads(line)
321
+ except ValueError:
322
+ return {"ok": False, "code": "bad_json", "error": "message is not JSON"}
323
+ if not isinstance(message, dict):
324
+ return {
325
+ "ok": False,
326
+ "code": "bad_json",
327
+ "error": "message is not an object",
328
+ }
329
+ reply = {"id": message.get("id")}
330
+ name = message.get("command")
331
+ handler = self._commands.get(name)
332
+ if handler is None:
333
+ reply.update(
334
+ ok=False,
335
+ code="unknown_command",
336
+ error=f"unknown command {name!r}",
337
+ )
338
+ return reply
339
+ params = message.get("params") or {}
340
+ if not isinstance(params, dict):
341
+ reply.update(ok=False, code="bad_params", error="params must be an object")
342
+ return reply
343
+ timeout = LONG_TIMEOUT if message.get("long") is True else DEFAULT_TIMEOUT
344
+ job = Job(handler, params)
345
+ self._jobs.put(job)
346
+ reply.update(job.wait(timeout))
347
+ return reply