livepilot 1.27.1 → 1.27.2
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 +20 -0
- package/README.md +1 -1
- package/bin/livepilot.js +82 -21
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/references/overview.md +1 -1
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +48 -11
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/composer/fast/brief_builder.py +6 -2
- package/mcp_server/composer/tools.py +12 -6
- package/mcp_server/connection.py +2 -1
- package/mcp_server/m4l_bridge.py +148 -55
- package/mcp_server/runtime/execution_router.py +6 -0
- package/mcp_server/runtime/mcp_dispatch.py +22 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/server.py +1 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +21 -8
- package/remote_script/LivePilot/tracks.py +11 -5
- package/server.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v1.27.2 — 2026-06-23
|
|
4
|
+
|
|
5
|
+
Maintenance release: M4L bridge response-correlation + chunk-reassembly hardening, plan-routing fixes, a Python 3.11 floor with clearer install diagnostics, and a Remote Script Group-track crash guard. No change to the tool surface (467 tools / 56 domains). Re-frozen LivePilot_Analyzer.amxd (analyzer reports 1.27.2).
|
|
6
|
+
|
|
7
|
+
### Fixed — M4L bridge
|
|
8
|
+
- Correlate responses by request id across batched reads (`get_params` / `get_hidden_params` / `get_auto_state`): the id is captured per command in the analyzer JS and echoed on both single-packet and chunked responses, so an interleaved or timed-out command can no longer resolve another command's future.
|
|
9
|
+
- Chunk reassembly buckets per request id, bounds-checks the chunk index, and requires every index present before reassembling — fixes a `KeyError` / silent-loss + full-timeout path on duplicate or out-of-range chunks.
|
|
10
|
+
- Non-blocking UDP socket so the miditool response path cannot stall the asyncio event loop; clear the capture future after completion.
|
|
11
|
+
|
|
12
|
+
### Fixed — routing & Remote Script
|
|
13
|
+
- `compressor_set_sidechain` now classifies as an MCP tool (TCP Remote Script path) instead of being mis-routed to the M4L JS bridge; `get_master_rms` is now dispatchable in plans; removed the dead duplicate `get_simpler_file_path` bridge entry.
|
|
14
|
+
- `get_track_info` no longer crashes on Group/Return tracks (guards the LOM-fragile arm/input properties).
|
|
15
|
+
- `reload_handlers` reports per-module reload errors instead of silently swallowing them.
|
|
16
|
+
- Connection retry tears down the stale socket under the lock.
|
|
17
|
+
|
|
18
|
+
### Changed — install & docs
|
|
19
|
+
- Raised the Python floor to 3.11 (numpy/scipy publish no wheels for 3.9/3.10) with a clear pre-flight message; pip failures surface captured output + a version hint, and the expected grpcio-tools resolver warning is pre-announced.
|
|
20
|
+
- `consult_ableton_knowledge` docstrings corrected; fast brief now notes the `mcp__Ableton_Knowledge__` prefix and that the knowledge MCP is optional; fixed an over-permissive tool-name test.
|
|
21
|
+
|
|
22
|
+
|
|
3
23
|
## v1.27.1 — 2026-06-21
|
|
4
24
|
|
|
5
25
|
Maintenance release: 35 verified fixes from a deep multi-agent audit, recursive installed-plugin scanning, and Windows-CI hardening. No change to the tool surface (467 tools / 56 domains).
|
package/README.md
CHANGED
|
@@ -751,7 +751,7 @@ npx livepilot --version # Show version
|
|
|
751
751
|
| Requirement | Minimum |
|
|
752
752
|
|-------------|---------|
|
|
753
753
|
| Ableton Live | **12** (any edition). Suite required for Max for Live bridge and stock instruments |
|
|
754
|
-
| Python | 3.
|
|
754
|
+
| Python | 3.11+ (numpy/scipy ship no wheels for 3.9/3.10) |
|
|
755
755
|
| Node.js | 18+ |
|
|
756
756
|
| OS | macOS / Windows |
|
|
757
757
|
| Splice | Desktop app with downloaded samples (optional — enables SQLite metadata search) |
|
package/bin/livepilot.js
CHANGED
|
@@ -15,6 +15,13 @@ const REQUIREMENTS = path.join(ROOT, "requirements.txt");
|
|
|
15
15
|
// Python detection
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
|
+
// Minimum Python is 3.11, NOT 3.9: numpy>=2.4.6 and scipy>=1.17.1 (see
|
|
19
|
+
// requirements.txt) publish no wheels and declare requires-python ">=3.11".
|
|
20
|
+
// A 3.9/3.10 interpreter passes a looser gate, the venv is created, then
|
|
21
|
+
// `pip install` aborts with a cryptic "no matching distribution" — the exact
|
|
22
|
+
// install failure users hit. Gate here so we fail early with a clear message.
|
|
23
|
+
const MIN_PY_MINOR = 11;
|
|
24
|
+
|
|
18
25
|
function findPython() {
|
|
19
26
|
// On Windows, also try the "py -3" launcher which avoids the
|
|
20
27
|
// Microsoft Store stub that "python3" resolves to.
|
|
@@ -22,6 +29,7 @@ function findPython() {
|
|
|
22
29
|
? ["python", "python3", "py"]
|
|
23
30
|
: ["python3", "python"];
|
|
24
31
|
|
|
32
|
+
let tooOld = null; // highest 3.x below the floor we saw, for a clear error
|
|
25
33
|
for (const cmd of candidates) {
|
|
26
34
|
try {
|
|
27
35
|
const args = cmd === "py" ? ["-3", "--version"] : ["--version"];
|
|
@@ -33,17 +41,26 @@ function findPython() {
|
|
|
33
41
|
if (match) {
|
|
34
42
|
const major = parseInt(match[1], 10);
|
|
35
43
|
const minor = parseInt(match[2], 10);
|
|
36
|
-
if (major === 3 && minor >=
|
|
44
|
+
if (major === 3 && minor >= MIN_PY_MINOR) {
|
|
37
45
|
// For "py" launcher, the actual command to use is "py -3"
|
|
38
46
|
const actualCmd = cmd === "py" ? "py" : cmd;
|
|
39
47
|
const actualArgs = cmd === "py" ? ["-3"] : [];
|
|
40
48
|
return { cmd: actualCmd, version: out, prefixArgs: actualArgs };
|
|
41
49
|
}
|
|
50
|
+
// Found Python 3 but below 3.11 — remember the newest for diagnostics.
|
|
51
|
+
if (major === 3 && (!tooOld || minor > tooOld.minor)) {
|
|
52
|
+
tooOld = { version: out, minor };
|
|
53
|
+
}
|
|
42
54
|
}
|
|
43
55
|
} catch {
|
|
44
56
|
// command not found or failed — try next
|
|
45
57
|
}
|
|
46
58
|
}
|
|
59
|
+
// Signal "present but too old" distinctly from "absent" so callers can give
|
|
60
|
+
// an actionable message instead of a misleading "not found".
|
|
61
|
+
if (tooOld) {
|
|
62
|
+
return { tooOld: true, version: tooOld.version };
|
|
63
|
+
}
|
|
47
64
|
return null;
|
|
48
65
|
}
|
|
49
66
|
|
|
@@ -84,6 +101,41 @@ function findOtherLiveClient(host, port) {
|
|
|
84
101
|
// Virtual environment bootstrap
|
|
85
102
|
// ---------------------------------------------------------------------------
|
|
86
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Run `pip install -r requirements.txt` with captured stderr so a failure
|
|
106
|
+
* yields an actionable message instead of an opaque "Command failed".
|
|
107
|
+
*/
|
|
108
|
+
function pipInstall(venvPy) {
|
|
109
|
+
// One pip resolver warning about grpcio-tools / protobuf<7 is EXPECTED and
|
|
110
|
+
// harmless: LivePilot imports only the pre-generated Splice stubs at runtime,
|
|
111
|
+
// never grpcio-tools. Pre-announce it so it doesn't read as a failure.
|
|
112
|
+
console.error(" (a single grpcio-tools/protobuf resolver warning is expected and safe)");
|
|
113
|
+
try {
|
|
114
|
+
execFileSync(venvPy, ["-m", "pip", "install", "-q", "-r", REQUIREMENTS], {
|
|
115
|
+
cwd: ROOT,
|
|
116
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
117
|
+
encoding: "utf-8",
|
|
118
|
+
timeout: 120000,
|
|
119
|
+
});
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const stderr = (err && err.stderr ? String(err.stderr) : "").trim();
|
|
122
|
+
console.error("");
|
|
123
|
+
console.error("LivePilot: dependency installation failed.");
|
|
124
|
+
if (/No matching distribution|requires-python|Could not find a version/i.test(stderr)) {
|
|
125
|
+
console.error(" Most likely your Python is too old — LivePilot needs Python >= 3.11");
|
|
126
|
+
console.error(" (numpy/scipy ship no wheels for 3.9/3.10). Install 3.11+, delete the");
|
|
127
|
+
console.error(" .venv folder, and retry.");
|
|
128
|
+
}
|
|
129
|
+
const tail = stderr.split("\n").filter(Boolean).slice(-12);
|
|
130
|
+
if (tail.length) {
|
|
131
|
+
console.error("");
|
|
132
|
+
console.error(" pip output (last lines):");
|
|
133
|
+
for (const line of tail) console.error(" " + line);
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
87
139
|
/**
|
|
88
140
|
* Ensure a local .venv exists with dependencies installed.
|
|
89
141
|
* Returns the path to the venv Python binary.
|
|
@@ -104,11 +156,7 @@ function ensureVenv(systemPython, prefixArgs) {
|
|
|
104
156
|
} catch {
|
|
105
157
|
// venv exists but deps missing — reinstall
|
|
106
158
|
console.error("LivePilot: reinstalling Python dependencies...");
|
|
107
|
-
|
|
108
|
-
cwd: ROOT,
|
|
109
|
-
stdio: ["pipe", "pipe", "inherit"],
|
|
110
|
-
timeout: 120000,
|
|
111
|
-
});
|
|
159
|
+
pipInstall(venvPy);
|
|
112
160
|
return venvPy;
|
|
113
161
|
}
|
|
114
162
|
}
|
|
@@ -122,11 +170,7 @@ function ensureVenv(systemPython, prefixArgs) {
|
|
|
122
170
|
});
|
|
123
171
|
|
|
124
172
|
console.error("LivePilot: installing dependencies...");
|
|
125
|
-
|
|
126
|
-
cwd: ROOT,
|
|
127
|
-
stdio: ["pipe", "pipe", "inherit"],
|
|
128
|
-
timeout: 120000,
|
|
129
|
-
});
|
|
173
|
+
pipInstall(venvPython());
|
|
130
174
|
|
|
131
175
|
return venvPython();
|
|
132
176
|
}
|
|
@@ -262,11 +306,15 @@ async function doctor() {
|
|
|
262
306
|
|
|
263
307
|
// 1. Python
|
|
264
308
|
const pyInfo = findPython();
|
|
265
|
-
if (pyInfo) {
|
|
309
|
+
if (pyInfo && !pyInfo.tooOld) {
|
|
266
310
|
console.log(" Python: %s (%s)", pyInfo.version, pyInfo.cmd);
|
|
311
|
+
} else if (pyInfo && pyInfo.tooOld) {
|
|
312
|
+
console.log(" Python: %s found, but LivePilot needs >= 3.11", pyInfo.version);
|
|
313
|
+
console.log(" Fix: install Python 3.11+ (numpy/scipy ship no wheels for 3.9/3.10)");
|
|
314
|
+
ok = false;
|
|
267
315
|
} else {
|
|
268
|
-
console.log(" Python: NOT FOUND (need >= 3.
|
|
269
|
-
console.log(" Fix: install Python 3.
|
|
316
|
+
console.log(" Python: NOT FOUND (need >= 3.11)");
|
|
317
|
+
console.log(" Fix: install Python 3.11+ and add to PATH");
|
|
270
318
|
ok = false;
|
|
271
319
|
}
|
|
272
320
|
|
|
@@ -686,10 +734,15 @@ async function setup() {
|
|
|
686
734
|
// 1. Python
|
|
687
735
|
console.log("Step 1/5: Checking Python...");
|
|
688
736
|
const pyInfo = findPython();
|
|
689
|
-
if (pyInfo) {
|
|
737
|
+
if (pyInfo && !pyInfo.tooOld) {
|
|
690
738
|
console.log(" ✓ %s", pyInfo.version);
|
|
739
|
+
} else if (pyInfo && pyInfo.tooOld) {
|
|
740
|
+
console.log(" ✗ %s found, but LivePilot needs Python >= 3.11", pyInfo.version);
|
|
741
|
+
console.log(" (numpy/scipy ship no wheels for 3.9/3.10 — pip would fail)");
|
|
742
|
+
console.log(" Install: brew install python@3.12 (macOS) or python.org (Windows)");
|
|
743
|
+
ok = false;
|
|
691
744
|
} else {
|
|
692
|
-
console.log(" ✗ Python >= 3.
|
|
745
|
+
console.log(" ✗ Python >= 3.11 not found");
|
|
693
746
|
console.log(" Install: brew install python@3.12 (macOS) or python.org (Windows)");
|
|
694
747
|
ok = false;
|
|
695
748
|
}
|
|
@@ -722,7 +775,7 @@ async function setup() {
|
|
|
722
775
|
// 3. Bootstrap Python venv
|
|
723
776
|
console.log("");
|
|
724
777
|
console.log("Step 3/5: Setting up Python environment...");
|
|
725
|
-
if (pyInfo) {
|
|
778
|
+
if (pyInfo && !pyInfo.tooOld) {
|
|
726
779
|
try {
|
|
727
780
|
ensureVenv(pyInfo.cmd, pyInfo.prefixArgs);
|
|
728
781
|
console.log(" ✓ Virtual environment ready");
|
|
@@ -730,6 +783,8 @@ async function setup() {
|
|
|
730
783
|
console.log(" ✗ Failed: %s", err.message);
|
|
731
784
|
ok = false;
|
|
732
785
|
}
|
|
786
|
+
} else if (pyInfo && pyInfo.tooOld) {
|
|
787
|
+
console.log(" ⊘ Skipped (%s found, need Python >= 3.11)", pyInfo.version);
|
|
733
788
|
} else {
|
|
734
789
|
console.log(" ⊘ Skipped (no Python)");
|
|
735
790
|
}
|
|
@@ -917,12 +972,18 @@ async function main() {
|
|
|
917
972
|
|
|
918
973
|
// Default: start MCP server
|
|
919
974
|
const pyInfo = findPython();
|
|
920
|
-
if (!pyInfo) {
|
|
921
|
-
|
|
975
|
+
if (!pyInfo || pyInfo.tooOld) {
|
|
976
|
+
if (pyInfo && pyInfo.tooOld) {
|
|
977
|
+
console.error("Error: found %s, but LivePilot requires Python >= 3.11.", pyInfo.version);
|
|
978
|
+
console.error(" numpy>=2.4.6 and scipy>=1.17.1 publish no wheels for Python 3.9/3.10,");
|
|
979
|
+
console.error(" so dependency installation would fail. Install Python 3.11 or newer.");
|
|
980
|
+
} else {
|
|
981
|
+
console.error("Error: Python >= 3.11 is required but was not found.");
|
|
982
|
+
console.error(" Install Python 3.11+ and ensure 'python3' or 'python' is on your PATH.");
|
|
983
|
+
}
|
|
922
984
|
console.error("");
|
|
923
|
-
console.error("Install Python 3.9+ and ensure 'python3' or 'python' is on your PATH.");
|
|
924
985
|
console.error(" macOS: brew install python@3.12");
|
|
925
|
-
console.error(" Ubuntu: sudo apt install python3");
|
|
986
|
+
console.error(" Ubuntu: sudo apt install python3.12");
|
|
926
987
|
console.error(" Windows: https://www.python.org/downloads/");
|
|
927
988
|
process.exit(1);
|
|
928
989
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.27.
|
|
3
|
+
"version": "1.27.2",
|
|
4
4
|
"description": "Agentic production system for Ableton Live 12 \u2014 467 tools, 56 domains, 44 semantic moves, device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pilot Studio"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.27.
|
|
3
|
+
"version": "1.27.2",
|
|
4
4
|
"description": "Agentic production system for Ableton Live 12 \u2014 467 tools, 56 domains, 44 semantic moves, device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pilot Studio"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# LivePilot v1.27.
|
|
1
|
+
# LivePilot v1.27.2 — Architecture & Tool Reference
|
|
2
2
|
|
|
3
3
|
Agentic production system for Ableton Live 12. 467 tools across 56 domains. Device atlas (5264 devices, 120 enriched, 47 with aesthetic-tagged `signature_techniques`), spectral perception (M4L analyzer with 9-band FFT — sub_low / sub / low / low_mid / mid / high_mid / high / presence / air), technique memory, automation intelligence (16 curve types, 15 recipes), music theory (Krumhansl-Schmuckler, species counterpoint), generative algorithms (Euclidean rhythm, tintinnabuli, phase shift, additive process), neo-Riemannian harmony (PRL transforms, Tonnetz), MIDI file I/O, **LIVE Splice describe-a-sound + variations via captured GraphQL endpoints (v1.17)**, drum-rack pad-by-pad construction, live dead-device detection via meter sampling, role-aware Simpler defaults, session-record arrangement-automation workaround.
|
|
4
4
|
|
|
@@ -173,7 +173,7 @@ Live 12.4 introduces a new capability tier that unlocks native LOM access for sa
|
|
|
173
173
|
**Tool signatures:** unchanged. Callers do not need to detect the tier —
|
|
174
174
|
routing is transparent.
|
|
175
175
|
|
|
176
|
-
**Probe-first 12.4 surfaces in v1.27.
|
|
176
|
+
**Probe-first 12.4 surfaces in v1.27.2:**
|
|
177
177
|
- `probe_link_audio()` reports observed peer/input/routing visibility and
|
|
178
178
|
returns `manual_only`, `readable`, `routable`, or `unavailable`.
|
|
179
179
|
- `probe_stem_workflow()` reports observed callable paths and returns
|
|
Binary file
|
|
@@ -34,7 +34,7 @@ outlets = 2; // 0: to udpsend (responses), 1: to buffer~/status
|
|
|
34
34
|
// Single source of truth for the bridge version — bumped alongside the
|
|
35
35
|
// rest of the release manifest. Surfaced in the UI via messnamed("livepilot_version", ...)
|
|
36
36
|
// so the frozen .amxd visibly reports which build it was last exported from.
|
|
37
|
-
var VERSION = "1.27.
|
|
37
|
+
var VERSION = "1.27.2";
|
|
38
38
|
|
|
39
39
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
40
40
|
|
|
@@ -45,6 +45,7 @@ var pitch_history = []; // Rolling buffer for key detection
|
|
|
45
45
|
var MAX_PITCH_HISTORY = 128;
|
|
46
46
|
var detected_key = "";
|
|
47
47
|
var detected_scale = "";
|
|
48
|
+
var current_response_request_id = "";
|
|
48
49
|
|
|
49
50
|
// Capture state
|
|
50
51
|
var capture_active = false;
|
|
@@ -85,9 +86,19 @@ function anything() {
|
|
|
85
86
|
var cmd = messagename;
|
|
86
87
|
if (cmd.charAt(0) === "/") cmd = cmd.substring(1);
|
|
87
88
|
var args = _decode_arg_strings(arrayfromargs(arguments));
|
|
89
|
+
var request_id = "";
|
|
90
|
+
if (args.length > 0 && typeof args[args.length - 1] === "string") {
|
|
91
|
+
var last_arg = args[args.length - 1];
|
|
92
|
+
var request_prefix = "__livepilot_request_id:";
|
|
93
|
+
if (last_arg.indexOf(request_prefix) === 0) {
|
|
94
|
+
request_id = last_arg.substring(request_prefix.length);
|
|
95
|
+
args.pop();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
88
98
|
|
|
89
99
|
// Defer to low-priority thread for LiveAPI safety
|
|
90
100
|
var task = new Task(function() {
|
|
101
|
+
current_response_request_id = request_id;
|
|
91
102
|
try {
|
|
92
103
|
dispatch(cmd, args);
|
|
93
104
|
} catch(e) {
|
|
@@ -223,6 +234,11 @@ function cmd_get_params(args) {
|
|
|
223
234
|
|
|
224
235
|
var batch_size = 8;
|
|
225
236
|
var current = 0;
|
|
237
|
+
// Capture the request id now: read_batch reschedules itself via
|
|
238
|
+
// Task.schedule(20), and an interleaved command arriving in that gap can
|
|
239
|
+
// overwrite the module-level current_response_request_id before the final
|
|
240
|
+
// send_response fires. The closure keeps OUR id stable across batches.
|
|
241
|
+
var captured_request_id = current_response_request_id;
|
|
226
242
|
|
|
227
243
|
function read_batch() {
|
|
228
244
|
try {
|
|
@@ -246,7 +262,7 @@ function cmd_get_params(args) {
|
|
|
246
262
|
var next_task = new Task(read_batch);
|
|
247
263
|
next_task.schedule(20);
|
|
248
264
|
} else {
|
|
249
|
-
send_response({"track": track_idx, "device": device_idx, "params": params});
|
|
265
|
+
send_response({"track": track_idx, "device": device_idx, "params": params}, captured_request_id);
|
|
250
266
|
}
|
|
251
267
|
} catch (e) {
|
|
252
268
|
send_response({
|
|
@@ -254,7 +270,7 @@ function cmd_get_params(args) {
|
|
|
254
270
|
"track": track_idx,
|
|
255
271
|
"device": device_idx,
|
|
256
272
|
"partial_params": params
|
|
257
|
-
});
|
|
273
|
+
}, captured_request_id);
|
|
258
274
|
}
|
|
259
275
|
}
|
|
260
276
|
|
|
@@ -275,6 +291,9 @@ function cmd_get_hidden_params(args) {
|
|
|
275
291
|
var params = [];
|
|
276
292
|
var current = 0;
|
|
277
293
|
var batch_size = 8;
|
|
294
|
+
// See cmd_get_params: capture the request id so the closure survives the
|
|
295
|
+
// Task.schedule(20) gaps without being clobbered by an interleaved command.
|
|
296
|
+
var captured_request_id = current_response_request_id;
|
|
278
297
|
|
|
279
298
|
function read_batch() {
|
|
280
299
|
try {
|
|
@@ -307,7 +326,7 @@ function cmd_get_hidden_params(args) {
|
|
|
307
326
|
"device_name": device_name,
|
|
308
327
|
"total_params": param_count,
|
|
309
328
|
"params": params
|
|
310
|
-
});
|
|
329
|
+
}, captured_request_id);
|
|
311
330
|
}
|
|
312
331
|
} catch (e) {
|
|
313
332
|
send_response({
|
|
@@ -316,7 +335,7 @@ function cmd_get_hidden_params(args) {
|
|
|
316
335
|
"device": device_idx,
|
|
317
336
|
"device_name": device_name,
|
|
318
337
|
"partial_params": params
|
|
319
|
-
});
|
|
338
|
+
}, captured_request_id);
|
|
320
339
|
}
|
|
321
340
|
}
|
|
322
341
|
|
|
@@ -334,6 +353,9 @@ function cmd_get_auto_state(args) {
|
|
|
334
353
|
var results = [];
|
|
335
354
|
var current = 0;
|
|
336
355
|
var batch_size = 8;
|
|
356
|
+
// See cmd_get_params: capture the request id so the closure survives the
|
|
357
|
+
// Task.schedule(20) gaps without being clobbered by an interleaved command.
|
|
358
|
+
var captured_request_id = current_response_request_id;
|
|
337
359
|
|
|
338
360
|
function read_batch() {
|
|
339
361
|
try {
|
|
@@ -362,7 +384,7 @@ function cmd_get_auto_state(args) {
|
|
|
362
384
|
"total_params": param_count,
|
|
363
385
|
"automated_params": results,
|
|
364
386
|
"automated_count": results.length
|
|
365
|
-
});
|
|
387
|
+
}, captured_request_id);
|
|
366
388
|
}
|
|
367
389
|
} catch (e) {
|
|
368
390
|
send_response({
|
|
@@ -372,7 +394,7 @@ function cmd_get_auto_state(args) {
|
|
|
372
394
|
"total_params": param_count,
|
|
373
395
|
"automated_params": results,
|
|
374
396
|
"automated_count": results.length
|
|
375
|
-
});
|
|
397
|
+
}, captured_request_id);
|
|
376
398
|
}
|
|
377
399
|
}
|
|
378
400
|
|
|
@@ -689,22 +711,37 @@ function correlate(a, b) {
|
|
|
689
711
|
|
|
690
712
|
// ── Response Encoding ──────────────────────────────────────────────────────
|
|
691
713
|
|
|
692
|
-
function send_response(obj) {
|
|
693
|
-
var
|
|
714
|
+
function send_response(obj, explicit_id) {
|
|
715
|
+
var response = obj || {};
|
|
716
|
+
// Async/batched handlers (cmd_get_params et al.) capture the request id in
|
|
717
|
+
// a closure and pass it explicitly here, because the module-level
|
|
718
|
+
// current_response_request_id can be clobbered by an interleaved command
|
|
719
|
+
// that arrives during a Task.schedule() gap between batches.
|
|
720
|
+
var rid = (typeof explicit_id === "string" && explicit_id !== "")
|
|
721
|
+
? explicit_id
|
|
722
|
+
: current_response_request_id;
|
|
723
|
+
if (rid) {
|
|
724
|
+
response._livepilot_request_id = rid;
|
|
725
|
+
}
|
|
726
|
+
var json = JSON.stringify(response);
|
|
694
727
|
var encoded = base64_encode(json);
|
|
695
728
|
|
|
696
729
|
// Check if chunking needed (Max OSC packet limit ~8KB)
|
|
697
730
|
if (encoded.length < 1400) {
|
|
698
731
|
outlet(0, "/response", encoded);
|
|
699
732
|
} else {
|
|
700
|
-
// Split into chunks
|
|
733
|
+
// Split into chunks. The request id rides the chunk header (first arg)
|
|
734
|
+
// so the Python side buckets chunks per-response and never mixes two
|
|
735
|
+
// commands' chunks — the id inside the JSON can't be read until the
|
|
736
|
+
// chunks are reassembled.
|
|
701
737
|
var chunk_size = 1400;
|
|
702
738
|
var total = Math.ceil(encoded.length / chunk_size);
|
|
703
739
|
for (var i = 0; i < total; i++) {
|
|
704
740
|
var piece = encoded.substring(i * chunk_size, (i + 1) * chunk_size);
|
|
705
|
-
outlet(0, "/response_chunk", i, total, piece);
|
|
741
|
+
outlet(0, "/response_chunk", (rid || ""), i, total, piece);
|
|
706
742
|
}
|
|
707
743
|
}
|
|
744
|
+
current_response_request_id = "";
|
|
708
745
|
}
|
|
709
746
|
|
|
710
747
|
function base64_encode(str) {
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.27.
|
|
2
|
+
__version__ = "1.27.2"
|
|
@@ -1402,8 +1402,12 @@ def build_creative_brief(
|
|
|
1402
1402
|
" — those are filtered out before the brief reaches you.\n"
|
|
1403
1403
|
"5. **TIER-1: Fire each search in `recommended_searches` BEFORE designing\n"
|
|
1404
1404
|
" that role.** Each entry gives you a (tool, query) pair — call the named\n"
|
|
1405
|
-
" Ableton Knowledge MCP tool
|
|
1406
|
-
"
|
|
1405
|
+
" Ableton Knowledge MCP tool with the `mcp__Ableton_Knowledge__` prefix\n"
|
|
1406
|
+
" (e.g. search_live_manual → mcp__Ableton_Knowledge__search_live_manual).\n"
|
|
1407
|
+
" Most queries hit the Live manual or video tutorials; capture 1 useful\n"
|
|
1408
|
+
" snippet per role and apply it. The Ableton Knowledge MCP is OPTIONAL —\n"
|
|
1409
|
+
" if any mcp__Ableton_Knowledge__* tool is unavailable or returns nothing,\n"
|
|
1410
|
+
" skip it and proceed using the creative guidance alone.\n"
|
|
1407
1411
|
"6. **TIER-2: If `reference_artist` is set**, also fire each search in\n"
|
|
1408
1412
|
" `reference_searches` and design USING that artist's signature techniques.\n"
|
|
1409
1413
|
"7. For each layer: pick ONE uri from instruments_by_role[role], design\n"
|
|
@@ -378,12 +378,18 @@ def consult_ableton_knowledge(
|
|
|
378
378
|
template, and surfaces sources alongside the answer.
|
|
379
379
|
|
|
380
380
|
Examples:
|
|
381
|
-
consult_ableton_knowledge("
|
|
382
|
-
→
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
381
|
+
consult_ableton_knowledge("what does the Saturator Drive knob do?")
|
|
382
|
+
→ intent: device
|
|
383
|
+
→ plan: [search_live_manual("what does the Saturator Drive knob do?"),
|
|
384
|
+
search_videos("what does the Saturator Drive knob do?"),
|
|
385
|
+
search_transcripts("what does the Saturator Drive knob do?")]
|
|
386
|
+
+ synthesis template
|
|
387
|
+
consult_ableton_knowledge("how do I make my kick punchier?", {"current_genre": "techno"})
|
|
388
|
+
→ intent: sound_design
|
|
389
|
+
→ plan: [search_transcripts("techno how do I make my kick punchier?"),
|
|
390
|
+
search_videos("techno how do I make my kick punchier? tutorial"),
|
|
391
|
+
search_knowledge_base("how do I make my kick punchier?")]
|
|
392
|
+
+ synthesis template
|
|
387
393
|
|
|
388
394
|
session_context (optional): {
|
|
389
395
|
"current_genre": "techno",
|
package/mcp_server/connection.py
CHANGED
|
@@ -258,7 +258,8 @@ class AbletonConnection:
|
|
|
258
258
|
needs_retry = fresh_connect and _is_single_client_state_error(response)
|
|
259
259
|
|
|
260
260
|
if needs_retry:
|
|
261
|
-
self.
|
|
261
|
+
with self._lock:
|
|
262
|
+
self.disconnect()
|
|
262
263
|
time.sleep(SINGLE_CLIENT_RETRY_DELAY)
|
|
263
264
|
with self._lock:
|
|
264
265
|
self.connect()
|
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -30,6 +30,7 @@ import socket
|
|
|
30
30
|
import struct
|
|
31
31
|
import threading
|
|
32
32
|
import time
|
|
33
|
+
import uuid
|
|
33
34
|
from typing import Any, Callable, Optional
|
|
34
35
|
|
|
35
36
|
|
|
@@ -477,7 +478,10 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
477
478
|
/rms f — RMS level
|
|
478
479
|
/pitch f f — MIDI note, amplitude
|
|
479
480
|
/response s — base64-encoded JSON (single packet)
|
|
480
|
-
/response_chunk i i s
|
|
481
|
+
/response_chunk [s] i i s — chunked response. New builds prefix the
|
|
482
|
+
per-response request id so chunks of
|
|
483
|
+
different commands never share a bucket;
|
|
484
|
+
legacy builds omit it: (index, total, data)
|
|
481
485
|
"""
|
|
482
486
|
|
|
483
487
|
# Band names keyed by how many bands the .amxd emits. 8 bands is the v1.x
|
|
@@ -499,6 +503,12 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
499
503
|
self._chunk_id = 0
|
|
500
504
|
self._chunk_key: Optional[str] = None # Key of the single active reassembly bucket
|
|
501
505
|
self._response_callback: Optional[asyncio.Future] = None
|
|
506
|
+
self._response_request_id: Optional[str] = None
|
|
507
|
+
# Set True the first time a response arrives carrying a request id.
|
|
508
|
+
# Once the analyzer build is known to stamp ids, a response that
|
|
509
|
+
# arrives with NO id (or a mismatched one) while a future is live is a
|
|
510
|
+
# stale straggler and must be dropped — see _handle_response.
|
|
511
|
+
self._seen_request_id = False
|
|
502
512
|
self._capture_future: Optional[asyncio.Future] = None
|
|
503
513
|
self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
|
|
504
514
|
|
|
@@ -653,7 +663,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
653
663
|
self._handle_response(str(args[0]))
|
|
654
664
|
|
|
655
665
|
elif address == "/response_chunk" and len(args) >= 3:
|
|
656
|
-
|
|
666
|
+
# New builds prefix the request id (string): (rid, index, total, data).
|
|
667
|
+
# Legacy builds send (index, total, data). Distinguish by the type
|
|
668
|
+
# of the first arg — OSC ints decode to int, the id to a str.
|
|
669
|
+
if len(args) >= 4 and isinstance(args[0], str):
|
|
670
|
+
self._handle_chunk(
|
|
671
|
+
int(args[1]), int(args[2]), str(args[3]),
|
|
672
|
+
request_id=str(args[0]),
|
|
673
|
+
)
|
|
674
|
+
else:
|
|
675
|
+
self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
|
|
657
676
|
|
|
658
677
|
elif address == "/miditool/request" and len(args) >= 1:
|
|
659
678
|
self._handle_miditool_request(str(args[0]))
|
|
@@ -665,24 +684,52 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
665
684
|
def _handle_response(self, encoded: str) -> None:
|
|
666
685
|
"""Decode a single-packet base64 response.
|
|
667
686
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
687
|
+
Updated analyzer JS echoes ``_livepilot_request_id`` on every response
|
|
688
|
+
(single-packet here, and on the chunk header for chunked responses).
|
|
689
|
+
Correlation rules, once this device is known to stamp ids:
|
|
690
|
+
* a response whose id does NOT match the in-flight future is dropped;
|
|
691
|
+
* a response with NO id at all is also dropped — it is a stale
|
|
692
|
+
straggler (e.g. a batched read that outlived its timeout), not a
|
|
693
|
+
pre-request-id build.
|
|
694
|
+
Pre-request-id builds (which never stamp an id) stay supported: until
|
|
695
|
+
the first id is ever seen, no-id responses are accepted.
|
|
674
696
|
"""
|
|
675
697
|
try:
|
|
676
698
|
# URL-safe base64 decode (- and _ instead of + and /)
|
|
677
699
|
padded = encoded + "=" * (-len(encoded) % 4)
|
|
678
700
|
decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
|
|
679
701
|
result = _normalize_bridge_payload(json.loads(decoded))
|
|
702
|
+
response_request_id = None
|
|
703
|
+
if isinstance(result, dict):
|
|
704
|
+
response_request_id = result.pop("_livepilot_request_id", None)
|
|
705
|
+
|
|
706
|
+
if response_request_id is not None:
|
|
707
|
+
# This analyzer build stamps request ids — remember it so a
|
|
708
|
+
# later no-id straggler is recognised as stale rather than
|
|
709
|
+
# mistaken for an old build that never sends ids.
|
|
710
|
+
self._seen_request_id = True
|
|
711
|
+
|
|
680
712
|
cb = self._response_callback
|
|
713
|
+
expected_request_id = self._response_request_id
|
|
714
|
+
if cb and not cb.done() and expected_request_id is not None:
|
|
715
|
+
mismatched = (
|
|
716
|
+
response_request_id is not None
|
|
717
|
+
and str(response_request_id) != str(expected_request_id)
|
|
718
|
+
)
|
|
719
|
+
missing_but_expected = (
|
|
720
|
+
response_request_id is None and self._seen_request_id
|
|
721
|
+
)
|
|
722
|
+
if mismatched or missing_but_expected:
|
|
723
|
+
# Stale/uncorrelated reply — drop it and keep waiting for
|
|
724
|
+
# the response that actually matches this command.
|
|
725
|
+
return
|
|
726
|
+
|
|
681
727
|
if cb and not cb.done():
|
|
682
728
|
cb.set_result(result)
|
|
683
729
|
# Clear regardless — either we consumed it, or it was already
|
|
684
730
|
# done/abandoned. Future packets with no owner get dropped.
|
|
685
731
|
self._response_callback = None
|
|
732
|
+
self._response_request_id = None
|
|
686
733
|
except Exception as exc:
|
|
687
734
|
import sys
|
|
688
735
|
print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
|
|
@@ -718,49 +765,62 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
718
765
|
import sys
|
|
719
766
|
print(f"LivePilot: miditool handler error: {exc}", file=sys.stderr)
|
|
720
767
|
|
|
721
|
-
def _handle_chunk(
|
|
768
|
+
def _handle_chunk(
|
|
769
|
+
self,
|
|
770
|
+
index: int,
|
|
771
|
+
total: int,
|
|
772
|
+
encoded: str,
|
|
773
|
+
request_id: Optional[str] = None,
|
|
774
|
+
) -> None:
|
|
722
775
|
"""Reassemble chunked responses.
|
|
723
776
|
|
|
724
|
-
|
|
725
|
-
``
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
777
|
+
New analyzer builds stamp the per-response request id on every
|
|
778
|
+
``/response_chunk`` header, so chunks from different commands land in
|
|
779
|
+
SEPARATE buckets and can never be interleaved — even if a stale chunk
|
|
780
|
+
from a timed-out command arrives mid-flight. Legacy builds omit the id
|
|
781
|
+
(``request_id`` None/""); they fall back to a single rolling bucket,
|
|
782
|
+
which is safe because ``send_command`` serialises on ``_cmd_lock``.
|
|
783
|
+
|
|
784
|
+
Hardening (v1.27.2): an index outside ``[0, total)`` is dropped — a
|
|
785
|
+
duplicate or malformed packet must never count toward completion — and
|
|
786
|
+
reassembly only fires once EVERY index ``0..total-1`` is present. The
|
|
787
|
+
old ``len(parts) == total`` check could KeyError on a duplicate or
|
|
788
|
+
out-of-range index, silently losing the response and forcing a full
|
|
789
|
+
timeout.
|
|
736
790
|
"""
|
|
737
|
-
#
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
self._chunks
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
791
|
+
# Drop chunks that cannot belong to a well-formed response.
|
|
792
|
+
if total <= 0 or index < 0 or index >= total:
|
|
793
|
+
return
|
|
794
|
+
|
|
795
|
+
if request_id:
|
|
796
|
+
# Collision-free per-response bucket.
|
|
797
|
+
key = f"rid:{request_id}"
|
|
798
|
+
active = self._chunks.get(key)
|
|
799
|
+
if active is None or active["total"] != total:
|
|
800
|
+
self._chunks[key] = {"parts": {}, "total": total}
|
|
801
|
+
self._chunk_times[key] = time.monotonic()
|
|
802
|
+
self._chunk_key = key
|
|
803
|
+
else:
|
|
804
|
+
# Legacy single-active-bucket model (no id on the wire). A chunk
|
|
805
|
+
# with a different `total` means a new response started (e.g. the
|
|
806
|
+
# previous one timed out without ever completing) — evict + reopen.
|
|
807
|
+
active = self._chunks.get(self._chunk_key)
|
|
808
|
+
if active is None or active["total"] != total:
|
|
809
|
+
if active is not None:
|
|
810
|
+
self._chunks.pop(self._chunk_key, None)
|
|
811
|
+
self._chunk_times.pop(self._chunk_key, None)
|
|
812
|
+
self._chunk_id += 1
|
|
813
|
+
self._chunk_key = str(self._chunk_id)
|
|
814
|
+
self._chunks[self._chunk_key] = {"parts": {}, "total": total}
|
|
815
|
+
self._chunk_times[self._chunk_key] = time.monotonic()
|
|
816
|
+
key = self._chunk_key
|
|
817
|
+
|
|
818
|
+
parts = self._chunks[key]["parts"]
|
|
819
|
+
parts[index] = encoded
|
|
820
|
+
|
|
821
|
+
if len(parts) == total and all(i in parts for i in range(total)):
|
|
822
|
+
# Every chunk present — reassemble in order.
|
|
823
|
+
full = "".join(parts[i] for i in range(total))
|
|
764
824
|
del self._chunks[key]
|
|
765
825
|
self._chunk_times.pop(key, None)
|
|
766
826
|
self._handle_response(full)
|
|
@@ -780,13 +840,22 @@ class SpectralReceiver(asyncio.DatagramProtocol):
|
|
|
780
840
|
result = _normalize_bridge_payload(json.loads(decoded))
|
|
781
841
|
if self._capture_future and not self._capture_future.done():
|
|
782
842
|
self._capture_future.set_result(result)
|
|
843
|
+
# Clear the reference so a completed future isn't retained until the
|
|
844
|
+
# next capture, and so send_capture's done()-guarded cancel has no
|
|
845
|
+
# stale object to consider.
|
|
846
|
+
self._capture_future = None
|
|
783
847
|
except Exception as exc:
|
|
784
848
|
import sys
|
|
785
849
|
print(f"LivePilot: failed to decode capture response: {exc}", file=sys.stderr)
|
|
786
850
|
|
|
787
|
-
def set_response_future(
|
|
851
|
+
def set_response_future(
|
|
852
|
+
self,
|
|
853
|
+
future: Optional[asyncio.Future],
|
|
854
|
+
request_id: Optional[str] = None,
|
|
855
|
+
) -> None:
|
|
788
856
|
"""Set a future to be resolved with the next response."""
|
|
789
857
|
self._response_callback = future
|
|
858
|
+
self._response_request_id = request_id if future is not None else None
|
|
790
859
|
|
|
791
860
|
def set_capture_future(self, future: asyncio.Future) -> None:
|
|
792
861
|
"""Set a future to be resolved when a capture_complete OSC arrives."""
|
|
@@ -810,6 +879,12 @@ class M4LBridge:
|
|
|
810
879
|
self.receiver = receiver
|
|
811
880
|
self.miditool_cache = miditool_cache
|
|
812
881
|
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
882
|
+
# Non-blocking so a momentarily-full send buffer never stalls the
|
|
883
|
+
# asyncio event loop. The miditool response path sends from inside a
|
|
884
|
+
# DatagramProtocol callback that runs ON the loop thread — a blocking
|
|
885
|
+
# sendto there would freeze every pending coroutine. UDP loopback sends
|
|
886
|
+
# essentially never block; _safe_sendto guards the rare case anyway.
|
|
887
|
+
self._sock.setblocking(False)
|
|
813
888
|
self._m4l_addr = ("127.0.0.1", 9881)
|
|
814
889
|
self._cmd_lock: Optional[asyncio.Lock] = None
|
|
815
890
|
# BUG-audit-C1: send_capture uses _capture_future, which is
|
|
@@ -825,6 +900,22 @@ class M4LBridge:
|
|
|
825
900
|
if self.receiver.miditool_cache is None and miditool_cache is not None:
|
|
826
901
|
self.receiver.miditool_cache = miditool_cache
|
|
827
902
|
|
|
903
|
+
def _safe_sendto(self, data: bytes) -> None:
|
|
904
|
+
"""Send a UDP packet without ever blocking the event loop.
|
|
905
|
+
|
|
906
|
+
The socket is non-blocking; if the OS send buffer is momentarily full
|
|
907
|
+
(vanishingly rare on loopback) sendto raises BlockingIOError. We drop
|
|
908
|
+
the packet rather than stall — the caller's timeout/retry handles it.
|
|
909
|
+
"""
|
|
910
|
+
try:
|
|
911
|
+
self._sock.sendto(data, self._m4l_addr)
|
|
912
|
+
except BlockingIOError:
|
|
913
|
+
import sys
|
|
914
|
+
print(
|
|
915
|
+
"LivePilot: M4L UDP send buffer full — packet dropped",
|
|
916
|
+
file=sys.stderr,
|
|
917
|
+
)
|
|
918
|
+
|
|
828
919
|
def _dispatch_miditool_request(
|
|
829
920
|
self, request_id: str, context: dict, notes: list,
|
|
830
921
|
) -> None:
|
|
@@ -869,7 +960,7 @@ class M4LBridge:
|
|
|
869
960
|
"""
|
|
870
961
|
payload = {"tool_name": tool_name or "", "params": params or {}}
|
|
871
962
|
osc = self._build_osc("miditool/config", (json.dumps(payload),))
|
|
872
|
-
self.
|
|
963
|
+
self._safe_sendto(osc)
|
|
873
964
|
|
|
874
965
|
def send_miditool_response(self, request_id: str, notes: list) -> None:
|
|
875
966
|
"""Send transformed notes back to the JS bridge.
|
|
@@ -879,7 +970,7 @@ class M4LBridge:
|
|
|
879
970
|
"""
|
|
880
971
|
payload = {"request_id": str(request_id or ""), "notes": list(notes or [])}
|
|
881
972
|
osc = self._build_osc("miditool/response", (json.dumps(payload),))
|
|
882
|
-
self.
|
|
973
|
+
self._safe_sendto(osc)
|
|
883
974
|
|
|
884
975
|
async def send_command(self, command: str, *args: Any, timeout: float = 5.0) -> dict:
|
|
885
976
|
"""Send an OSC command to the M4L device and wait for the response."""
|
|
@@ -905,12 +996,14 @@ class M4LBridge:
|
|
|
905
996
|
# Create a future for the response
|
|
906
997
|
loop = asyncio.get_running_loop()
|
|
907
998
|
future = loop.create_future()
|
|
908
|
-
|
|
999
|
+
request_id = uuid.uuid4().hex
|
|
1000
|
+
self.receiver.set_response_future(future, request_id=request_id)
|
|
909
1001
|
|
|
910
1002
|
# Build and send OSC message (no leading / — Max udpreceive
|
|
911
1003
|
# passes messagename with / intact to JS, breaking dispatch)
|
|
912
|
-
|
|
913
|
-
self.
|
|
1004
|
+
request_arg = f"__livepilot_request_id:{request_id}"
|
|
1005
|
+
osc_data = self._build_osc(command, (*args, request_arg))
|
|
1006
|
+
self._safe_sendto(osc_data)
|
|
914
1007
|
|
|
915
1008
|
# Wait for response with timeout
|
|
916
1009
|
try:
|
|
@@ -958,7 +1051,7 @@ class M4LBridge:
|
|
|
958
1051
|
self.receiver.set_capture_future(future)
|
|
959
1052
|
|
|
960
1053
|
osc_data = self._build_osc(command, args)
|
|
961
|
-
self.
|
|
1054
|
+
self._safe_sendto(osc_data)
|
|
962
1055
|
|
|
963
1056
|
try:
|
|
964
1057
|
result = await asyncio.wait_for(future, timeout=timeout)
|
|
@@ -66,6 +66,12 @@ MCP_TOOLS: frozenset[str] = frozenset({
|
|
|
66
66
|
# set_drum_chain_note + insert_device + replace_sample_native. Used by
|
|
67
67
|
# the create_drum_rack_pad semantic move.
|
|
68
68
|
"add_drum_rack_pad",
|
|
69
|
+
# Routing-correctness (v1.27.2): these have @mcp.tool wrappers that call
|
|
70
|
+
# the TCP Remote Script / read the SpectralCache in-process. They must
|
|
71
|
+
# classify as mcp_tool so plan steps take the SAME path as direct callers
|
|
72
|
+
# — not the M4L JS bridge, and not the "unknown" dead-end.
|
|
73
|
+
"compressor_set_sidechain", # was mis-listed in BRIDGE_COMMANDS → JS bridge
|
|
74
|
+
"get_master_rms", # was READ_ONLY but unclassified → plan failure
|
|
69
75
|
})
|
|
70
76
|
|
|
71
77
|
|
|
@@ -153,6 +153,25 @@ async def _add_drum_rack_pad(params: dict, ctx: Any = None) -> dict:
|
|
|
153
153
|
return await _call(add_drum_rack_pad, ctx, params)
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
# ── Routing-correctness (v1.27.2) ─────────────────────────────────────────
|
|
157
|
+
#
|
|
158
|
+
# Both have @mcp.tool wrappers in tools/analyzer.py. compressor_set_sidechain
|
|
159
|
+
# was mis-listed in BRIDGE_COMMANDS, so plan steps silently routed to the M4L
|
|
160
|
+
# JS bridge while direct callers used the TCP Remote Script — divergent paths
|
|
161
|
+
# with different error handling. get_master_rms was tagged READ_ONLY but never
|
|
162
|
+
# classified (classify_step returned "unknown"), so plans could not use it at
|
|
163
|
+
# all. Both now dispatch in-process here, matching their direct @mcp.tool path.
|
|
164
|
+
|
|
165
|
+
async def _compressor_set_sidechain(params: dict, ctx: Any = None) -> dict:
|
|
166
|
+
from ..tools.analyzer import compressor_set_sidechain
|
|
167
|
+
return await _call(compressor_set_sidechain, ctx, params)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _get_master_rms(params: dict, ctx: Any = None) -> dict:
|
|
171
|
+
from ..tools.analyzer import get_master_rms
|
|
172
|
+
return await _call(get_master_rms, ctx, params)
|
|
173
|
+
|
|
174
|
+
|
|
156
175
|
def build_mcp_dispatch_registry() -> dict[str, Callable]:
|
|
157
176
|
"""Return the canonical registry of MCP-only tools for plan execution.
|
|
158
177
|
|
|
@@ -186,4 +205,7 @@ def build_mcp_dispatch_registry() -> dict[str, Callable]:
|
|
|
186
205
|
"add_session_memory": _add_session_memory,
|
|
187
206
|
# v1.20 — drum rack pad construction (async orchestrator).
|
|
188
207
|
"add_drum_rack_pad": _add_drum_rack_pad,
|
|
208
|
+
# v1.27.2 — routing-correctness (see adapters above).
|
|
209
|
+
"compressor_set_sidechain": _compressor_set_sidechain,
|
|
210
|
+
"get_master_rms": _get_master_rms,
|
|
189
211
|
}
|
|
@@ -139,9 +139,11 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
|
|
|
139
139
|
"get_params", "get_hidden_params", "get_auto_state", "walk_rack",
|
|
140
140
|
"get_chains_deep", "get_track_cpu", "get_selected", "get_key",
|
|
141
141
|
"get_clip_file_path", "replace_simpler_sample", "get_simpler_slices",
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
# NOTE: get_simpler_file_path is NOT here — it lives in REMOTE_COMMANDS
|
|
143
|
+
# (the primary Python LOM path since v1.23.3). classify_step checks
|
|
144
|
+
# REMOTE first, so listing it here too was dead for dispatch. The
|
|
145
|
+
# backwards-compat bridge call still exists, but analyzer.py invokes it
|
|
146
|
+
# directly via bridge.send_command(), bypassing classify_step.
|
|
145
147
|
"crop_simpler", "reverse_simpler", "warp_simpler",
|
|
146
148
|
"get_warp_markers", "add_warp_marker", "move_warp_marker",
|
|
147
149
|
"remove_warp_marker", "capture_audio", "capture_stop",
|
|
@@ -152,7 +154,10 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
|
|
|
152
154
|
# only Max JS LiveAPI exposes). See mcp_server/tools/analyzer.py for
|
|
153
155
|
# the matching MCP tools that route through bridge.send_command.
|
|
154
156
|
"simpler_set_warp",
|
|
155
|
-
|
|
157
|
+
# NOTE: compressor_set_sidechain is NOT here — its @mcp.tool wrapper calls
|
|
158
|
+
# the TCP Remote Script ("set_compressor_sidechain", the BUG-A3 Python
|
|
159
|
+
# path), so it belongs in MCP_TOOLS. Listing it here routed plan steps to
|
|
160
|
+
# the M4L JS bridge, a divergent path. Moved to execution_router.MCP_TOOLS.
|
|
156
161
|
# NOTE: load_sample_to_simpler used to live here, but it's actually an
|
|
157
162
|
# async Python MCP tool in mcp_server/tools/analyzer.py, not a bridge
|
|
158
163
|
# command. It has no case in livepilot_bridge.js and no @register handler
|
package/mcp_server/server.py
CHANGED
|
@@ -362,7 +362,7 @@ def _get_all_tools():
|
|
|
362
362
|
to the next path rather than exploding.
|
|
363
363
|
|
|
364
364
|
WARNING: Accesses FastMCP private internals. Pinned to
|
|
365
|
-
fastmcp>=3.
|
|
365
|
+
fastmcp>=3.4.2,<3.5.0 in requirements.txt. The startup self-test
|
|
366
366
|
(_assert_tool_registry_accessible) will fail loudly if every probe
|
|
367
367
|
returns empty — better than silently returning [] and disabling
|
|
368
368
|
schema coercion.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.27.
|
|
3
|
+
"version": "1.27.2",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
5
|
"description": "Agentic production system for Ableton Live 12 — 467 tools, 56 domains, 44 semantic moves. Device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, 12 creative intelligence engines",
|
|
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.27.
|
|
8
|
+
__version__ = "1.27.2"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|
|
@@ -98,22 +98,28 @@ def _force_reload_handlers(cs=None):
|
|
|
98
98
|
except Exception:
|
|
99
99
|
pass
|
|
100
100
|
|
|
101
|
+
failures = []
|
|
102
|
+
|
|
101
103
|
try:
|
|
102
104
|
importlib.reload(router)
|
|
103
105
|
except Exception as exc:
|
|
104
|
-
|
|
106
|
+
msg = "reload(router) FAILED — %s: %s" % (type(exc).__name__, exc)
|
|
107
|
+
_log(msg)
|
|
108
|
+
failures.append(("LivePilot.router", "%s: %s" % (type(exc).__name__, exc)))
|
|
105
109
|
|
|
106
110
|
try:
|
|
107
111
|
importlib.reload(utils)
|
|
108
112
|
except Exception as exc:
|
|
109
|
-
|
|
113
|
+
msg = "reload(utils) FAILED — %s: %s" % (type(exc).__name__, exc)
|
|
114
|
+
_log(msg)
|
|
115
|
+
failures.append(("LivePilot.utils", "%s: %s" % (type(exc).__name__, exc)))
|
|
110
116
|
|
|
111
117
|
# Invalidate caches so iter_modules sees newly-added files even if
|
|
112
118
|
# an importer cached the previous directory listing.
|
|
113
119
|
importlib.invalidate_caches()
|
|
114
120
|
pkg = _sys.modules.get("LivePilot")
|
|
115
121
|
if pkg is None or getattr(pkg, "__path__", None) is None:
|
|
116
|
-
return
|
|
122
|
+
return {"discovered": 0, "reloaded": 0, "first_imported": 0, "failures": failures}
|
|
117
123
|
|
|
118
124
|
discovered = reloaded = first_imported = 0
|
|
119
125
|
for _finder, modname, _is_pkg in pkgutil.iter_modules(pkg.__path__):
|
|
@@ -130,8 +136,9 @@ def _force_reload_handlers(cs=None):
|
|
|
130
136
|
importlib.import_module(full_name)
|
|
131
137
|
first_imported += 1
|
|
132
138
|
except Exception as exc:
|
|
133
|
-
|
|
134
|
-
|
|
139
|
+
msg = "reload(%s) FAILED — %s: %s" % (full_name, type(exc).__name__, exc)
|
|
140
|
+
_log(msg)
|
|
141
|
+
failures.append((full_name, "%s: %s" % (type(exc).__name__, exc)))
|
|
135
142
|
|
|
136
143
|
# reload_handlers_cmd lives in __init__.py (not a handler module),
|
|
137
144
|
# so the step-3 loop does not cover it. Re-register manually.
|
|
@@ -140,15 +147,21 @@ def _force_reload_handlers(cs=None):
|
|
|
140
147
|
_log("reload complete — %d discovered (%d reloaded, %d first-imported)" % (
|
|
141
148
|
discovered, reloaded, first_imported))
|
|
142
149
|
|
|
150
|
+
return {"discovered": discovered, "reloaded": reloaded,
|
|
151
|
+
"first_imported": first_imported, "failures": failures}
|
|
152
|
+
|
|
143
153
|
|
|
144
154
|
def reload_handlers_cmd(song, params):
|
|
145
155
|
"""TCP-accessible reload trigger. Lets automation refresh handlers
|
|
146
156
|
without a UI Control Surface toggle — the core dev-loop improvement.
|
|
147
|
-
Returns the handler count so the caller can assert before/after.
|
|
148
|
-
|
|
157
|
+
Returns the handler count so the caller can assert before/after.
|
|
158
|
+
Returns any reload failures so the caller can detect silent errors."""
|
|
159
|
+
result = _force_reload_handlers(cs=None)
|
|
160
|
+
failures = result.get("failures", []) if isinstance(result, dict) else []
|
|
149
161
|
return {
|
|
150
162
|
"reloaded": True,
|
|
151
163
|
"handler_count": len(router._handlers),
|
|
164
|
+
"errors": [{"module": m, "error": e} for m, e in failures],
|
|
152
165
|
}
|
|
153
166
|
|
|
154
167
|
|
|
@@ -91,12 +91,18 @@ def get_track_info(song, params):
|
|
|
91
91
|
if track.is_foldable:
|
|
92
92
|
result["fold_state"] = bool(track.fold_state)
|
|
93
93
|
|
|
94
|
-
# Regular tracks
|
|
94
|
+
# Regular tracks expose arm + input type; Group/Return/Master tracks raise
|
|
95
|
+
# RuntimeError on these LOM properties. hasattr() does NOT catch it (Live
|
|
96
|
+
# raises rather than omitting the attribute), so a Group track at a positive
|
|
97
|
+
# index used to crash get_track_info. Guard each access — mirrors the
|
|
98
|
+
# transport.py get_session_info fix (commit 4aec8e6, closing PR #35).
|
|
95
99
|
if track_index >= 0:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
for _prop in ("arm", "has_midi_input", "has_audio_input",
|
|
101
|
+
"current_monitoring_state"):
|
|
102
|
+
try:
|
|
103
|
+
result[_prop] = getattr(track, _prop)
|
|
104
|
+
except Exception:
|
|
105
|
+
result[_prop] = None
|
|
100
106
|
else:
|
|
101
107
|
result["arm"] = None
|
|
102
108
|
result["has_midi_input"] = None
|