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.
- package/CHANGELOG.md +187 -144
- package/README.md +136 -61
- package/m4l_device/BUILD_GUIDE.md +161 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +680 -0
- package/m4l_device/livepilot_bridge.js +942 -0
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +22 -16
- package/mcp_server/curves.py +741 -0
- package/mcp_server/m4l_bridge.py +285 -0
- package/mcp_server/server.py +29 -3
- package/mcp_server/tools/analyzer.py +508 -0
- package/mcp_server/tools/automation.py +431 -0
- package/mcp_server/tools/clips.py +16 -12
- package/mcp_server/tools/devices.py +2 -2
- package/mcp_server/tools/mixing.py +50 -14
- package/mcp_server/tools/tracks.py +2 -2
- package/package.json +2 -3
- package/plugin/agents/livepilot-producer/AGENT.md +32 -2
- package/plugin/plugin.json +2 -2
- package/plugin/skills/livepilot-core/SKILL.md +76 -11
- package/plugin/skills/livepilot-core/references/automation-atlas.md +272 -0
- package/plugin/skills/livepilot-core/references/overview.md +68 -5
- package/plugin/skills/livepilot-release/SKILL.md +101 -0
- package/remote_script/LivePilot/__init__.py +3 -2
- package/remote_script/LivePilot/clip_automation.py +220 -0
- package/remote_script/LivePilot/mixing.py +90 -1
- package/remote_script/LivePilot/server.py +3 -0
|
@@ -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 (
|
|
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
|
|