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,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
@@ -0,0 +1,2 @@
1
+ # Must equal the tonable package version: `tonable doctor` and live_status compare them.
2
+ VERSION = "0.5.2"
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
+ )