livepilot 1.4.5 → 1.6.1

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,220 @@
1
+ """Clip automation envelope handlers.
2
+
3
+ Provides CRUD access to session clip automation envelopes.
4
+ Uses the same LOM API as arrangement automation (AutomationEnvelope)
5
+ but targets session clips via track.clip_slots[i].clip.
6
+ """
7
+
8
+ from .router import register
9
+ from .utils import get_track
10
+
11
+
12
+ @register("get_clip_automation")
13
+ def get_clip_automation(song, params):
14
+ """List automation envelopes on a session clip."""
15
+ track_index = params["track_index"]
16
+ clip_index = params["clip_index"]
17
+
18
+ track = get_track(song, track_index)
19
+ clip_slot = list(track.clip_slots)[clip_index]
20
+ if not clip_slot.has_clip:
21
+ return {"error": {"code": "NOT_FOUND",
22
+ "message": "No clip at slot %d" % clip_index}}
23
+
24
+ clip = clip_slot.clip
25
+ envelopes = []
26
+
27
+ # Check mixer parameters: volume, panning, sends
28
+ mixer = track.mixer_device
29
+ for param_name, param in [
30
+ ("Volume", mixer.volume),
31
+ ("Pan", mixer.panning),
32
+ ]:
33
+ env = clip.automation_envelope(param)
34
+ if env is not None:
35
+ envelopes.append({
36
+ "parameter_name": param_name,
37
+ "parameter_type": "mixer",
38
+ "has_envelope": True,
39
+ })
40
+
41
+ # Check send parameters
42
+ sends = list(mixer.sends)
43
+ for i, send in enumerate(sends):
44
+ env = clip.automation_envelope(send)
45
+ if env is not None:
46
+ envelopes.append({
47
+ "parameter_name": "Send %s" % chr(65 + i),
48
+ "parameter_type": "send",
49
+ "send_index": i,
50
+ "has_envelope": True,
51
+ })
52
+
53
+ # Check device parameters
54
+ devices = list(track.devices)
55
+ for di, device in enumerate(devices):
56
+ dev_params = list(device.parameters)
57
+ for pi, param in enumerate(dev_params):
58
+ try:
59
+ env = clip.automation_envelope(param)
60
+ if env is not None:
61
+ envelopes.append({
62
+ "parameter_name": param.name,
63
+ "parameter_type": "device",
64
+ "device_index": di,
65
+ "device_name": device.name,
66
+ "parameter_index": pi,
67
+ "has_envelope": True,
68
+ })
69
+ except Exception:
70
+ pass
71
+
72
+ return {
73
+ "track_index": track_index,
74
+ "clip_index": clip_index,
75
+ "clip_name": clip.name,
76
+ "envelope_count": len(envelopes),
77
+ "envelopes": envelopes,
78
+ }
79
+
80
+
81
+ @register("set_clip_automation")
82
+ def set_clip_automation(song, params):
83
+ """Write automation points to a session clip envelope.
84
+
85
+ parameter_type: "device", "volume", "panning", "send"
86
+ points: [{time, value, duration?}] — time relative to clip start
87
+ """
88
+ track_index = params["track_index"]
89
+ clip_index = params["clip_index"]
90
+ parameter_type = params["parameter_type"]
91
+ points = params["points"]
92
+ device_index = params.get("device_index")
93
+ parameter_index = params.get("parameter_index")
94
+ send_index = params.get("send_index")
95
+
96
+ track = get_track(song, track_index)
97
+ clip_slot = list(track.clip_slots)[clip_index]
98
+ if not clip_slot.has_clip:
99
+ return {"error": {"code": "NOT_FOUND",
100
+ "message": "No clip at slot %d" % clip_index}}
101
+
102
+ clip = clip_slot.clip
103
+
104
+ # Resolve the target parameter
105
+ if parameter_type == "volume":
106
+ parameter = track.mixer_device.volume
107
+ elif parameter_type == "panning":
108
+ parameter = track.mixer_device.panning
109
+ elif parameter_type == "send":
110
+ if send_index is None:
111
+ return {"error": {"code": "INVALID_PARAM",
112
+ "message": "send_index required for send automation"}}
113
+ sends = list(track.mixer_device.sends)
114
+ if send_index >= len(sends):
115
+ return {"error": {"code": "INDEX_ERROR",
116
+ "message": "send_index %d out of range" % send_index}}
117
+ parameter = sends[send_index]
118
+ elif parameter_type == "device":
119
+ if device_index is None or parameter_index is None:
120
+ return {"error": {"code": "INVALID_PARAM",
121
+ "message": "device_index and parameter_index required"}}
122
+ devices = list(track.devices)
123
+ if device_index >= len(devices):
124
+ return {"error": {"code": "INDEX_ERROR",
125
+ "message": "device_index %d out of range" % device_index}}
126
+ dev_params = list(devices[device_index].parameters)
127
+ if parameter_index >= len(dev_params):
128
+ return {"error": {"code": "INDEX_ERROR",
129
+ "message": "parameter_index %d out of range" % parameter_index}}
130
+ parameter = dev_params[parameter_index]
131
+ else:
132
+ return {"error": {"code": "INVALID_PARAM",
133
+ "message": "parameter_type must be device/volume/panning/send"}}
134
+
135
+ # Get or create envelope
136
+ song.begin_undo_step()
137
+ try:
138
+ envelope = clip.automation_envelope(parameter)
139
+ if envelope is None:
140
+ envelope = clip.create_automation_envelope(parameter)
141
+
142
+ # Write points
143
+ written = 0
144
+ for pt in points:
145
+ time = float(pt["time"])
146
+ value = float(pt["value"])
147
+ duration = float(pt.get("duration", 0.125))
148
+ # Clamp value to parameter range
149
+ value = max(parameter.min, min(parameter.max, value))
150
+ envelope.insert_step(time, duration, value)
151
+ written += 1
152
+ finally:
153
+ song.end_undo_step()
154
+
155
+ return {
156
+ "track_index": track_index,
157
+ "clip_index": clip_index,
158
+ "parameter_name": parameter.name,
159
+ "parameter_type": parameter_type,
160
+ "points_written": written,
161
+ }
162
+
163
+
164
+ @register("clear_clip_automation")
165
+ def clear_clip_automation(song, params):
166
+ """Clear automation envelopes from a session clip.
167
+
168
+ If parameter_type is provided, clears only that parameter's envelope.
169
+ If omitted, clears ALL envelopes on the clip.
170
+ """
171
+ track_index = params["track_index"]
172
+ clip_index = params["clip_index"]
173
+ parameter_type = params.get("parameter_type")
174
+
175
+ track = get_track(song, track_index)
176
+ clip_slot = list(track.clip_slots)[clip_index]
177
+ if not clip_slot.has_clip:
178
+ return {"error": {"code": "NOT_FOUND",
179
+ "message": "No clip at slot %d" % clip_index}}
180
+
181
+ clip = clip_slot.clip
182
+
183
+ song.begin_undo_step()
184
+ try:
185
+ if parameter_type is None:
186
+ # Clear all envelopes
187
+ clip.clear_all_envelopes()
188
+ return {
189
+ "track_index": track_index,
190
+ "clip_index": clip_index,
191
+ "cleared": "all",
192
+ }
193
+
194
+ # Clear specific parameter
195
+ if parameter_type == "volume":
196
+ parameter = track.mixer_device.volume
197
+ elif parameter_type == "panning":
198
+ parameter = track.mixer_device.panning
199
+ elif parameter_type == "send":
200
+ send_index = params.get("send_index", 0)
201
+ parameter = list(track.mixer_device.sends)[send_index]
202
+ elif parameter_type == "device":
203
+ device_index = params.get("device_index", 0)
204
+ parameter_index = params.get("parameter_index", 0)
205
+ device = list(track.devices)[device_index]
206
+ parameter = list(device.parameters)[parameter_index]
207
+ else:
208
+ return {"error": {"code": "INVALID_PARAM",
209
+ "message": "Unknown parameter_type"}}
210
+
211
+ clip.clear_envelope(parameter)
212
+ finally:
213
+ song.end_undo_step()
214
+
215
+ return {
216
+ "track_index": track_index,
217
+ "clip_index": clip_index,
218
+ "cleared": parameter_type,
219
+ "parameter_name": parameter.name,
220
+ }
@@ -1,5 +1,5 @@
1
1
  """
2
- LivePilot - Mixing domain handlers (8 commands).
2
+ LivePilot - Mixing domain handlers (11 commands).
3
3
  """
4
4
 
5
5
  from .router import register
@@ -114,6 +114,95 @@ def get_track_routing(song, params):
114
114
  return result
115
115
 
116
116
 
117
+ @register("get_track_meters")
118
+ def get_track_meters(song, params):
119
+ """Read output meter levels for one or all tracks.
120
+
121
+ Returns peak level (0.0-1.0) for each track. When track_index is
122
+ provided, returns a single track. Otherwise returns all tracks.
123
+
124
+ The 'level' value is the hold-peak of max(L, R). It's cheap to read.
125
+ The 'left'/'right' values add GUI load — only included when
126
+ include_stereo=True.
127
+ """
128
+ include_stereo = bool(params.get("include_stereo", False))
129
+ track_index = params.get("track_index")
130
+
131
+ def read_meters(track, idx):
132
+ entry = {
133
+ "index": idx,
134
+ "name": track.name,
135
+ "level": track.output_meter_level,
136
+ }
137
+ if include_stereo:
138
+ entry["left"] = track.output_meter_left
139
+ entry["right"] = track.output_meter_right
140
+ return entry
141
+
142
+ if track_index is not None:
143
+ track = get_track(song, int(track_index))
144
+ return {"tracks": [read_meters(track, int(track_index))]}
145
+
146
+ tracks = []
147
+ for i, track in enumerate(song.tracks):
148
+ tracks.append(read_meters(track, i))
149
+ return {"tracks": tracks}
150
+
151
+
152
+ @register("get_master_meters")
153
+ def get_master_meters(song, params):
154
+ """Read output meter levels for the master track."""
155
+ master = song.master_track
156
+ result = {
157
+ "level": master.output_meter_level,
158
+ "left": master.output_meter_left,
159
+ "right": master.output_meter_right,
160
+ }
161
+ return result
162
+
163
+
164
+ @register("get_mix_snapshot")
165
+ def get_mix_snapshot(song, params):
166
+ """Get a complete snapshot of the mix: all track levels, volumes, pans,
167
+ mute/solo states, and master meters. One call to assess the full mix."""
168
+ tracks = []
169
+ for i, track in enumerate(song.tracks):
170
+ tracks.append({
171
+ "index": i,
172
+ "name": track.name,
173
+ "meter_level": track.output_meter_level,
174
+ "volume": track.mixer_device.volume.value,
175
+ "pan": track.mixer_device.panning.value,
176
+ "mute": track.mute,
177
+ "solo": track.solo,
178
+ "has_audio_output": track.has_audio_output,
179
+ })
180
+ returns = []
181
+ for i, track in enumerate(song.return_tracks):
182
+ returns.append({
183
+ "index": i,
184
+ "name": track.name,
185
+ "meter_level": track.output_meter_level,
186
+ "volume": track.mixer_device.volume.value,
187
+ "pan": track.mixer_device.panning.value,
188
+ "mute": track.mute,
189
+ "solo": track.solo,
190
+ })
191
+ master = song.master_track
192
+ return {
193
+ "tracks": tracks,
194
+ "return_tracks": returns,
195
+ "master": {
196
+ "level": master.output_meter_level,
197
+ "left": master.output_meter_left,
198
+ "right": master.output_meter_right,
199
+ "volume": master.mixer_device.volume.value,
200
+ },
201
+ "is_playing": song.is_playing,
202
+ "tempo": song.tempo,
203
+ }
204
+
205
+
117
206
  @register("set_track_routing")
118
207
  def set_track_routing(song, params):
119
208
  """Set input/output routing for a track by display name."""
@@ -52,6 +52,9 @@ WRITE_COMMANDS = frozenset([
52
52
  "modify_arrangement_notes", "duplicate_arrangement_notes",
53
53
  "transpose_arrangement_notes", "set_arrangement_automation",
54
54
  "set_arrangement_clip_name",
55
+ # clip automation
56
+ "set_clip_automation",
57
+ "clear_clip_automation",
55
58
  ])
56
59
 
57
60