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/presets.py ADDED
@@ -0,0 +1,119 @@
1
+ """What is inside a .adg / .adv preset, read from the file without Live.
2
+
3
+ A preset is gzip-compressed XML. A rack (GroupDevicePreset) holds its device, macro
4
+ names and BranchPresets (chains); each chain holds DevicePresets, which may be racks
5
+ again. Drum Rack pads keep their note as ReceivingNote = 128 - MIDI note (a Carbonized
6
+ Kit has Kick at 92 = C1, Snare at 90 = D1).
7
+
8
+ Only files in folders Live's browser shows are read (see docs/plan.md):
9
+ the User Library, the packs and the Core Library.
10
+ """
11
+
12
+ import gzip
13
+ import os
14
+ import xml.etree.ElementTree as ET
15
+ from pathlib import Path
16
+
17
+ from tonable import cli
18
+
19
+ SUFFIXES = (".adg", ".adv")
20
+ NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
21
+ CORE_LIBRARY = Path(
22
+ "/Applications/Ableton Live 12 Suite.app/Contents/App-Resources/Core Library"
23
+ )
24
+
25
+
26
+ class PresetError(Exception):
27
+ pass
28
+
29
+
30
+ def allowed_folders():
31
+ """The User Library, every pack folder Live knows, and the Core Library."""
32
+ prefs, _ = cli.live_preferences()
33
+ folders = [cli.user_library(prefs), CORE_LIBRARY]
34
+ if prefs is not None:
35
+ try:
36
+ root = ET.parse(Path(prefs) / "Library.cfg").getroot()
37
+ except (OSError, ET.ParseError):
38
+ root = None
39
+ if root is not None:
40
+ for slice_info in root.iter("LibrarySliceInfo"):
41
+ if slice_info.get("Path"):
42
+ folders.append(Path(slice_info.get("Path")))
43
+ preferred = root.find(".//PreferredFactoryPacksInstallationPath")
44
+ if preferred is not None and preferred.get("Value"):
45
+ folders.append(Path(preferred.get("Value")))
46
+ return [Path(os.path.realpath(f)) for f in folders if f is not None]
47
+
48
+
49
+ def inspect(path, folders=None):
50
+ file = Path(os.path.realpath(os.path.expanduser(str(path))))
51
+ if file.suffix.lower() not in SUFFIXES:
52
+ raise PresetError("only .adg and .adv presets can be read")
53
+ folders = allowed_folders() if folders is None else folders
54
+ if not any(file.is_relative_to(f) for f in folders):
55
+ raise PresetError(
56
+ f"{file} is outside the folders Live's browser shows (User Library, "
57
+ "packs, Core Library)"
58
+ )
59
+ if not file.is_file():
60
+ raise PresetError(f"no file {file}")
61
+ try:
62
+ with gzip.open(file) as f:
63
+ root = ET.parse(f).getroot()
64
+ except (OSError, EOFError, ET.ParseError) as err:
65
+ raise PresetError(f"cannot read {file.name}: {err}") from None
66
+ preset = root[0] if len(root) else None
67
+ if preset is None:
68
+ raise PresetError(f"{file.name} holds no device")
69
+ out = {"file": str(file), "created_by": root.get("Creator")}
70
+ out.update(_preset(preset))
71
+ return out
72
+
73
+
74
+ def _preset(preset):
75
+ """A device preset or a rack preset, with its chains."""
76
+ holder = preset.find("Device")
77
+ device = holder[0] if holder is not None and len(holder) else None
78
+ if device is None:
79
+ return {"device": preset.tag}
80
+ out = {"device": _name(device), "class": device.tag}
81
+ if preset.tag == "GroupDevicePreset":
82
+ out["macros"] = _macros(device)
83
+ chains = []
84
+ branches = preset.find("BranchPresets")
85
+ for i, branch in enumerate(branches if branches is not None else []):
86
+ presets = branch.find("DevicePresets")
87
+ devices = [_preset(p) for p in (presets if presets is not None else [])]
88
+ # An unnamed chain shows its first device's name in Live.
89
+ first = devices[0]["device"] if devices else None
90
+ entry = {"name": _value(branch.find("Name")) or first or f"Chain {i + 1}"}
91
+ note = branch.find("ZoneSettings/ReceivingNote")
92
+ if branch.tag == "DrumBranchPreset" and note is not None:
93
+ entry["note"] = _note_name(128 - int(note.get("Value")))
94
+ entry["devices"] = devices
95
+ chains.append(entry)
96
+ out["pads" if device.tag == "DrumGroupDevice" else "chains"] = chains
97
+ return out
98
+
99
+
100
+ def _macros(device):
101
+ count = device.find("NumVisibleMacroControls")
102
+ visible = int(count.get("Value")) if count is not None else 0
103
+ names = []
104
+ for i in range(visible):
105
+ name = device.find(f"MacroDisplayNames.{i}")
106
+ names.append(_value(name) or f"Macro {i + 1}")
107
+ return names
108
+
109
+
110
+ def _name(device):
111
+ return _value(device.find("UserName")) or device.tag
112
+
113
+
114
+ def _value(element):
115
+ return element.get("Value") if element is not None else None
116
+
117
+
118
+ def _note_name(number):
119
+ return f"{NOTE_NAMES[number % 12]}{number // 12 - 2}"
@@ -0,0 +1,146 @@
1
+ """Tonable: the Remote Script half of the Tonable MCP server for Ableton Live 12.4+.
2
+
3
+ `tonable install` copies this folder to <User Library>/Remote Scripts/Tonable. Choose
4
+ Tonable in Live's Settings -> Link, Tempo & MIDI -> Control Surface. The script opens a
5
+ loopback port (see server.py) and writes its number and a key to
6
+ ~/.tonable/session.json for the MCP server.
7
+ """
8
+
9
+ import importlib
10
+ import sys
11
+
12
+ from . import dispatch, version
13
+ from .server import BridgeServer
14
+ from .version import VERSION
15
+
16
+ # `tonable install` reloads these in a running Live; dispatch then reloads the
17
+ # command modules it lists. server.py is never reloaded: it owns the open socket.
18
+ RELOADABLE = ("version", "dispatch")
19
+
20
+ try:
21
+ import Live
22
+ except ImportError: # imported by the tests, outside Live
23
+ Live = None
24
+
25
+
26
+ def create_instance(c_instance):
27
+ """Live calls this once when Tonable is chosen in the Control Surface list."""
28
+ return Tonable(c_instance)
29
+
30
+
31
+ class Tonable:
32
+ def __init__(self, c_instance):
33
+ self._c_instance = c_instance
34
+ self.live_version = self._read_live_version()
35
+ self._commands = dispatch.commands(self)
36
+ self._commands["reload"] = self._reload
37
+ self._server = BridgeServer(
38
+ self._commands,
39
+ {"script_version": VERSION, "live_version": self.live_version},
40
+ self.log,
41
+ )
42
+ try:
43
+ self._server.start()
44
+ except Exception as err:
45
+ self.log(f"could not start: {type(err).__name__}: {err}")
46
+ self.show_message(f"Tonable could not start: {err}")
47
+ self._server = None
48
+ return
49
+ self.log(f"{VERSION} listening on 127.0.0.1:{self._server.port}")
50
+ self.show_message("Tonable is ready")
51
+
52
+ def _reload(self, params):
53
+ """Load the newly installed command modules without restarting Live."""
54
+ for name in RELOADABLE:
55
+ module = sys.modules.get(f"{__name__}.{name}")
56
+ if module is None:
57
+ importlib.import_module(f".{name}", __name__)
58
+ else:
59
+ importlib.reload(module)
60
+ fresh = dispatch.commands(self)
61
+ fresh["reload"] = self._reload
62
+ self._commands.clear()
63
+ self._commands.update(fresh)
64
+ self.log(f"reloaded {version.VERSION}")
65
+ return {"script_version": version.VERSION, "commands": sorted(fresh)}
66
+
67
+ @property
68
+ def song(self):
69
+ return self._c_instance.song()
70
+
71
+ def log(self, message):
72
+ self._c_instance.log_message("[Tonable] " + str(message))
73
+
74
+ def _read_live_version(self):
75
+ if Live is None:
76
+ return None
77
+ app = Live.Application.get_application()
78
+ parts = (
79
+ app.get_major_version(),
80
+ app.get_minor_version(),
81
+ app.get_bugfix_version(),
82
+ )
83
+ return ".".join(str(p) for p in parts)
84
+
85
+ # --- Live calls these ---------------------------------------------
86
+
87
+ def update_display(self):
88
+ """Live calls this about ten times a second on its main thread."""
89
+ if self._server is not None:
90
+ self._server.run_pending()
91
+
92
+ def disconnect(self):
93
+ if self._server is not None:
94
+ self._server.stop()
95
+ self._server = None
96
+
97
+ def connect_script_instances(self, instances):
98
+ pass
99
+
100
+ def can_lock_to_devices(self):
101
+ return False
102
+
103
+ def lock_to_device(self, device):
104
+ pass
105
+
106
+ def unlock_from_device(self, device):
107
+ pass
108
+
109
+ def toggle_lock(self):
110
+ pass
111
+
112
+ def set_appointed_device(self, device):
113
+ pass
114
+
115
+ def suggest_input_port(self):
116
+ return ""
117
+
118
+ def suggest_output_port(self):
119
+ return ""
120
+
121
+ def suggest_map_mode(self, cc_no, channel):
122
+ return Live.MidiMap.MapMode.absolute
123
+
124
+ def supports_pad_translation(self):
125
+ return False
126
+
127
+ def show_message(self, message):
128
+ self._c_instance.show_message(message)
129
+
130
+ def request_rebuild_midi_map(self):
131
+ self._c_instance.request_rebuild_midi_map()
132
+
133
+ def build_midi_map(self, midi_map_handle):
134
+ pass
135
+
136
+ def receive_midi(self, midi_bytes):
137
+ pass
138
+
139
+ def handle_sysex(self, midi_bytes):
140
+ pass
141
+
142
+ def refresh_state(self):
143
+ pass
144
+
145
+ def is_extension(self):
146
+ return False
@@ -0,0 +1,236 @@
1
+ """The Arrangement: list clips, place a scene, copy or create clips, locators.
2
+
3
+ Times are given as at_bar (bars from 1, as Live numbers them) or at_beats; answers
4
+ show both, with bar|beat like Live's display ("17|1"). Live's API copies one clip at a
5
+ time (duplicate_clip_to_arrangement), so place_scene walks the tracks itself, as one
6
+ undo step. Envelopes in a Session clip become track automation on its span.
7
+ """
8
+
9
+ from . import clips, objects
10
+ from .server import CommandError
11
+ from .song import find_scene, undo_step
12
+
13
+ ACTIONS = (
14
+ "list",
15
+ "place_scene",
16
+ "copy_clip",
17
+ "create_clip",
18
+ "delete_clip",
19
+ "add_locator",
20
+ "rename_locator",
21
+ "delete_locator",
22
+ )
23
+
24
+
25
+ def arrangement(song, p):
26
+ action = p.get("action")
27
+ if action not in ACTIONS:
28
+ raise CommandError("bad_params", "action must be one of: " + ", ".join(ACTIONS))
29
+ if action == "list":
30
+ return _list(song)
31
+ handler = {
32
+ "place_scene": _place_scene,
33
+ "copy_clip": _copy_clip,
34
+ "create_clip": _create_clip,
35
+ "delete_clip": _delete_clip,
36
+ "add_locator": _add_locator,
37
+ "rename_locator": _rename_locator,
38
+ "delete_locator": _delete_locator,
39
+ }[action]
40
+ with undo_step(song):
41
+ return handler(song, p)
42
+
43
+
44
+ def bar_beat(song, beats):
45
+ """Live's bar|beat for a time in beats: 0 -> "1|1", 18 in 4/4 -> "5|3"."""
46
+ bar_length = _bar(song)
47
+ bar = int(beats // bar_length) + 1
48
+ beat = beats - (bar - 1) * bar_length + 1
49
+ return f"{bar}|{beat:g}"
50
+
51
+
52
+ def _list(song):
53
+ tracks = []
54
+ for t, track in enumerate(song.tracks):
55
+ found = [
56
+ _clip_out(song, c) for c in getattr(track, "arrangement_clips", []) or []
57
+ ]
58
+ if found:
59
+ tracks.append({"track": f"t{t}", "name": track.name, "clips": found})
60
+ return {
61
+ "time_signature": f"{song.signature_numerator}/{song.signature_denominator}",
62
+ "tracks": tracks,
63
+ "locators": [_locator_out(song, c) for c in song.cue_points],
64
+ "loop": {
65
+ "on": bool(song.loop),
66
+ "start": bar_beat(song, song.loop_start),
67
+ "length_beats": song.loop_length,
68
+ },
69
+ }
70
+
71
+
72
+ def _place_scene(song, p):
73
+ index, scene = find_scene(song, p.get("scene"))
74
+ at = _time(song, p)
75
+ placed, end = [], at
76
+ for t, track in enumerate(song.tracks):
77
+ slots = list(track.clip_slots)
78
+ if index < len(slots) and slots[index].has_clip:
79
+ clip = slots[index].clip
80
+ copy = track.duplicate_clip_to_arrangement(clip, at)
81
+ placed.append({"track": f"t{t}", "clip": clip.name})
82
+ end = max(end, copy.end_time if copy is not None else at + clip.length)
83
+ if not placed:
84
+ raise CommandError("not_found", f"scene {index} ({scene.name!r}) has no clips")
85
+ return {
86
+ "scene": {"index": index, "name": scene.name},
87
+ "start": _time_out(song, at),
88
+ "end": _time_out(song, end),
89
+ "placed": placed,
90
+ }
91
+
92
+
93
+ def _copy_clip(song, p):
94
+ track, slot, where = clips._slot(song, p.get("track"), p.get("slot"))
95
+ if not slot.has_clip:
96
+ raise CommandError("not_found", f"{where} is empty")
97
+ at = _time(song, p)
98
+ copy = track.duplicate_clip_to_arrangement(slot.clip, at)
99
+ return {
100
+ "copied": where,
101
+ "clip": _clip_out(song, copy) if copy is not None else None,
102
+ }
103
+
104
+
105
+ def _create_clip(song, p):
106
+ track = objects.track(song, p.get("track") or "")
107
+ path = objects.track_path(song, track)
108
+ if not path.startswith("t") or not getattr(track, "has_midi_input", False):
109
+ raise CommandError("bad_target", f"{path} is not a MIDI track")
110
+ at = _time(song, p)
111
+ notes, rejected = clips.parse_notes(p.get("notes") or [])
112
+ length = clips._beats(p.get("length_beats"), "length_beats", allow_zero=True)
113
+ length = length or clips.fit_length(song, notes)
114
+ clip = track.create_midi_clip(at, length)
115
+ if notes:
116
+ clip.add_new_notes(tuple(clips._note_spec(n) for n in notes))
117
+ if p.get("name"):
118
+ clip.name = str(p["name"])
119
+ out = {"track": path, "clip": _clip_out(song, clip), "notes_added": len(notes)}
120
+ if rejected:
121
+ out["rejected"] = rejected
122
+ return out
123
+
124
+
125
+ def _delete_clip(song, p):
126
+ track = objects.track(song, p.get("track") or "")
127
+ path = objects.track_path(song, track)
128
+ at = _time(song, p)
129
+ hits = [c for c in track.arrangement_clips if c.start_time == at]
130
+ if not hits:
131
+ starts = [bar_beat(song, c.start_time) for c in track.arrangement_clips]
132
+ raise CommandError(
133
+ "not_found",
134
+ f"no clip starts at {bar_beat(song, at)} on {path}; starts: {starts}",
135
+ )
136
+ clip = hits[0]
137
+ name = clip.name
138
+ if str(p.get("name") or "").strip().casefold() != name.strip().casefold():
139
+ raise CommandError(
140
+ "confirm_name", f"to delete it give its name exactly: {name!r}"
141
+ )
142
+ track.delete_clip(clip) # the clip object is dead afterwards
143
+ return {"deleted": {"track": path, "name": name, "start": _time_out(song, at)}}
144
+
145
+
146
+ def _add_locator(song, p):
147
+ at = _time(song, p)
148
+ if any(c.time == at for c in song.cue_points):
149
+ raise CommandError("exists", f"a locator is already at {bar_beat(song, at)}")
150
+ _playhead_at(song, at)
151
+ song.set_or_delete_cue()
152
+ cue = next((c for c in song.cue_points if c.time == at), None)
153
+ if cue is None:
154
+ raise CommandError("not_supported", "Live did not add the locator")
155
+ if p.get("name"):
156
+ cue.name = str(p["name"])
157
+ return {"added": _locator_out(song, cue), "note": "the playhead is now there"}
158
+
159
+
160
+ def _rename_locator(song, p):
161
+ cue = _cue(song, p.get("locator"))
162
+ if not p.get("name"):
163
+ raise CommandError("bad_params", "name is required")
164
+ cue.name = str(p["name"])
165
+ return {"renamed": _locator_out(song, cue)}
166
+
167
+
168
+ def _delete_locator(song, p):
169
+ cue = _cue(song, p.get("locator"))
170
+ shown = _locator_out(song, cue)
171
+ _playhead_at(song, cue.time)
172
+ song.set_or_delete_cue()
173
+ return {"deleted": shown}
174
+
175
+
176
+ def _playhead_at(song, beats):
177
+ """Live adds and removes locators at the playhead, and a new playhead position
178
+ only counts from the next tick. If it is elsewhere, move it and ask the MCP
179
+ server to send the command again (code retry); the second call finds it there."""
180
+ if song.current_song_time == beats:
181
+ return
182
+ if song.is_playing:
183
+ raise CommandError(
184
+ "playing", "stop playback first: locators go at the playhead"
185
+ )
186
+ song.current_song_time = beats
187
+ raise CommandError("retry", "the playhead moves on the next tick; send again")
188
+
189
+
190
+ # --- helpers ---------------------------------------------------------------------
191
+
192
+
193
+ def _bar(song):
194
+ return song.signature_numerator * 4.0 / song.signature_denominator
195
+
196
+
197
+ def _time(song, p):
198
+ if p.get("at_bar") not in (None, ""):
199
+ bar = float(p["at_bar"])
200
+ if bar < 1:
201
+ raise CommandError("bad_params", "at_bar counts from 1, like Live")
202
+ return (bar - 1) * _bar(song)
203
+ if p.get("at_beats") not in (None, ""):
204
+ beats = float(p["at_beats"])
205
+ if beats < 0:
206
+ raise CommandError("bad_params", "at_beats must be 0 or more")
207
+ return beats
208
+ raise CommandError("bad_params", "give at_bar (from 1) or at_beats")
209
+
210
+
211
+ def _time_out(song, beats):
212
+ return {"bar_beat": bar_beat(song, beats), "beats": beats}
213
+
214
+
215
+ def _clip_out(song, clip):
216
+ return {
217
+ "name": clip.name,
218
+ "start": bar_beat(song, clip.start_time),
219
+ "start_beats": clip.start_time,
220
+ "end_beats": clip.end_time,
221
+ }
222
+
223
+
224
+ def _locator_out(song, cue):
225
+ return {"name": cue.name, "at": bar_beat(song, cue.time), "beats": cue.time}
226
+
227
+
228
+ def _cue(song, ref):
229
+ text = str(ref or "").strip()
230
+ if not text:
231
+ raise CommandError("bad_params", "locator is required: its name")
232
+ for cue in song.cue_points:
233
+ if cue.name.strip().casefold() == text.casefold():
234
+ return cue
235
+ names = [c.name for c in song.cue_points]
236
+ raise CommandError("not_found", f"no locator {text!r}; locators: {names}")