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.
@@ -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)