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/tools/clips.py ADDED
@@ -0,0 +1,179 @@
1
+ """read_clip and write_clip: Session clips and their notes."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ from mcp.types import ToolAnnotations
6
+ from pydantic import BeforeValidator, Field, WithJsonSchema
7
+
8
+ from tonable.server import call, mcp, read_only
9
+ from tonable.tools import TrackAddress, changes, empty_to, objects_or_names, text
10
+
11
+ NOTE_SCHEMA = {
12
+ "type": "object",
13
+ "properties": {
14
+ "pitch": {
15
+ "type": "string",
16
+ "description": 'MIDI number or note name, C3 = 60. Example: "60" or "C3"',
17
+ },
18
+ "start": {
19
+ "type": "number",
20
+ "description": "Start in beats from the clip start",
21
+ },
22
+ "length": {"type": "number", "description": "Length in beats, more than 0"},
23
+ "velocity": {"type": "number", "description": "1-127"},
24
+ "mute": {"type": "boolean", "description": "Optional: a muted note"},
25
+ },
26
+ "required": ["pitch", "start", "length", "velocity"],
27
+ }
28
+
29
+
30
+ def _note_list(value):
31
+ if value is None or value == "":
32
+ return []
33
+ if isinstance(value, dict):
34
+ return [value]
35
+ return value
36
+
37
+
38
+ Slot = Annotated[
39
+ int,
40
+ Field(
41
+ description="Clip slot: the scene row, from 0. Example: 0 for the first scene"
42
+ ),
43
+ ]
44
+
45
+ # Notes are checked one by one in Live, so one bad note never rejects the call.
46
+ Notes = Annotated[
47
+ list[dict],
48
+ BeforeValidator(_note_list),
49
+ WithJsonSchema(
50
+ {
51
+ "type": "array",
52
+ "items": NOTE_SCHEMA,
53
+ "description": (
54
+ 'Notes to write. Example: [{"pitch": "C3", "start": 0, "length": 1, '
55
+ '"velocity": 100}, {"pitch": "E3", "start": 1, "length": 1, '
56
+ '"velocity": 90}]'
57
+ ),
58
+ }
59
+ ),
60
+ ]
61
+
62
+ Mode = Annotated[
63
+ Literal["replace", "add"],
64
+ BeforeValidator(empty_to("replace")),
65
+ Field(
66
+ description=(
67
+ "replace: the clip ends up with exactly these notes (old ones removed). "
68
+ "add: keep the old notes and add these."
69
+ )
70
+ ),
71
+ ]
72
+
73
+ LengthBeats = Annotated[
74
+ float,
75
+ BeforeValidator(empty_to(0)),
76
+ Field(
77
+ description=(
78
+ "Clip length in beats (16 = four 4/4 bars). 0 or empty: a new clip gets "
79
+ "whole bars that fit the notes, an existing clip keeps its length."
80
+ )
81
+ ),
82
+ ]
83
+
84
+ ClipName = Annotated[
85
+ str,
86
+ BeforeValidator(empty_to("")),
87
+ Field(description="Optional new clip name; empty keeps the current name."),
88
+ ]
89
+
90
+
91
+ @mcp.tool(annotations=read_only("Read a clip"))
92
+ def read_clip(track: TrackAddress, slot: Slot) -> dict:
93
+ """A Session clip: name, length, loop, and for a MIDI clip every note with pitch
94
+ name and number, start and length in beats, and velocity.
95
+
96
+ Use it before changing notes and to check what write_clip did.
97
+ """
98
+ return call("read_clip", {"track": track, "slot": slot})
99
+
100
+
101
+ @mcp.tool(
102
+ annotations=ToolAnnotations(
103
+ title="Write a clip",
104
+ readOnlyHint=False,
105
+ destructiveHint=True,
106
+ idempotentHint=False,
107
+ openWorldHint=False,
108
+ )
109
+ )
110
+ def write_clip(
111
+ track: TrackAddress,
112
+ slot: Slot,
113
+ notes: Notes = [], # noqa: B006 - pydantic copies defaults
114
+ mode: Mode = "replace",
115
+ length_beats: LengthBeats = 0,
116
+ name: ClipName = "",
117
+ ) -> dict:
118
+ """Create a MIDI clip in a Session slot, or change one, and write its notes.
119
+
120
+ An empty slot gets a new clip. mode=replace (default) leaves exactly the given
121
+ notes in the clip; mode=add keeps the existing ones. All of it is one undo step.
122
+ Notes that fail a check come back in "rejected" with the reason; the rest are
123
+ written. Works on MIDI tracks only; read_set shows the tracks and scenes.
124
+ """
125
+ return call(
126
+ "write_clip",
127
+ {
128
+ "track": track,
129
+ "slot": slot,
130
+ "notes": notes,
131
+ "mode": mode,
132
+ "length_beats": length_beats,
133
+ "name": name,
134
+ },
135
+ )
136
+
137
+
138
+ @mcp.tool(annotations=changes("Edit notes", destructive=True))
139
+ def edit_notes(
140
+ track: TrackAddress,
141
+ slot: Slot,
142
+ action: Annotated[
143
+ Literal["delete", "transpose", "shift", "quantize", "velocity", "length"],
144
+ Field(description="What to do with the chosen notes."),
145
+ ],
146
+ from_beats: text("Only notes starting at or after this beat.") = "",
147
+ to_beats: text("Only notes starting before this beat.") = "",
148
+ pitches: objects_or_names(
149
+ 'Only these pitches, e.g. ["C3", "E3"] or ["60"]. Empty: all pitches.'
150
+ ) = [], # noqa: B006 - pydantic copies defaults
151
+ semitones: text("For transpose: e.g. 12 or -5.") = "",
152
+ beats: text("For shift: move by this many beats, e.g. 0.5 or -1.") = "",
153
+ grid_beats: text("For quantize: grid in beats, e.g. 0.25 for 1/16 notes.") = "",
154
+ velocity: text("For velocity: new velocity 1-127.") = "",
155
+ length_beats: text("For length: new note length in beats.") = "",
156
+ ) -> dict:
157
+ """Change some notes of a MIDI clip without rewriting it: delete, transpose,
158
+ shift in time, quantize starts to a grid, set velocity or set length.
159
+
160
+ Choose notes by start time (from_beats/to_beats) and pitches; with neither, all
161
+ notes. Notes that would leave the MIDI range or the clip start are skipped and
162
+ listed. One undo step. To replace all notes use write_clip.
163
+ """
164
+ return call(
165
+ "edit_notes",
166
+ {
167
+ "track": track,
168
+ "slot": slot,
169
+ "action": action,
170
+ "from_beats": from_beats,
171
+ "to_beats": to_beats,
172
+ "pitches": pitches,
173
+ "semitones": semitones,
174
+ "beats": beats,
175
+ "grid_beats": grid_beats,
176
+ "velocity": velocity,
177
+ "length_beats": length_beats,
178
+ },
179
+ )
@@ -0,0 +1,207 @@
1
+ """read_device, set_device_params, update_device, find_in_library, load_device."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import BeforeValidator, Field
6
+
7
+ from tonable.server import call, mcp, read_only
8
+ from tonable.tools import (
9
+ AllParameters,
10
+ ObjectAddress,
11
+ ParameterNames,
12
+ changes,
13
+ empty_to,
14
+ objects_list,
15
+ text,
16
+ )
17
+
18
+ PARAM_SCHEMA = {
19
+ "type": "object",
20
+ "properties": {
21
+ "param": {
22
+ "type": "string",
23
+ "description": "Parameter name as Live shows it (Release), or its index",
24
+ },
25
+ "value": {
26
+ "type": "string",
27
+ "description": (
28
+ 'Display text: "400 ms", "-6 dB", "50 %", "On", "Sine"; or a raw number'
29
+ ),
30
+ },
31
+ },
32
+ "required": ["param", "value"],
33
+ }
34
+
35
+
36
+ @mcp.tool(annotations=read_only("Read a device"))
37
+ def read_device(
38
+ target: ObjectAddress,
39
+ params: ParameterNames = [], # noqa: B006 - pydantic copies defaults
40
+ all_params: AllParameters = False,
41
+ ) -> dict:
42
+ """A device's parameters as Live shows them ("262 ms", "-6.0 dB", "On"), with
43
+ raw value and range, plus whether the device is on.
44
+
45
+ For a rack: its visible mapped macro knobs and its chains, not every parameter
46
+ (set all_params to see them all). For other devices: all parameters. Give
47
+ params to see only some, by name; a wrong name comes back with similar names.
48
+ A chain or pad address shows its devices instead.
49
+ """
50
+ return call(
51
+ "read_device", {"target": target, "params": params, "all_params": all_params}
52
+ )
53
+
54
+
55
+ @mcp.tool(annotations=changes("Set device parameters", idempotent=True))
56
+ def set_device_params(
57
+ target: Annotated[str, Field(description="Device address: t0/d0, t1/d0/c0/d1")],
58
+ params: objects_list(
59
+ 'Parameters to set. Example: [{"param": "Release", "value": "400 ms"}, '
60
+ '{"param": "Decay", "value": "1.2 s"}]',
61
+ PARAM_SCHEMA,
62
+ ),
63
+ ) -> dict:
64
+ """Set one or more parameters of a device or rack macro, by name, in the units
65
+ Live shows ("400 ms", "-6 dB", "50 %", "On") or as raw numbers.
66
+
67
+ Each parameter gets its own line: "now" is what Live shows afterwards, a wrong
68
+ name comes back with similar names, and one failure never stops the others. If
69
+ a parameter has automation, Live stops playing it (the answer warns; use
70
+ transport re_enable_automation). One undo step.
71
+ """
72
+ return call("set_device_params", {"target": target, "params": params})
73
+
74
+
75
+ @mcp.tool(annotations=changes("Change a device"))
76
+ def update_device(
77
+ target: ObjectAddress,
78
+ on: text("Device on: true / false. Empty: unchanged.") = "",
79
+ name: text("New device name.") = "",
80
+ move_to: text("Move into this track or chain: t2, t0/d0/c1.") = "",
81
+ index: text("With move_to: position from 0. Empty: at the end.") = "",
82
+ visible_macros: text("For a rack: how many macro knobs to show.") = "",
83
+ variation: text("For a rack: store, or a variation number to recall.") = "",
84
+ mute: text("For a chain or pad: true / false.") = "",
85
+ solo: text("For a chain or pad: true / false.") = "",
86
+ ) -> dict:
87
+ """Turn a device on or off, rename it, move it to another track or chain, set a
88
+ rack's visible macros, store or recall a macro variation, or mute and solo a
89
+ rack chain or Drum Rack pad. Only given fields change. One undo step.
90
+ """
91
+ return call(
92
+ "update_device",
93
+ {
94
+ "target": target,
95
+ "on": on,
96
+ "name": name,
97
+ "move_to": move_to,
98
+ "index": index,
99
+ "visible_macros": visible_macros,
100
+ "variation": variation,
101
+ "mute": mute,
102
+ "solo": solo,
103
+ },
104
+ )
105
+
106
+
107
+ @mcp.tool(annotations=read_only("Find in the library"))
108
+ def find_in_library(
109
+ query: Annotated[
110
+ str, Field(description="Words in the name, e.g. electric bass, reverb")
111
+ ],
112
+ limit: Annotated[
113
+ int,
114
+ BeforeValidator(empty_to(0)),
115
+ Field(description="At most this many results. Empty or 0: all results."),
116
+ ] = 0,
117
+ kind: Annotated[
118
+ Literal[
119
+ "any",
120
+ "instrument",
121
+ "audio_effect",
122
+ "midi_effect",
123
+ "sound",
124
+ "drum",
125
+ "max_for_live",
126
+ "plugin",
127
+ "pack",
128
+ "user_library",
129
+ ],
130
+ BeforeValidator(empty_to("any")),
131
+ Field(description="Only one part of the browser; any searches all."),
132
+ ] = "any",
133
+ refresh: Annotated[
134
+ bool,
135
+ BeforeValidator(empty_to(False)),
136
+ Field(description="Walk the browser again after adding presets or packs."),
137
+ ] = False,
138
+ ) -> dict:
139
+ """Search Live's browser: devices, presets (.adg, .adv) and samples in the
140
+ Core Library, Packs and User Library. Returns paths for load_device.
141
+
142
+ The first search walks the whole browser (about 5 s, Live pauses briefly); later
143
+ ones are instant. Exact names come first. Places are not searchable: Live does
144
+ not show them to scripts.
145
+ """
146
+ return call(
147
+ "find_in_library",
148
+ {"query": query, "limit": limit, "kind": kind, "refresh": refresh},
149
+ long=True,
150
+ )
151
+
152
+
153
+ @mcp.tool(annotations=changes("Load a device or preset", destructive=True))
154
+ def load_device(
155
+ target: Annotated[
156
+ str,
157
+ Field(description="Track (t0, rA, m) or, for built-in devices, a chain"),
158
+ ],
159
+ item: Annotated[
160
+ str,
161
+ Field(
162
+ description=(
163
+ "A built-in device name (Operator, Reverb, CC Control), a path from "
164
+ "find_in_library (Instruments/Instrument Rack/Bass/Electric Bass "
165
+ "Soft.adg), a preset name, or a file path in the User Library or Packs"
166
+ )
167
+ ),
168
+ ],
169
+ index: text("For built-in devices: position from 0. Empty: at the end.") = "",
170
+ ) -> dict:
171
+ """Load a device or preset onto a track and return its address and, for a rack,
172
+ its macro names.
173
+
174
+ Built-in devices go exactly at index. Presets load like a drag from the
175
+ browser: an instrument replaces the track's instrument (the answer names what
176
+ it replaced). A file outside the User Library and Packs is refused with a hint
177
+ to move it into the User Library. Live selects the track it loads onto.
178
+ """
179
+ return call(
180
+ "load_device", {"target": target, "item": item, "index": index}, long=True
181
+ )
182
+
183
+
184
+ @mcp.tool(annotations=read_only("Inspect a preset file"))
185
+ def inspect_preset_file(
186
+ path: Annotated[
187
+ str,
188
+ Field(
189
+ description=(
190
+ "A .adg or .adv file in the User Library, a pack or the Core "
191
+ "Library, e.g. ~/Music/Ableton/User Library/Presets/Example.adg"
192
+ )
193
+ ),
194
+ ],
195
+ ) -> dict:
196
+ """What a preset file holds, without loading it and without Live: the device,
197
+ a rack's visible macro names, its chains (or Drum Rack pads with their notes)
198
+ and the devices in each, nested racks included.
199
+ """
200
+ from mcp.server.fastmcp.exceptions import ToolError
201
+
202
+ from tonable import presets
203
+
204
+ try:
205
+ return presets.inspect(path)
206
+ except presets.PresetError as err:
207
+ raise ToolError(str(err)) from None
@@ -0,0 +1,76 @@
1
+ """read_envelope and write_envelope: automation in Session clips."""
2
+
3
+ from typing import Annotated
4
+
5
+ from pydantic import Field
6
+
7
+ from tonable.server import call, mcp, read_only
8
+ from tonable.tools import TrackAddress, changes, objects_list
9
+ from tonable.tools.clips import Slot
10
+
11
+ POINT_SCHEMA = {
12
+ "type": "object",
13
+ "properties": {
14
+ "time": {"type": "number", "description": "Beats from the clip start"},
15
+ "value": {
16
+ "type": "string",
17
+ "description": 'Display text ("-6 dB", "400 ms", "25L") or a raw number',
18
+ },
19
+ "jump": {
20
+ "type": "boolean",
21
+ "description": "Hold the previous value until this time, then jump",
22
+ },
23
+ "curve": {
24
+ "type": "array",
25
+ "items": {"type": "number"},
26
+ "description": (
27
+ "Optional bend of the segment from this point: x1, y1, x2, y2, each "
28
+ "0-1 (0.5 four times is straight)"
29
+ ),
30
+ },
31
+ },
32
+ "required": ["time", "value"],
33
+ }
34
+
35
+ Parameter = Annotated[
36
+ str,
37
+ Field(
38
+ description=(
39
+ "volume, pan, send A, or a device parameter on the same track as "
40
+ "t0/d0:Release (device address, colon, parameter name)"
41
+ )
42
+ ),
43
+ ]
44
+
45
+
46
+ @mcp.tool(annotations=read_only("Read an envelope"))
47
+ def read_envelope(track: TrackAddress, slot: Slot, parameter: Parameter) -> dict:
48
+ """The automation points of one parameter in a Session clip: time in beats,
49
+ value as Live shows it, raw value, and curve bends. exists=false when the clip
50
+ has no envelope for it.
51
+ """
52
+ return call("read_envelope", {"track": track, "slot": slot, "parameter": parameter})
53
+
54
+
55
+ @mcp.tool(annotations=changes("Write an envelope", destructive=True, idempotent=True))
56
+ def write_envelope(
57
+ track: TrackAddress,
58
+ slot: Slot,
59
+ parameter: Parameter,
60
+ points: objects_list(
61
+ 'Points in time order. Example: [{"time": 0, "value": "200 ms"}, '
62
+ '{"time": 8, "value": "800 ms"}]. Empty: clear the envelope.',
63
+ POINT_SCHEMA,
64
+ ) = [], # noqa: B006 - pydantic copies defaults
65
+ ) -> dict:
66
+ """Replace one parameter's automation in a Session clip with these points, or
67
+ clear it with no points. Values ramp from point to point unless a point has
68
+ jump=true. Works for the mixer (volume, pan, sends) and device parameters,
69
+ including rack macros; MIDI CC is not reachable (use CC Control's Custom A, CC
70
+ 64 Sustain, for the pedal). Copy the clip to the Arrangement to keep the
71
+ automation there. One undo step.
72
+ """
73
+ return call(
74
+ "write_envelope",
75
+ {"track": track, "slot": slot, "parameter": parameter, "points": points},
76
+ )
@@ -0,0 +1,163 @@
1
+ """live_status, read_set, update_set, transport, undo and scene."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import BeforeValidator, Field
6
+
7
+ from tonable import __version__
8
+ from tonable.bridge import BridgeError
9
+ from tonable.server import call, mcp, read_only
10
+ from tonable.tools import changes, empty_to, text
11
+
12
+
13
+ @mcp.tool(annotations=read_only("Live status"))
14
+ def live_status() -> dict:
15
+ """Check the connection to Ableton Live and show the open Set.
16
+
17
+ Returns whether Live is connected, the Live and Tonable versions, and the Set's
18
+ tempo, time signature and numbers of tracks, return tracks and scenes. When Live
19
+ is not reachable it returns connected=false with the reason and what to do.
20
+ Use it first in a conversation and after any connection error.
21
+ """
22
+ from tonable import server
23
+
24
+ try:
25
+ status = server.bridge.request("status")
26
+ except BridgeError as err:
27
+ return {"connected": False, "server_version": __version__, **err.as_dict()}
28
+ out = {"connected": True, "server_version": __version__, **status}
29
+ if status.get("script_version") != __version__:
30
+ out["warning"] = (
31
+ f"The Remote Script in Live is version {status.get('script_version')}, "
32
+ f"the server is {__version__}. Run `tonable install` and restart Live."
33
+ )
34
+ return out
35
+
36
+
37
+ @mcp.tool(annotations=read_only("Read the Set"))
38
+ def read_set() -> dict:
39
+ """Overview of the open Live Set: tempo, time signature, loop, tracks, return
40
+ tracks, scenes and locators.
41
+
42
+ Each scene lists the clips in its row as {track address: clip name}; empty slots
43
+ are left out. Each track shows its address, name, kind (midi, audio, group) and
44
+ device names. Use read_track for one track's mixer, devices and clips.
45
+ """
46
+ return call("read_set")
47
+
48
+
49
+ @mcp.tool(annotations=changes("Change the Set"))
50
+ def update_set(
51
+ tempo: text("BPM, e.g. 92. Empty: unchanged.") = "",
52
+ time_signature: text("e.g. 4/4, 3/4, 6/8. Empty: unchanged.") = "",
53
+ loop: text("Arrangement loop on or off: true / false. Empty: unchanged.") = "",
54
+ loop_start_beats: text("Loop start in beats. Empty: unchanged.") = "",
55
+ loop_length_beats: text("Loop length in beats (16 = four 4/4 bars).") = "",
56
+ metronome: text("true / false. Empty: unchanged.") = "",
57
+ root_note: text(
58
+ "Key root for Live's scale mode: C, F#, Bb. Empty: unchanged."
59
+ ) = "",
60
+ scale: text("Scale name as Live shows it: Major, Minor, Dorian...") = "",
61
+ ) -> dict:
62
+ """Change the Set's tempo, time signature, Arrangement loop, metronome or key.
63
+
64
+ Only the fields you give change; the answer shows the Set's state afterwards.
65
+ One undo step.
66
+ """
67
+ return call(
68
+ "update_set",
69
+ {
70
+ "tempo": tempo,
71
+ "time_signature": time_signature,
72
+ "loop": loop,
73
+ "loop_start_beats": loop_start_beats,
74
+ "loop_length_beats": loop_length_beats,
75
+ "metronome": metronome,
76
+ "root_note": root_note,
77
+ "scale": scale,
78
+ },
79
+ )
80
+
81
+
82
+ @mcp.tool(annotations=changes("Transport"))
83
+ def transport(
84
+ action: Annotated[
85
+ Literal[
86
+ "play",
87
+ "stop",
88
+ "continue",
89
+ "fire_clip",
90
+ "stop_track",
91
+ "fire_scene",
92
+ "stop_all_clips",
93
+ "jump",
94
+ "re_enable_automation",
95
+ ],
96
+ Field(description="What to do; see the tool description."),
97
+ ],
98
+ track: text("For fire_clip and stop_track: t0, t1...") = "",
99
+ slot: text("For fire_clip: the slot (scene row) from 0.") = "",
100
+ scene: text("For fire_scene: scene number from 0 or its exact name.") = "",
101
+ time_beats: text("For jump: song position in beats.") = "",
102
+ locator: text("For jump: a locator name instead of time_beats.") = "",
103
+ ) -> dict:
104
+ """Play, stop or continue; launch a clip (fire_clip track+slot) or a scene
105
+ (fire_scene); stop one track's clips (stop_track) or all clips; jump to a time
106
+ in beats or to a locator; re_enable_automation after a parameter was moved by
107
+ hand or by set_device_params.
108
+ """
109
+ return call(
110
+ "transport",
111
+ {
112
+ "action": action,
113
+ "track": track,
114
+ "slot": slot,
115
+ "scene": scene,
116
+ "time_beats": time_beats,
117
+ "locator": locator,
118
+ },
119
+ )
120
+
121
+
122
+ @mcp.tool(annotations=changes("Undo"))
123
+ def undo(
124
+ action: Annotated[
125
+ Literal["undo", "redo"],
126
+ BeforeValidator(empty_to("undo")),
127
+ Field(description="undo (Cmd+Z) or redo (Shift+Cmd+Z)."),
128
+ ] = "undo",
129
+ ) -> dict:
130
+ """Undo or redo one step in Live, like Cmd+Z. Every Tonable change is one step
131
+ (a batch too). Live's undo history is shared with everyone working in the Set,
132
+ so read the state afterwards to see what changed.
133
+ """
134
+ return call("undo", {"action": action})
135
+
136
+
137
+ @mcp.tool(annotations=changes("Scene"))
138
+ def scene(
139
+ action: Annotated[
140
+ Literal["create", "update", "duplicate", "capture"],
141
+ Field(description="create, update (rename, tempo, color), duplicate, capture."),
142
+ ],
143
+ scene: text("For update and duplicate: number from 0 or exact name.") = "",
144
+ index: text("For create: where to insert, from 0. Empty: at the end.") = "",
145
+ name: text("New name.") = "",
146
+ tempo: text("Scene tempo in BPM, or off. Empty: unchanged.") = "",
147
+ color_index: text("Live's color number. Empty: unchanged.") = "",
148
+ ) -> dict:
149
+ """Create a scene, rename it or set its tempo and color, duplicate it, or capture
150
+ the playing clips into a new scene. Delete scenes with delete (target s2).
151
+ Launch one with transport fire_scene. One undo step.
152
+ """
153
+ return call(
154
+ "scene",
155
+ {
156
+ "action": action,
157
+ "scene": scene,
158
+ "index": index,
159
+ "name": name,
160
+ "tempo": tempo,
161
+ "color_index": color_index,
162
+ },
163
+ )