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
|
@@ -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
|