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,300 @@
|
|
|
1
|
+
"""find_in_library and load_device. Main thread only.
|
|
2
|
+
|
|
3
|
+
The browser tree is walked once and kept (stage 0: 60 115 items in 5.2 s); `refresh`
|
|
4
|
+
walks it again. Places are left out: Live 12.4 does not show them to scripts
|
|
5
|
+
(see docs/plan.md), so presets live in the User Library, Packs and the Core Library.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from . import objects, reading
|
|
12
|
+
from .server import CommandError
|
|
13
|
+
from .song import undo_step
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import Live
|
|
17
|
+
except ImportError: # imported by the tests, outside Live
|
|
18
|
+
Live = None
|
|
19
|
+
|
|
20
|
+
# Browser attribute -> the name used in paths and answers.
|
|
21
|
+
ROOTS = (
|
|
22
|
+
("instruments", "Instruments"),
|
|
23
|
+
("audio_effects", "Audio Effects"),
|
|
24
|
+
("midi_effects", "MIDI Effects"),
|
|
25
|
+
("sounds", "Sounds"),
|
|
26
|
+
("drums", "Drums"),
|
|
27
|
+
("max_for_live", "Max for Live"),
|
|
28
|
+
("plugins", "Plug-ins"),
|
|
29
|
+
("packs", "Packs"),
|
|
30
|
+
("user_library", "User Library"),
|
|
31
|
+
("current_project", "Current Project"),
|
|
32
|
+
)
|
|
33
|
+
KINDS = {
|
|
34
|
+
"instrument": "Instruments",
|
|
35
|
+
"audio_effect": "Audio Effects",
|
|
36
|
+
"midi_effect": "MIDI Effects",
|
|
37
|
+
"sound": "Sounds",
|
|
38
|
+
"drum": "Drums",
|
|
39
|
+
"max_for_live": "Max for Live",
|
|
40
|
+
"plugin": "Plug-ins",
|
|
41
|
+
"pack": "Packs",
|
|
42
|
+
"user_library": "User Library",
|
|
43
|
+
}
|
|
44
|
+
SUFFIXES = (".adg", ".adv", ".amxd", ".aupreset", ".fxp")
|
|
45
|
+
# Live Sets in packs (demo songs) are loadable but not onto a track.
|
|
46
|
+
SETS = (".als",)
|
|
47
|
+
# This file is <User Library>/Remote Scripts/Tonable/library.py once installed.
|
|
48
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
49
|
+
USER_LIBRARY = os.path.dirname(os.path.dirname(_HERE))
|
|
50
|
+
|
|
51
|
+
_cache = {"items": None, "built": None, "seconds": None}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def browser():
|
|
55
|
+
return Live.Application.get_application().browser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def index(refresh=False):
|
|
59
|
+
"""[(root name, path, item)] for every loadable item, walked once."""
|
|
60
|
+
if _cache["items"] is None or refresh:
|
|
61
|
+
started = time.perf_counter()
|
|
62
|
+
items = []
|
|
63
|
+
b = browser()
|
|
64
|
+
for attr, label in ROOTS:
|
|
65
|
+
root = getattr(b, attr, None)
|
|
66
|
+
if root is not None:
|
|
67
|
+
_walk(root, label, items)
|
|
68
|
+
_cache.update(
|
|
69
|
+
items=items,
|
|
70
|
+
built=time.strftime("%H:%M:%S"),
|
|
71
|
+
seconds=round(time.perf_counter() - started, 1),
|
|
72
|
+
)
|
|
73
|
+
return _cache["items"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _walk(item, path, out):
|
|
77
|
+
for child in item.children:
|
|
78
|
+
child_path = f"{path}/{child.name}"
|
|
79
|
+
if child.is_loadable and not plain(child.name).endswith(SETS):
|
|
80
|
+
out.append((path.split("/", 1)[0], child_path, child))
|
|
81
|
+
# Folders hold items, and a device's children are its presets.
|
|
82
|
+
_walk(child, child_path, out)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def plain(name):
|
|
86
|
+
text = str(name).strip().casefold()
|
|
87
|
+
for suffix in SUFFIXES:
|
|
88
|
+
if text.endswith(suffix):
|
|
89
|
+
return text[: -len(suffix)]
|
|
90
|
+
return text
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_in_library(p):
|
|
94
|
+
query = str(p.get("query") or "").strip()
|
|
95
|
+
if not query:
|
|
96
|
+
raise CommandError("bad_params", "query is required, e.g. Electric Bass")
|
|
97
|
+
limit = p.get("limit")
|
|
98
|
+
try:
|
|
99
|
+
limit = int(limit) if limit not in (None, "") else 0
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
raise CommandError("bad_params", "limit must be a whole number") from None
|
|
102
|
+
if limit < 0:
|
|
103
|
+
raise CommandError("bad_params", "limit must be 0 (all) or more")
|
|
104
|
+
# No limit (empty or 0) returns everything found.
|
|
105
|
+
limit = limit or None
|
|
106
|
+
kind = p.get("kind") or "any"
|
|
107
|
+
if kind != "any" and kind not in KINDS:
|
|
108
|
+
raise CommandError(
|
|
109
|
+
"bad_params", "kind must be any or one of: " + ", ".join(KINDS)
|
|
110
|
+
)
|
|
111
|
+
words = plain(query).split()
|
|
112
|
+
hits = []
|
|
113
|
+
seen = set()
|
|
114
|
+
for root, path, item in index(bool(p.get("refresh"))):
|
|
115
|
+
if kind != "any" and root != KINDS[kind]:
|
|
116
|
+
continue
|
|
117
|
+
name = plain(item.name)
|
|
118
|
+
if not all(w in name for w in words):
|
|
119
|
+
continue
|
|
120
|
+
key = _identity(item, path)
|
|
121
|
+
if key in seen:
|
|
122
|
+
continue
|
|
123
|
+
seen.add(key)
|
|
124
|
+
rank = 0 if name == plain(query) else 1 if name.startswith(words[0]) else 2
|
|
125
|
+
hits.append((rank, len(path), path, item))
|
|
126
|
+
hits.sort(key=lambda h: (h[0], h[1], h[2]))
|
|
127
|
+
shown = [
|
|
128
|
+
{"name": item.name, "path": path, "kind": _item_kind(item)}
|
|
129
|
+
for _, _, path, item in hits[:limit]
|
|
130
|
+
]
|
|
131
|
+
if p.get("show_uri"):
|
|
132
|
+
for entry, (_, _, _, item) in zip(shown, hits, strict=False):
|
|
133
|
+
entry["uri"] = getattr(item, "uri", None)
|
|
134
|
+
out = {"query": query, "found": len(hits), "results": shown}
|
|
135
|
+
if limit is not None and len(hits) > limit:
|
|
136
|
+
out["more"] = len(hits) - limit
|
|
137
|
+
out["index"] = {"items": len(_cache["items"]), "built": _cache["built"]}
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_device(song, p):
|
|
142
|
+
"""Load a native device by name, a browser item by path, or a preset file."""
|
|
143
|
+
item_ref = str(p.get("item") or "").strip()
|
|
144
|
+
if not item_ref:
|
|
145
|
+
raise CommandError(
|
|
146
|
+
"bad_params", "item is required: Operator, a browser path or a file"
|
|
147
|
+
)
|
|
148
|
+
kind, holder, path = objects.resolve(song, p.get("target") or "")
|
|
149
|
+
if kind not in ("track", "chain"):
|
|
150
|
+
raise CommandError(
|
|
151
|
+
"bad_target", f"{path} is a {kind}: load onto a track or chain"
|
|
152
|
+
)
|
|
153
|
+
position = p.get("index")
|
|
154
|
+
before = list(holder.devices)
|
|
155
|
+
before_names = [(d.name, d.class_name) for d in before]
|
|
156
|
+
position = len(before) if position in (None, "", -1) else int(position)
|
|
157
|
+
if not 0 <= position <= len(before):
|
|
158
|
+
raise CommandError("bad_params", f"index must be 0-{len(before)}")
|
|
159
|
+
|
|
160
|
+
with undo_step(song):
|
|
161
|
+
how = _insert_native(holder, item_ref, position)
|
|
162
|
+
if how is None:
|
|
163
|
+
if kind != "track":
|
|
164
|
+
raise CommandError(
|
|
165
|
+
"bad_target",
|
|
166
|
+
"Live loads presets onto tracks only; load it on the track, then "
|
|
167
|
+
"move it into the chain with update_device move_to",
|
|
168
|
+
)
|
|
169
|
+
item, how = _browser_item(item_ref, bool(p.get("refresh")))
|
|
170
|
+
song.view.selected_track = holder
|
|
171
|
+
browser().load_item(item)
|
|
172
|
+
|
|
173
|
+
after = list(holder.devices)
|
|
174
|
+
after_names = [(d.name, d.class_name) for d in after]
|
|
175
|
+
new = [i for i, d in enumerate(after) if all(d != old for old in before)]
|
|
176
|
+
replaced = None
|
|
177
|
+
if not new and len(after) == len(before):
|
|
178
|
+
# A track has one instrument: loading another one replaces it in place.
|
|
179
|
+
changed = [i for i, n in enumerate(after_names) if n != before_names[i]]
|
|
180
|
+
if changed:
|
|
181
|
+
new = changed
|
|
182
|
+
replaced = before_names[changed[0]][0]
|
|
183
|
+
if not new:
|
|
184
|
+
return {
|
|
185
|
+
"loaded": item_ref,
|
|
186
|
+
"how": how,
|
|
187
|
+
"warning": "Live has not shown the device yet; check with read_track",
|
|
188
|
+
}
|
|
189
|
+
device_path = f"{path}/d{new[0]}"
|
|
190
|
+
out = {"loaded": after[new[0]].name, "how": how, "path": device_path}
|
|
191
|
+
if replaced:
|
|
192
|
+
out["replaced"] = replaced
|
|
193
|
+
info = reading.read_device(song, device_path)
|
|
194
|
+
if "macros" in info:
|
|
195
|
+
out["macros"] = [m["name"] for m in info["macros"]]
|
|
196
|
+
out["note"] = "Live selects the track it loads onto"
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _insert_native(holder, name, position):
|
|
201
|
+
"""Insert a built-in device by name; None if Live doesn't know the name."""
|
|
202
|
+
if "/" in name or plain(name) != name.strip().casefold():
|
|
203
|
+
return None
|
|
204
|
+
insert = getattr(holder, "insert_device", None)
|
|
205
|
+
if insert is None:
|
|
206
|
+
return None
|
|
207
|
+
try:
|
|
208
|
+
insert(name, position)
|
|
209
|
+
except Exception:
|
|
210
|
+
return None
|
|
211
|
+
return "native device"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _browser_item(ref, refresh):
|
|
215
|
+
"""The browser item for a file path, a browser path, or a unique name."""
|
|
216
|
+
if ref.startswith(("/", "~")):
|
|
217
|
+
return _by_file(os.path.expanduser(ref), refresh), "file"
|
|
218
|
+
items = index(refresh)
|
|
219
|
+
if "/" in ref:
|
|
220
|
+
wanted = [plain(s) for s in ref.split("/") if s]
|
|
221
|
+
for _, path, item in items:
|
|
222
|
+
if [plain(s) for s in path.split("/")] == wanted:
|
|
223
|
+
return item, "browser path"
|
|
224
|
+
raise CommandError(
|
|
225
|
+
"not_found",
|
|
226
|
+
f"no browser item at {ref!r}; use find_in_library to get its path",
|
|
227
|
+
)
|
|
228
|
+
return _unique(items, plain(ref), ref), "name"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _by_file(file_path, refresh):
|
|
232
|
+
if not os.path.isfile(file_path):
|
|
233
|
+
raise CommandError("not_found", f"no file {file_path}")
|
|
234
|
+
items = index(refresh)
|
|
235
|
+
segments = [s for s in os.path.realpath(file_path).split(os.sep) if s]
|
|
236
|
+
library = [s for s in os.path.realpath(USER_LIBRARY).split(os.sep) if s]
|
|
237
|
+
if segments[: len(library)] == library:
|
|
238
|
+
wanted = ["user library"] + [plain(s) for s in segments[len(library) :]]
|
|
239
|
+
for _, path, item in items:
|
|
240
|
+
if [plain(s) for s in path.split("/")] == wanted:
|
|
241
|
+
return item
|
|
242
|
+
for _, path, item in items:
|
|
243
|
+
parts = [plain(s) for s in path.split("/")]
|
|
244
|
+
if parts[0] == "packs":
|
|
245
|
+
pack = parts[1]
|
|
246
|
+
if pack in [plain(s) for s in segments]:
|
|
247
|
+
tail = [
|
|
248
|
+
plain(s)
|
|
249
|
+
for s in segments[[plain(s) for s in segments].index(pack) + 1 :]
|
|
250
|
+
]
|
|
251
|
+
if parts[2:] == tail:
|
|
252
|
+
return item
|
|
253
|
+
# The Core Library and anything else: by file name, if only one file has it.
|
|
254
|
+
try:
|
|
255
|
+
return _unique(items, plain(segments[-1]), file_path)
|
|
256
|
+
except CommandError as err:
|
|
257
|
+
if err.code != "not_found":
|
|
258
|
+
raise
|
|
259
|
+
raise CommandError(
|
|
260
|
+
"outside_library",
|
|
261
|
+
f"{file_path} is not in a folder Live's browser shows. Move it into the User "
|
|
262
|
+
f"Library ({USER_LIBRARY}/Presets/...) and try again.",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _unique(items, name, shown):
|
|
267
|
+
matches = {}
|
|
268
|
+
for _, path, item in items:
|
|
269
|
+
if plain(item.name) == name:
|
|
270
|
+
matches.setdefault(_identity(item, path), (path, item))
|
|
271
|
+
if not matches:
|
|
272
|
+
raise CommandError("not_found", f"nothing named {shown!r} in the library")
|
|
273
|
+
# The category views (Instruments, Sounds...) list the Core Library and the
|
|
274
|
+
# packs again, with a FileId in the uri. One FileId item plus same-named items
|
|
275
|
+
# under Packs is one file seen twice.
|
|
276
|
+
filed = [m for key, m in matches.items() if key.startswith("FileId_")]
|
|
277
|
+
rest = [m for key, m in matches.items() if not key.startswith("FileId_")]
|
|
278
|
+
if len(filed) == 1 and all(path.startswith("Packs/") for path, _ in rest):
|
|
279
|
+
return filed[0][1]
|
|
280
|
+
if len(matches) > 1:
|
|
281
|
+
paths = sorted(path for path, _ in matches.values())
|
|
282
|
+
raise CommandError(
|
|
283
|
+
"ambiguous", f"{len(paths)} items are named {shown!r}; give a path: {paths}"
|
|
284
|
+
)
|
|
285
|
+
return next(iter(matches.values()))[1]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _identity(item, path):
|
|
289
|
+
"""The same file shows up in several folders (Instrument Rack/Bass and
|
|
290
|
+
Simpler/Bass); its uri ends with the same FileId."""
|
|
291
|
+
uri = getattr(item, "uri", "") or ""
|
|
292
|
+
if "FileId_" in uri:
|
|
293
|
+
return uri[uri.rindex("FileId_") :]
|
|
294
|
+
return uri or path
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _item_kind(item):
|
|
298
|
+
if item.is_device:
|
|
299
|
+
return "device"
|
|
300
|
+
return "preset" if plain(item.name) != item.name.strip().casefold() else "item"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Addresses for Live objects (see docs/tools.md). Main thread only.
|
|
2
|
+
|
|
3
|
+
t0 first track (numbers from 0)
|
|
4
|
+
rA return track A
|
|
5
|
+
m master track
|
|
6
|
+
t0/d1 second device on t0
|
|
7
|
+
t0/d0/c1/d0 first device in the rack's second chain
|
|
8
|
+
t0/d0/pC1 the Drum Rack pad on note C1 (C3 = MIDI 60, as Live shows it)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from .server import CommandError
|
|
14
|
+
|
|
15
|
+
NOTE_NAMES = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}
|
|
16
|
+
_NOTE = re.compile(r"([A-Ga-g])([#b]?)(-?\d+)")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def note_number(name):
|
|
20
|
+
"""MIDI note for a name like C1, F#-1 or Bb3, in Live's octave numbering."""
|
|
21
|
+
match = _NOTE.fullmatch(name)
|
|
22
|
+
if not match:
|
|
23
|
+
raise CommandError("bad_address", f"{name!r} is not a note name like C1 or F#2")
|
|
24
|
+
letter, accidental, octave = match.groups()
|
|
25
|
+
number = (int(octave) + 2) * 12 + NOTE_NAMES[letter.upper()]
|
|
26
|
+
number += {"#": 1, "b": -1, "": 0}[accidental]
|
|
27
|
+
if not 0 <= number <= 127:
|
|
28
|
+
raise CommandError("bad_address", f"note {name} is outside MIDI notes 0-127")
|
|
29
|
+
return number
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def note_name(number):
|
|
33
|
+
"""Live's name for a MIDI note: 36 -> C1, 61 -> C#3."""
|
|
34
|
+
names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
|
35
|
+
return f"{names[number % 12]}{number // 12 - 2}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def return_letter(index):
|
|
39
|
+
return chr(ord("A") + index)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def track_path(song, track):
|
|
43
|
+
"""The address of a track object: t3, rB or m."""
|
|
44
|
+
for i, t in enumerate(song.tracks):
|
|
45
|
+
if t == track:
|
|
46
|
+
return f"t{i}"
|
|
47
|
+
for i, t in enumerate(song.return_tracks):
|
|
48
|
+
if t == track:
|
|
49
|
+
return "r" + return_letter(i)
|
|
50
|
+
return "m"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def track(song, ref):
|
|
54
|
+
"""The track for `t0`, `rA` or `m`."""
|
|
55
|
+
ref = str(ref).strip()
|
|
56
|
+
if ref == "m":
|
|
57
|
+
return song.master_track
|
|
58
|
+
match = re.fullmatch(r"t(\d+)", ref)
|
|
59
|
+
if match:
|
|
60
|
+
tracks = list(song.tracks)
|
|
61
|
+
i = int(match.group(1))
|
|
62
|
+
if i >= len(tracks):
|
|
63
|
+
raise CommandError("not_found", f"no {ref}: {_range('t', len(tracks))}")
|
|
64
|
+
return tracks[i]
|
|
65
|
+
match = re.fullmatch(r"r([A-Za-z])", ref)
|
|
66
|
+
if match:
|
|
67
|
+
returns = list(song.return_tracks)
|
|
68
|
+
i = ord(match.group(1).upper()) - ord("A")
|
|
69
|
+
if i >= len(returns):
|
|
70
|
+
letters = [return_letter(n) for n in range(len(returns))]
|
|
71
|
+
have = "returns " + ", ".join(letters) if letters else "no return tracks"
|
|
72
|
+
raise CommandError("not_found", f"no {ref}: the Set has {have}")
|
|
73
|
+
return returns[i]
|
|
74
|
+
raise CommandError(
|
|
75
|
+
"bad_address", f"{ref!r} is not a track address: use t0, rA or m"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve(song, path):
|
|
80
|
+
"""The object at `path` and the path in canonical form.
|
|
81
|
+
|
|
82
|
+
Returns (kind, obj, path) where kind is track, device, chain or pad.
|
|
83
|
+
"""
|
|
84
|
+
parts = [p for p in str(path).strip().split("/") if p]
|
|
85
|
+
if not parts:
|
|
86
|
+
raise CommandError("bad_address", "empty address")
|
|
87
|
+
obj = track(song, parts[0])
|
|
88
|
+
kind = "track"
|
|
89
|
+
walked = [parts[0] if parts[0] == "m" else track_path(song, obj)]
|
|
90
|
+
for part in parts[1:]:
|
|
91
|
+
kind, obj = _step(kind, obj, part, "/".join(walked))
|
|
92
|
+
walked.append(part if kind != "pad" else "p" + note_name(obj.note))
|
|
93
|
+
return kind, obj, "/".join(walked)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _step(kind, obj, part, where):
|
|
97
|
+
if kind in ("track", "chain") and re.fullmatch(r"d\d+", part):
|
|
98
|
+
return "device", _index(list(obj.devices), int(part[1:]), part, where, "d")
|
|
99
|
+
if kind in ("device", "pad") and re.fullmatch(r"c\d+", part):
|
|
100
|
+
chains = list(getattr(obj, "chains", []) or [])
|
|
101
|
+
if kind == "device" and not getattr(obj, "can_have_chains", False):
|
|
102
|
+
raise CommandError("not_found", f"{where} is not a rack: it has no chains")
|
|
103
|
+
return "chain", _index(chains, int(part[1:]), part, where, "c")
|
|
104
|
+
if kind == "device" and part.startswith("p"):
|
|
105
|
+
if not getattr(obj, "can_have_drum_pads", False):
|
|
106
|
+
raise CommandError(
|
|
107
|
+
"not_found", f"{where} is not a Drum Rack: it has no pads"
|
|
108
|
+
)
|
|
109
|
+
number = note_number(part[1:])
|
|
110
|
+
for pad in obj.drum_pads:
|
|
111
|
+
if pad.note == number:
|
|
112
|
+
return "pad", pad
|
|
113
|
+
raise CommandError("not_found", f"{where} has no pad {part}")
|
|
114
|
+
raise CommandError(
|
|
115
|
+
"bad_address",
|
|
116
|
+
f"{part!r} after {where} is not valid: tracks and chains hold devices (d0), "
|
|
117
|
+
"racks hold chains (c0), Drum Racks hold pads (pC1)",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _index(items, i, part, where, letter):
|
|
122
|
+
if i >= len(items):
|
|
123
|
+
raise CommandError(
|
|
124
|
+
"not_found", f"no {part} in {where}: {_range(letter, len(items))}"
|
|
125
|
+
)
|
|
126
|
+
return items[i]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _range(letter, count):
|
|
130
|
+
if count == 0:
|
|
131
|
+
return "there are none"
|
|
132
|
+
if count == 1:
|
|
133
|
+
return f"only {letter}0"
|
|
134
|
+
return f"{letter}0-{letter}{count - 1}"
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Device parameters as the model should see them: name, display text, raw value."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from .server import CommandError
|
|
7
|
+
|
|
8
|
+
# DeviceParameter.automation_state
|
|
9
|
+
AUTOMATION_STATES = {0: None, 1: "automated", 2: "overridden"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def describe(param, index=None):
|
|
13
|
+
"""One parameter: display text first, then the raw value and its range."""
|
|
14
|
+
out = {}
|
|
15
|
+
if index is not None:
|
|
16
|
+
out["index"] = index
|
|
17
|
+
out["name"] = param.name
|
|
18
|
+
out["display"] = display(param)
|
|
19
|
+
out["value"] = param.value
|
|
20
|
+
if param.is_quantized:
|
|
21
|
+
out["options"] = [str(item) for item in param.value_items]
|
|
22
|
+
else:
|
|
23
|
+
out["min"] = param.min
|
|
24
|
+
out["max"] = param.max
|
|
25
|
+
if not param.is_enabled:
|
|
26
|
+
out["enabled"] = False
|
|
27
|
+
state = AUTOMATION_STATES.get(int(getattr(param, "automation_state", 0)))
|
|
28
|
+
if state:
|
|
29
|
+
out["automation"] = state
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def display(param):
|
|
34
|
+
"""The text Live shows for the current value ("262 ms", "-6.0 dB", "On")."""
|
|
35
|
+
try:
|
|
36
|
+
return param.str_for_value(param.value)
|
|
37
|
+
except Exception:
|
|
38
|
+
return str(param)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- display text -> raw value ------------------------------------------------
|
|
42
|
+
|
|
43
|
+
# Base unit for each unit Live shows, and the factor to get there.
|
|
44
|
+
UNITS = {
|
|
45
|
+
"hz": ("hz", 1.0),
|
|
46
|
+
"khz": ("hz", 1000.0),
|
|
47
|
+
"ms": ("ms", 1.0),
|
|
48
|
+
"s": ("ms", 1000.0),
|
|
49
|
+
"db": ("db", 1.0),
|
|
50
|
+
"%": ("%", 1.0),
|
|
51
|
+
"": ("", 1.0),
|
|
52
|
+
}
|
|
53
|
+
_NUMBER = re.compile(r"\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+)|-?inf)\s*([a-zA-Z%]*)\s*")
|
|
54
|
+
_PAN = re.compile(r"\s*(\d+(?:\.\d*)?)\s*([LR])\s*|\s*(C)\s*", re.I)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_display(text):
|
|
58
|
+
"""("number", base unit) for display text like "22.0 kHz", "-inf dB", "25L", "C".
|
|
59
|
+
|
|
60
|
+
Pan is a number from left (negative) to right. Returns None if not numeric.
|
|
61
|
+
"""
|
|
62
|
+
text = str(text)
|
|
63
|
+
pan = _PAN.fullmatch(text)
|
|
64
|
+
if pan:
|
|
65
|
+
if pan.group(3):
|
|
66
|
+
return 0.0, "pan"
|
|
67
|
+
sign = -1.0 if pan.group(2).upper() == "L" else 1.0
|
|
68
|
+
return sign * float(pan.group(1)), "pan"
|
|
69
|
+
match = _NUMBER.fullmatch(text)
|
|
70
|
+
if not match:
|
|
71
|
+
return None
|
|
72
|
+
number = match.group(1).lower()
|
|
73
|
+
unit = match.group(2).lower()
|
|
74
|
+
if unit not in UNITS:
|
|
75
|
+
return None
|
|
76
|
+
base, factor = UNITS[unit]
|
|
77
|
+
value = float("-inf") if number == "-inf" else float(number) * factor
|
|
78
|
+
return value, base
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def value_for(param, requested):
|
|
82
|
+
"""(raw value, note or None) for a JSON number (raw) or display text.
|
|
83
|
+
|
|
84
|
+
Text is matched against what Live shows: an option name for switches
|
|
85
|
+
("On", "Sine"), or a number with or without its unit ("400 ms", "-6", "25L").
|
|
86
|
+
Nothing is written here; the caller writes and reads the result back.
|
|
87
|
+
"""
|
|
88
|
+
if isinstance(requested, bool):
|
|
89
|
+
if param.is_quantized and len(list(param.value_items)) == 2:
|
|
90
|
+
return (param.max if requested else param.min), None
|
|
91
|
+
requested = "On" if requested else "Off"
|
|
92
|
+
if isinstance(requested, (int, float)):
|
|
93
|
+
raw = float(requested)
|
|
94
|
+
if not math.isfinite(raw):
|
|
95
|
+
raise CommandError("bad_value", "the value must be finite")
|
|
96
|
+
if param.min <= raw <= param.max:
|
|
97
|
+
return raw, None
|
|
98
|
+
# Out of the raw range: most likely meant as shown (-6 for -6 dB, 400 for
|
|
99
|
+
# 400 ms, 30 for 30R), so try it as display text before clamping.
|
|
100
|
+
if not param.is_quantized:
|
|
101
|
+
try:
|
|
102
|
+
return _search(param, f"{raw:g}")
|
|
103
|
+
except CommandError:
|
|
104
|
+
pass
|
|
105
|
+
clamped = min(max(raw, param.min), param.max)
|
|
106
|
+
return clamped, "clamped to the range"
|
|
107
|
+
text = str(requested).strip()
|
|
108
|
+
if param.is_quantized:
|
|
109
|
+
return _option(param, text), None
|
|
110
|
+
return _search(param, text)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _option(param, text):
|
|
114
|
+
items = [str(item) for item in param.value_items]
|
|
115
|
+
for i, item in enumerate(items):
|
|
116
|
+
if item.strip().casefold() == text.casefold():
|
|
117
|
+
return param.min + i
|
|
118
|
+
if text.lstrip("-").isdigit() and param.min <= int(text) <= param.max:
|
|
119
|
+
return float(int(text))
|
|
120
|
+
raise CommandError("bad_value", f"{text!r} is not an option; options: {items}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _search(param, text):
|
|
124
|
+
target = parse_display(text)
|
|
125
|
+
if target is None:
|
|
126
|
+
raise CommandError(
|
|
127
|
+
"bad_value",
|
|
128
|
+
f"{text!r} is not a value like 400 ms, -6 dB, 50 %, 25L or a number",
|
|
129
|
+
)
|
|
130
|
+
want, unit = target
|
|
131
|
+
lo_shown = parse_display(param.str_for_value(param.min))
|
|
132
|
+
hi_shown = parse_display(param.str_for_value(param.max))
|
|
133
|
+
if lo_shown is None or hi_shown is None:
|
|
134
|
+
raise CommandError(
|
|
135
|
+
"bad_value",
|
|
136
|
+
f"{param.name} shows {display(param)!r}; give a raw number from "
|
|
137
|
+
f"{param.min:g} to {param.max:g}",
|
|
138
|
+
)
|
|
139
|
+
shown_unit = lo_shown[1]
|
|
140
|
+
if unit and shown_unit and unit != shown_unit:
|
|
141
|
+
raise CommandError(
|
|
142
|
+
"bad_value",
|
|
143
|
+
f"{param.name} is in {shown_unit or 'plain numbers'}, not {unit}",
|
|
144
|
+
)
|
|
145
|
+
rising = hi_shown[0] >= lo_shown[0]
|
|
146
|
+
lo, hi = param.min, param.max
|
|
147
|
+
low_end, high_end = sorted((lo_shown[0], hi_shown[0]))
|
|
148
|
+
if want <= low_end:
|
|
149
|
+
return (lo if rising else hi), _edge_note(want, low_end)
|
|
150
|
+
if want >= high_end:
|
|
151
|
+
return (hi if rising else lo), _edge_note(want, high_end)
|
|
152
|
+
# Bisect until the floats run out; keep the closest display seen.
|
|
153
|
+
best = (math.inf, param.value)
|
|
154
|
+
while True:
|
|
155
|
+
mid = (lo + hi) / 2
|
|
156
|
+
if mid in (lo, hi):
|
|
157
|
+
break
|
|
158
|
+
shown = parse_display(param.str_for_value(mid))
|
|
159
|
+
if shown is None:
|
|
160
|
+
break
|
|
161
|
+
best = min(best, (abs(shown[0] - want), mid))
|
|
162
|
+
if (shown[0] < want) == rising:
|
|
163
|
+
lo = mid
|
|
164
|
+
else:
|
|
165
|
+
hi = mid
|
|
166
|
+
return best[1], None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _edge_note(want, edge):
|
|
170
|
+
return None if want == edge else "outside the range: set to the nearest end"
|