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
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}")
|