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,160 @@
|
|
|
1
|
+
"""The command table: what the MCP server may ask Live to do. Main thread only."""
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
# Reload every command module that is already loaded before importing them. A
|
|
7
|
+
# running Live reloads this module last (see __init__._reload), and the list in an
|
|
8
|
+
# older __init__ may not know the newer modules.
|
|
9
|
+
MODULES = (
|
|
10
|
+
"version",
|
|
11
|
+
"params",
|
|
12
|
+
"objects",
|
|
13
|
+
"reading",
|
|
14
|
+
"song",
|
|
15
|
+
"clips",
|
|
16
|
+
"edit",
|
|
17
|
+
"devices",
|
|
18
|
+
"library",
|
|
19
|
+
"envelopes",
|
|
20
|
+
"arrangement",
|
|
21
|
+
)
|
|
22
|
+
for _name in MODULES:
|
|
23
|
+
_module = sys.modules.get(f"{__package__}.{_name}")
|
|
24
|
+
if _module is not None:
|
|
25
|
+
importlib.reload(_module)
|
|
26
|
+
|
|
27
|
+
from . import ( # noqa: E402
|
|
28
|
+
arrangement,
|
|
29
|
+
clips,
|
|
30
|
+
devices,
|
|
31
|
+
edit,
|
|
32
|
+
envelopes,
|
|
33
|
+
library,
|
|
34
|
+
reading,
|
|
35
|
+
song,
|
|
36
|
+
)
|
|
37
|
+
from .server import CommandError # noqa: E402
|
|
38
|
+
from .version import VERSION # noqa: E402
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def commands(surface):
|
|
42
|
+
"""Command name -> function of `params`, bound to the running control surface."""
|
|
43
|
+
table = {
|
|
44
|
+
"status": lambda params: status(surface),
|
|
45
|
+
"read_set": lambda params: reading.read_set(surface.song),
|
|
46
|
+
"read_track": lambda params: reading.read_track(
|
|
47
|
+
surface.song, _required(params, "track")
|
|
48
|
+
),
|
|
49
|
+
"read_device": lambda params: reading.read_device(
|
|
50
|
+
surface.song,
|
|
51
|
+
_required(params, "target"),
|
|
52
|
+
wanted=params.get("params") or None,
|
|
53
|
+
all_params=bool(params.get("all_params")),
|
|
54
|
+
),
|
|
55
|
+
"read_clip": lambda params: clips.read_clip(
|
|
56
|
+
surface.song, _required(params, "track"), params.get("slot")
|
|
57
|
+
),
|
|
58
|
+
"write_clip": lambda params: clips.write_clip(surface.song, params),
|
|
59
|
+
"undo": lambda params: song.undo(surface.song, params),
|
|
60
|
+
"transport": lambda params: song.transport(surface.song, params),
|
|
61
|
+
"update_set": lambda params: song.update_set(surface.song, params),
|
|
62
|
+
"scene": lambda params: song.scene(surface.song, params),
|
|
63
|
+
"edit_notes": lambda params: clips.edit_notes(surface.song, params),
|
|
64
|
+
"create_track": lambda params: edit.create_track(surface.song, params),
|
|
65
|
+
"update_track": lambda params: edit.update_track(surface.song, params),
|
|
66
|
+
"delete": lambda params: edit.delete(surface.song, params),
|
|
67
|
+
"duplicate": lambda params: edit.duplicate(surface.song, params),
|
|
68
|
+
"set_device_params": lambda params: devices.set_device_params(
|
|
69
|
+
surface.song, params
|
|
70
|
+
),
|
|
71
|
+
"update_device": lambda params: devices.update_device(surface.song, params),
|
|
72
|
+
"find_in_library": lambda params: library.find_in_library(params),
|
|
73
|
+
"load_device": lambda params: library.load_device(surface.song, params),
|
|
74
|
+
"read_envelope": lambda params: envelopes.read_envelope(surface.song, params),
|
|
75
|
+
"write_envelope": lambda params: envelopes.write_envelope(surface.song, params),
|
|
76
|
+
"arrangement": lambda params: arrangement.arrangement(surface.song, params),
|
|
77
|
+
}
|
|
78
|
+
table["batch"] = lambda params: batch(surface.song, table, params)
|
|
79
|
+
return table
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Never inside a batch: they would nest batches or swap the table mid-run.
|
|
83
|
+
NOT_IN_BATCH = ("batch", "reload")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def batch(song_, table, params):
|
|
87
|
+
"""Run several commands in order as one undo step; each gets its own result.
|
|
88
|
+
|
|
89
|
+
A failed action does not stop the rest unless stop_on_error is set.
|
|
90
|
+
"""
|
|
91
|
+
actions = params.get("actions")
|
|
92
|
+
if isinstance(actions, dict):
|
|
93
|
+
actions = [actions]
|
|
94
|
+
if not isinstance(actions, list) or not actions:
|
|
95
|
+
raise CommandError(
|
|
96
|
+
"bad_params", 'actions is required: [{"command": ..., "params": {...}}]'
|
|
97
|
+
)
|
|
98
|
+
stop = bool(params.get("stop_on_error"))
|
|
99
|
+
results = []
|
|
100
|
+
with song.undo_step(song_):
|
|
101
|
+
for i, action in enumerate(actions):
|
|
102
|
+
if not isinstance(action, dict):
|
|
103
|
+
results.append(
|
|
104
|
+
{"index": i, "ok": False, "error": "an action is an object"}
|
|
105
|
+
)
|
|
106
|
+
continue
|
|
107
|
+
name = action.get("command") or action.get("tool")
|
|
108
|
+
handler = None if name in NOT_IN_BATCH else table.get(name)
|
|
109
|
+
if handler is None:
|
|
110
|
+
results.append(
|
|
111
|
+
{"index": i, "ok": False, "error": f"unknown command {name!r}"}
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
try:
|
|
115
|
+
out = handler(action.get("params") or action.get("args") or {})
|
|
116
|
+
results.append(
|
|
117
|
+
{"index": i, "command": name, "ok": True, "result": out}
|
|
118
|
+
)
|
|
119
|
+
except CommandError as err:
|
|
120
|
+
results.append(
|
|
121
|
+
{
|
|
122
|
+
"index": i,
|
|
123
|
+
"command": name,
|
|
124
|
+
"ok": False,
|
|
125
|
+
"code": err.code,
|
|
126
|
+
"error": str(err),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
if stop and not results[-1]["ok"]:
|
|
130
|
+
break
|
|
131
|
+
return {
|
|
132
|
+
"ran": len(results),
|
|
133
|
+
"failed": sum(not r["ok"] for r in results),
|
|
134
|
+
"results": results,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def status(surface):
|
|
139
|
+
"""Versions and a one-line picture of the open Set, for live_status and doctor."""
|
|
140
|
+
song = surface.song
|
|
141
|
+
signature = f"{song.signature_numerator}/{song.signature_denominator}"
|
|
142
|
+
return {
|
|
143
|
+
"live_version": surface.live_version,
|
|
144
|
+
"script_version": VERSION,
|
|
145
|
+
"set": {
|
|
146
|
+
"tempo": song.tempo,
|
|
147
|
+
"time_signature": signature,
|
|
148
|
+
"tracks": len(song.tracks),
|
|
149
|
+
"return_tracks": len(song.return_tracks),
|
|
150
|
+
"scenes": len(song.scenes),
|
|
151
|
+
"is_playing": bool(song.is_playing),
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _required(params, name):
|
|
157
|
+
value = params.get(name)
|
|
158
|
+
if value is None or str(value).strip() == "":
|
|
159
|
+
raise CommandError("bad_params", f"{name} is required")
|
|
160
|
+
return str(value).strip()
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Tracks and the objects in them: create_track, update_track, delete, duplicate.
|
|
2
|
+
|
|
3
|
+
delete and duplicate take one address for anything they can act on:
|
|
4
|
+
t0, rA a track or return track
|
|
5
|
+
t0/d1, ... a device (inside racks too)
|
|
6
|
+
t0/s2 the clip in slot 2 of t0
|
|
7
|
+
s2 scene 2
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from . import objects, params
|
|
13
|
+
from .server import CommandError
|
|
14
|
+
from .song import find_scene, undo_step
|
|
15
|
+
|
|
16
|
+
TRACK_KINDS = ("midi", "audio", "return")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_track(song, p):
|
|
20
|
+
kind = p.get("kind") or "midi"
|
|
21
|
+
if kind not in TRACK_KINDS:
|
|
22
|
+
raise CommandError("bad_params", "kind must be midi, audio or return")
|
|
23
|
+
with undo_step(song):
|
|
24
|
+
if kind == "return":
|
|
25
|
+
song.create_return_track()
|
|
26
|
+
track = list(song.return_tracks)[-1]
|
|
27
|
+
else:
|
|
28
|
+
tracks = list(song.tracks)
|
|
29
|
+
index = p.get("index")
|
|
30
|
+
index = len(tracks) if index in (None, "", -1) else int(index)
|
|
31
|
+
if not 0 <= index <= len(tracks):
|
|
32
|
+
raise CommandError("not_found", f"index must be 0-{len(tracks)}")
|
|
33
|
+
create = (
|
|
34
|
+
song.create_midi_track if kind == "midi" else song.create_audio_track
|
|
35
|
+
)
|
|
36
|
+
create(index)
|
|
37
|
+
track = list(song.tracks)[index]
|
|
38
|
+
if p.get("name"):
|
|
39
|
+
track.name = str(p["name"])
|
|
40
|
+
return {
|
|
41
|
+
"created": objects.track_path(song, track),
|
|
42
|
+
"name": track.name,
|
|
43
|
+
"kind": kind,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def update_track(song, p):
|
|
48
|
+
"""Change only the given fields; one line per field in the answer."""
|
|
49
|
+
track = objects.track(song, p.get("track") or "")
|
|
50
|
+
path = objects.track_path(song, track) if p.get("track") != "m" else "m"
|
|
51
|
+
results = []
|
|
52
|
+
with undo_step(song):
|
|
53
|
+
for field in ("name", "color_index", "mute", "solo", "arm"):
|
|
54
|
+
if p.get(field) in (None, ""):
|
|
55
|
+
continue
|
|
56
|
+
results.append(_set_field(track, field, p[field]))
|
|
57
|
+
mixer = track.mixer_device
|
|
58
|
+
if p.get("volume_db") not in (None, ""):
|
|
59
|
+
results.append(_set_param("volume", mixer.volume, _db(p["volume_db"])))
|
|
60
|
+
if p.get("pan") not in (None, ""):
|
|
61
|
+
results.append(_set_param("pan", mixer.panning, _pan(p["pan"])))
|
|
62
|
+
for send in _sends(p.get("sends")):
|
|
63
|
+
results.append(_set_send(song, mixer, send))
|
|
64
|
+
for key, attr in (
|
|
65
|
+
("input", "input_routing_type"),
|
|
66
|
+
("output", "output_routing_type"),
|
|
67
|
+
):
|
|
68
|
+
if p.get(key) not in (None, ""):
|
|
69
|
+
results.append(_set_routing(track, key, attr, str(p[key])))
|
|
70
|
+
if not results:
|
|
71
|
+
raise CommandError("bad_params", "nothing to change: give at least one field")
|
|
72
|
+
return {"track": path, "name": track.name, "results": results}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def delete(song, p):
|
|
76
|
+
"""Delete after checking `name` against the object's name, as one undo step."""
|
|
77
|
+
target = str(p.get("target") or "").strip()
|
|
78
|
+
confirm = str(p.get("name") or "").strip()
|
|
79
|
+
kind, obj, holder, index, path = _locate(song, target)
|
|
80
|
+
actual = obj.name if kind != "clip" else obj.clip.name
|
|
81
|
+
if not confirm or confirm.casefold() != actual.strip().casefold():
|
|
82
|
+
raise CommandError(
|
|
83
|
+
"confirm_name",
|
|
84
|
+
f"to delete {path} give its name exactly: {actual!r}",
|
|
85
|
+
)
|
|
86
|
+
with undo_step(song):
|
|
87
|
+
if kind == "track":
|
|
88
|
+
song.delete_track(index)
|
|
89
|
+
elif kind == "return":
|
|
90
|
+
song.delete_return_track(index)
|
|
91
|
+
elif kind == "device":
|
|
92
|
+
holder.delete_device(index)
|
|
93
|
+
elif kind == "clip":
|
|
94
|
+
obj.delete_clip()
|
|
95
|
+
elif kind == "scene":
|
|
96
|
+
song.delete_scene(index)
|
|
97
|
+
return {"deleted": path, "kind": kind, "name": actual}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def duplicate(song, p):
|
|
101
|
+
target = str(p.get("target") or "").strip()
|
|
102
|
+
kind, obj, holder, index, path = _locate(song, target)
|
|
103
|
+
with undo_step(song):
|
|
104
|
+
if kind == "track":
|
|
105
|
+
song.duplicate_track(index)
|
|
106
|
+
return {"duplicated": path, "copy": f"t{index + 1}", "name": obj.name}
|
|
107
|
+
if kind == "scene":
|
|
108
|
+
song.duplicate_scene(index)
|
|
109
|
+
return {"duplicated": path, "copy": f"s{index + 1}", "name": obj.name}
|
|
110
|
+
if kind == "clip":
|
|
111
|
+
to = p.get("to_slot")
|
|
112
|
+
track = holder
|
|
113
|
+
if to in (None, ""):
|
|
114
|
+
track.duplicate_clip_slot(index)
|
|
115
|
+
to = index + 1
|
|
116
|
+
else:
|
|
117
|
+
slots = list(track.clip_slots)
|
|
118
|
+
to = int(to)
|
|
119
|
+
if not 0 <= to < len(slots):
|
|
120
|
+
raise CommandError(
|
|
121
|
+
"not_found", f"no slot {to}: slots 0-{len(slots) - 1}"
|
|
122
|
+
)
|
|
123
|
+
if slots[to].has_clip:
|
|
124
|
+
raise CommandError(
|
|
125
|
+
"slot_taken",
|
|
126
|
+
f"slot {to} holds {slots[to].clip.name!r}; delete it first",
|
|
127
|
+
)
|
|
128
|
+
obj.duplicate_clip_to(slots[to])
|
|
129
|
+
copy = path.rsplit("/", 1)[0] + f"/s{to}"
|
|
130
|
+
return {"duplicated": path, "copy": copy, "name": obj.clip.name}
|
|
131
|
+
if kind == "device":
|
|
132
|
+
dup = getattr(holder, "duplicate_device", None)
|
|
133
|
+
if dup is None:
|
|
134
|
+
raise CommandError(
|
|
135
|
+
"not_supported",
|
|
136
|
+
"Live's API cannot duplicate a device; load it again",
|
|
137
|
+
)
|
|
138
|
+
dup(index)
|
|
139
|
+
copy = path.rsplit("/", 1)[0] + f"/d{index + 1}"
|
|
140
|
+
return {"duplicated": path, "copy": copy, "name": obj.name}
|
|
141
|
+
raise CommandError("not_supported", f"cannot duplicate a {kind}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --- addresses -----------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _locate(song, target):
|
|
148
|
+
"""(kind, object, holder, index, path) for a delete/duplicate address."""
|
|
149
|
+
if not target:
|
|
150
|
+
raise CommandError(
|
|
151
|
+
"bad_params", "target is required, e.g. t0, t0/d1, t0/s2, s2"
|
|
152
|
+
)
|
|
153
|
+
scene = re.fullmatch(r"[sS](\d+)", target)
|
|
154
|
+
if scene:
|
|
155
|
+
index, obj = find_scene(song, scene.group(1))
|
|
156
|
+
return "scene", obj, song, index, f"s{index}"
|
|
157
|
+
clip = re.fullmatch(r"(.+)/[sS](\d+)", target)
|
|
158
|
+
if clip:
|
|
159
|
+
track = objects.track(song, clip.group(1))
|
|
160
|
+
path = objects.track_path(song, track)
|
|
161
|
+
slots = list(track.clip_slots)
|
|
162
|
+
index = int(clip.group(2))
|
|
163
|
+
if not path.startswith("t") or not 0 <= index < len(slots):
|
|
164
|
+
raise CommandError("not_found", f"no slot {index} on {path}")
|
|
165
|
+
if not slots[index].has_clip:
|
|
166
|
+
raise CommandError("not_found", f"{path}/s{index} is empty")
|
|
167
|
+
return "clip", slots[index], track, index, f"{path}/s{index}"
|
|
168
|
+
kind, obj, path = objects.resolve(song, target)
|
|
169
|
+
if kind == "track":
|
|
170
|
+
if path == "m":
|
|
171
|
+
raise CommandError(
|
|
172
|
+
"bad_target", "the master track cannot be deleted or copied"
|
|
173
|
+
)
|
|
174
|
+
if path.startswith("r"):
|
|
175
|
+
return "return", obj, song, ord(path[1]) - ord("A"), path
|
|
176
|
+
return "track", obj, song, int(path[1:]), path
|
|
177
|
+
if kind == "device":
|
|
178
|
+
parent_path, last = path.rsplit("/", 1)
|
|
179
|
+
_, holder, _ = objects.resolve(song, parent_path)
|
|
180
|
+
return "device", obj, holder, int(last[1:]), path
|
|
181
|
+
raise CommandError(
|
|
182
|
+
"bad_target", f"{path} is a {kind}; give a track, device, clip or scene"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# --- fields --------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _set_field(track, field, value):
|
|
190
|
+
try:
|
|
191
|
+
if field in ("mute", "solo", "arm"):
|
|
192
|
+
setattr(track, field, _bool(value))
|
|
193
|
+
elif field == "color_index":
|
|
194
|
+
track.color_index = int(value)
|
|
195
|
+
else:
|
|
196
|
+
track.name = str(value)
|
|
197
|
+
except Exception as err:
|
|
198
|
+
return {"field": field, "error": _why(err)}
|
|
199
|
+
return {"field": field, "now": getattr(track, field)}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _set_param(field, param, text):
|
|
203
|
+
try:
|
|
204
|
+
raw, note = params.value_for(param, text)
|
|
205
|
+
param.value = raw
|
|
206
|
+
except CommandError as err:
|
|
207
|
+
return {"field": field, "error": str(err)}
|
|
208
|
+
except Exception as err:
|
|
209
|
+
return {"field": field, "error": _why(err)}
|
|
210
|
+
out = {"field": field, "now": params.display(param)}
|
|
211
|
+
if note:
|
|
212
|
+
out["note"] = note
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _set_send(song, mixer, send):
|
|
217
|
+
letter = str(send.get("return") or "").strip().upper()
|
|
218
|
+
sends = list(mixer.sends)
|
|
219
|
+
i = ord(letter) - ord("A") if len(letter) == 1 else -1
|
|
220
|
+
field = f"send {letter or '?'}"
|
|
221
|
+
if not 0 <= i < len(sends):
|
|
222
|
+
have = ", ".join(objects.return_letter(n) for n in range(len(sends))) or "none"
|
|
223
|
+
return {"field": field, "error": f"no return {letter or '?'}; returns: {have}"}
|
|
224
|
+
value = send.get("db", send.get("level"))
|
|
225
|
+
if value in (None, ""):
|
|
226
|
+
return {"field": field, "error": "give db, e.g. -12 or -inf"}
|
|
227
|
+
return _set_param(field, sends[i], _db(value))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _set_routing(track, key, attr, wanted):
|
|
231
|
+
options = list(getattr(track, "available_" + attr + "s", []) or [])
|
|
232
|
+
names = [o.display_name for o in options]
|
|
233
|
+
for option in options:
|
|
234
|
+
if option.display_name.strip().casefold() == wanted.strip().casefold():
|
|
235
|
+
try:
|
|
236
|
+
setattr(track, attr, option)
|
|
237
|
+
except Exception as err:
|
|
238
|
+
return {"field": key, "error": _why(err)}
|
|
239
|
+
return {"field": key, "now": getattr(track, attr).display_name}
|
|
240
|
+
return {"field": key, "error": f"no {key} {wanted!r}; options: {names}"}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _sends(raw):
|
|
244
|
+
if raw in (None, ""):
|
|
245
|
+
return []
|
|
246
|
+
if isinstance(raw, dict):
|
|
247
|
+
# {"A": -12} or one send object
|
|
248
|
+
if "return" in raw:
|
|
249
|
+
return [raw]
|
|
250
|
+
return [{"return": k, "db": v} for k, v in raw.items()]
|
|
251
|
+
return [s for s in raw if isinstance(s, dict)]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _db(value):
|
|
255
|
+
text = str(value).strip()
|
|
256
|
+
if text.lower() in ("-inf", "inf", "off"):
|
|
257
|
+
return "-inf dB"
|
|
258
|
+
return text if text.lower().endswith("db") else text + " dB"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _pan(value):
|
|
262
|
+
"""Pan as Live shows it: C, 25L, 30R; numbers -50 (left) .. 50 (right) too."""
|
|
263
|
+
text = str(value).strip()
|
|
264
|
+
if re.fullmatch(r"[+-]?\d+(\.\d*)?", text):
|
|
265
|
+
number = float(text)
|
|
266
|
+
if number == 0:
|
|
267
|
+
return "C"
|
|
268
|
+
return f"{abs(number):g}{'L' if number < 0 else 'R'}"
|
|
269
|
+
return text
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _bool(value):
|
|
273
|
+
if isinstance(value, str):
|
|
274
|
+
return value.strip().lower() in ("true", "on", "yes", "1")
|
|
275
|
+
return bool(value)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _why(err):
|
|
279
|
+
return (
|
|
280
|
+
f"Live refused: {err}" if str(err) else f"Live refused ({type(err).__name__})"
|
|
281
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""read_envelope and write_envelope: automation in Session clips. Main thread only.
|
|
2
|
+
|
|
3
|
+
Live's API reaches envelopes of Session clips only, for mixer and device
|
|
4
|
+
parameters; MIDI CC envelopes are invisible (stage 0: use CC Control's Custom A,
|
|
5
|
+
CC 64 Sustain, for the pedal). To get automation into the Arrangement, copy the clip
|
|
6
|
+
there: Live writes the track automation for its span.
|
|
7
|
+
|
|
8
|
+
The parameter is one of:
|
|
9
|
+
volume, pan, send A the track's mixer
|
|
10
|
+
t0/d0:Release a device parameter by name (or index) on this track
|
|
11
|
+
Curves follow Producer Pal's findings: each event's control_coefficients
|
|
12
|
+
(x1, y1, x2, y2) shape the segment it starts, and (0.5, 0.5, 0.5, 0.5) is straight.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import struct
|
|
16
|
+
|
|
17
|
+
from . import objects, params
|
|
18
|
+
from .clips import _slot
|
|
19
|
+
from .server import CommandError
|
|
20
|
+
from .song import undo_step
|
|
21
|
+
|
|
22
|
+
STRAIGHT = (0.5, 0.5, 0.5, 0.5)
|
|
23
|
+
# Reads cover events past the clip end too (Live keeps them). Live refuses larger
|
|
24
|
+
# ranges; this is its own limit for song time, from Track.create_midi_clip's doc.
|
|
25
|
+
LIVE_MAX_BEATS = 1576800.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_envelope(song, p):
|
|
29
|
+
track, clip, param, label = _target(song, p)
|
|
30
|
+
env = clip.automation_envelope(param)
|
|
31
|
+
out = {
|
|
32
|
+
"track": objects.track_path(song, track),
|
|
33
|
+
"slot": int(p.get("slot")),
|
|
34
|
+
"parameter": label,
|
|
35
|
+
"now": params.display(param),
|
|
36
|
+
}
|
|
37
|
+
if env is None:
|
|
38
|
+
out["exists"] = False
|
|
39
|
+
return out
|
|
40
|
+
events = list(env.events_in_range(0.0, LIVE_MAX_BEATS))
|
|
41
|
+
out["exists"] = True
|
|
42
|
+
out["points"] = [_event_out(env, events, i, param) for i in range(len(events))]
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_envelope(song, p):
|
|
47
|
+
"""Replace the envelope with `points` (or clear it), as one undo step."""
|
|
48
|
+
track, clip, param, label = _target(song, p)
|
|
49
|
+
raw_points = p.get("points") or []
|
|
50
|
+
if isinstance(raw_points, dict):
|
|
51
|
+
raw_points = [raw_points]
|
|
52
|
+
points = [_point(param, item, i) for i, item in enumerate(raw_points)]
|
|
53
|
+
if any(b["time"] < a["time"] for a, b in zip(points, points[1:], strict=False)):
|
|
54
|
+
raise CommandError("bad_params", "point times must not go back")
|
|
55
|
+
with undo_step(song):
|
|
56
|
+
if clip.automation_envelope(param) is not None:
|
|
57
|
+
clip.clear_envelope(param)
|
|
58
|
+
if points:
|
|
59
|
+
env = clip.create_automation_envelope(param)
|
|
60
|
+
if env is None:
|
|
61
|
+
raise CommandError(
|
|
62
|
+
"not_supported", f"Live gives no envelope for {label} in this clip"
|
|
63
|
+
)
|
|
64
|
+
_write_events(env, points)
|
|
65
|
+
param.re_enable_automation()
|
|
66
|
+
out = read_envelope(song, p)
|
|
67
|
+
out["written"] = len(points)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --- pieces ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _target(song, p):
|
|
75
|
+
track, slot, where = _slot(song, p.get("track"), p.get("slot"))
|
|
76
|
+
if not slot.has_clip:
|
|
77
|
+
raise CommandError("not_found", f"{where} is empty")
|
|
78
|
+
ref = str(p.get("parameter") or "").strip()
|
|
79
|
+
if not ref:
|
|
80
|
+
raise CommandError(
|
|
81
|
+
"bad_params", "parameter is required: volume, pan, send A or t0/d0:Name"
|
|
82
|
+
)
|
|
83
|
+
mixer = track.mixer_device
|
|
84
|
+
low = ref.casefold()
|
|
85
|
+
if low == "volume":
|
|
86
|
+
return track, slot.clip, mixer.volume, "volume"
|
|
87
|
+
if low in ("pan", "panning"):
|
|
88
|
+
return track, slot.clip, mixer.panning, "pan"
|
|
89
|
+
if low.startswith("send"):
|
|
90
|
+
letter = ref[4:].strip().upper()
|
|
91
|
+
sends = list(mixer.sends)
|
|
92
|
+
i = ord(letter) - ord("A") if len(letter) == 1 else -1
|
|
93
|
+
if not 0 <= i < len(sends):
|
|
94
|
+
raise CommandError(
|
|
95
|
+
"not_found", f"no {ref}: the Set has {len(sends)} return tracks"
|
|
96
|
+
)
|
|
97
|
+
return track, slot.clip, sends[i], f"send {letter}"
|
|
98
|
+
if ":" not in ref:
|
|
99
|
+
raise CommandError(
|
|
100
|
+
"bad_params", f"{ref!r}: use volume, pan, send A or t0/d0:Name"
|
|
101
|
+
)
|
|
102
|
+
device_path, name = ref.rsplit(":", 1)
|
|
103
|
+
kind, device, path = objects.resolve(song, device_path)
|
|
104
|
+
if kind != "device":
|
|
105
|
+
raise CommandError("bad_target", f"{path} is not a device")
|
|
106
|
+
if not path.startswith(objects.track_path(song, track) + "/"):
|
|
107
|
+
raise CommandError(
|
|
108
|
+
"bad_target", f"{path} is not on {objects.track_path(song, track)}"
|
|
109
|
+
)
|
|
110
|
+
plist = list(device.parameters)
|
|
111
|
+
name = name.strip()
|
|
112
|
+
hits = [x for x in plist if x.name.casefold() == name.casefold()]
|
|
113
|
+
if not hits and name.isdigit() and int(name) < len(plist):
|
|
114
|
+
hits = [plist[int(name)]]
|
|
115
|
+
if not hits:
|
|
116
|
+
raise CommandError("not_found", f"{device.name} has no parameter {name!r}")
|
|
117
|
+
return track, slot.clip, hits[0], f"{path}:{hits[0].name}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _point(param, item, i):
|
|
121
|
+
if not isinstance(item, dict):
|
|
122
|
+
raise CommandError(
|
|
123
|
+
"bad_params", f"point {i} must be an object with time and value"
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
time = float(item.get("time", item.get("time_beats")))
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
raise CommandError(
|
|
129
|
+
"bad_params", f"point {i}: time is required (beats)"
|
|
130
|
+
) from None
|
|
131
|
+
if time < 0:
|
|
132
|
+
raise CommandError("bad_params", f"point {i}: time must be 0 or more")
|
|
133
|
+
if item.get("value") in (None, ""):
|
|
134
|
+
raise CommandError("bad_params", f"point {i}: value is required")
|
|
135
|
+
raw, _ = params.value_for(param, item["value"])
|
|
136
|
+
curve = item.get("curve")
|
|
137
|
+
if curve not in (None, "", []):
|
|
138
|
+
if not (isinstance(curve, list) and len(curve) == 4):
|
|
139
|
+
raise CommandError(
|
|
140
|
+
"bad_params", f"point {i}: curve is 4 numbers x1, y1, x2, y2"
|
|
141
|
+
)
|
|
142
|
+
curve = tuple(float(c) for c in curve)
|
|
143
|
+
if not all(0 <= c <= 1 for c in curve):
|
|
144
|
+
raise CommandError("bad_params", f"point {i}: curve numbers are 0-1")
|
|
145
|
+
else:
|
|
146
|
+
curve = None
|
|
147
|
+
return {"time": time, "value": raw, "jump": bool(item.get("jump")), "curve": curve}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _write_events(env, points):
|
|
151
|
+
"""Create the events last time first: Live straightens a curve whose segment
|
|
152
|
+
end doesn't exist yet. A jump is two events at one time: the previous value,
|
|
153
|
+
then the new one."""
|
|
154
|
+
from Live.Envelope import EnvelopeEvent, EnvelopeEventControlCoefficients
|
|
155
|
+
|
|
156
|
+
events = []
|
|
157
|
+
previous = None
|
|
158
|
+
for point in points:
|
|
159
|
+
if point["jump"] and previous is not None:
|
|
160
|
+
events.append((point["time"], previous, None))
|
|
161
|
+
events.append((point["time"], point["value"], point["curve"]))
|
|
162
|
+
previous = point["value"]
|
|
163
|
+
groups = []
|
|
164
|
+
for event in events:
|
|
165
|
+
if groups and groups[-1][0][0] == event[0]:
|
|
166
|
+
groups[-1].append(event)
|
|
167
|
+
else:
|
|
168
|
+
groups.append([event])
|
|
169
|
+
for group in reversed(groups):
|
|
170
|
+
for time, value, curve in group:
|
|
171
|
+
if curve is None:
|
|
172
|
+
env.create_event(EnvelopeEvent(time, value))
|
|
173
|
+
else:
|
|
174
|
+
coefficients = EnvelopeEventControlCoefficients(*curve)
|
|
175
|
+
env.create_event(EnvelopeEvent(time, value, coefficients))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _event_out(env, events, i, param):
|
|
179
|
+
event = events[i]
|
|
180
|
+
# Two events at one time are a jump: sample just after it for the second.
|
|
181
|
+
same_time = i > 0 and events[i - 1].time == event.time
|
|
182
|
+
raw = env.value_at_time(event.time if not same_time else _after(event.time))
|
|
183
|
+
out = {"time": event.time, "value": raw, "display": param.str_for_value(raw)}
|
|
184
|
+
c = getattr(event, "control_coefficients", None)
|
|
185
|
+
if c is not None and (c.x1, c.y1, c.x2, c.y2) != STRAIGHT:
|
|
186
|
+
out["curve"] = [c.x1, c.y1, c.x2, c.y2]
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _after(time):
|
|
191
|
+
"""The next time Live can tell apart from `time`: it keeps envelope times as
|
|
192
|
+
32-bit floats, so step to the next float32, not the next double."""
|
|
193
|
+
(bits,) = struct.unpack("<I", struct.pack("<f", time))
|
|
194
|
+
(later,) = struct.unpack("<f", struct.pack("<I", bits + 1))
|
|
195
|
+
return later
|