livepilot 1.10.5 → 1.10.7
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 +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -14,6 +14,17 @@
|
|
|
14
14
|
* - Chunk parameter reads: 4 per batch, 50ms delay
|
|
15
15
|
* - Base64 encode all JSON responses
|
|
16
16
|
* - Defer all LiveAPI operations via deferlow()
|
|
17
|
+
*
|
|
18
|
+
* OSC address convention:
|
|
19
|
+
* - OUTGOING (this file → server via udpsend): use WITH leading slash,
|
|
20
|
+
* e.g. outlet(0, "/response", encoded). The slash is part of the
|
|
21
|
+
* OSC address that udpsend packs into the packet.
|
|
22
|
+
* - INCOMING (server → this file via udpreceive): Max's udpreceive
|
|
23
|
+
* routes on the selector, so the server's address string must be
|
|
24
|
+
* "response"/"cmd" WITHOUT a leading slash (Max would otherwise
|
|
25
|
+
* treat the slash as a literal selector character). See
|
|
26
|
+
* mcp_server/m4l_bridge.py for the sending side and `_parse_osc`
|
|
27
|
+
* for the tolerant normalization.
|
|
17
28
|
*/
|
|
18
29
|
|
|
19
30
|
autowatch = 1;
|
|
@@ -84,7 +95,7 @@ function anything() {
|
|
|
84
95
|
function dispatch(cmd, args) {
|
|
85
96
|
switch(cmd) {
|
|
86
97
|
case "ping":
|
|
87
|
-
send_response({"ok": true, "version": "1.10.
|
|
98
|
+
send_response({"ok": true, "version": "1.10.6"});
|
|
88
99
|
break;
|
|
89
100
|
case "get_params":
|
|
90
101
|
cmd_get_params(args);
|
|
@@ -172,6 +183,13 @@ function dispatch(cmd, args) {
|
|
|
172
183
|
case "get_plugin_presets":
|
|
173
184
|
cmd_get_plugin_presets(args);
|
|
174
185
|
break;
|
|
186
|
+
// ── BUG-A2 / A3: deep-LOM properties not on the automatable surface ──
|
|
187
|
+
case "simpler_set_warp":
|
|
188
|
+
cmd_simpler_set_warp(args);
|
|
189
|
+
break;
|
|
190
|
+
case "compressor_set_sidechain":
|
|
191
|
+
cmd_compressor_set_sidechain(args);
|
|
192
|
+
break;
|
|
175
193
|
default:
|
|
176
194
|
send_response({"error": "Unknown command: " + cmd});
|
|
177
195
|
}
|
|
@@ -630,8 +648,13 @@ function detect_key() {
|
|
|
630
648
|
detected_key = note_names[best_key];
|
|
631
649
|
detected_scale = best_scale;
|
|
632
650
|
|
|
633
|
-
// Send to UI
|
|
634
|
-
|
|
651
|
+
// Send to UI — use abbreviated scale ("min"/"maj") so text fits in the
|
|
652
|
+
// 72-pixel presentation widget, and pass a SINGLE symbol so Max's
|
|
653
|
+
// [route] + [prepend set] chain doesn't split atoms on the internal
|
|
654
|
+
// space. Max's [comment] displays whatever the `set` message carries.
|
|
655
|
+
var scale_abbr = (detected_scale === "minor") ? "min" : "maj";
|
|
656
|
+
var display = detected_key + " " + scale_abbr; // e.g., "D min"
|
|
657
|
+
outlet(1, "key", display);
|
|
635
658
|
}
|
|
636
659
|
|
|
637
660
|
function correlate(a, b) {
|
|
@@ -980,6 +1003,206 @@ function cmd_simpler_warp(args) {
|
|
|
980
1003
|
}
|
|
981
1004
|
}
|
|
982
1005
|
|
|
1006
|
+
// ── BUG-A2: Simpler warping property + warp_mode ─────────────────────
|
|
1007
|
+
//
|
|
1008
|
+
// Python's Remote Script ControlSurface API only exposes automatable
|
|
1009
|
+
// parameters. Simpler's `warping` and `warp_mode` live on the sample
|
|
1010
|
+
// child object (SimplerDevice.sample.*) — unreachable from the Python
|
|
1011
|
+
// side. Max JS LiveAPI can step INTO the sample child, so we do the
|
|
1012
|
+
// property write here and surface the result to the MCP server.
|
|
1013
|
+
//
|
|
1014
|
+
// args: [track_index, device_index, warp_on (0/1), warp_mode (-1 = leave alone, 0..6)]
|
|
1015
|
+
// warp_mode: 0=Beats, 1=Tones, 2=Texture, 3=Re-Pitch, 4=Complex, 6=Complex Pro
|
|
1016
|
+
//
|
|
1017
|
+
// Returns: {ok, warping, warp_mode} on success, {error} otherwise.
|
|
1018
|
+
function cmd_simpler_set_warp(args) {
|
|
1019
|
+
var track_idx = parseInt(args[0]);
|
|
1020
|
+
var device_idx = parseInt(args[1]);
|
|
1021
|
+
var warp_on = parseInt(args[2]);
|
|
1022
|
+
var warp_mode = args.length > 3 ? parseInt(args[3]) : -1;
|
|
1023
|
+
|
|
1024
|
+
var device_path = build_device_path(track_idx, device_idx);
|
|
1025
|
+
cursor_a.goto(device_path);
|
|
1026
|
+
if (cursor_a.id === 0) {
|
|
1027
|
+
send_response({"error": "Device not found at track " + track_idx + ", device " + device_idx});
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (cursor_a.get("class_name").toString() !== "OriginalSimpler") {
|
|
1031
|
+
send_response({"error": "Not a Simpler device (class is " + cursor_a.get("class_name") + ")"});
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Step into the sample child — warping + warp_mode live there, not on
|
|
1036
|
+
// the device itself.
|
|
1037
|
+
try {
|
|
1038
|
+
cursor_a.goto(device_path + " sample");
|
|
1039
|
+
if (cursor_a.id === 0) {
|
|
1040
|
+
send_response({"error": "Simpler has no sample loaded (warping not applicable)"});
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
cursor_a.set("warping", warp_on ? 1 : 0);
|
|
1044
|
+
if (warp_on && warp_mode >= 0) {
|
|
1045
|
+
cursor_a.set("warp_mode", warp_mode);
|
|
1046
|
+
}
|
|
1047
|
+
// Read back so the caller can confirm the write landed
|
|
1048
|
+
var read_warping = parseInt(cursor_a.get("warping"));
|
|
1049
|
+
var read_warp_mode = parseInt(cursor_a.get("warp_mode"));
|
|
1050
|
+
send_response({
|
|
1051
|
+
"ok": true,
|
|
1052
|
+
"track_index": track_idx,
|
|
1053
|
+
"device_index": device_idx,
|
|
1054
|
+
"warping": read_warping,
|
|
1055
|
+
"warp_mode": read_warp_mode,
|
|
1056
|
+
});
|
|
1057
|
+
} catch(e) {
|
|
1058
|
+
send_response({"error": "simpler_set_warp failed: " + e.message});
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ── BUG-A3: Compressor sidechain input routing ───────────────────────
|
|
1063
|
+
//
|
|
1064
|
+
// Sidechain INPUT ROUTING is exposed as LiveAPI properties on the
|
|
1065
|
+
// Compressor device in Live 11+: sidechain_input_routing_type and
|
|
1066
|
+
// sidechain_input_routing_channel. They don't appear in the automatable
|
|
1067
|
+
// parameter list so the Python Remote Script can't reach them; Max JS
|
|
1068
|
+
// LiveAPI can.
|
|
1069
|
+
//
|
|
1070
|
+
// args: [track_index, device_index, routing_type, routing_channel]
|
|
1071
|
+
// routing_type: string — e.g. "1-Audio From" / track name / "Ext. In"
|
|
1072
|
+
// routing_channel: string — "Post FX" / "Pre FX" / "Post Mixer" / ...
|
|
1073
|
+
//
|
|
1074
|
+
// Returns: {ok, sidechain: {type, channel}} on success.
|
|
1075
|
+
// Older Live versions without these properties return a clean error.
|
|
1076
|
+
function cmd_compressor_set_sidechain(args) {
|
|
1077
|
+
var track_idx = parseInt(args[0]);
|
|
1078
|
+
var device_idx = parseInt(args[1]);
|
|
1079
|
+
var routing_type = String(args[2] || "");
|
|
1080
|
+
var routing_channel = String(args[3] || "");
|
|
1081
|
+
|
|
1082
|
+
var path = build_device_path(track_idx, device_idx);
|
|
1083
|
+
cursor_a.goto(path);
|
|
1084
|
+
if (cursor_a.id === 0) {
|
|
1085
|
+
send_response({"error": "Device not found at track " + track_idx + ", device " + device_idx});
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
var class_name = String(cursor_a.get("class_name"));
|
|
1089
|
+
if (class_name !== "Compressor2" && class_name !== "Compressor") {
|
|
1090
|
+
send_response({"error": "Not a Compressor device (class is " + class_name + ")"});
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
// Helper: read a LiveAPI property that returns a JSON-serialized dict
|
|
1095
|
+
// or list. Max's `get()` wraps results in a single-element array,
|
|
1096
|
+
// and complex properties come back as JSON strings.
|
|
1097
|
+
function read_json_prop(name) {
|
|
1098
|
+
try {
|
|
1099
|
+
var raw = cursor_a.get(name);
|
|
1100
|
+
if (raw === null || raw === undefined) return null;
|
|
1101
|
+
if (Object.prototype.toString.call(raw) === "[object Array]" && raw.length === 1) {
|
|
1102
|
+
raw = raw[0];
|
|
1103
|
+
}
|
|
1104
|
+
if (typeof raw === "string") {
|
|
1105
|
+
try { return JSON.parse(raw); } catch(e) { return raw; }
|
|
1106
|
+
}
|
|
1107
|
+
return raw;
|
|
1108
|
+
} catch(e) {
|
|
1109
|
+
return null;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function find_by_name(list, name) {
|
|
1114
|
+
if (!list || !list.length || !name) return null;
|
|
1115
|
+
for (var i = 0; i < list.length; i++) {
|
|
1116
|
+
var entry = list[i];
|
|
1117
|
+
if (!entry) continue;
|
|
1118
|
+
var n = entry.display_name || entry.name;
|
|
1119
|
+
if (n === name) return entry;
|
|
1120
|
+
}
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
try {
|
|
1125
|
+
// Enable sidechain first — Live rejects routing writes on a
|
|
1126
|
+
// compressor with the sidechain disabled. Property is available
|
|
1127
|
+
// on Live 10+. Try/catch for legacy builds.
|
|
1128
|
+
try { cursor_a.set("sidechain_enabled", 1); } catch(e) {}
|
|
1129
|
+
|
|
1130
|
+
var debug = {};
|
|
1131
|
+
|
|
1132
|
+
// --- Routing TYPE (the source: "1-DRUMS", "Ext. In", "No Input", …)
|
|
1133
|
+
if (routing_type) {
|
|
1134
|
+
var types = read_json_prop("available_sidechain_input_routing_types");
|
|
1135
|
+
debug.requested_type = routing_type;
|
|
1136
|
+
debug.type_count = types && types.length ? types.length : 0;
|
|
1137
|
+
var t_match = find_by_name(types, routing_type);
|
|
1138
|
+
if (t_match && t_match.identifier !== undefined) {
|
|
1139
|
+
// LOM expects a RoutingType object; Max JS accepts a
|
|
1140
|
+
// JSON-encoded {identifier: N} for the `set`.
|
|
1141
|
+
cursor_a.set(
|
|
1142
|
+
"sidechain_input_routing_type",
|
|
1143
|
+
JSON.stringify({identifier: t_match.identifier})
|
|
1144
|
+
);
|
|
1145
|
+
debug.set_type = "ok (identifier=" + t_match.identifier + ")";
|
|
1146
|
+
} else {
|
|
1147
|
+
debug.set_type = "FAIL: no matching type";
|
|
1148
|
+
if (types) {
|
|
1149
|
+
debug.available_types = [];
|
|
1150
|
+
for (var i = 0; i < types.length; i++) {
|
|
1151
|
+
debug.available_types.push(types[i].display_name || types[i].name || "?");
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// --- Routing CHANNEL (the tap point: "Post FX", "Pre FX", …)
|
|
1158
|
+
if (routing_channel) {
|
|
1159
|
+
var channels = read_json_prop("available_sidechain_input_routing_channels");
|
|
1160
|
+
debug.requested_channel = routing_channel;
|
|
1161
|
+
debug.channel_count = channels && channels.length ? channels.length : 0;
|
|
1162
|
+
var c_match = find_by_name(channels, routing_channel);
|
|
1163
|
+
if (c_match && c_match.identifier !== undefined) {
|
|
1164
|
+
cursor_a.set(
|
|
1165
|
+
"sidechain_input_routing_channel",
|
|
1166
|
+
JSON.stringify({identifier: c_match.identifier})
|
|
1167
|
+
);
|
|
1168
|
+
debug.set_channel = "ok (identifier=" + c_match.identifier + ")";
|
|
1169
|
+
} else {
|
|
1170
|
+
debug.set_channel = "FAIL: no matching channel";
|
|
1171
|
+
if (channels) {
|
|
1172
|
+
debug.available_channels = [];
|
|
1173
|
+
for (var j = 0; j < channels.length; j++) {
|
|
1174
|
+
debug.available_channels.push(channels[j].display_name || channels[j].name || "?");
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Read back canonical display_name for confirmation
|
|
1181
|
+
var cur_type = read_json_prop("sidechain_input_routing_type");
|
|
1182
|
+
var cur_channel = read_json_prop("sidechain_input_routing_channel");
|
|
1183
|
+
var read_type_name = (cur_type && cur_type.display_name) || "";
|
|
1184
|
+
var read_channel_name = (cur_channel && cur_channel.display_name) || "";
|
|
1185
|
+
|
|
1186
|
+
send_response({
|
|
1187
|
+
"ok": true,
|
|
1188
|
+
"track_index": track_idx,
|
|
1189
|
+
"device_index": device_idx,
|
|
1190
|
+
"sidechain": {
|
|
1191
|
+
"type": read_type_name,
|
|
1192
|
+
"channel": read_channel_name,
|
|
1193
|
+
"enabled": 1
|
|
1194
|
+
},
|
|
1195
|
+
"debug": debug
|
|
1196
|
+
});
|
|
1197
|
+
} catch(e) {
|
|
1198
|
+
send_response({
|
|
1199
|
+
"error": "compressor_set_sidechain failed: " + e.message
|
|
1200
|
+
+ " (this Live build may not expose sidechain_input_routing_* —"
|
|
1201
|
+
+ " user must set routing manually)"
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
983
1206
|
// ── Phase 2: Warp Markers ─────────────────────────────────────────────
|
|
984
1207
|
|
|
985
1208
|
function cmd_get_warp_markers(args) {
|
package/manifest.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"manifest_version": "0.3",
|
|
3
3
|
"name": "livepilot",
|
|
4
4
|
"display_name": "LivePilot — AI for Ableton Live",
|
|
5
|
-
"version": "1.10.
|
|
6
|
-
"description": "Agentic production system for Ableton Live 12. Make beats, mix tracks, design sounds, and arrange songs with
|
|
7
|
-
"long_description": "LivePilot is an agentic production system for Ableton Live 12.
|
|
5
|
+
"version": "1.10.7",
|
|
6
|
+
"description": "Agentic production system for Ableton Live 12. Make beats, mix tracks, design sounds, and arrange songs with 323 AI-powered tools.",
|
|
7
|
+
"long_description": "LivePilot is an agentic production system for Ableton Live 12. 323 tools across 45 domains — device atlas (1305 devices), sample intelligence (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, and 12 creative engines.\n\n**What it does:**\n- Creates MIDI clips with notes, chords, and rhythms\n- Loads instruments and effects via Device Atlas (1305 devices indexed)\n- Searches samples across Splice, Ableton browser, and filesystem\n- Plans compositions from text prompts with genre-aware layering\n- Slices samples with intent-based MIDI generation\n- Mixes with volume, panning, sends, and automation\n- Analyzes your mix with real-time spectral data (M4L bridge)\n- Diagnoses stuck sessions and generates creative rescue variants\n- Remembers your production style across sessions\n\n**How it works:**\nLivePilot installs a Remote Script in Ableton that communicates with the AI over a local TCP connection. Everything runs on your machine — no audio leaves your computer.",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Pilot Studio",
|
|
10
10
|
"url": "https://github.com/dreamrec/LivePilot"
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.10.
|
|
2
|
+
__version__ = "1.10.7"
|
|
@@ -115,26 +115,70 @@ class AtlasManager:
|
|
|
115
115
|
query_words = query_lower.split()
|
|
116
116
|
results: List[Dict[str, Any]] = []
|
|
117
117
|
|
|
118
|
+
# BUG-B39: the real atlas scanner emits "instruments" /
|
|
119
|
+
# "audio_effects" but older callers and test fixtures sometimes
|
|
120
|
+
# pass the singular "instrument" / "effect". Build a tolerant
|
|
121
|
+
# category alias set so both forms work.
|
|
122
|
+
_CAT_ALIASES = {
|
|
123
|
+
"instrument": {"instrument", "instruments"},
|
|
124
|
+
"instruments": {"instrument", "instruments"},
|
|
125
|
+
"effect": {"effect", "effects", "audio_effects"},
|
|
126
|
+
"effects": {"effect", "effects", "audio_effects"},
|
|
127
|
+
"audio_effect": {"effect", "effects", "audio_effects",
|
|
128
|
+
"audio_effect"},
|
|
129
|
+
"audio_effects": {"effect", "effects", "audio_effects",
|
|
130
|
+
"audio_effect"},
|
|
131
|
+
}
|
|
132
|
+
allowed_cats = (
|
|
133
|
+
_CAT_ALIASES.get(category, {category})
|
|
134
|
+
if category != "all" else None
|
|
135
|
+
)
|
|
136
|
+
|
|
118
137
|
for dev in self._devices:
|
|
119
138
|
# Category filter
|
|
120
|
-
if
|
|
139
|
+
if allowed_cats is not None and dev.get("category", "") not in allowed_cats:
|
|
121
140
|
continue
|
|
122
141
|
|
|
123
142
|
score = 0
|
|
124
143
|
dev_name = dev.get("name", "")
|
|
125
144
|
dev_name_lower = dev_name.lower()
|
|
126
145
|
|
|
127
|
-
# Name scoring:
|
|
146
|
+
# Name scoring. BUG-B41 fix: dropped weight dramatically
|
|
147
|
+
# (was 100 exact / 50 substring) so a device literally
|
|
148
|
+
# named "Bass" no longer blows past character-tag matches
|
|
149
|
+
# for a sonic query like "warm analog bass". An exact name
|
|
150
|
+
# match is still the strongest single signal, but a device
|
|
151
|
+
# with 2+ matching character-tags now beats a name-only
|
|
152
|
+
# accident.
|
|
128
153
|
if dev_name_lower == query_lower:
|
|
129
|
-
score += 100
|
|
154
|
+
score += 45 # was 100
|
|
130
155
|
elif query_lower in dev_name_lower:
|
|
131
|
-
score += 50
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
156
|
+
score += 20 # was 50
|
|
157
|
+
else:
|
|
158
|
+
# Partial: any query word present in name — small signal
|
|
159
|
+
for word in query_words:
|
|
160
|
+
if len(word) >= 3 and word in dev_name_lower:
|
|
161
|
+
score += 5
|
|
162
|
+
|
|
163
|
+
# Tag scoring — prefer enriched character_tags.
|
|
164
|
+
# BUG-B40 / B41: also read character_tags so enriched devices
|
|
165
|
+
# actually compete with name-based matches.
|
|
166
|
+
dev_tags = [
|
|
167
|
+
t.lower() for t in (
|
|
168
|
+
dev.get("character_tags") or dev.get("tags", [])
|
|
169
|
+
)
|
|
170
|
+
]
|
|
171
|
+
# BUG-B41: bumped to 35pts per tag so multi-tag matches beat
|
|
172
|
+
# a single substring-name match.
|
|
135
173
|
for word in query_words:
|
|
136
174
|
if word in dev_tags:
|
|
137
|
-
score +=
|
|
175
|
+
score += 35
|
|
176
|
+
# Partial tag match (word appears as substring in a tag)
|
|
177
|
+
else:
|
|
178
|
+
for tag in dev_tags:
|
|
179
|
+
if word in tag:
|
|
180
|
+
score += 10
|
|
181
|
+
break
|
|
138
182
|
|
|
139
183
|
# Use case scoring: 25pts per match
|
|
140
184
|
for use_case in dev.get("use_cases", []):
|
|
@@ -142,10 +186,11 @@ class AtlasManager:
|
|
|
142
186
|
for word in query_words:
|
|
143
187
|
if word in use_lower:
|
|
144
188
|
score += 25
|
|
145
|
-
break
|
|
189
|
+
break
|
|
146
190
|
|
|
147
|
-
# Genre scoring: 20pts primary, 10pts secondary
|
|
148
|
-
|
|
191
|
+
# Genre scoring: 20pts primary, 10pts secondary.
|
|
192
|
+
# BUG-B40: also read genre_affinity (enriched field).
|
|
193
|
+
genres = dev.get("genre_affinity") or dev.get("genres", {}) or {}
|
|
149
194
|
for genre in genres.get("primary", []):
|
|
150
195
|
if query_lower in genre.lower() or genre.lower() in query_lower:
|
|
151
196
|
score += 20
|
|
@@ -153,8 +198,11 @@ class AtlasManager:
|
|
|
153
198
|
if query_lower in genre.lower() or genre.lower() in query_lower:
|
|
154
199
|
score += 10
|
|
155
200
|
|
|
156
|
-
# Description keyword scoring: 15pts
|
|
157
|
-
|
|
201
|
+
# Description keyword scoring: 15pts.
|
|
202
|
+
# BUG-B40: prefer sonic_description when present.
|
|
203
|
+
description = (
|
|
204
|
+
dev.get("sonic_description") or dev.get("description", "")
|
|
205
|
+
).lower()
|
|
158
206
|
for word in query_words:
|
|
159
207
|
if len(word) >= 3 and word in description:
|
|
160
208
|
score += 15
|
|
@@ -224,7 +272,13 @@ class AtlasManager:
|
|
|
224
272
|
def chain_suggest(
|
|
225
273
|
self, role: str, genre: str = ""
|
|
226
274
|
) -> Dict[str, Any]:
|
|
227
|
-
"""Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad').
|
|
275
|
+
"""Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad').
|
|
276
|
+
|
|
277
|
+
BUG-B39 fix: the old code passed category="instrument" (singular)
|
|
278
|
+
and category="effect" to self.search(), but the atlas stores
|
|
279
|
+
devices with category="instruments" / "audio_effects" (plural).
|
|
280
|
+
Every filtered search missed and the chain came back empty.
|
|
281
|
+
"""
|
|
228
282
|
chain: List[Dict[str, Any]] = []
|
|
229
283
|
position = 0
|
|
230
284
|
|
|
@@ -244,8 +298,8 @@ class AtlasManager:
|
|
|
244
298
|
intent = instrument_intents.get(role_lower, role_lower)
|
|
245
299
|
search_q = f"{intent} {genre}" if genre else intent
|
|
246
300
|
|
|
247
|
-
# Find instrument
|
|
248
|
-
instrument_candidates = self.search(search_q, category="
|
|
301
|
+
# Find instrument — atlas category is "instruments" (plural)
|
|
302
|
+
instrument_candidates = self.search(search_q, category="instruments", limit=3)
|
|
249
303
|
if instrument_candidates:
|
|
250
304
|
best = instrument_candidates[0]["device"]
|
|
251
305
|
chain.append({
|
|
@@ -255,7 +309,7 @@ class AtlasManager:
|
|
|
255
309
|
})
|
|
256
310
|
position += 1
|
|
257
311
|
|
|
258
|
-
# Stage 2: Effects
|
|
312
|
+
# Stage 2: Effects — atlas category is "audio_effects"
|
|
259
313
|
effect_stages = [
|
|
260
314
|
("eq", f"Shape the {role} tone"),
|
|
261
315
|
("compression", f"Control {role} dynamics"),
|
|
@@ -264,7 +318,9 @@ class AtlasManager:
|
|
|
264
318
|
|
|
265
319
|
for effect_type, reason in effect_stages:
|
|
266
320
|
effect_q = f"{effect_type} {genre}" if genre else effect_type
|
|
267
|
-
effect_candidates = self.search(
|
|
321
|
+
effect_candidates = self.search(
|
|
322
|
+
effect_q, category="audio_effects", limit=2,
|
|
323
|
+
)
|
|
268
324
|
if effect_candidates:
|
|
269
325
|
best = effect_candidates[0]["device"]
|
|
270
326
|
chain.append({
|
|
@@ -295,37 +351,48 @@ class AtlasManager:
|
|
|
295
351
|
return {"error": f"Device not found: {device_b}"}
|
|
296
352
|
|
|
297
353
|
def _summarize(dev: Dict[str, Any]) -> Dict[str, Any]:
|
|
354
|
+
# BUG-B40 fix: enriched atlas entries use character_tags /
|
|
355
|
+
# sonic_description / genre_affinity — the old _summarize
|
|
356
|
+
# looked for "tags" / "description" / "genres" which are
|
|
357
|
+
# the UN-enriched raw scanner fields. We prefer enriched
|
|
358
|
+
# fields, fall back to raw when enrichment is absent.
|
|
298
359
|
return {
|
|
299
360
|
"name": dev.get("name", ""),
|
|
300
361
|
"category": dev.get("category", ""),
|
|
301
|
-
"tags": dev.get("tags", []),
|
|
302
|
-
"genres": dev.get("genres", {}),
|
|
362
|
+
"tags": dev.get("character_tags") or dev.get("tags", []),
|
|
363
|
+
"genres": dev.get("genre_affinity") or dev.get("genres", {}),
|
|
303
364
|
"use_cases": dev.get("use_cases", []),
|
|
304
|
-
"description":
|
|
365
|
+
"description": (
|
|
366
|
+
dev.get("sonic_description")
|
|
367
|
+
or dev.get("description", "")
|
|
368
|
+
),
|
|
305
369
|
"cpu_weight": dev.get("cpu_weight", "unknown"),
|
|
306
370
|
"sweet_spot": dev.get("sweet_spot", ""),
|
|
371
|
+
"enriched": dev.get("enriched", False),
|
|
307
372
|
}
|
|
308
373
|
|
|
309
374
|
summary_a = _summarize(dev_a)
|
|
310
375
|
summary_b = _summarize(dev_b)
|
|
311
376
|
|
|
312
|
-
# Recommendation logic: score each for the role
|
|
377
|
+
# Recommendation logic: score each for the role.
|
|
378
|
+
# BUG-B40: scorer also reads the enriched field names.
|
|
313
379
|
score_a = 0
|
|
314
380
|
score_b = 0
|
|
315
381
|
if role:
|
|
316
382
|
role_lower = role.lower()
|
|
317
|
-
# Check use_cases
|
|
318
383
|
for uc in dev_a.get("use_cases", []):
|
|
319
384
|
if role_lower in uc.lower():
|
|
320
385
|
score_a += 20
|
|
321
386
|
for uc in dev_b.get("use_cases", []):
|
|
322
387
|
if role_lower in uc.lower():
|
|
323
388
|
score_b += 20
|
|
324
|
-
#
|
|
325
|
-
|
|
389
|
+
# Tag scoring — prefer character_tags (enriched)
|
|
390
|
+
a_tags = dev_a.get("character_tags") or dev_a.get("tags", [])
|
|
391
|
+
b_tags = dev_b.get("character_tags") or dev_b.get("tags", [])
|
|
392
|
+
for tag in a_tags:
|
|
326
393
|
if role_lower in tag.lower():
|
|
327
394
|
score_a += 10
|
|
328
|
-
for tag in
|
|
395
|
+
for tag in b_tags:
|
|
329
396
|
if role_lower in tag.lower():
|
|
330
397
|
score_b += 10
|
|
331
398
|
|
|
@@ -45,6 +45,10 @@ from pathlib import Path
|
|
|
45
45
|
from typing import Optional, Tuple
|
|
46
46
|
|
|
47
47
|
from .layer_planner import LayerSpec
|
|
48
|
+
import logging
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
48
52
|
|
|
49
53
|
|
|
50
54
|
_AUDIO_EXTENSIONS = (".wav", ".aif", ".aiff", ".flac")
|
|
@@ -216,9 +220,9 @@ async def _splice_resolve(
|
|
|
216
220
|
query=layer.search_query,
|
|
217
221
|
per_page=5,
|
|
218
222
|
)
|
|
219
|
-
except Exception:
|
|
223
|
+
except Exception as exc:
|
|
224
|
+
logger.debug("_splice_resolve failed: %s", exc)
|
|
220
225
|
return None, "unresolved"
|
|
221
|
-
|
|
222
226
|
samples = list(result.samples) if result and hasattr(result, "samples") else []
|
|
223
227
|
if not samples:
|
|
224
228
|
return None, "unresolved"
|
|
@@ -243,7 +247,8 @@ async def _splice_resolve(
|
|
|
243
247
|
downloaded = await splice_client.download_sample(file_hash)
|
|
244
248
|
if downloaded and Path(downloaded).exists():
|
|
245
249
|
return downloaded, "splice_remote"
|
|
246
|
-
except Exception:
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
logger.debug("_splice_resolve failed: %s", exc)
|
|
247
252
|
continue # try next hit
|
|
248
253
|
|
|
249
254
|
return None, "unresolved"
|
|
@@ -286,7 +291,6 @@ async def resolve_sample_for_layer(
|
|
|
286
291
|
lp = hit.get("file_path") if isinstance(hit, dict) else None
|
|
287
292
|
if lp and Path(lp).exists():
|
|
288
293
|
return lp, "browser"
|
|
289
|
-
except Exception:
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
except Exception as exc:
|
|
295
|
+
logger.debug("resolve_sample_for_layer failed: %s", exc)
|
|
292
296
|
return None, "unresolved"
|
|
@@ -14,6 +14,10 @@ from fastmcp import Context
|
|
|
14
14
|
from ..server import mcp
|
|
15
15
|
from .prompt_parser import parse_prompt
|
|
16
16
|
from .engine import ComposerEngine
|
|
17
|
+
import logging
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
17
21
|
|
|
18
22
|
|
|
19
23
|
# Singleton engine — stateless, safe to reuse
|
|
@@ -29,8 +33,8 @@ def _get_search_roots(ctx: Context) -> list:
|
|
|
29
33
|
cfg = ctx.lifespan_context.get("sample_search_roots") if hasattr(ctx, "lifespan_context") else None
|
|
30
34
|
if cfg:
|
|
31
35
|
roots.extend(cfg)
|
|
32
|
-
except Exception:
|
|
33
|
-
|
|
36
|
+
except Exception as exc:
|
|
37
|
+
logger.debug("_get_search_roots failed: %s", exc)
|
|
34
38
|
return roots
|
|
35
39
|
|
|
36
40
|
|
|
@@ -52,7 +56,8 @@ async def _credit_safety_prelude(splice_client, max_credits: int) -> tuple[int,
|
|
|
52
56
|
try:
|
|
53
57
|
info = await splice_client.get_credits()
|
|
54
58
|
credits_remaining = getattr(info, "credits", None)
|
|
55
|
-
except Exception:
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logger.debug("_credit_safety_prelude failed: %s", exc)
|
|
56
61
|
credits_remaining = None
|
|
57
62
|
|
|
58
63
|
if credits_remaining is None:
|
|
@@ -153,9 +158,8 @@ async def augment_with_samples(
|
|
|
153
158
|
info = ableton.send_command("get_session_info", {})
|
|
154
159
|
session_context["tempo"] = info.get("tempo", 120)
|
|
155
160
|
session_context["track_count"] = info.get("track_count", 0)
|
|
156
|
-
except Exception:
|
|
157
|
-
|
|
158
|
-
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
logger.debug("augment_with_samples failed: %s", exc)
|
|
159
163
|
result = await _engine.augment(
|
|
160
164
|
request=request,
|
|
161
165
|
max_credits=max_credits,
|
package/mcp_server/connection.py
CHANGED
|
@@ -11,6 +11,10 @@ import time
|
|
|
11
11
|
import uuid
|
|
12
12
|
from collections import deque
|
|
13
13
|
from typing import Optional
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
CONNECT_TIMEOUT = 5
|
|
16
20
|
RECV_TIMEOUT = 20
|
|
@@ -153,7 +157,8 @@ class AbletonConnection:
|
|
|
153
157
|
"""Send a ping and return True if a pong is received."""
|
|
154
158
|
try:
|
|
155
159
|
return self.send_command("ping").get("pong") is True
|
|
156
|
-
except Exception:
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
logger.debug("ping failed: %s", exc)
|
|
157
162
|
return False
|
|
158
163
|
|
|
159
164
|
def send_command(self, command_type: str, params: Optional[dict] = None) -> dict:
|