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,364 @@
|
|
|
1
|
+
"""Session clips and their notes: read_clip and write_clip. Main thread only.
|
|
2
|
+
|
|
3
|
+
A clip is addressed by its track (t0) and slot (the scene row, from 0). Times are in
|
|
4
|
+
beats from the clip start. Pitches are MIDI numbers or names with C3 = 60.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
from . import objects
|
|
10
|
+
from .server import CommandError
|
|
11
|
+
from .song import undo_step
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import Live
|
|
15
|
+
except ImportError: # imported by the tests, outside Live
|
|
16
|
+
Live = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_clip(song, track_ref, slot_index):
|
|
20
|
+
track, slot, where = _slot(song, track_ref, slot_index)
|
|
21
|
+
if not slot.has_clip:
|
|
22
|
+
raise CommandError("not_found", f"{where} is empty")
|
|
23
|
+
clip = slot.clip
|
|
24
|
+
out = {"track": objects.track_path(song, track), "slot": slot_index}
|
|
25
|
+
out.update(_clip_info(clip))
|
|
26
|
+
if clip.is_midi_clip:
|
|
27
|
+
out["notes"] = [_note_out(n) for n in _sorted(clip.get_all_notes_extended())]
|
|
28
|
+
return out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def write_clip(song, params):
|
|
32
|
+
"""Create a MIDI clip or change one, and write notes, as one undo step."""
|
|
33
|
+
track_ref = params.get("track")
|
|
34
|
+
slot_index = params.get("slot")
|
|
35
|
+
mode = params.get("mode") or "replace"
|
|
36
|
+
if mode not in ("replace", "add"):
|
|
37
|
+
raise CommandError("bad_params", "mode must be replace or add")
|
|
38
|
+
track, slot, where = _slot(song, track_ref, slot_index)
|
|
39
|
+
if not getattr(track, "has_midi_input", False):
|
|
40
|
+
raise CommandError("bad_target", f"{where}: {track.name} is not a MIDI track")
|
|
41
|
+
|
|
42
|
+
notes, rejected = parse_notes(params.get("notes") or [])
|
|
43
|
+
length = _beats(params.get("length_beats"), "length_beats", allow_zero=True)
|
|
44
|
+
name = params.get("name") or ""
|
|
45
|
+
|
|
46
|
+
with undo_step(song):
|
|
47
|
+
created = not slot.has_clip
|
|
48
|
+
if created:
|
|
49
|
+
slot.create_clip(length or fit_length(song, notes))
|
|
50
|
+
clip = slot.clip
|
|
51
|
+
if not clip.is_midi_clip:
|
|
52
|
+
raise CommandError("bad_target", f"{where} holds an audio clip")
|
|
53
|
+
if length and not created:
|
|
54
|
+
_set_length(clip, length)
|
|
55
|
+
removed = 0
|
|
56
|
+
if mode == "replace":
|
|
57
|
+
old = list(clip.get_all_notes_extended())
|
|
58
|
+
if old:
|
|
59
|
+
clip.remove_notes_by_id(tuple(n.note_id for n in old))
|
|
60
|
+
removed = len(old)
|
|
61
|
+
if notes:
|
|
62
|
+
clip.add_new_notes(tuple(_note_spec(n) for n in notes))
|
|
63
|
+
if name:
|
|
64
|
+
clip.name = name
|
|
65
|
+
|
|
66
|
+
out = {
|
|
67
|
+
"track": objects.track_path(song, track),
|
|
68
|
+
"slot": slot_index,
|
|
69
|
+
"created": created,
|
|
70
|
+
**_clip_info(clip),
|
|
71
|
+
"notes_added": len(notes),
|
|
72
|
+
"notes_removed": removed,
|
|
73
|
+
"notes_total": len(list(clip.get_all_notes_extended())),
|
|
74
|
+
}
|
|
75
|
+
late = [i for i, n in enumerate(notes) if n["start"] >= clip.length]
|
|
76
|
+
if late:
|
|
77
|
+
out["warning"] = (
|
|
78
|
+
f"{len(late)} note(s) start after the clip end ({clip.length:g} beats) and "
|
|
79
|
+
"will not play; give a longer length_beats"
|
|
80
|
+
)
|
|
81
|
+
if rejected:
|
|
82
|
+
out["rejected"] = rejected
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- notes -----------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_notes(raw):
|
|
90
|
+
"""(good notes, rejected notes). One bad note never rejects the others."""
|
|
91
|
+
if isinstance(raw, dict):
|
|
92
|
+
raw = [raw]
|
|
93
|
+
if not isinstance(raw, list):
|
|
94
|
+
raise CommandError("bad_params", "notes must be a list of note objects")
|
|
95
|
+
good, rejected = [], []
|
|
96
|
+
for i, item in enumerate(raw):
|
|
97
|
+
try:
|
|
98
|
+
good.append(parse_note(item))
|
|
99
|
+
except CommandError as err:
|
|
100
|
+
rejected.append({"index": i, "note": item, "error": str(err)})
|
|
101
|
+
return good, rejected
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parse_note(item):
|
|
105
|
+
if not isinstance(item, dict):
|
|
106
|
+
raise CommandError(
|
|
107
|
+
"bad_note", "a note must be an object with pitch, start, length"
|
|
108
|
+
)
|
|
109
|
+
return {
|
|
110
|
+
"pitch": parse_pitch(_field(item, "pitch", "note")),
|
|
111
|
+
"start": _beats(_field(item, "start", "start_beats", "time"), "start", True),
|
|
112
|
+
"length": _beats(_field(item, "length", "duration", "length_beats"), "length"),
|
|
113
|
+
"velocity": _velocity(_field(item, "velocity", "vel")),
|
|
114
|
+
"mute": bool(item.get("mute") or False),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_pitch(value):
|
|
119
|
+
if value is None or value == "":
|
|
120
|
+
raise CommandError("bad_note", "pitch is required (60 or C3)")
|
|
121
|
+
if isinstance(value, bool):
|
|
122
|
+
raise CommandError("bad_note", "pitch must be a number or a note name")
|
|
123
|
+
if isinstance(value, (int, float)) or str(value).strip().lstrip("-").isdigit():
|
|
124
|
+
number = float(value)
|
|
125
|
+
if number != int(number) or not 0 <= number <= 127:
|
|
126
|
+
raise CommandError("bad_note", f"pitch {value} is not a MIDI note 0-127")
|
|
127
|
+
return int(number)
|
|
128
|
+
return objects.note_number(str(value).strip())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fit_length(song, notes):
|
|
132
|
+
"""Whole bars that hold every note; one bar when there are none."""
|
|
133
|
+
bar = song.signature_numerator * 4.0 / song.signature_denominator
|
|
134
|
+
end = max((n["start"] + n["length"] for n in notes), default=0.0)
|
|
135
|
+
return max(1, math.ceil(end / bar)) * bar
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _field(item, *names):
|
|
139
|
+
for name in names:
|
|
140
|
+
if item.get(name) not in (None, ""):
|
|
141
|
+
return item[name]
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _beats(value, name, allow_zero=False):
|
|
146
|
+
if value is None or value == "":
|
|
147
|
+
if allow_zero:
|
|
148
|
+
return 0.0
|
|
149
|
+
raise CommandError("bad_note", f"{name} is required (in beats)")
|
|
150
|
+
try:
|
|
151
|
+
number = float(value)
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
raise CommandError("bad_note", f"{name} must be a number of beats") from None
|
|
154
|
+
if not math.isfinite(number) or number < 0 or (number == 0 and not allow_zero):
|
|
155
|
+
raise CommandError(
|
|
156
|
+
"bad_note", f"{name} must be {'0 or more' if allow_zero else 'more than 0'}"
|
|
157
|
+
)
|
|
158
|
+
return number
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _velocity(value):
|
|
162
|
+
if value is None or value == "":
|
|
163
|
+
raise CommandError("bad_note", "velocity is required (1-127)")
|
|
164
|
+
try:
|
|
165
|
+
number = float(value)
|
|
166
|
+
except (TypeError, ValueError):
|
|
167
|
+
raise CommandError("bad_note", "velocity must be a number 1-127") from None
|
|
168
|
+
if not 1 <= number <= 127:
|
|
169
|
+
raise CommandError("bad_note", f"velocity {value} is outside 1-127")
|
|
170
|
+
return number
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _note_spec(note):
|
|
174
|
+
return Live.Clip.MidiNoteSpecification(
|
|
175
|
+
pitch=note["pitch"],
|
|
176
|
+
start_time=note["start"],
|
|
177
|
+
duration=note["length"],
|
|
178
|
+
velocity=note["velocity"],
|
|
179
|
+
mute=note["mute"],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _note_out(note):
|
|
184
|
+
out = {
|
|
185
|
+
"pitch": objects.note_name(note.pitch),
|
|
186
|
+
"midi": note.pitch,
|
|
187
|
+
"start": note.start_time,
|
|
188
|
+
"length": note.duration,
|
|
189
|
+
"velocity": note.velocity,
|
|
190
|
+
}
|
|
191
|
+
if note.mute:
|
|
192
|
+
out["mute"] = True
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _sorted(notes):
|
|
197
|
+
return sorted(notes, key=lambda n: (n.start_time, n.pitch))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# --- clips and slots ---------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _slot(song, track_ref, slot_index):
|
|
204
|
+
if track_ref is None or str(track_ref).strip() == "":
|
|
205
|
+
raise CommandError("bad_params", "track is required (t0)")
|
|
206
|
+
track = objects.track(song, track_ref)
|
|
207
|
+
path = objects.track_path(song, track)
|
|
208
|
+
if not path.startswith("t"):
|
|
209
|
+
raise CommandError(
|
|
210
|
+
"bad_target", f"{path} has no clip slots: use a track t0, t1..."
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
index = int(slot_index)
|
|
214
|
+
except (TypeError, ValueError):
|
|
215
|
+
raise CommandError("bad_params", "slot must be a scene number from 0") from None
|
|
216
|
+
slots = list(track.clip_slots)
|
|
217
|
+
if not 0 <= index < len(slots):
|
|
218
|
+
raise CommandError(
|
|
219
|
+
"not_found",
|
|
220
|
+
f"no slot {index} on {path}: the Set has {len(slots)} scenes (slots 0-"
|
|
221
|
+
f"{len(slots) - 1}); add a scene first",
|
|
222
|
+
)
|
|
223
|
+
return track, slots[index], f"{path} slot {index}"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _clip_info(clip):
|
|
227
|
+
out = {
|
|
228
|
+
"name": clip.name,
|
|
229
|
+
"type": "midi" if clip.is_midi_clip else "audio",
|
|
230
|
+
"length_beats": clip.length,
|
|
231
|
+
"looping": bool(clip.looping),
|
|
232
|
+
}
|
|
233
|
+
if clip.looping:
|
|
234
|
+
out["loop"] = {"start_beats": clip.loop_start, "end_beats": clip.loop_end}
|
|
235
|
+
return out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _set_length(clip, length):
|
|
239
|
+
"""Make the clip `length` beats long from its loop start."""
|
|
240
|
+
end = clip.loop_start + length
|
|
241
|
+
if end > clip.end_marker:
|
|
242
|
+
clip.end_marker = end
|
|
243
|
+
clip.loop_end = end
|
|
244
|
+
else:
|
|
245
|
+
clip.loop_end = end
|
|
246
|
+
clip.end_marker = end
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# --- edit_notes -------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
EDIT_ACTIONS = ("delete", "transpose", "shift", "quantize", "velocity", "length")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def edit_notes(song, params):
|
|
255
|
+
"""Change the notes in a time and pitch range, as one undo step.
|
|
256
|
+
|
|
257
|
+
The range: from_beats (inclusive) to to_beats (exclusive) by note start, and
|
|
258
|
+
pitches (names or numbers); empty means all notes.
|
|
259
|
+
"""
|
|
260
|
+
action = params.get("action")
|
|
261
|
+
if action not in EDIT_ACTIONS:
|
|
262
|
+
raise CommandError(
|
|
263
|
+
"bad_params", "action must be one of: " + ", ".join(EDIT_ACTIONS)
|
|
264
|
+
)
|
|
265
|
+
track, slot, where = _slot(song, params.get("track"), params.get("slot"))
|
|
266
|
+
if not slot.has_clip or not slot.clip.is_midi_clip:
|
|
267
|
+
raise CommandError("not_found", f"{where} has no MIDI clip")
|
|
268
|
+
clip = slot.clip
|
|
269
|
+
notes = clip.get_all_notes_extended()
|
|
270
|
+
chosen = [n for n in notes if _in_range(n, params)]
|
|
271
|
+
skipped = []
|
|
272
|
+
with undo_step(song):
|
|
273
|
+
if action == "delete":
|
|
274
|
+
if chosen:
|
|
275
|
+
clip.remove_notes_by_id(tuple(n.note_id for n in chosen))
|
|
276
|
+
else:
|
|
277
|
+
change = _change(action, params)
|
|
278
|
+
for note in chosen:
|
|
279
|
+
reason = change(note)
|
|
280
|
+
if reason:
|
|
281
|
+
skipped.append(
|
|
282
|
+
{
|
|
283
|
+
"pitch": objects.note_name(note.pitch),
|
|
284
|
+
"start": note.start_time,
|
|
285
|
+
"reason": reason,
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
clip.apply_note_modifications(notes)
|
|
289
|
+
out = {
|
|
290
|
+
"track": objects.track_path(song, track),
|
|
291
|
+
"slot": int(params.get("slot")),
|
|
292
|
+
"action": action,
|
|
293
|
+
"notes_matched": len(chosen),
|
|
294
|
+
"notes_total": len(list(clip.get_all_notes_extended())),
|
|
295
|
+
}
|
|
296
|
+
if skipped:
|
|
297
|
+
out["skipped"] = skipped
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _in_range(note, params):
|
|
302
|
+
start, end = params.get("from_beats"), params.get("to_beats")
|
|
303
|
+
if start not in (None, "") and note.start_time < float(start):
|
|
304
|
+
return False
|
|
305
|
+
if end not in (None, "") and note.start_time >= float(end):
|
|
306
|
+
return False
|
|
307
|
+
pitches = params.get("pitches")
|
|
308
|
+
if pitches not in (None, "", []):
|
|
309
|
+
if not isinstance(pitches, list):
|
|
310
|
+
pitches = [pitches]
|
|
311
|
+
if note.pitch not in {parse_pitch(p) for p in pitches}:
|
|
312
|
+
return False
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _change(action, params):
|
|
317
|
+
"""A function that changes one note and returns why it could not, or None."""
|
|
318
|
+
if action == "transpose":
|
|
319
|
+
steps = int(_number(params, "semitones"))
|
|
320
|
+
|
|
321
|
+
def apply(note):
|
|
322
|
+
if not 0 <= note.pitch + steps <= 127:
|
|
323
|
+
return "would leave MIDI notes 0-127"
|
|
324
|
+
note.pitch += steps
|
|
325
|
+
|
|
326
|
+
elif action == "shift":
|
|
327
|
+
beats = _number(params, "beats")
|
|
328
|
+
|
|
329
|
+
def apply(note):
|
|
330
|
+
if note.start_time + beats < 0:
|
|
331
|
+
return "would start before the clip"
|
|
332
|
+
note.start_time += beats
|
|
333
|
+
|
|
334
|
+
elif action == "quantize":
|
|
335
|
+
grid = _number(params, "grid_beats")
|
|
336
|
+
if grid <= 0:
|
|
337
|
+
raise CommandError(
|
|
338
|
+
"bad_params", "grid_beats must be more than 0, e.g. 0.25"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
def apply(note):
|
|
342
|
+
note.start_time = round(note.start_time / grid) * grid
|
|
343
|
+
|
|
344
|
+
elif action == "velocity":
|
|
345
|
+
velocity = _velocity(params.get("velocity"))
|
|
346
|
+
|
|
347
|
+
def apply(note):
|
|
348
|
+
note.velocity = velocity
|
|
349
|
+
|
|
350
|
+
else: # length
|
|
351
|
+
length = _beats(params.get("length_beats"), "length_beats")
|
|
352
|
+
|
|
353
|
+
def apply(note):
|
|
354
|
+
note.duration = length
|
|
355
|
+
|
|
356
|
+
return apply
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _number(params, name):
|
|
360
|
+
value = params.get(name)
|
|
361
|
+
try:
|
|
362
|
+
return float(value)
|
|
363
|
+
except (TypeError, ValueError):
|
|
364
|
+
raise CommandError("bad_params", f"{name} is required (a number)") from None
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""set_device_params and update_device. Main thread only."""
|
|
2
|
+
|
|
3
|
+
from . import objects, params, reading
|
|
4
|
+
from .server import CommandError
|
|
5
|
+
from .song import undo_step
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def set_device_params(song, p):
|
|
9
|
+
"""Write parameters by name or index; one result line per parameter.
|
|
10
|
+
|
|
11
|
+
One bad parameter never stops the others. Values are raw numbers or display
|
|
12
|
+
text ("400 ms", "-6 dB", "On"); each line says what Live shows afterwards.
|
|
13
|
+
"""
|
|
14
|
+
kind, device, path = objects.resolve(song, p.get("target") or "")
|
|
15
|
+
if kind != "device":
|
|
16
|
+
raise CommandError("bad_target", f"{path} is a {kind}, not a device")
|
|
17
|
+
wanted = _param_list(p.get("params"))
|
|
18
|
+
if not wanted:
|
|
19
|
+
raise CommandError(
|
|
20
|
+
"bad_params",
|
|
21
|
+
'params is required: [{"param": "Release", "value": "400 ms"}]',
|
|
22
|
+
)
|
|
23
|
+
plist = list(device.parameters)
|
|
24
|
+
results = []
|
|
25
|
+
with undo_step(song):
|
|
26
|
+
for item in wanted:
|
|
27
|
+
results.append(_write_one(plist, item))
|
|
28
|
+
return {"target": path, "device": device.name, "results": results}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def update_device(song, p):
|
|
32
|
+
"""Turn on/off, rename, move, store or recall a variation, show macros, or mute
|
|
33
|
+
and solo a chain or pad. Only given fields change."""
|
|
34
|
+
kind, obj, path = objects.resolve(song, p.get("target") or "")
|
|
35
|
+
results = []
|
|
36
|
+
with undo_step(song):
|
|
37
|
+
if kind in ("chain", "pad"):
|
|
38
|
+
for flag in ("mute", "solo"):
|
|
39
|
+
if p.get(flag) not in (None, ""):
|
|
40
|
+
setattr(obj, flag, _bool(p[flag]))
|
|
41
|
+
results.append({"field": flag, "now": bool(getattr(obj, flag))})
|
|
42
|
+
elif kind == "device":
|
|
43
|
+
results.extend(_update_device(song, obj, path, p))
|
|
44
|
+
else:
|
|
45
|
+
raise CommandError("bad_target", f"{path} is a track: use update_track")
|
|
46
|
+
if not results:
|
|
47
|
+
raise CommandError("bad_params", "nothing to change: give at least one field")
|
|
48
|
+
return {"target": path, "results": results}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _update_device(song, device, path, p):
|
|
52
|
+
out = []
|
|
53
|
+
if p.get("on") not in (None, ""):
|
|
54
|
+
switch = device.parameters[0]
|
|
55
|
+
switch.value = switch.max if _bool(p["on"]) else switch.min
|
|
56
|
+
out.append({"field": "on", "now": switch.value > switch.min})
|
|
57
|
+
if p.get("name") not in (None, ""):
|
|
58
|
+
device.name = str(p["name"])
|
|
59
|
+
out.append({"field": "name", "now": device.name})
|
|
60
|
+
if p.get("visible_macros") not in (None, ""):
|
|
61
|
+
out.append(_visible_macros(device, int(p["visible_macros"])))
|
|
62
|
+
variation = p.get("variation")
|
|
63
|
+
if variation not in (None, ""):
|
|
64
|
+
out.append(_variation(device, variation))
|
|
65
|
+
if p.get("move_to") not in (None, ""):
|
|
66
|
+
out.append(_move(song, device, str(p["move_to"]), p.get("index")))
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_one(plist, item):
|
|
71
|
+
ref = item.get("param", item.get("name", item.get("index")))
|
|
72
|
+
value = item.get("value")
|
|
73
|
+
label = str(ref)
|
|
74
|
+
param = _find(plist, ref)
|
|
75
|
+
if param is None:
|
|
76
|
+
names = [x.name for x in plist]
|
|
77
|
+
similar = reading._similar(str(ref), plist) if names else []
|
|
78
|
+
return {"param": label, "error": "no such parameter", "similar": similar}
|
|
79
|
+
label = param.name
|
|
80
|
+
if value in (None, ""):
|
|
81
|
+
return {"param": label, "error": "value is required"}
|
|
82
|
+
if not param.is_enabled:
|
|
83
|
+
return {"param": label, "error": "disabled: a macro or modulation controls it"}
|
|
84
|
+
try:
|
|
85
|
+
raw, note = params.value_for(param, value)
|
|
86
|
+
param.value = raw
|
|
87
|
+
except CommandError as err:
|
|
88
|
+
return {"param": label, "error": str(err)}
|
|
89
|
+
except Exception as err:
|
|
90
|
+
return {"param": label, "error": f"Live refused: {err}"}
|
|
91
|
+
out = {"param": label, "now": params.display(param), "value": param.value}
|
|
92
|
+
if note:
|
|
93
|
+
out["note"] = note
|
|
94
|
+
if int(getattr(param, "automation_state", 0)) == 1:
|
|
95
|
+
out["warning"] = (
|
|
96
|
+
"this parameter has automation, which Live now ignores until "
|
|
97
|
+
"transport re_enable_automation"
|
|
98
|
+
)
|
|
99
|
+
return out
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find(plist, ref):
|
|
103
|
+
if isinstance(ref, bool) or ref in (None, ""):
|
|
104
|
+
return None
|
|
105
|
+
if isinstance(ref, int) or str(ref).strip().isdigit():
|
|
106
|
+
i = int(ref)
|
|
107
|
+
return plist[i] if 0 <= i < len(plist) else None
|
|
108
|
+
wanted = str(ref).strip().casefold()
|
|
109
|
+
for param in plist:
|
|
110
|
+
if param.name.casefold() == wanted:
|
|
111
|
+
return param
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _param_list(raw):
|
|
116
|
+
"""[{param, value}] from a list, one object, or {"Release": "400 ms"} (also
|
|
117
|
+
when a client wraps that mapping in a list)."""
|
|
118
|
+
if raw in (None, ""):
|
|
119
|
+
return []
|
|
120
|
+
if isinstance(raw, dict):
|
|
121
|
+
raw = [raw]
|
|
122
|
+
out = []
|
|
123
|
+
for item in raw:
|
|
124
|
+
if not isinstance(item, dict):
|
|
125
|
+
continue
|
|
126
|
+
if any(key in item for key in ("param", "name", "index")):
|
|
127
|
+
out.append(item)
|
|
128
|
+
else:
|
|
129
|
+
out.extend({"param": k, "value": v} for k, v in item.items())
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _visible_macros(rack, count):
|
|
134
|
+
if not getattr(rack, "can_have_chains", False):
|
|
135
|
+
return {"field": "visible_macros", "error": "only racks have macros"}
|
|
136
|
+
try:
|
|
137
|
+
while rack.visible_macro_count < count:
|
|
138
|
+
rack.add_macro()
|
|
139
|
+
while rack.visible_macro_count > count:
|
|
140
|
+
rack.remove_macro()
|
|
141
|
+
except Exception as err:
|
|
142
|
+
return {"field": "visible_macros", "error": f"Live refused: {err}"}
|
|
143
|
+
return {"field": "visible_macros", "now": rack.visible_macro_count}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _variation(rack, action):
|
|
147
|
+
if not hasattr(rack, "store_variation"):
|
|
148
|
+
return {"field": "variation", "error": "only racks have macro variations"}
|
|
149
|
+
text = str(action).strip().lower()
|
|
150
|
+
try:
|
|
151
|
+
if text == "store":
|
|
152
|
+
rack.store_variation()
|
|
153
|
+
elif text.isdigit():
|
|
154
|
+
rack.selected_variation_index = int(text)
|
|
155
|
+
rack.recall_selected_variation()
|
|
156
|
+
else:
|
|
157
|
+
return {
|
|
158
|
+
"field": "variation",
|
|
159
|
+
"error": "use store or a variation number from 0",
|
|
160
|
+
}
|
|
161
|
+
except Exception as err:
|
|
162
|
+
return {"field": "variation", "error": f"Live refused: {err}"}
|
|
163
|
+
return {
|
|
164
|
+
"field": "variation",
|
|
165
|
+
"now": {
|
|
166
|
+
"selected": rack.selected_variation_index,
|
|
167
|
+
"count": rack.variation_count,
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _move(song, device, holder_path, index):
|
|
173
|
+
kind, holder, path = objects.resolve(song, holder_path)
|
|
174
|
+
if kind not in ("track", "chain"):
|
|
175
|
+
return {"field": "move_to", "error": f"{path} cannot hold devices"}
|
|
176
|
+
count = len(list(holder.devices))
|
|
177
|
+
index = count if index in (None, "", -1) else int(index)
|
|
178
|
+
try:
|
|
179
|
+
song.move_device(device, holder, index)
|
|
180
|
+
except Exception as err:
|
|
181
|
+
return {"field": "move_to", "error": f"Live refused: {err}"}
|
|
182
|
+
return {"field": "move_to", "now": f"{path}/d{min(index, count)}"}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _bool(value):
|
|
186
|
+
if isinstance(value, str):
|
|
187
|
+
return value.strip().lower() in ("true", "on", "yes", "1")
|
|
188
|
+
return bool(value)
|