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,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