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,304 @@
|
|
|
1
|
+
"""The Set as a whole: undo, transport, update_set and scene. Main thread only."""
|
|
2
|
+
|
|
3
|
+
from . import objects
|
|
4
|
+
from .server import CommandError
|
|
5
|
+
|
|
6
|
+
UNDO_ACTIONS = ("undo", "redo")
|
|
7
|
+
TRANSPORT_ACTIONS = (
|
|
8
|
+
"play",
|
|
9
|
+
"stop",
|
|
10
|
+
"continue",
|
|
11
|
+
"fire_clip",
|
|
12
|
+
"stop_track",
|
|
13
|
+
"fire_scene",
|
|
14
|
+
"stop_all_clips",
|
|
15
|
+
"jump",
|
|
16
|
+
"re_enable_automation",
|
|
17
|
+
)
|
|
18
|
+
SCENE_ACTIONS = ("create", "update", "duplicate", "capture")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class undo_step:
|
|
22
|
+
"""Everything inside is one step for Cmd+Z in Live.
|
|
23
|
+
|
|
24
|
+
Live's undo steps do not nest (a batch's inner step would close the outer one),
|
|
25
|
+
so only the outermost step talks to Live. Commands run one at a time on Live's
|
|
26
|
+
main thread, so a plain counter is enough.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
depth = 0
|
|
30
|
+
|
|
31
|
+
def __init__(self, song):
|
|
32
|
+
self._song = song
|
|
33
|
+
|
|
34
|
+
def __enter__(self):
|
|
35
|
+
if undo_step.depth == 0:
|
|
36
|
+
begin = getattr(self._song, "begin_undo_step", None)
|
|
37
|
+
if begin is not None:
|
|
38
|
+
begin()
|
|
39
|
+
undo_step.depth += 1
|
|
40
|
+
|
|
41
|
+
def __exit__(self, *exc):
|
|
42
|
+
undo_step.depth -= 1
|
|
43
|
+
if undo_step.depth == 0:
|
|
44
|
+
end = getattr(self._song, "end_undo_step", None)
|
|
45
|
+
if end is not None:
|
|
46
|
+
end()
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def undo(song, params):
|
|
51
|
+
action = params.get("action") or "undo"
|
|
52
|
+
if action not in UNDO_ACTIONS:
|
|
53
|
+
raise CommandError("bad_params", "action must be undo or redo")
|
|
54
|
+
can = song.can_undo if action == "undo" else song.can_redo
|
|
55
|
+
if not can:
|
|
56
|
+
raise CommandError("nothing_to_" + action, f"there is nothing to {action}")
|
|
57
|
+
(song.undo if action == "undo" else song.redo)()
|
|
58
|
+
return {
|
|
59
|
+
"done": action,
|
|
60
|
+
"can_undo": bool(song.can_undo),
|
|
61
|
+
"can_redo": bool(song.can_redo),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def transport(song, params):
|
|
66
|
+
action = params.get("action")
|
|
67
|
+
if action not in TRANSPORT_ACTIONS:
|
|
68
|
+
raise CommandError(
|
|
69
|
+
"bad_params", "action must be one of: " + ", ".join(TRANSPORT_ACTIONS)
|
|
70
|
+
)
|
|
71
|
+
out = {"done": action}
|
|
72
|
+
if action == "play":
|
|
73
|
+
song.start_playing()
|
|
74
|
+
elif action == "stop":
|
|
75
|
+
song.stop_playing()
|
|
76
|
+
elif action == "continue":
|
|
77
|
+
song.continue_playing()
|
|
78
|
+
elif action == "stop_all_clips":
|
|
79
|
+
song.stop_all_clips()
|
|
80
|
+
elif action == "re_enable_automation":
|
|
81
|
+
song.re_enable_automation()
|
|
82
|
+
elif action in ("fire_clip", "stop_track"):
|
|
83
|
+
track = objects.track(song, _need(params, "track"))
|
|
84
|
+
path = objects.track_path(song, track)
|
|
85
|
+
if not path.startswith("t"):
|
|
86
|
+
raise CommandError("bad_target", f"{path} has no clips to launch")
|
|
87
|
+
if action == "stop_track":
|
|
88
|
+
track.stop_all_clips()
|
|
89
|
+
else:
|
|
90
|
+
slot = _slot(track, params.get("slot"), path)
|
|
91
|
+
slot.fire()
|
|
92
|
+
out["clip"] = slot.clip.name if slot.has_clip else None
|
|
93
|
+
out["track"] = path
|
|
94
|
+
elif action == "fire_scene":
|
|
95
|
+
index, scene = find_scene(song, params.get("scene"))
|
|
96
|
+
scene.fire()
|
|
97
|
+
out["scene"] = {"index": index, "name": scene.name}
|
|
98
|
+
elif action == "jump":
|
|
99
|
+
out.update(_jump(song, params))
|
|
100
|
+
out["is_playing"] = bool(song.is_playing)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def update_set(song, params):
|
|
105
|
+
"""Tempo, time signature, loop, metronome, key. Only given fields change."""
|
|
106
|
+
changed = []
|
|
107
|
+
with undo_step(song):
|
|
108
|
+
if _given(params, "tempo"):
|
|
109
|
+
song.tempo = _number(params["tempo"], "tempo")
|
|
110
|
+
changed.append("tempo")
|
|
111
|
+
if _given(params, "time_signature"):
|
|
112
|
+
num, den = _signature(params["time_signature"])
|
|
113
|
+
song.signature_numerator = num
|
|
114
|
+
song.signature_denominator = den
|
|
115
|
+
changed.append("time_signature")
|
|
116
|
+
if _given(params, "loop"):
|
|
117
|
+
song.loop = _bool(params["loop"])
|
|
118
|
+
changed.append("loop")
|
|
119
|
+
if _given(params, "loop_start_beats"):
|
|
120
|
+
song.loop_start = _number(params["loop_start_beats"], "loop_start_beats")
|
|
121
|
+
changed.append("loop_start_beats")
|
|
122
|
+
if _given(params, "loop_length_beats"):
|
|
123
|
+
song.loop_length = _number(params["loop_length_beats"], "loop_length_beats")
|
|
124
|
+
changed.append("loop_length_beats")
|
|
125
|
+
if _given(params, "metronome"):
|
|
126
|
+
song.metronome = _bool(params["metronome"])
|
|
127
|
+
changed.append("metronome")
|
|
128
|
+
if _given(params, "root_note"):
|
|
129
|
+
song.root_note = _root_note(params["root_note"])
|
|
130
|
+
changed.append("root_note")
|
|
131
|
+
if _given(params, "scale"):
|
|
132
|
+
song.scale_name = str(params["scale"]).strip()
|
|
133
|
+
changed.append("scale")
|
|
134
|
+
if not changed:
|
|
135
|
+
raise CommandError("bad_params", "nothing to change: give at least one field")
|
|
136
|
+
return {"changed": changed, **set_state(song)}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def set_state(song):
|
|
140
|
+
out = {
|
|
141
|
+
"tempo": song.tempo,
|
|
142
|
+
"time_signature": f"{song.signature_numerator}/{song.signature_denominator}",
|
|
143
|
+
"loop": {
|
|
144
|
+
"on": bool(song.loop),
|
|
145
|
+
"start_beats": song.loop_start,
|
|
146
|
+
"length_beats": song.loop_length,
|
|
147
|
+
},
|
|
148
|
+
"metronome": bool(getattr(song, "metronome", False)),
|
|
149
|
+
}
|
|
150
|
+
root = getattr(song, "root_note", None)
|
|
151
|
+
if root is not None:
|
|
152
|
+
out["key"] = {
|
|
153
|
+
"root": objects.note_name(int(root) % 12 + 60)[:-1],
|
|
154
|
+
"scale": getattr(song, "scale_name", ""),
|
|
155
|
+
}
|
|
156
|
+
return out
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def scene(song, params):
|
|
160
|
+
action = params.get("action")
|
|
161
|
+
if action not in SCENE_ACTIONS:
|
|
162
|
+
raise CommandError(
|
|
163
|
+
"bad_params", "action must be one of: " + ", ".join(SCENE_ACTIONS)
|
|
164
|
+
)
|
|
165
|
+
with undo_step(song):
|
|
166
|
+
if action == "create":
|
|
167
|
+
scenes = list(song.scenes)
|
|
168
|
+
index = params.get("index")
|
|
169
|
+
index = len(scenes) if index in (None, "", -1) else int(index)
|
|
170
|
+
if not 0 <= index <= len(scenes):
|
|
171
|
+
raise CommandError("not_found", f"index must be 0-{len(scenes)}")
|
|
172
|
+
song.create_scene(index)
|
|
173
|
+
elif action == "capture":
|
|
174
|
+
song.capture_and_insert_scene()
|
|
175
|
+
index = list(song.scenes).index(song.view.selected_scene)
|
|
176
|
+
else:
|
|
177
|
+
index, _ = find_scene(song, params.get("scene"))
|
|
178
|
+
if action == "duplicate":
|
|
179
|
+
song.duplicate_scene(index)
|
|
180
|
+
index += 1
|
|
181
|
+
target = list(song.scenes)[index]
|
|
182
|
+
_update_scene(target, params)
|
|
183
|
+
return {"done": action, **_scene_info(index, target)}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def find_scene(song, ref):
|
|
187
|
+
"""A scene by index (0) or exact name ("Verse")."""
|
|
188
|
+
scenes = list(song.scenes)
|
|
189
|
+
if ref is None or str(ref).strip() == "":
|
|
190
|
+
raise CommandError("bad_params", "scene is required: an index from 0 or a name")
|
|
191
|
+
text = str(ref).strip()
|
|
192
|
+
if text.lstrip("-").isdigit():
|
|
193
|
+
index = int(text)
|
|
194
|
+
if not 0 <= index < len(scenes):
|
|
195
|
+
raise CommandError(
|
|
196
|
+
"not_found", f"no scene {index}: scenes are 0-{len(scenes) - 1}"
|
|
197
|
+
)
|
|
198
|
+
return index, scenes[index]
|
|
199
|
+
hits = [
|
|
200
|
+
i for i, s in enumerate(scenes) if s.name.strip().casefold() == text.casefold()
|
|
201
|
+
]
|
|
202
|
+
if len(hits) != 1:
|
|
203
|
+
names = [s.name for s in scenes if s.name]
|
|
204
|
+
what = "no scene named" if not hits else "several scenes named"
|
|
205
|
+
raise CommandError("not_found", f"{what} {text!r}; scenes: {names}")
|
|
206
|
+
return hits[0], scenes[hits[0]]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --- helpers -----------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _update_scene(target, params):
|
|
213
|
+
if _given(params, "name"):
|
|
214
|
+
target.name = str(params["name"])
|
|
215
|
+
if _given(params, "color_index"):
|
|
216
|
+
target.color_index = int(params["color_index"])
|
|
217
|
+
if _given(params, "tempo"):
|
|
218
|
+
tempo = params["tempo"]
|
|
219
|
+
if str(tempo).strip().lower() in ("off", "none", "0"):
|
|
220
|
+
target.tempo_enabled = False
|
|
221
|
+
else:
|
|
222
|
+
target.tempo = _number(tempo, "tempo")
|
|
223
|
+
target.tempo_enabled = True
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _scene_info(index, target):
|
|
227
|
+
out = {"index": index, "name": target.name}
|
|
228
|
+
if getattr(target, "tempo_enabled", False):
|
|
229
|
+
out["tempo"] = target.tempo
|
|
230
|
+
return out
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _jump(song, params):
|
|
234
|
+
if _given(params, "locator"):
|
|
235
|
+
name = str(params["locator"]).strip().casefold()
|
|
236
|
+
cues = [c for c in song.cue_points if c.name.strip().casefold() == name]
|
|
237
|
+
if not cues:
|
|
238
|
+
names = [c.name for c in song.cue_points]
|
|
239
|
+
raise CommandError(
|
|
240
|
+
"not_found", f"no locator {params['locator']!r}; locators: {names}"
|
|
241
|
+
)
|
|
242
|
+
cues[0].jump()
|
|
243
|
+
return {"locator": cues[0].name, "time_beats": cues[0].time}
|
|
244
|
+
time = _number(_need(params, "time_beats"), "time_beats")
|
|
245
|
+
if time < 0:
|
|
246
|
+
raise CommandError("bad_params", "time_beats must be 0 or more")
|
|
247
|
+
song.current_song_time = time
|
|
248
|
+
return {"time_beats": time}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _slot(track, slot_index, path):
|
|
252
|
+
try:
|
|
253
|
+
index = int(slot_index)
|
|
254
|
+
except (TypeError, ValueError):
|
|
255
|
+
raise CommandError(
|
|
256
|
+
"bad_params", "slot is required: a scene row from 0"
|
|
257
|
+
) from None
|
|
258
|
+
slots = list(track.clip_slots)
|
|
259
|
+
if not 0 <= index < len(slots):
|
|
260
|
+
raise CommandError(
|
|
261
|
+
"not_found", f"no slot {index} on {path}: slots 0-{len(slots) - 1}"
|
|
262
|
+
)
|
|
263
|
+
return slots[index]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _given(params, name):
|
|
267
|
+
return params.get(name) not in (None, "")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _need(params, name):
|
|
271
|
+
if not _given(params, name):
|
|
272
|
+
raise CommandError("bad_params", f"{name} is required")
|
|
273
|
+
return params[name]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _number(value, name):
|
|
277
|
+
try:
|
|
278
|
+
return float(value)
|
|
279
|
+
except (TypeError, ValueError):
|
|
280
|
+
raise CommandError("bad_params", f"{name} must be a number") from None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _bool(value):
|
|
284
|
+
if isinstance(value, str):
|
|
285
|
+
return value.strip().lower() in ("true", "on", "yes", "1")
|
|
286
|
+
return bool(value)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _signature(value):
|
|
290
|
+
text = str(value).replace(" ", "")
|
|
291
|
+
try:
|
|
292
|
+
num, den = (int(part) for part in text.split("/"))
|
|
293
|
+
except ValueError:
|
|
294
|
+
raise CommandError(
|
|
295
|
+
"bad_params", "time_signature must look like 4/4 or 6/8"
|
|
296
|
+
) from None
|
|
297
|
+
return num, den
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _root_note(value):
|
|
301
|
+
text = str(value).strip()
|
|
302
|
+
if text.isdigit():
|
|
303
|
+
return int(text) % 12
|
|
304
|
+
return objects.note_number(text + "3") % 12
|
tonable/server.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The MCP server: tool descriptions for the model, calls forwarded to Live over stdio.
|
|
2
|
+
|
|
3
|
+
Tool descriptions never change at run time: clients cache the tool list
|
|
4
|
+
(see docs/clients.md). The tools live in tonable/tools/.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
9
|
+
from mcp.types import ToolAnnotations
|
|
10
|
+
|
|
11
|
+
from tonable import __version__
|
|
12
|
+
from tonable.bridge import Bridge, BridgeError
|
|
13
|
+
|
|
14
|
+
INSTRUCTIONS = """\
|
|
15
|
+
Tonable controls Ableton Live 12 on this computer through a Remote Script inside Live.
|
|
16
|
+
Call live_status first: it says whether Live is connected and what Set is open.
|
|
17
|
+
Addresses: t0 is the first track (numbers from 0), rA return track A, m the master.
|
|
18
|
+
Devices: t0/d0 first device; inside racks t0/d0/c1/d0 (chain 1, device 0);
|
|
19
|
+
Drum Rack pads t0/d0/pC1 (note names with C3 = MIDI 60).
|
|
20
|
+
Times are in beats (quarter notes) unless a field says bars.
|
|
21
|
+
Read before you change anything, and quote the address and name to the user.
|
|
22
|
+
If a call fails with code not_running, tell the user to choose Tonable in Live's
|
|
23
|
+
Settings -> Link, Tempo & MIDI -> Control Surface.
|
|
24
|
+
A timeout_not_started error means nothing changed. timeout_running or
|
|
25
|
+
connection_lost means the command may still have run: read the state before
|
|
26
|
+
repeating it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_only(title):
|
|
31
|
+
"""Annotations for a tool that only reads Live."""
|
|
32
|
+
return ToolAnnotations(
|
|
33
|
+
title=title,
|
|
34
|
+
readOnlyHint=True,
|
|
35
|
+
destructiveHint=False,
|
|
36
|
+
idempotentHint=True,
|
|
37
|
+
openWorldHint=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
mcp = FastMCP("tonable", instructions=INSTRUCTIONS)
|
|
42
|
+
# FastMCP would report the mcp library's version. Clients may cache the tool list
|
|
43
|
+
# by server name and version, so report ours: a new release then looks new.
|
|
44
|
+
mcp._mcp_server.version = __version__
|
|
45
|
+
bridge = Bridge()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def call(command, params=None, long=False):
|
|
49
|
+
"""Run a command in Live; a failure becomes a tool error the model can read."""
|
|
50
|
+
try:
|
|
51
|
+
try:
|
|
52
|
+
return bridge.request(command, params, long=long)
|
|
53
|
+
except BridgeError as err:
|
|
54
|
+
# The command needed one more Live tick (the playhead, for locators).
|
|
55
|
+
if err.code != "retry":
|
|
56
|
+
raise
|
|
57
|
+
return bridge.request(command, params, long=long)
|
|
58
|
+
except BridgeError as err:
|
|
59
|
+
text = f"{err.code}: {err}"
|
|
60
|
+
if err.hint:
|
|
61
|
+
text += f" {err.hint}"
|
|
62
|
+
raise ToolError(text) from None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run():
|
|
66
|
+
mcp.run("stdio")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Importing the modules registers their tools on `mcp`.
|
|
70
|
+
from tonable.tools import ( # noqa: E402, F401
|
|
71
|
+
arrangement,
|
|
72
|
+
batch,
|
|
73
|
+
clips,
|
|
74
|
+
devices,
|
|
75
|
+
envelopes,
|
|
76
|
+
session,
|
|
77
|
+
tracks,
|
|
78
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""MCP tools, one module per group (see docs/tools.md).
|
|
2
|
+
|
|
3
|
+
Argument types are shared so every tool explains them the same way. They use
|
|
4
|
+
plain JSON types without anyOf; null or "" in an optional field means "not given".
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from pydantic import BeforeValidator, Field, WithJsonSchema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _names(value):
|
|
13
|
+
"""null/"" -> [], one string -> [it], {"a": ..} -> its keys; lists unchanged."""
|
|
14
|
+
if value is None or value == "":
|
|
15
|
+
return []
|
|
16
|
+
if isinstance(value, str):
|
|
17
|
+
return [value]
|
|
18
|
+
if isinstance(value, dict):
|
|
19
|
+
return list(value)
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def empty_to(default):
|
|
24
|
+
"""A BeforeValidator body: null or "" become `default`."""
|
|
25
|
+
|
|
26
|
+
def convert(value):
|
|
27
|
+
return default if value is None or value == "" else value
|
|
28
|
+
|
|
29
|
+
return convert
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _flag(value):
|
|
33
|
+
if value is None or value == "":
|
|
34
|
+
return False
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
return value.strip().lower() in ("true", "yes", "1")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
TrackAddress = Annotated[
|
|
41
|
+
str,
|
|
42
|
+
Field(
|
|
43
|
+
description=(
|
|
44
|
+
"Track address: t0 for the first track (numbers from 0), rA for return "
|
|
45
|
+
"track A, m for the master. Example: t2"
|
|
46
|
+
)
|
|
47
|
+
),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
ObjectAddress = Annotated[
|
|
51
|
+
str,
|
|
52
|
+
Field(
|
|
53
|
+
description=(
|
|
54
|
+
"Device, rack chain or Drum Rack pad address. Examples: t0/d0 (first "
|
|
55
|
+
"device on t0), t1/d0/c1/d0 (first device in chain 1 of that rack), "
|
|
56
|
+
"t2/d0/pC1 (Drum Rack pad C1), rA/d0, m/d0"
|
|
57
|
+
)
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
ParameterNames = Annotated[
|
|
62
|
+
list[str],
|
|
63
|
+
BeforeValidator(_names),
|
|
64
|
+
Field(
|
|
65
|
+
description=(
|
|
66
|
+
"Only these parameters, by name as Live shows them. Example: "
|
|
67
|
+
'["Release", "Decay"]. Leave empty for the default view.'
|
|
68
|
+
)
|
|
69
|
+
),
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
AllParameters = Annotated[
|
|
73
|
+
bool,
|
|
74
|
+
BeforeValidator(_flag),
|
|
75
|
+
Field(description="For a rack: list every parameter, not only the macros."),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _to_text(value):
|
|
80
|
+
"""null -> "", true/false -> "true"/"false", numbers -> their text."""
|
|
81
|
+
if value is None:
|
|
82
|
+
return ""
|
|
83
|
+
if isinstance(value, bool):
|
|
84
|
+
return "true" if value else "false"
|
|
85
|
+
if isinstance(value, (int, float)):
|
|
86
|
+
return f"{value:g}" if isinstance(value, float) else str(value)
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def text(description):
|
|
91
|
+
"""An optional field sent as text; empty means "leave unchanged".
|
|
92
|
+
|
|
93
|
+
JSON numbers and booleans are accepted too, so a client that sends 120 or true
|
|
94
|
+
for a text field is not refused.
|
|
95
|
+
"""
|
|
96
|
+
return Annotated[str, BeforeValidator(_to_text), Field(description=description)]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def objects_list(description, item_schema):
|
|
100
|
+
"""A list of objects, checked one by one in Live (a bad item never rejects the
|
|
101
|
+
call). null, "" and a single object are accepted."""
|
|
102
|
+
|
|
103
|
+
def convert(value):
|
|
104
|
+
if value is None or value == "":
|
|
105
|
+
return []
|
|
106
|
+
if isinstance(value, dict):
|
|
107
|
+
return [value]
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
return Annotated[
|
|
111
|
+
list[dict],
|
|
112
|
+
BeforeValidator(convert),
|
|
113
|
+
WithJsonSchema(
|
|
114
|
+
{"type": "array", "items": item_schema, "description": description}
|
|
115
|
+
),
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def changes(title, destructive=False, idempotent=False):
|
|
120
|
+
"""Annotations for a tool that changes the Set."""
|
|
121
|
+
from mcp.types import ToolAnnotations
|
|
122
|
+
|
|
123
|
+
return ToolAnnotations(
|
|
124
|
+
title=title,
|
|
125
|
+
readOnlyHint=False,
|
|
126
|
+
destructiveHint=destructive,
|
|
127
|
+
idempotentHint=idempotent,
|
|
128
|
+
openWorldHint=False,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def objects_or_names(description):
|
|
133
|
+
"""A list of names: one string, numbers or null are accepted too."""
|
|
134
|
+
|
|
135
|
+
def convert(value):
|
|
136
|
+
if value is None or value == "":
|
|
137
|
+
return []
|
|
138
|
+
if not isinstance(value, list):
|
|
139
|
+
value = [value]
|
|
140
|
+
return [str(v) for v in value]
|
|
141
|
+
|
|
142
|
+
return Annotated[
|
|
143
|
+
list[str], BeforeValidator(convert), Field(description=description)
|
|
144
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""arrangement: clips and locators on the timeline."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from tonable.server import call, mcp
|
|
8
|
+
from tonable.tools import changes, objects_list, text
|
|
9
|
+
from tonable.tools.clips import NOTE_SCHEMA
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@mcp.tool(annotations=changes("Arrangement", destructive=True))
|
|
13
|
+
def arrangement(
|
|
14
|
+
action: Annotated[
|
|
15
|
+
Literal[
|
|
16
|
+
"list",
|
|
17
|
+
"place_scene",
|
|
18
|
+
"copy_clip",
|
|
19
|
+
"create_clip",
|
|
20
|
+
"delete_clip",
|
|
21
|
+
"add_locator",
|
|
22
|
+
"rename_locator",
|
|
23
|
+
"delete_locator",
|
|
24
|
+
],
|
|
25
|
+
Field(description="What to do; see the tool description."),
|
|
26
|
+
],
|
|
27
|
+
at_bar: text("Where: bar number from 1, as Live shows it (17 = bar 17).") = "",
|
|
28
|
+
at_beats: text("Where, in beats from the start, instead of at_bar.") = "",
|
|
29
|
+
scene: text("For place_scene: scene number from 0 or its exact name.") = "",
|
|
30
|
+
track: text("For copy_clip, create_clip, delete_clip: t0, t1...") = "",
|
|
31
|
+
slot: text("For copy_clip: the Session slot to copy, from 0.") = "",
|
|
32
|
+
length_beats: text("For create_clip: length in beats. Empty: fit the notes.") = "",
|
|
33
|
+
notes: objects_list("For create_clip: notes, as in write_clip.", NOTE_SCHEMA) = [], # noqa: B006 - pydantic copies defaults
|
|
34
|
+
name: text(
|
|
35
|
+
"Clip or locator name; for delete_clip the clip's name as a check."
|
|
36
|
+
) = "",
|
|
37
|
+
locator: text("For rename_locator, delete_locator: the locator's name.") = "",
|
|
38
|
+
) -> dict:
|
|
39
|
+
"""Work on the Arrangement timeline.
|
|
40
|
+
|
|
41
|
+
list: clips per track, locators, loop. place_scene: copy every clip of a Session
|
|
42
|
+
scene to the timeline at a bar, in one undo step (envelopes come along as track
|
|
43
|
+
automation). copy_clip: one Session clip to a bar. create_clip: a MIDI clip on
|
|
44
|
+
the timeline with notes. delete_clip: by track, start and name. add_locator,
|
|
45
|
+
rename_locator, delete_locator. Times answer as bar|beat ("17|1") and beats.
|
|
46
|
+
Live cannot read Arrangement automation back.
|
|
47
|
+
"""
|
|
48
|
+
return call(
|
|
49
|
+
"arrangement",
|
|
50
|
+
{
|
|
51
|
+
"action": action,
|
|
52
|
+
"at_bar": at_bar,
|
|
53
|
+
"at_beats": at_beats,
|
|
54
|
+
"scene": scene,
|
|
55
|
+
"track": track,
|
|
56
|
+
"slot": slot,
|
|
57
|
+
"length_beats": length_beats,
|
|
58
|
+
"notes": notes,
|
|
59
|
+
"name": name,
|
|
60
|
+
"locator": locator,
|
|
61
|
+
},
|
|
62
|
+
)
|
tonable/tools/batch.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""batch: several commands in one request and one undo step."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
from pydantic import BeforeValidator, Field
|
|
6
|
+
|
|
7
|
+
from tonable.server import call, mcp
|
|
8
|
+
from tonable.tools import changes, empty_to, objects_list
|
|
9
|
+
|
|
10
|
+
# Commands that may walk the browser get Live's long timeout.
|
|
11
|
+
LONG = {"find_in_library", "load_device"}
|
|
12
|
+
|
|
13
|
+
ACTION_SCHEMA = {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"command": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "A Tonable tool name, e.g. write_clip, update_track",
|
|
19
|
+
},
|
|
20
|
+
"params": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"description": "That tool's arguments, as you would pass them to it",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
"required": ["command", "params"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mcp.tool(annotations=changes("Batch", destructive=True))
|
|
30
|
+
def batch(
|
|
31
|
+
actions: objects_list(
|
|
32
|
+
'Actions in order. Example: [{"command": "create_track", "params": {"name": '
|
|
33
|
+
'"Bass"}}, {"command": "write_clip", "params": {"track": "t4", "slot": 0, '
|
|
34
|
+
'"notes": [...]}}]',
|
|
35
|
+
ACTION_SCHEMA,
|
|
36
|
+
),
|
|
37
|
+
stop_on_error: Annotated[
|
|
38
|
+
bool,
|
|
39
|
+
BeforeValidator(empty_to(False)),
|
|
40
|
+
Field(description="Stop at the first failed action. Default: run them all."),
|
|
41
|
+
] = False,
|
|
42
|
+
) -> dict:
|
|
43
|
+
"""Run several Tonable tools in one go, as ONE undo step (one Cmd+Z undoes them
|
|
44
|
+
all). Each action gets its own result; a failed one does not stop the rest
|
|
45
|
+
unless stop_on_error is set. Arguments are passed as given, so use the forms
|
|
46
|
+
the tools document.
|
|
47
|
+
"""
|
|
48
|
+
names = {str(a.get("command") or a.get("tool")) for a in actions}
|
|
49
|
+
return call(
|
|
50
|
+
"batch",
|
|
51
|
+
{"actions": actions, "stop_on_error": stop_on_error},
|
|
52
|
+
long=bool(names & LONG),
|
|
53
|
+
)
|