livepilot 1.9.9 → 1.9.11
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/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +45 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +131 -0
- package/README.md +119 -395
- package/SECURITY.md +48 -0
- package/bin/livepilot.js +41 -1
- package/livepilot/.Codex-plugin/plugin.json +8 -0
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/commands/beat.md +18 -6
- package/livepilot/commands/sounddesign.md +6 -5
- package/livepilot/skills/livepilot-core/SKILL.md +30 -7
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +7 -4
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.maxpat +378 -4
- package/m4l_device/livepilot_bridge.js +413 -184
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +35 -0
- package/mcp_server/m4l_bridge.py +55 -5
- package/mcp_server/server.py +16 -29
- package/mcp_server/tools/_perception_engine.py +26 -3
- package/mcp_server/tools/_theory_engine.py +36 -5
- package/mcp_server/tools/analyzer.py +62 -17
- package/mcp_server/tools/automation.py +4 -1
- package/mcp_server/tools/devices.py +199 -10
- package/mcp_server/tools/generative.py +4 -1
- package/mcp_server/tools/harmony.py +16 -3
- package/mcp_server/tools/notes.py +4 -1
- package/mcp_server/tools/perception.py +27 -1
- package/mcp_server/tools/scenes.py +4 -1
- package/mcp_server/tools/theory.py +15 -7
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +13 -116
- package/remote_script/LivePilot/diagnostics.py +81 -5
- package/remote_script/LivePilot/router.py +22 -0
- package/remote_script/LivePilot/server.py +25 -13
|
@@ -10,13 +10,16 @@ from typing import Any, Optional
|
|
|
10
10
|
|
|
11
11
|
from fastmcp import Context
|
|
12
12
|
|
|
13
|
-
from ..server import mcp
|
|
13
|
+
from ..server import mcp, _identify_port_holder
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
def _ensure_list(value: Any) -> list:
|
|
17
17
|
"""Parse JSON strings into lists. MCP clients may serialize list params as strings."""
|
|
18
18
|
if isinstance(value, str):
|
|
19
|
-
|
|
19
|
+
try:
|
|
20
|
+
return json.loads(value)
|
|
21
|
+
except json.JSONDecodeError as exc:
|
|
22
|
+
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
20
23
|
return value
|
|
21
24
|
|
|
22
25
|
|
|
@@ -26,6 +29,153 @@ def _get_ableton(ctx: Context):
|
|
|
26
29
|
|
|
27
30
|
|
|
28
31
|
MASTER_TRACK_INDEX = -1000
|
|
32
|
+
_PLUGIN_CLASS_NAMES = {"PluginDevice", "AuPluginDevice"}
|
|
33
|
+
_SAMPLE_DEPENDENT_DEVICE_NAMES = {
|
|
34
|
+
"idensity": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
|
|
35
|
+
"tardigrain": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
|
|
36
|
+
"koala sampler": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
|
|
37
|
+
"burns audio granular": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
|
|
38
|
+
"audiolayer": "Requires samples loaded inside the plugin UI before MIDI can produce sound.",
|
|
39
|
+
"segments": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
|
|
40
|
+
"segments (instr)": "Requires source audio loaded inside the plugin UI before MIDI can produce sound.",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _sample_dependency_reason(device_name: str) -> Optional[str]:
|
|
45
|
+
lowered = device_name.strip().lower()
|
|
46
|
+
for candidate, reason in _SAMPLE_DEPENDENT_DEVICE_NAMES.items():
|
|
47
|
+
if candidate in lowered:
|
|
48
|
+
return reason
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _annotate_device_info(result: dict) -> dict:
|
|
53
|
+
"""Attach MCP-focused health hints to raw get_device_info results."""
|
|
54
|
+
if not isinstance(result, dict):
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
class_name = str(result.get("class_name") or "")
|
|
58
|
+
device_name = str(result.get("name") or "")
|
|
59
|
+
parameter_count = int(result.get("parameter_count") or 0)
|
|
60
|
+
is_plugin = class_name in _PLUGIN_CLASS_NAMES
|
|
61
|
+
|
|
62
|
+
plugin_host_status = "not_plugin"
|
|
63
|
+
if is_plugin:
|
|
64
|
+
plugin_host_status = "host_visible" if parameter_count > 1 else "opaque_or_failed"
|
|
65
|
+
|
|
66
|
+
flags: list[str] = []
|
|
67
|
+
warnings: list[str] = []
|
|
68
|
+
|
|
69
|
+
sample_reason = _sample_dependency_reason(device_name)
|
|
70
|
+
if sample_reason:
|
|
71
|
+
flags.append("sample_dependent")
|
|
72
|
+
warnings.append(sample_reason)
|
|
73
|
+
|
|
74
|
+
if plugin_host_status == "opaque_or_failed":
|
|
75
|
+
flags.append("opaque_or_failed_plugin")
|
|
76
|
+
warnings.append(
|
|
77
|
+
"Ableton only sees %d host parameter(s) for this plugin. "
|
|
78
|
+
"If auditioning produces no audio, the plugin likely failed to initialize. "
|
|
79
|
+
"If audio is flowing, the plugin is usable but opaque to MCP sound design."
|
|
80
|
+
% parameter_count
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
annotated = dict(result)
|
|
84
|
+
annotated["is_plugin"] = is_plugin
|
|
85
|
+
annotated["plugin_host_status"] = plugin_host_status
|
|
86
|
+
annotated["health_flags"] = flags
|
|
87
|
+
annotated["mcp_sound_design_ready"] = len(flags) == 0
|
|
88
|
+
if warnings:
|
|
89
|
+
annotated["warnings"] = warnings
|
|
90
|
+
return annotated
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _annotate_loaded_device_result(result: dict) -> dict:
|
|
94
|
+
"""Attach preflight warnings to load results based on loaded device names."""
|
|
95
|
+
if not isinstance(result, dict):
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
loaded_name = str(result.get("loaded") or "")
|
|
99
|
+
sample_reason = _sample_dependency_reason(loaded_name)
|
|
100
|
+
if not sample_reason:
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
annotated = dict(result)
|
|
104
|
+
annotated["health_flags"] = ["sample_dependent"]
|
|
105
|
+
annotated["warnings"] = [sample_reason]
|
|
106
|
+
annotated["mcp_sound_design_ready"] = False
|
|
107
|
+
return annotated
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _merge_unique(base: list[str], extra: list[str]) -> list[str]:
|
|
111
|
+
merged = list(base)
|
|
112
|
+
for item in extra:
|
|
113
|
+
if item not in merged:
|
|
114
|
+
merged.append(item)
|
|
115
|
+
return merged
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _postflight_loaded_device(ctx: Context, result: dict) -> dict:
|
|
119
|
+
"""Attach post-load health info by inspecting the newly loaded device."""
|
|
120
|
+
annotated = _annotate_loaded_device_result(result)
|
|
121
|
+
if not isinstance(annotated, dict):
|
|
122
|
+
return annotated
|
|
123
|
+
|
|
124
|
+
track_index = annotated.get("track_index")
|
|
125
|
+
loaded_name = str(annotated.get("loaded") or "")
|
|
126
|
+
if track_index is None or not loaded_name:
|
|
127
|
+
return annotated
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
track_info = _get_ableton(ctx).send_command("get_track_info", {
|
|
131
|
+
"track_index": int(track_index),
|
|
132
|
+
})
|
|
133
|
+
except Exception:
|
|
134
|
+
return annotated
|
|
135
|
+
|
|
136
|
+
devices = track_info.get("devices", []) if isinstance(track_info, dict) else []
|
|
137
|
+
if not isinstance(devices, list) or not devices:
|
|
138
|
+
return annotated
|
|
139
|
+
|
|
140
|
+
match = None
|
|
141
|
+
for device in reversed(devices):
|
|
142
|
+
if str(device.get("name") or "") == loaded_name:
|
|
143
|
+
match = device
|
|
144
|
+
break
|
|
145
|
+
if match is None:
|
|
146
|
+
match = devices[-1]
|
|
147
|
+
|
|
148
|
+
device_info = _annotate_device_info({
|
|
149
|
+
"name": match.get("name"),
|
|
150
|
+
"class_name": match.get("class_name"),
|
|
151
|
+
"is_active": match.get("is_active"),
|
|
152
|
+
"parameter_count": len(match.get("parameters", [])),
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
merged = dict(annotated)
|
|
156
|
+
merged["device_index"] = match.get("index")
|
|
157
|
+
merged["class_name"] = device_info.get("class_name")
|
|
158
|
+
merged["parameter_count"] = device_info.get("parameter_count")
|
|
159
|
+
merged["is_plugin"] = device_info.get("is_plugin")
|
|
160
|
+
merged["plugin_host_status"] = device_info.get("plugin_host_status")
|
|
161
|
+
merged["mcp_sound_design_ready"] = (
|
|
162
|
+
merged.get("mcp_sound_design_ready", True)
|
|
163
|
+
and device_info.get("mcp_sound_design_ready", True)
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
merged["health_flags"] = _merge_unique(
|
|
167
|
+
annotated.get("health_flags", []),
|
|
168
|
+
device_info.get("health_flags", []),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
warnings = _merge_unique(
|
|
172
|
+
annotated.get("warnings", []),
|
|
173
|
+
device_info.get("warnings", []),
|
|
174
|
+
)
|
|
175
|
+
if warnings:
|
|
176
|
+
merged["warnings"] = warnings
|
|
177
|
+
|
|
178
|
+
return merged
|
|
29
179
|
|
|
30
180
|
|
|
31
181
|
def _validate_track_index(track_index: int):
|
|
@@ -54,10 +204,11 @@ def get_device_info(ctx: Context, track_index: int, device_index: int) -> dict:
|
|
|
54
204
|
track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master."""
|
|
55
205
|
_validate_track_index(track_index)
|
|
56
206
|
_validate_device_index(device_index)
|
|
57
|
-
|
|
207
|
+
result = _get_ableton(ctx).send_command("get_device_info", {
|
|
58
208
|
"track_index": track_index,
|
|
59
209
|
"device_index": device_index,
|
|
60
210
|
})
|
|
211
|
+
return _annotate_device_info(result)
|
|
61
212
|
|
|
62
213
|
|
|
63
214
|
@mcp.tool()
|
|
@@ -157,10 +308,11 @@ def load_device_by_uri(ctx: Context, track_index: int, uri: str) -> dict:
|
|
|
157
308
|
_validate_track_index(track_index)
|
|
158
309
|
if not uri.strip():
|
|
159
310
|
raise ValueError("URI cannot be empty")
|
|
160
|
-
|
|
311
|
+
result = _get_ableton(ctx).send_command("load_device_by_uri", {
|
|
161
312
|
"track_index": track_index,
|
|
162
313
|
"uri": uri,
|
|
163
314
|
})
|
|
315
|
+
return _postflight_loaded_device(ctx, result)
|
|
164
316
|
|
|
165
317
|
|
|
166
318
|
@mcp.tool()
|
|
@@ -170,10 +322,11 @@ def find_and_load_device(ctx: Context, track_index: int, device_name: str) -> di
|
|
|
170
322
|
_validate_track_index(track_index)
|
|
171
323
|
if not device_name.strip():
|
|
172
324
|
raise ValueError("device_name cannot be empty")
|
|
173
|
-
|
|
325
|
+
result = _get_ableton(ctx).send_command("find_and_load_device", {
|
|
174
326
|
"track_index": track_index,
|
|
175
327
|
"device_name": device_name,
|
|
176
328
|
})
|
|
329
|
+
return _postflight_loaded_device(ctx, result)
|
|
177
330
|
|
|
178
331
|
|
|
179
332
|
@mcp.tool()
|
|
@@ -263,7 +416,7 @@ def _get_m4l(ctx: Context):
|
|
|
263
416
|
"""Get M4LBridge from lifespan context."""
|
|
264
417
|
bridge = ctx.lifespan_context.get("m4l")
|
|
265
418
|
if not bridge:
|
|
266
|
-
raise
|
|
419
|
+
raise ValueError("M4L bridge not initialized — restart the MCP server")
|
|
267
420
|
return bridge
|
|
268
421
|
|
|
269
422
|
|
|
@@ -271,12 +424,48 @@ def _get_spectral(ctx: Context):
|
|
|
271
424
|
"""Get SpectralCache from lifespan context."""
|
|
272
425
|
cache = ctx.lifespan_context.get("spectral")
|
|
273
426
|
if not cache:
|
|
274
|
-
raise
|
|
427
|
+
raise ValueError("Spectral cache not initialized — restart the MCP server")
|
|
428
|
+
# Keep the active request context attached so analyzer error paths can
|
|
429
|
+
# distinguish "device missing" from "bridge disconnected".
|
|
430
|
+
setattr(cache, "_livepilot_ctx", ctx)
|
|
275
431
|
return cache
|
|
276
432
|
|
|
277
433
|
|
|
278
434
|
def _require_analyzer(cache) -> None:
|
|
279
435
|
if not cache.is_connected:
|
|
436
|
+
ctx = getattr(cache, "_livepilot_ctx", None)
|
|
437
|
+
try:
|
|
438
|
+
track = (
|
|
439
|
+
ctx.lifespan_context["ableton"].send_command("get_master_track")
|
|
440
|
+
if ctx else {}
|
|
441
|
+
)
|
|
442
|
+
except Exception:
|
|
443
|
+
track = {}
|
|
444
|
+
|
|
445
|
+
devices = track.get("devices", []) if isinstance(track, dict) else []
|
|
446
|
+
analyzer_loaded = False
|
|
447
|
+
for device in devices:
|
|
448
|
+
normalized = " ".join(
|
|
449
|
+
str(device.get("name") or "").replace("_", " ").replace("-", " ").lower().split()
|
|
450
|
+
)
|
|
451
|
+
if normalized == "livepilot analyzer":
|
|
452
|
+
analyzer_loaded = True
|
|
453
|
+
break
|
|
454
|
+
|
|
455
|
+
if analyzer_loaded:
|
|
456
|
+
holder = _identify_port_holder(9880)
|
|
457
|
+
detail = (
|
|
458
|
+
"LivePilot Analyzer is loaded on the master track, but its UDP bridge is not connected. "
|
|
459
|
+
)
|
|
460
|
+
if holder:
|
|
461
|
+
detail += (
|
|
462
|
+
"UDP port 9880 is currently held by another LivePilot instance "
|
|
463
|
+
f"({holder}). Close the other client/server, then retry."
|
|
464
|
+
)
|
|
465
|
+
else:
|
|
466
|
+
detail += "Reload the analyzer device or restart the MCP server."
|
|
467
|
+
raise ValueError(detail)
|
|
468
|
+
|
|
280
469
|
raise ValueError(
|
|
281
470
|
"LivePilot Analyzer not detected. "
|
|
282
471
|
"Drag 'LivePilot Analyzer' onto the master track."
|
|
@@ -302,7 +491,7 @@ async def get_plugin_parameters(
|
|
|
302
491
|
cache = _get_spectral(ctx)
|
|
303
492
|
_require_analyzer(cache)
|
|
304
493
|
bridge = _get_m4l(ctx)
|
|
305
|
-
return await bridge.send_command("get_plugin_params", track_index, device_index)
|
|
494
|
+
return await bridge.send_command("get_plugin_params", track_index, device_index, timeout=20.0)
|
|
306
495
|
|
|
307
496
|
|
|
308
497
|
@mcp.tool()
|
|
@@ -326,7 +515,7 @@ async def map_plugin_parameter(
|
|
|
326
515
|
cache = _get_spectral(ctx)
|
|
327
516
|
_require_analyzer(cache)
|
|
328
517
|
bridge = _get_m4l(ctx)
|
|
329
|
-
return await bridge.send_command("map_plugin_param", track_index, device_index, parameter_index)
|
|
518
|
+
return await bridge.send_command("map_plugin_param", track_index, device_index, parameter_index, timeout=10.0)
|
|
330
519
|
|
|
331
520
|
|
|
332
521
|
@mcp.tool()
|
|
@@ -346,4 +535,4 @@ async def get_plugin_presets(
|
|
|
346
535
|
cache = _get_spectral(ctx)
|
|
347
536
|
_require_analyzer(cache)
|
|
348
537
|
bridge = _get_m4l(ctx)
|
|
349
|
-
return await bridge.send_command("get_plugin_presets", track_index, device_index)
|
|
538
|
+
return await bridge.send_command("get_plugin_presets", track_index, device_index, timeout=15.0)
|
|
@@ -17,7 +17,10 @@ from . import _theory_engine as theory
|
|
|
17
17
|
|
|
18
18
|
def _ensure_list(value: Any) -> list:
|
|
19
19
|
if isinstance(value, str):
|
|
20
|
-
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(value)
|
|
22
|
+
except json.JSONDecodeError as exc:
|
|
23
|
+
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
21
24
|
return value
|
|
22
25
|
|
|
23
26
|
|
|
@@ -19,7 +19,10 @@ from . import _theory_engine as theory
|
|
|
19
19
|
|
|
20
20
|
def _ensure_list(value: Any) -> list:
|
|
21
21
|
if isinstance(value, str):
|
|
22
|
-
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(value)
|
|
24
|
+
except json.JSONDecodeError as exc:
|
|
25
|
+
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
23
26
|
return value
|
|
24
27
|
|
|
25
28
|
|
|
@@ -168,8 +171,18 @@ def classify_progression(
|
|
|
168
171
|
if len(chords) < 2:
|
|
169
172
|
return {"error": "Need at least 2 chords to classify"}
|
|
170
173
|
|
|
174
|
+
# Normalize dict inputs like {"root": "F#", "quality": "minor"} to strings
|
|
175
|
+
normalized = []
|
|
176
|
+
for c in chords:
|
|
177
|
+
if isinstance(c, dict):
|
|
178
|
+
root = c.get("root", "")
|
|
179
|
+
quality = c.get("quality", "major")
|
|
180
|
+
normalized.append(f"{root} {quality}")
|
|
181
|
+
else:
|
|
182
|
+
normalized.append(str(c))
|
|
183
|
+
|
|
171
184
|
try:
|
|
172
|
-
parsed = [harmony.parse_chord(c) for c in
|
|
185
|
+
parsed = [harmony.parse_chord(c) for c in normalized]
|
|
173
186
|
except ValueError as e:
|
|
174
187
|
return {"error": str(e)}
|
|
175
188
|
|
|
@@ -198,7 +211,7 @@ def classify_progression(
|
|
|
198
211
|
classification = names.get(clean, classification)
|
|
199
212
|
|
|
200
213
|
return {
|
|
201
|
-
"chords":
|
|
214
|
+
"chords": normalized,
|
|
202
215
|
"transforms": transforms,
|
|
203
216
|
"pattern": pattern,
|
|
204
217
|
"classification": classification,
|
|
@@ -21,7 +21,10 @@ def _get_ableton(ctx: Context):
|
|
|
21
21
|
def _ensure_list(value: Any) -> list:
|
|
22
22
|
"""Parse JSON strings into lists. MCP clients may serialize list params as strings."""
|
|
23
23
|
if isinstance(value, str):
|
|
24
|
-
|
|
24
|
+
try:
|
|
25
|
+
return json.loads(value)
|
|
26
|
+
except json.JSONDecodeError as exc:
|
|
27
|
+
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
25
28
|
return value
|
|
26
29
|
|
|
27
30
|
|
|
@@ -34,11 +34,31 @@ _LOSSY_EXTS = {".mp3", ".m4a"}
|
|
|
34
34
|
_MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
def _normalize_audio_path(file_path: str) -> str:
|
|
38
|
+
"""Convert Max-style HFS-ish paths into POSIX paths when possible."""
|
|
39
|
+
if len(file_path) >= 3 and file_path[1] == ":" and file_path[2] in ("/", "\\"):
|
|
40
|
+
return file_path
|
|
41
|
+
|
|
42
|
+
colon = file_path.find(":")
|
|
43
|
+
slash = file_path.find("/")
|
|
44
|
+
if colon <= 0 or (slash != -1 and colon > slash):
|
|
45
|
+
return file_path
|
|
46
|
+
|
|
47
|
+
rest = file_path[colon + 1:]
|
|
48
|
+
if ":" in rest:
|
|
49
|
+
rest = rest.replace(":", "/")
|
|
50
|
+
if not rest.startswith("/"):
|
|
51
|
+
rest = "/" + rest.lstrip("/\\")
|
|
52
|
+
return rest
|
|
53
|
+
|
|
54
|
+
|
|
37
55
|
def _validate_audio(file_path: str, allow_mp3: bool = False) -> Optional[dict]:
|
|
38
56
|
"""Validate that a file exists, has a supported extension, and is < 500 MB.
|
|
39
57
|
|
|
40
58
|
Returns None if valid, or an error dict if invalid.
|
|
41
59
|
"""
|
|
60
|
+
file_path = _normalize_audio_path(file_path)
|
|
61
|
+
|
|
42
62
|
if not os.path.exists(file_path):
|
|
43
63
|
return {"error": f"File not found: {file_path}", "code": "INVALID_PARAM"}
|
|
44
64
|
|
|
@@ -84,7 +104,8 @@ def analyze_loudness(
|
|
|
84
104
|
array (up to 100 points, mean-pooled).
|
|
85
105
|
|
|
86
106
|
Returns:
|
|
87
|
-
On success: dict with integrated_lufs, true_peak_dbtp
|
|
107
|
+
On success: dict with integrated_lufs, true_peak_dbtp (4x oversampled),
|
|
108
|
+
sample_peak_dbfs (raw sample peak, kept for backward compat), rms_dbfs,
|
|
88
109
|
crest_factor_db, lra_lu, meets_streaming {spotify, apple, youtube, tidal},
|
|
89
110
|
and optionally short_term_lufs.
|
|
90
111
|
On error: {"error": ..., "code": ...}
|
|
@@ -97,6 +118,7 @@ def analyze_loudness(
|
|
|
97
118
|
return {"error": "detail must be 'summary' or 'full'", "code": "INVALID_PARAM"}
|
|
98
119
|
|
|
99
120
|
try:
|
|
121
|
+
file_path = _normalize_audio_path(file_path)
|
|
100
122
|
return compute_loudness(file_path, detail=detail)
|
|
101
123
|
except FileNotFoundError as exc:
|
|
102
124
|
return {"error": str(exc), "code": "INVALID_PARAM"}
|
|
@@ -135,6 +157,7 @@ def analyze_spectrum_offline(
|
|
|
135
157
|
return {"error": "hop_length must be between 1 and n_fft", "code": "INVALID_PARAM"}
|
|
136
158
|
|
|
137
159
|
try:
|
|
160
|
+
file_path = _normalize_audio_path(file_path)
|
|
138
161
|
return compute_spectral(file_path, n_fft=n_fft, hop_length=hop_length)
|
|
139
162
|
except FileNotFoundError as exc:
|
|
140
163
|
return {"error": str(exc), "code": "INVALID_PARAM"}
|
|
@@ -176,6 +199,8 @@ def compare_to_reference(
|
|
|
176
199
|
return {"error": f"reference_path: {err['error']}", "code": err["code"]}
|
|
177
200
|
|
|
178
201
|
try:
|
|
202
|
+
mix_path = _normalize_audio_path(mix_path)
|
|
203
|
+
reference_path = _normalize_audio_path(reference_path)
|
|
179
204
|
return _compare(mix_path, reference_path, normalize=normalize)
|
|
180
205
|
except FileNotFoundError as exc:
|
|
181
206
|
return {"error": str(exc), "code": "INVALID_PARAM"}
|
|
@@ -207,6 +232,7 @@ def read_audio_metadata(
|
|
|
207
232
|
return err
|
|
208
233
|
|
|
209
234
|
try:
|
|
235
|
+
file_path = _normalize_audio_path(file_path)
|
|
210
236
|
return _read_metadata(file_path)
|
|
211
237
|
except FileNotFoundError as exc:
|
|
212
238
|
return {"error": str(exc), "code": "INVALID_PARAM"}
|
|
@@ -97,7 +97,10 @@ def set_scene_tempo(ctx: Context, scene_index: int, tempo: float) -> dict:
|
|
|
97
97
|
|
|
98
98
|
def _ensure_list(value: Any) -> list:
|
|
99
99
|
if isinstance(value, str):
|
|
100
|
-
|
|
100
|
+
try:
|
|
101
|
+
return json.loads(value)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
raise ValueError(f"Invalid JSON in parameter: {exc}") from exc
|
|
101
104
|
return value
|
|
102
105
|
|
|
103
106
|
|
|
@@ -48,7 +48,12 @@ def _detect_or_parse_key(notes: list[dict], key_hint: str | None = None) -> dict
|
|
|
48
48
|
|
|
49
49
|
def _key_display(key_info: dict) -> str:
|
|
50
50
|
"""Format key info as 'C major' string."""
|
|
51
|
-
return f"{key_info['tonic_name']} {key_info['mode']}"
|
|
51
|
+
return f"{key_info['tonic_name']} {key_info['mode'].replace('_', ' ')}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _mode_display(mode: str) -> str:
|
|
55
|
+
"""Format a canonical mode id for user-facing output."""
|
|
56
|
+
return mode.replace("_", " ")
|
|
52
57
|
|
|
53
58
|
|
|
54
59
|
# -- Tool 1: analyze_harmony ------------------------------------------------
|
|
@@ -345,8 +350,9 @@ def identify_scale(
|
|
|
345
350
|
) -> dict:
|
|
346
351
|
"""Identify the scale/mode of a MIDI clip beyond basic major/minor.
|
|
347
352
|
|
|
348
|
-
Uses Krumhansl-Schmuckler
|
|
349
|
-
dorian, phrygian, lydian, mixolydian, locrian
|
|
353
|
+
Uses Krumhansl-Schmuckler-style profiles with 8 mode profiles (major,
|
|
354
|
+
minor, dorian, phrygian, lydian, mixolydian, locrian, and phrygian
|
|
355
|
+
dominant / Hijaz).
|
|
350
356
|
|
|
351
357
|
Returns ranked key matches with confidence scores.
|
|
352
358
|
"""
|
|
@@ -357,17 +363,19 @@ def identify_scale(
|
|
|
357
363
|
detected = engine.detect_key(notes, mode_detection=True)
|
|
358
364
|
|
|
359
365
|
results = [{
|
|
360
|
-
"key": f"{detected['tonic_name']} {detected['mode']}",
|
|
366
|
+
"key": f"{detected['tonic_name']} {_mode_display(detected['mode'])}",
|
|
361
367
|
"confidence": detected["confidence"],
|
|
362
|
-
"mode": detected["mode"],
|
|
368
|
+
"mode": _mode_display(detected["mode"]),
|
|
369
|
+
"mode_id": detected["mode"],
|
|
363
370
|
"tonic": detected["tonic_name"],
|
|
364
371
|
}]
|
|
365
372
|
|
|
366
373
|
for alt in detected.get("alternatives", [])[:7]:
|
|
367
374
|
results.append({
|
|
368
|
-
"key": f"{alt['tonic_name']} {alt['mode']}",
|
|
375
|
+
"key": f"{alt['tonic_name']} {_mode_display(alt['mode'])}",
|
|
369
376
|
"confidence": alt["confidence"],
|
|
370
|
-
"mode": alt["mode"],
|
|
377
|
+
"mode": _mode_display(alt["mode"]),
|
|
378
|
+
"mode_id": alt["mode"],
|
|
371
379
|
"tonic": alt["tonic_name"],
|
|
372
380
|
})
|
|
373
381
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.11",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
5
|
"description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
6
6
|
"author": "Pilot Studio",
|
|
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
|
|
|
5
5
|
when this script is selected in Preferences > Link, Tempo & MIDI.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__ = "1.9.
|
|
8
|
+
__version__ = "1.9.11"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from .server import LivePilotServer
|
|
@@ -494,6 +494,10 @@ def set_arrangement_automation(song, params):
|
|
|
494
494
|
"parameter_type must be 'device', 'volume', 'panning', or 'send'"
|
|
495
495
|
)
|
|
496
496
|
|
|
497
|
+
# Clamp values to parameter range
|
|
498
|
+
p_min = float(parameter.min)
|
|
499
|
+
p_max = float(parameter.max)
|
|
500
|
+
|
|
497
501
|
# Try direct envelope access on the arrangement clip
|
|
498
502
|
envelope = clip.automation_envelope(parameter)
|
|
499
503
|
if envelope is None:
|
|
@@ -509,7 +513,7 @@ def set_arrangement_automation(song, params):
|
|
|
509
513
|
points_written = 0
|
|
510
514
|
for pt in points:
|
|
511
515
|
time = float(pt["time"])
|
|
512
|
-
value = float(pt["value"])
|
|
516
|
+
value = max(p_min, min(p_max, float(pt["value"])))
|
|
513
517
|
duration = float(pt.get("duration", 0.125))
|
|
514
518
|
envelope.insert_step(time, duration, value)
|
|
515
519
|
points_written += 1
|
|
@@ -523,121 +527,14 @@ def set_arrangement_automation(song, params):
|
|
|
523
527
|
"method": "direct",
|
|
524
528
|
}
|
|
525
529
|
|
|
526
|
-
#
|
|
527
|
-
#
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
temp_slot_index = None
|
|
535
|
-
for si, slot in enumerate(slots):
|
|
536
|
-
if not slot.has_clip:
|
|
537
|
-
temp_slot_index = si
|
|
538
|
-
break
|
|
539
|
-
|
|
540
|
-
# If all clip slots are full, create a temporary scene to get an empty slot
|
|
541
|
-
created_temp_scene = False
|
|
542
|
-
if temp_slot_index is None:
|
|
543
|
-
song.create_scene(-1) # append a new scene at the end
|
|
544
|
-
created_temp_scene = True
|
|
545
|
-
# Re-read slots — the new scene added one slot per track
|
|
546
|
-
slots = list(track.clip_slots)
|
|
547
|
-
temp_slot_index = len(slots) - 1
|
|
548
|
-
|
|
549
|
-
song.begin_undo_step()
|
|
550
|
-
try:
|
|
551
|
-
# Create temporary session clip
|
|
552
|
-
slot = slots[temp_slot_index]
|
|
553
|
-
slot.create_clip(arr_length)
|
|
554
|
-
temp_clip = slot.clip
|
|
555
|
-
|
|
556
|
-
# Write automation to the session clip
|
|
557
|
-
temp_envelope = temp_clip.automation_envelope(parameter)
|
|
558
|
-
if temp_envelope is None:
|
|
559
|
-
try:
|
|
560
|
-
temp_envelope = temp_clip.create_automation_envelope(parameter)
|
|
561
|
-
except (AttributeError, RuntimeError):
|
|
562
|
-
pass
|
|
563
|
-
|
|
564
|
-
if temp_envelope is None:
|
|
565
|
-
# Neither direct nor session clip approach works
|
|
566
|
-
slot.delete_clip()
|
|
567
|
-
if created_temp_scene:
|
|
568
|
-
song.delete_scene(len(list(song.scenes)) - 1)
|
|
569
|
-
raise ValueError(
|
|
570
|
-
"Cannot create automation envelope for parameter '%s' "
|
|
571
|
-
"(neither arrangement nor session clip supports it)"
|
|
572
|
-
% parameter.name
|
|
573
|
-
)
|
|
574
|
-
|
|
575
|
-
points_written = 0
|
|
576
|
-
for pt in points:
|
|
577
|
-
time = float(pt["time"])
|
|
578
|
-
value = float(pt["value"])
|
|
579
|
-
duration = float(pt.get("duration", 0.125))
|
|
580
|
-
temp_envelope.insert_step(time, duration, value)
|
|
581
|
-
points_written += 1
|
|
582
|
-
|
|
583
|
-
# Copy notes from original arrangement clip to the temp session clip
|
|
584
|
-
# so the replacement clip has the same musical content.
|
|
585
|
-
try:
|
|
586
|
-
orig_notes = clip.get_notes_extended(0, 128, 0.0, arr_length + 1.0)
|
|
587
|
-
import Live
|
|
588
|
-
note_specs = []
|
|
589
|
-
for note in orig_notes:
|
|
590
|
-
kwargs = dict(
|
|
591
|
-
pitch=note.pitch,
|
|
592
|
-
start_time=note.start_time,
|
|
593
|
-
duration=note.duration,
|
|
594
|
-
velocity=note.velocity,
|
|
595
|
-
mute=note.mute,
|
|
596
|
-
)
|
|
597
|
-
# Preserve per-note expression data
|
|
598
|
-
if hasattr(note, 'probability') and note.probability is not None:
|
|
599
|
-
kwargs['probability'] = note.probability
|
|
600
|
-
if hasattr(note, 'velocity_deviation') and note.velocity_deviation is not None:
|
|
601
|
-
kwargs['velocity_deviation'] = note.velocity_deviation
|
|
602
|
-
if hasattr(note, 'release_velocity') and note.release_velocity is not None:
|
|
603
|
-
kwargs['release_velocity'] = note.release_velocity
|
|
604
|
-
spec = Live.Clip.MidiNoteSpecification(**kwargs)
|
|
605
|
-
note_specs.append(spec)
|
|
606
|
-
if note_specs:
|
|
607
|
-
temp_clip.add_new_notes(tuple(note_specs))
|
|
608
|
-
except Exception:
|
|
609
|
-
pass # Non-MIDI clips or errors — automation-only is still valid
|
|
610
|
-
|
|
611
|
-
# Place the session clip (with automation + notes) into arrangement.
|
|
612
|
-
# Do this BEFORE deleting the original — if placement fails, the
|
|
613
|
-
# original clip is preserved (no data loss on partial failure).
|
|
614
|
-
track.duplicate_clip_to_arrangement(temp_clip, arr_start)
|
|
615
|
-
|
|
616
|
-
# Placement succeeded — now safe to remove the original clip
|
|
617
|
-
# to avoid a second overlapping clip at the same position.
|
|
618
|
-
try:
|
|
619
|
-
clip.delete_clip()
|
|
620
|
-
except (AttributeError, RuntimeError):
|
|
621
|
-
# delete_clip may not exist on arrangement clips in all versions;
|
|
622
|
-
# in that case we accept the overlap as a known limitation.
|
|
623
|
-
pass
|
|
624
|
-
|
|
625
|
-
# Clean up the temporary session clip
|
|
626
|
-
slot.delete_clip()
|
|
627
|
-
|
|
628
|
-
# Clean up the temporary scene if we created one
|
|
629
|
-
if created_temp_scene:
|
|
630
|
-
song.delete_scene(len(list(song.scenes)) - 1)
|
|
631
|
-
finally:
|
|
632
|
-
song.end_undo_step()
|
|
633
|
-
|
|
634
|
-
return {
|
|
635
|
-
"track_index": track_index,
|
|
636
|
-
"clip_index": clip_index,
|
|
637
|
-
"parameter_name": parameter.name,
|
|
638
|
-
"points_written": points_written,
|
|
639
|
-
"method": "session_workaround",
|
|
640
|
-
}
|
|
530
|
+
# No fallback — direct envelope creation is the only safe approach.
|
|
531
|
+
# Session-clip duplication can silently create overlapping clips.
|
|
532
|
+
raise ValueError(
|
|
533
|
+
"Cannot create automation envelope for parameter '%s' on this "
|
|
534
|
+
"arrangement clip. Direct envelope access is not supported for "
|
|
535
|
+
"this parameter type or Live version."
|
|
536
|
+
% parameter.name
|
|
537
|
+
)
|
|
641
538
|
|
|
642
539
|
|
|
643
540
|
@register("transpose_arrangement_notes")
|