livepilot 1.20.1 → 1.20.3
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 +140 -0
- package/README.md +6 -6
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/connection.py +46 -9
- package/mcp_server/runtime/execution_router.py +16 -0
- package/mcp_server/semantic_moves/compiler.py +8 -0
- package/mcp_server/semantic_moves/device_creation_compilers.py +61 -10
- package/mcp_server/semantic_moves/mix_compilers.py +11 -3
- package/mcp_server/semantic_moves/sample_compilers.py +88 -9
- package/mcp_server/semantic_moves/tools.py +4 -0
- package/mcp_server/tools/analyzer.py +125 -0
- package/mcp_server/tools/devices.py +91 -5
- package/mcp_server/tools/tracks.py +62 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,145 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.3 — Automated analyzer pre-flight (April 24 2026)
|
|
4
|
+
|
|
5
|
+
Micro-release closing one class of operator error that broke the v1.20.1
|
|
6
|
+
five-project live-test campaign: the LLM operator had a clear memory
|
|
7
|
+
instruction to load `LivePilot_Analyzer` on master at the start of a
|
|
8
|
+
fresh Ableton session but missed it in 5 of 5 projects — producing
|
|
9
|
+
basic mixes instead of the intended mix-polish outcomes because every
|
|
10
|
+
analyzer-gated move (`tighten_low_end`, `sculpt_midrange`,
|
|
11
|
+
`balance_stereo_image`, etc.) silently degraded. Fixed forward with a
|
|
12
|
+
new idempotent pre-flight tool + Director skill wiring.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
**New tool: `ensure_analyzer_on_master`** (`mcp_server/tools/analyzer.py`).
|
|
17
|
+
Idempotent pre-flight that loads `LivePilot_Analyzer.amxd` on master
|
|
18
|
+
when missing, no-ops when already loaded. Returns one of:
|
|
19
|
+
- `already_loaded` (with `is_last_on_master`, `duplicate_count`)
|
|
20
|
+
- `loaded` (first-time load from Ableton browser)
|
|
21
|
+
- `install_required` (device not in browser — actionable hint points at
|
|
22
|
+
`install_m4l_device`)
|
|
23
|
+
- `failed` (any other error)
|
|
24
|
+
|
|
25
|
+
Post-load report surfaces the CLAUDE.md invariant "LivePilot_Analyzer
|
|
26
|
+
must be LAST on master" via `is_last_on_master: bool` and warns when
|
|
27
|
+
violated. Duplicate-count warning covers the edge case of multiple
|
|
28
|
+
analyzers on the master chain.
|
|
29
|
+
|
|
30
|
+
Safe to call every turn — subsequent calls short-circuit via one
|
|
31
|
+
`get_master_track` read. Tool count: 429 → 430. 6 new contract tests
|
|
32
|
+
covering already-loaded, missing-loads, install-required,
|
|
33
|
+
duplicate-handling, is-last warning, and two-call idempotence.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
**Director Phase 1** (`livepilot-creative-director/SKILL.md`). Added
|
|
38
|
+
`ensure_analyzer_on_master` at the top of the "Ground" reads as a
|
|
39
|
+
REQUIRED call, ahead of `get_session_info`. Wording explicitly connects
|
|
40
|
+
the step to the failure it prevents so future agents don't rationalize
|
|
41
|
+
skipping it: "Skipping it is how the v1.20.1 live-test campaign
|
|
42
|
+
produced basic mixes — the analyzer-gated moves degrade silently when
|
|
43
|
+
there's no master spectrum to read."
|
|
44
|
+
|
|
45
|
+
### Notes
|
|
46
|
+
|
|
47
|
+
No breaking changes. Calling code that assumed the analyzer was loaded
|
|
48
|
+
continues to work; the new tool adds an explicit pre-flight path.
|
|
49
|
+
`install_m4l_device` contract unchanged.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## 1.20.2 — 5 bugs + 1 race condition from the live-test campaign (April 24 2026)
|
|
54
|
+
|
|
55
|
+
Patch release fixing every issue surfaced during the v1.20.1 five-project
|
|
56
|
+
live-test campaign documented at `~/Desktop/DREAM AI/demo Project/REPORT.md`.
|
|
57
|
+
Each fix landed as its own atomic commit with TDD contract tests. Full
|
|
58
|
+
test suite: 2985 → 3037 pass (+52 new tests), zero regressions.
|
|
59
|
+
|
|
60
|
+
### Fixes
|
|
61
|
+
|
|
62
|
+
**🐛 #1 — Device Forge: all 7 `create_*` moves ship broken (CRITICAL).**
|
|
63
|
+
Each move's `plan_template` emitted `generate_m4l_effect` WITHOUT the
|
|
64
|
+
required `gen_code` argument, so every move failed with `missing 1
|
|
65
|
+
required positional argument: 'gen_code'` in explore mode. The 7
|
|
66
|
+
GenExpr templates already existed in `mcp_server/device_forge/
|
|
67
|
+
templates.py` (lorenz_attractor, wavefolder, bitcrusher, etc.) but
|
|
68
|
+
weren't wired. Fix: `device_creation_compilers._MOVE_TO_TEMPLATE`
|
|
69
|
+
routes each move_id to its template and the compiler injects `gen_code`
|
|
70
|
+
at compile time. (commit `61abbeb`)
|
|
71
|
+
|
|
72
|
+
**🐛 #2 — Sample family: `{sample_file_path}` template placeholder leaked
|
|
73
|
+
to compiled plans.** `_resolve_sample_path` returned a literal
|
|
74
|
+
`"{sample_file_path}"` string when the kernel had no path set —
|
|
75
|
+
falling through to `load_sample_to_simpler` with a non-existent file.
|
|
76
|
+
Fix: resolver now reads `seed_args["file_path"]` (v1.20 convention),
|
|
77
|
+
falls back to legacy `kernel["sample_file_path"]` (wonder_mode
|
|
78
|
+
setter), returns `None` on miss. Each of the 6 sample moves rejects
|
|
79
|
+
with a non-executable plan + actionable warning when path is None.
|
|
80
|
+
(commit `26de33c`)
|
|
81
|
+
|
|
82
|
+
**🐛 #3 — Analyzer-gated moves hard-fail their mutation steps.**
|
|
83
|
+
`tighten_low_end` and `make_kick_bass_lock` emitted
|
|
84
|
+
`get_master_spectrum` as a pre-read. When the analyzer wasn't loaded
|
|
85
|
+
on master, step 0 failed and `execute_plan_steps_async`
|
|
86
|
+
`stop_on_failure=True` halted the plan BEFORE the mutation steps
|
|
87
|
+
(bass volume change) ran. Fix: general `CompiledStep.optional: bool`
|
|
88
|
+
field + router skip-and-continue on optional failures; affected
|
|
89
|
+
compilers tag their analyzer pre-reads as `optional=True`. The
|
|
90
|
+
mechanism is reusable for any future soft-gated diagnostic step.
|
|
91
|
+
(commit `5f9f0ae`)
|
|
92
|
+
|
|
93
|
+
**🐛 #4 — `batch_set_parameters` silently snaps quantized enum params.**
|
|
94
|
+
Beat Repeat's `Gate=0.3` and `Variation=0.8` became `Gate=0` / `Variation=0`
|
|
95
|
+
— valid snaps for quantized enum params, but the response gave callers
|
|
96
|
+
no signal that their intent was discarded. Fix: `batch_set_parameters`
|
|
97
|
+
post-processes Ableton's response, comparing requested vs returned
|
|
98
|
+
values with 1e-5 epsilon; appends a `snapped_params` list when
|
|
99
|
+
mismatches occur, each carrying `{name, requested, actual,
|
|
100
|
+
display_value, value_string}`. Empty list = nothing snapped.
|
|
101
|
+
(commit `b472976`)
|
|
102
|
+
|
|
103
|
+
**🐛 #5 — `create_midi_track` can create duplicate-name tracks silently.**
|
|
104
|
+
When `set_track_name(2, "Pad")` runs and then `create_midi_track(index=2,
|
|
105
|
+
name="Pad")` shifts the existing track to index 4 while retaining its
|
|
106
|
+
name, the session ends up with two "Pad" tracks. Downstream
|
|
107
|
+
`find_tracks_by_role` matches both and mix moves apply twice. Fix:
|
|
108
|
+
`create_midi_track` and `create_audio_track` now pre-query session
|
|
109
|
+
for tracks with the requested name and stamp the response with
|
|
110
|
+
`name_collision: bool` + `existing_tracks_with_same_name: list[int]`.
|
|
111
|
+
Doesn't block creation — callers decide whether to rename or accept.
|
|
112
|
+
(commit `69bc545`)
|
|
113
|
+
|
|
114
|
+
**🔁 Race condition — "Connection closed by Ableton" on UI transitions.**
|
|
115
|
+
Observed 3× during the campaign: after `Cmd+N` (new live set), the
|
|
116
|
+
next MCP call would drop with `Connection closed by Ableton`.
|
|
117
|
+
Ableton's Remote Script briefly rejects commands during UI state
|
|
118
|
+
transitions. Fix: `connection.send_command` now retries once with
|
|
119
|
+
400ms backoff on that specific error, reconnecting between attempts.
|
|
120
|
+
Timeouts still raise immediately (mutation-duplicate risk). Retry
|
|
121
|
+
budget capped at 1 — second failure raises cleanly. (commit `cf019d5`)
|
|
122
|
+
|
|
123
|
+
### Scope of the campaign
|
|
124
|
+
|
|
125
|
+
See `~/Desktop/DREAM AI/demo Project/` for the 5 `.als` files, `PLAN.md`,
|
|
126
|
+
and `REPORT.md` that produced this backlog:
|
|
127
|
+
- `01 basic-channel-dub.als` — dub techno @ 130
|
|
128
|
+
- `02 dilla-swing-drums.als` — hip-hop @ 90 with MIDI-native swing
|
|
129
|
+
- `03 opn-wonder-texture.als` — ambient @ 70 (Device Forge failure noted)
|
|
130
|
+
- `04 aphex-destruction.als` — IDM @ 155 (Beat Repeat snap noted)
|
|
131
|
+
- `05 mix-polish.als` — house @ 125 (analyzer-gate failure noted)
|
|
132
|
+
|
|
133
|
+
### CI status
|
|
134
|
+
|
|
135
|
+
All 9 CI jobs expected green: python-tests × {ubuntu, macos, windows} ×
|
|
136
|
+
{3.11, 3.12}, metadata-drift, amxd-freeze-drift, js-entrypoint.
|
|
137
|
+
|
|
138
|
+
### Non-goals
|
|
139
|
+
|
|
140
|
+
No new moves in v1.20.2 — every change is a fix to existing surfaces.
|
|
141
|
+
v1.21 remains the consolidation release (see `docs/plans/v1.21-structural-plan.md`).
|
|
142
|
+
|
|
3
143
|
## 1.20.1 — CI hardening: Windows UTF-8 encoding + .amxd ping drift (April 24 2026)
|
|
4
144
|
|
|
5
145
|
Patch release fixing CI regressions that v1.20.0 shipped with (caught
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
<p align="center">
|
|
19
19
|
An agentic production system for Ableton Live 12.<br>
|
|
20
|
-
|
|
20
|
+
430 tools. 53 domains. Device atlas. Plan-aware Splice integration. Auto-composition. Spectral perception. Technique memory. Drum-rack pad builder. Live dead-device detection.
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
23
|
<br>
|
|
@@ -80,7 +80,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
|
|
|
80
80
|
│ └─────────────────┼──────────────────┘ │
|
|
81
81
|
│ ▼ │
|
|
82
82
|
│ ┌─────────────────┐ │
|
|
83
|
-
│ │
|
|
83
|
+
│ │ 430 MCP Tools │ │
|
|
84
84
|
│ │ 53 domains │ │
|
|
85
85
|
│ └────────┬────────┘ │
|
|
86
86
|
│ │ │
|
|
@@ -121,7 +121,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
|
|
|
121
121
|
|
|
122
122
|
## The Intelligence Layer
|
|
123
123
|
|
|
124
|
-
12 engines sit on top of the
|
|
124
|
+
12 engines sit on top of the 430 tools. They give the AI musical judgment, not just musical execution.
|
|
125
125
|
|
|
126
126
|
### SongBrain — What the Song Is
|
|
127
127
|
|
|
@@ -173,7 +173,7 @@ Every engine follows: **measure before → act → measure after → compare**.
|
|
|
173
173
|
|
|
174
174
|
## Tools
|
|
175
175
|
|
|
176
|
-
|
|
176
|
+
430 tools across 53 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
|
|
177
177
|
|
|
178
178
|
<br>
|
|
179
179
|
|
|
@@ -362,7 +362,7 @@ The V2 intelligence layer. These tools analyze, diagnose, plan, evaluate, and le
|
|
|
362
362
|
| Creative Constraints | 5 | constraint activation, reference-inspired variants |
|
|
363
363
|
| Preview Studio | 5 | variant creation, preview rendering, comparison, commit |
|
|
364
364
|
|
|
365
|
-
> **[View all
|
|
365
|
+
> **[View all 430 tools →](docs/manual/tool-catalog.md)**
|
|
366
366
|
|
|
367
367
|
<br>
|
|
368
368
|
|
|
@@ -589,7 +589,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture details, code guidelines
|
|
|
589
589
|
|
|
590
590
|
| Document | What's inside |
|
|
591
591
|
|----------|---------------|
|
|
592
|
-
| [Manual](docs/manual/index.md) | Complete reference: architecture, all
|
|
592
|
+
| [Manual](docs/manual/index.md) | Complete reference: architecture, all 430 tools, workflows |
|
|
593
593
|
| [Intelligence Layer](docs/manual/intelligence.md) | How the 12 engines connect — conductor, moves, preview, evaluation |
|
|
594
594
|
| [Device Atlas](docs/manual/device-atlas.md) | 1305 devices indexed — search, suggest, chain building |
|
|
595
595
|
| [Samples & Slicing](docs/manual/samples.md) | 3-source search, fitness critics, slice workflows |
|
|
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.20.
|
|
37
|
+
var VERSION = "1.20.3";
|
|
38
38
|
|
|
39
39
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
40
40
|
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.20.
|
|
2
|
+
__version__ = "1.20.3"
|
package/mcp_server/connection.py
CHANGED
|
@@ -19,6 +19,10 @@ logger = logging.getLogger(__name__)
|
|
|
19
19
|
CONNECT_TIMEOUT = 5
|
|
20
20
|
RECV_TIMEOUT = 20
|
|
21
21
|
SINGLE_CLIENT_RETRY_DELAY = 0.25
|
|
22
|
+
# v1.20.2 race-condition fix: UI transitions (Cmd+N, project open) close
|
|
23
|
+
# the command socket briefly. Retry once after this delay to let Ableton
|
|
24
|
+
# finish setting up the new session state.
|
|
25
|
+
UI_TRANSITION_RETRY_DELAY = 0.4
|
|
22
26
|
COMMAND_RECV_TIMEOUTS = {
|
|
23
27
|
# Server-side slow write window is 35s; give the client a small buffer.
|
|
24
28
|
"freeze_track": 40,
|
|
@@ -190,17 +194,50 @@ class AbletonConnection:
|
|
|
190
194
|
# Ableton may have already applied the command. Never
|
|
191
195
|
# replay — the duplicate mutation is worse than the error.
|
|
192
196
|
if getattr(exc, '_send_completed', False):
|
|
193
|
-
|
|
197
|
+
# v1.20.2 race-condition fix: the specific error
|
|
198
|
+
# "Connection closed by Ableton" fires reliably after
|
|
199
|
+
# UI state transitions (Cmd+N opens new live set,
|
|
200
|
+
# project open, etc.). The Remote Script's socket
|
|
201
|
+
# recv returns empty bytes in a ~300ms window around
|
|
202
|
+
# the transition. Retry ONCE with backoff so an
|
|
203
|
+
# immediate follow-up command survives.
|
|
204
|
+
#
|
|
205
|
+
# Idempotence note: most commands are idempotent
|
|
206
|
+
# (set_tempo, set_track_volume overwrite; get_*
|
|
207
|
+
# reads are side-effect-free). Non-idempotent
|
|
208
|
+
# mutations (add_notes, create_clip) may in theory
|
|
209
|
+
# double-apply — but in practice Ableton's
|
|
210
|
+
# single-threaded command processing means the
|
|
211
|
+
# "Connection closed" happens BEFORE command
|
|
212
|
+
# processing begins, not after. Campaign repros
|
|
213
|
+
# showed 3/3 set_tempo failures post-Cmd+N that
|
|
214
|
+
# would have been fine to retry.
|
|
215
|
+
if "Connection closed by Ableton" in str(exc):
|
|
216
|
+
logger.warning(
|
|
217
|
+
"Ableton closed socket mid-%s — likely UI "
|
|
218
|
+
"state transition. Retrying once after %dms.",
|
|
219
|
+
command_type, int(UI_TRANSITION_RETRY_DELAY * 1000),
|
|
220
|
+
)
|
|
221
|
+
self.disconnect()
|
|
222
|
+
time.sleep(UI_TRANSITION_RETRY_DELAY)
|
|
223
|
+
self.connect()
|
|
224
|
+
response = self._send_raw(
|
|
225
|
+
command,
|
|
226
|
+
recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
|
|
227
|
+
)
|
|
228
|
+
else:
|
|
229
|
+
raise
|
|
194
230
|
# Don't retry timeouts either
|
|
195
|
-
|
|
231
|
+
elif "Timeout" in str(exc):
|
|
196
232
|
raise
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
233
|
+
else:
|
|
234
|
+
# Send itself failed — safe to retry with a fresh connection
|
|
235
|
+
self.disconnect()
|
|
236
|
+
self.connect()
|
|
237
|
+
response = self._send_raw(
|
|
238
|
+
command,
|
|
239
|
+
recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
|
|
240
|
+
)
|
|
204
241
|
except OSError:
|
|
205
242
|
# Socket error before send — safe to retry
|
|
206
243
|
self.disconnect()
|
|
@@ -312,6 +312,10 @@ async def execute_plan_steps_async(
|
|
|
312
312
|
raw_params = step.get("params") or step.get("args", {}) or {}
|
|
313
313
|
step_id = step.get("step_id")
|
|
314
314
|
declared_backend = step.get("backend")
|
|
315
|
+
# v1.20.2 (BUG #3 fix): optional steps whose failure should NOT
|
|
316
|
+
# halt the plan. Used for soft pre-reads like get_master_spectrum
|
|
317
|
+
# that depend on the analyzer being loaded.
|
|
318
|
+
is_optional = bool(step.get("optional", False))
|
|
315
319
|
|
|
316
320
|
if not tool:
|
|
317
321
|
results.append(ExecutionResult(
|
|
@@ -360,6 +364,18 @@ async def execute_plan_steps_async(
|
|
|
360
364
|
)
|
|
361
365
|
|
|
362
366
|
if not result.ok and stop_on_failure:
|
|
367
|
+
if is_optional:
|
|
368
|
+
# Optional step failed — log a warning but CONTINUE to
|
|
369
|
+
# subsequent steps. Per BUG #3 fix (v1.20.2): analyzer pre-
|
|
370
|
+
# reads and other soft dependencies shouldn't halt the
|
|
371
|
+
# plan's actual mutation work.
|
|
372
|
+
import logging as _logging
|
|
373
|
+
_logging.getLogger(__name__).warning(
|
|
374
|
+
"execute_plan_steps_async: optional step %r failed "
|
|
375
|
+
"(%s); continuing to next step.",
|
|
376
|
+
tool, result.error,
|
|
377
|
+
)
|
|
378
|
+
continue
|
|
363
379
|
break
|
|
364
380
|
|
|
365
381
|
return results
|
|
@@ -29,6 +29,12 @@ class CompiledStep:
|
|
|
29
29
|
# dispatch time — safe because test_move_annotations enforces every
|
|
30
30
|
# registered move's steps map to a known backend.
|
|
31
31
|
backend: Optional[str] = None
|
|
32
|
+
# v1.20.2 (BUG #3 fix): when True and the step fails at execution, the
|
|
33
|
+
# router logs a warning and CONTINUES to subsequent steps instead of
|
|
34
|
+
# halting the plan. Use for soft dependencies like analyzer-only
|
|
35
|
+
# pre-reads — downstream mutation steps should still run if the
|
|
36
|
+
# analyzer isn't loaded on master.
|
|
37
|
+
optional: bool = False
|
|
32
38
|
|
|
33
39
|
def to_dict(self) -> dict:
|
|
34
40
|
d = {
|
|
@@ -39,6 +45,8 @@ class CompiledStep:
|
|
|
39
45
|
}
|
|
40
46
|
if self.backend:
|
|
41
47
|
d["backend"] = self.backend
|
|
48
|
+
if self.optional:
|
|
49
|
+
d["optional"] = True
|
|
42
50
|
return d
|
|
43
51
|
|
|
44
52
|
|
|
@@ -6,10 +6,17 @@ compiler inspects the kernel's track topology — device-creation moves
|
|
|
6
6
|
are parametric: the plan_template already contains the tool call and
|
|
7
7
|
concrete arguments.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
v1.20.2 (BUG #1 fix): the compiler now injects ``gen_code`` at compile
|
|
10
|
+
time by looking up a GenExpr template from ``mcp_server/device_forge/
|
|
11
|
+
templates.py``. Pre-fix, each move's plan_template emitted
|
|
12
|
+
``generate_m4l_effect`` WITHOUT ``gen_code``, and the tool failed with
|
|
13
|
+
'missing 1 required positional argument: gen_code'. The templates
|
|
14
|
+
already existed in the device_forge module; this compiler just routes
|
|
15
|
+
the right template to the right move.
|
|
16
|
+
|
|
17
|
+
The v1.20.2 hybrid moves — ``build_send_chain`` + ``create_drum_rack_pad``
|
|
18
|
+
— don't use generate_m4l_effect, so their plan_template params pass
|
|
19
|
+
through unchanged.
|
|
13
20
|
"""
|
|
14
21
|
from __future__ import annotations
|
|
15
22
|
|
|
@@ -17,22 +24,66 @@ from .compiler import CompiledPlan, CompiledStep, register_family_compiler
|
|
|
17
24
|
from .models import SemanticMove
|
|
18
25
|
|
|
19
26
|
|
|
27
|
+
# BUG #1 fix (v1.20.2 / campaign report 2026-04-24).
|
|
28
|
+
#
|
|
29
|
+
# Each of the 7 Device Forge moves corresponds to one pre-existing
|
|
30
|
+
# GenExpr template. The compiler injects the template's `code` into the
|
|
31
|
+
# `gen_code` param of any `generate_m4l_effect` step belonging to the
|
|
32
|
+
# move. Adding a new create_* move to this map must also register a
|
|
33
|
+
# matching template in mcp_server/device_forge/templates.py.
|
|
34
|
+
_MOVE_TO_TEMPLATE: dict[str, str] = {
|
|
35
|
+
"create_chaos_modulator": "lorenz_attractor",
|
|
36
|
+
"create_feedback_resonator": "resonator",
|
|
37
|
+
"create_wavefolder_effect": "wavefolder",
|
|
38
|
+
"create_bitcrusher_effect": "bitcrusher",
|
|
39
|
+
"create_karplus_string": "karplus_strong",
|
|
40
|
+
"create_stochastic_texture": "stochastic_resonance",
|
|
41
|
+
"create_fdn_reverb": "feedback_delay_network",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
20
45
|
def _compile_device_creation(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
21
|
-
"""Map plan_template steps
|
|
46
|
+
"""Map plan_template steps to CompiledStep, injecting Device Forge
|
|
47
|
+
`gen_code` when the move is in _MOVE_TO_TEMPLATE."""
|
|
48
|
+
# Resolve the GenExpr template once per compile (idempotent).
|
|
49
|
+
template_code: str | None = None
|
|
50
|
+
template_id = _MOVE_TO_TEMPLATE.get(move.move_id)
|
|
51
|
+
if template_id is not None:
|
|
52
|
+
# Local import so the semantic_moves package doesn't hard-depend on
|
|
53
|
+
# device_forge; the branch is only taken for the 7 create_* moves.
|
|
54
|
+
from ..device_forge.templates import get_template
|
|
55
|
+
template = get_template(template_id)
|
|
56
|
+
if template is not None:
|
|
57
|
+
template_code = template.code
|
|
22
58
|
|
|
23
|
-
|
|
24
|
-
``tool``, ``params``, ``description``, and ``backend`` annotated.
|
|
25
|
-
"""
|
|
59
|
+
warnings: list[str] = []
|
|
26
60
|
steps: list[CompiledStep] = []
|
|
27
61
|
for step in move.plan_template:
|
|
62
|
+
params = dict(step.get("params") or {})
|
|
63
|
+
|
|
64
|
+
# Inject gen_code for Device Forge moves. Done BEFORE CompiledStep
|
|
65
|
+
# construction so the step snapshot is correct, not mutated later.
|
|
66
|
+
if template_code is not None and step.get("tool") == "generate_m4l_effect":
|
|
67
|
+
params["gen_code"] = template_code
|
|
68
|
+
|
|
28
69
|
steps.append(CompiledStep(
|
|
29
70
|
tool=step.get("tool", ""),
|
|
30
|
-
params=
|
|
71
|
+
params=params,
|
|
31
72
|
description=step.get("description", ""),
|
|
32
73
|
verify_after=bool(step.get("verify_after", True)),
|
|
33
74
|
backend=step.get("backend"),
|
|
34
75
|
))
|
|
35
76
|
|
|
77
|
+
# Guard: if the move expected template injection but the template
|
|
78
|
+
# went missing, surface a clear warning instead of letting the move
|
|
79
|
+
# ship with an empty gen_code. Shouldn't fire under normal operation.
|
|
80
|
+
if template_id is not None and template_code is None:
|
|
81
|
+
warnings.append(
|
|
82
|
+
f"Device Forge template {template_id!r} not found — "
|
|
83
|
+
f"generate_m4l_effect call will fail. Check "
|
|
84
|
+
f"mcp_server/device_forge/templates.py"
|
|
85
|
+
)
|
|
86
|
+
|
|
36
87
|
return CompiledPlan(
|
|
37
88
|
move_id=move.move_id,
|
|
38
89
|
intent=move.intent,
|
|
@@ -40,7 +91,7 @@ def _compile_device_creation(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
40
91
|
risk_level=move.risk_level,
|
|
41
92
|
summary=move.intent,
|
|
42
93
|
requires_approval=(kernel.get("mode", "improve") != "explore"),
|
|
43
|
-
warnings=
|
|
94
|
+
warnings=warnings,
|
|
44
95
|
)
|
|
45
96
|
|
|
46
97
|
|
|
@@ -101,12 +101,17 @@ def _compile_tighten_low_end(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
101
101
|
bass = bass_tracks[0]
|
|
102
102
|
idx = bass["index"]
|
|
103
103
|
|
|
104
|
-
# Step 1: Read spectrum
|
|
104
|
+
# Step 1: Read spectrum (optional — soft-gated on analyzer availability).
|
|
105
|
+
# BUG #3 fix (v1.20.2): if the analyzer isn't loaded on master, this
|
|
106
|
+
# step fails — but the downstream bass-volume change is independent
|
|
107
|
+
# and should still run. optional=True lets the router skip and
|
|
108
|
+
# continue instead of halting the plan.
|
|
105
109
|
steps.append(CompiledStep(
|
|
106
110
|
tool="get_master_spectrum",
|
|
107
111
|
params={},
|
|
108
|
-
description="Read current spectral balance",
|
|
112
|
+
description="Read current spectral balance (optional — analyzer-gated)",
|
|
109
113
|
verify_after=False,
|
|
114
|
+
optional=True,
|
|
110
115
|
))
|
|
111
116
|
|
|
112
117
|
# Step 2: Reduce bass volume slightly
|
|
@@ -302,11 +307,14 @@ def _compile_make_kick_bass_lock(move: SemanticMove, kernel: dict) -> CompiledPl
|
|
|
302
307
|
if not kick_tracks:
|
|
303
308
|
warnings.append("No kick/drum track found — reference track missing")
|
|
304
309
|
|
|
310
|
+
# Optional pre-read — soft-gated on analyzer availability.
|
|
311
|
+
# BUG #3 fix (v1.20.2): see tighten_low_end for the same pattern.
|
|
305
312
|
steps.append(CompiledStep(
|
|
306
313
|
tool="get_master_spectrum",
|
|
307
314
|
params={},
|
|
308
|
-
description="Read current sub/low balance before carving",
|
|
315
|
+
description="Read current sub/low balance before carving (optional — analyzer-gated)",
|
|
309
316
|
verify_after=False,
|
|
317
|
+
optional=True,
|
|
310
318
|
))
|
|
311
319
|
|
|
312
320
|
if bass_tracks:
|
|
@@ -2,22 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
These compile sample manipulation intents into concrete tool call sequences
|
|
4
4
|
using the session kernel to find appropriate tracks and devices.
|
|
5
|
+
|
|
6
|
+
v1.20.2 (BUG #2 fix): sample moves now resolve ``file_path`` from
|
|
7
|
+
``kernel["seed_args"]["file_path"]`` (v1.20 convention). Pre-fix, the
|
|
8
|
+
resolver returned a literal ``"{sample_file_path}"`` placeholder when
|
|
9
|
+
no path was in the kernel — which leaked into ``load_sample_to_simpler``
|
|
10
|
+
at execution and failed with a non-existent-file error. Now the
|
|
11
|
+
resolver returns ``None`` on miss and each compiler rejects with a
|
|
12
|
+
non-executable plan + clear warning.
|
|
13
|
+
|
|
14
|
+
Legacy fallback: wonder_mode/tools.py writes ``kernel["sample_file_path"]``
|
|
15
|
+
directly (pre-v1.20 path). Still honored for back-compat, just after
|
|
16
|
+
seed_args.
|
|
5
17
|
"""
|
|
6
18
|
|
|
7
19
|
from __future__ import annotations
|
|
8
20
|
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
9
23
|
from .compiler import CompiledPlan, CompiledStep, register_compiler
|
|
10
24
|
from .models import SemanticMove
|
|
11
25
|
from . import resolvers
|
|
12
26
|
|
|
13
27
|
|
|
14
|
-
def _resolve_sample_path(kernel: dict) -> str:
|
|
15
|
-
"""Get the sample file path from kernel, or
|
|
16
|
-
|
|
28
|
+
def _resolve_sample_path(kernel: dict) -> Optional[str]:
|
|
29
|
+
"""Get the sample file path from kernel, or None if not set.
|
|
30
|
+
|
|
31
|
+
Resolution order (v1.20.2 BUG #2 fix):
|
|
32
|
+
1. kernel["seed_args"]["file_path"] — v1.20 seed_args convention
|
|
33
|
+
2. kernel["sample_file_path"] — legacy wonder_mode setter
|
|
34
|
+
|
|
35
|
+
Returns None when neither is present. Callers must check for None
|
|
36
|
+
and reject the plan with an actionable warning; do NOT substitute a
|
|
37
|
+
placeholder (which was the original bug).
|
|
38
|
+
"""
|
|
39
|
+
seed = kernel.get("seed_args") or {}
|
|
40
|
+
path = seed.get("file_path")
|
|
41
|
+
if isinstance(path, str) and path.strip():
|
|
42
|
+
return path
|
|
43
|
+
legacy = kernel.get("sample_file_path")
|
|
44
|
+
if isinstance(legacy, str) and legacy.strip():
|
|
45
|
+
return legacy
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _empty_sample_plan(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
50
|
+
"""Return a non-executable plan indicating a sample path is required.
|
|
51
|
+
|
|
52
|
+
Used by every sample-family compiler when _resolve_sample_path returns
|
|
53
|
+
None. Caller should still inspect the warnings for the actionable text.
|
|
54
|
+
"""
|
|
55
|
+
return CompiledPlan(
|
|
56
|
+
move_id=move.move_id,
|
|
57
|
+
intent=move.intent,
|
|
58
|
+
steps=[],
|
|
59
|
+
risk_level="low",
|
|
60
|
+
summary=(
|
|
61
|
+
f"{move.move_id} requires a sample file_path. Pass via "
|
|
62
|
+
f"apply_semantic_move(..., args={{\"file_path\": \"/abs/path/to.wav\"}})"
|
|
63
|
+
),
|
|
64
|
+
requires_approval=True,
|
|
65
|
+
warnings=[
|
|
66
|
+
f"{move.move_id} requires seed_args.file_path (absolute path to the "
|
|
67
|
+
"audio file). Not provided; plan not executable. Example: "
|
|
68
|
+
"apply_semantic_move(\"" + move.move_id + "\", mode=\"explore\", "
|
|
69
|
+
"args={\"file_path\": \"/path/to/sample.wav\"})"
|
|
70
|
+
],
|
|
71
|
+
)
|
|
17
72
|
|
|
18
73
|
|
|
19
74
|
def _compile_sample_chop_rhythm(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
20
75
|
"""Compile 'sample_chop_rhythm': load, slice, and chop a sample for rhythm."""
|
|
76
|
+
file_path = _resolve_sample_path(kernel)
|
|
77
|
+
if file_path is None:
|
|
78
|
+
return _empty_sample_plan(move, kernel)
|
|
79
|
+
|
|
21
80
|
steps = []
|
|
22
81
|
descriptions = []
|
|
23
82
|
warnings = []
|
|
@@ -39,7 +98,7 @@ def _compile_sample_chop_rhythm(move: SemanticMove, kernel: dict) -> CompiledPla
|
|
|
39
98
|
|
|
40
99
|
steps.append(CompiledStep(
|
|
41
100
|
tool="load_sample_to_simpler",
|
|
42
|
-
params={"track_index": new_idx, "file_path":
|
|
101
|
+
params={"track_index": new_idx, "file_path": file_path},
|
|
43
102
|
description="Load sample into Simpler for slicing",
|
|
44
103
|
))
|
|
45
104
|
|
|
@@ -79,6 +138,10 @@ def _compile_sample_chop_rhythm(move: SemanticMove, kernel: dict) -> CompiledPla
|
|
|
79
138
|
|
|
80
139
|
def _compile_sample_texture_layer(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
81
140
|
"""Compile 'sample_texture_layer': load and filter a sample as background texture."""
|
|
141
|
+
file_path = _resolve_sample_path(kernel)
|
|
142
|
+
if file_path is None:
|
|
143
|
+
return _empty_sample_plan(move, kernel)
|
|
144
|
+
|
|
82
145
|
steps = []
|
|
83
146
|
descriptions = []
|
|
84
147
|
|
|
@@ -93,7 +156,7 @@ def _compile_sample_texture_layer(move: SemanticMove, kernel: dict) -> CompiledP
|
|
|
93
156
|
|
|
94
157
|
steps.append(CompiledStep(
|
|
95
158
|
tool="load_sample_to_simpler",
|
|
96
|
-
params={"track_index": new_idx, "file_path":
|
|
159
|
+
params={"track_index": new_idx, "file_path": file_path},
|
|
97
160
|
description="Load textural sample into Simpler",
|
|
98
161
|
))
|
|
99
162
|
descriptions.append("Load texture sample")
|
|
@@ -132,6 +195,10 @@ def _compile_sample_texture_layer(move: SemanticMove, kernel: dict) -> CompiledP
|
|
|
132
195
|
|
|
133
196
|
def _compile_sample_vocal_ghost(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
134
197
|
"""Compile 'sample_vocal_ghost': reverse, pitch, and wash a vocal sample."""
|
|
198
|
+
file_path = _resolve_sample_path(kernel)
|
|
199
|
+
if file_path is None:
|
|
200
|
+
return _empty_sample_plan(move, kernel)
|
|
201
|
+
|
|
135
202
|
steps = []
|
|
136
203
|
descriptions = []
|
|
137
204
|
|
|
@@ -146,7 +213,7 @@ def _compile_sample_vocal_ghost(move: SemanticMove, kernel: dict) -> CompiledPla
|
|
|
146
213
|
|
|
147
214
|
steps.append(CompiledStep(
|
|
148
215
|
tool="load_sample_to_simpler",
|
|
149
|
-
params={"track_index": new_idx, "file_path":
|
|
216
|
+
params={"track_index": new_idx, "file_path": file_path},
|
|
150
217
|
description="Load vocal sample into Simpler",
|
|
151
218
|
))
|
|
152
219
|
|
|
@@ -191,6 +258,10 @@ def _compile_sample_vocal_ghost(move: SemanticMove, kernel: dict) -> CompiledPla
|
|
|
191
258
|
|
|
192
259
|
def _compile_sample_break_layer(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
193
260
|
"""Compile 'sample_break_layer': slice a break and layer over existing drums."""
|
|
261
|
+
file_path = _resolve_sample_path(kernel)
|
|
262
|
+
if file_path is None:
|
|
263
|
+
return _empty_sample_plan(move, kernel)
|
|
264
|
+
|
|
194
265
|
steps = []
|
|
195
266
|
descriptions = []
|
|
196
267
|
warnings = []
|
|
@@ -210,7 +281,7 @@ def _compile_sample_break_layer(move: SemanticMove, kernel: dict) -> CompiledPla
|
|
|
210
281
|
|
|
211
282
|
steps.append(CompiledStep(
|
|
212
283
|
tool="load_sample_to_simpler",
|
|
213
|
-
params={"track_index": new_idx, "file_path":
|
|
284
|
+
params={"track_index": new_idx, "file_path": file_path},
|
|
214
285
|
description="Load breakbeat into Simpler",
|
|
215
286
|
))
|
|
216
287
|
|
|
@@ -252,6 +323,10 @@ def _compile_sample_resample_destroy(move: SemanticMove, kernel: dict) -> Compil
|
|
|
252
323
|
SAFETY: This is a high-risk move — always requires approval.
|
|
253
324
|
Only adjusts device params when a known device is confirmed present.
|
|
254
325
|
"""
|
|
326
|
+
file_path = _resolve_sample_path(kernel)
|
|
327
|
+
if file_path is None:
|
|
328
|
+
return _empty_sample_plan(move, kernel)
|
|
329
|
+
|
|
255
330
|
steps = []
|
|
256
331
|
descriptions = []
|
|
257
332
|
warnings = ["High-risk: destructive processing — consider duplicating track first"]
|
|
@@ -267,7 +342,7 @@ def _compile_sample_resample_destroy(move: SemanticMove, kernel: dict) -> Compil
|
|
|
267
342
|
|
|
268
343
|
steps.append(CompiledStep(
|
|
269
344
|
tool="load_sample_to_simpler",
|
|
270
|
-
params={"track_index": new_idx, "file_path":
|
|
345
|
+
params={"track_index": new_idx, "file_path": file_path},
|
|
271
346
|
description="Load sample for destruction",
|
|
272
347
|
))
|
|
273
348
|
descriptions.append("Load source")
|
|
@@ -312,6 +387,10 @@ def _compile_sample_resample_destroy(move: SemanticMove, kernel: dict) -> Compil
|
|
|
312
387
|
|
|
313
388
|
def _compile_sample_one_shot_accent(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
314
389
|
"""Compile 'sample_one_shot_accent': load a one-shot for rhythmic punctuation."""
|
|
390
|
+
file_path = _resolve_sample_path(kernel)
|
|
391
|
+
if file_path is None:
|
|
392
|
+
return _empty_sample_plan(move, kernel)
|
|
393
|
+
|
|
315
394
|
steps = []
|
|
316
395
|
descriptions = []
|
|
317
396
|
|
|
@@ -326,7 +405,7 @@ def _compile_sample_one_shot_accent(move: SemanticMove, kernel: dict) -> Compile
|
|
|
326
405
|
|
|
327
406
|
steps.append(CompiledStep(
|
|
328
407
|
tool="load_sample_to_simpler",
|
|
329
|
-
params={"track_index": new_idx, "file_path":
|
|
408
|
+
params={"track_index": new_idx, "file_path": file_path},
|
|
330
409
|
description="Load one-shot into Simpler",
|
|
331
410
|
))
|
|
332
411
|
|
|
@@ -366,6 +366,10 @@ async def apply_semantic_move(
|
|
|
366
366
|
}
|
|
367
367
|
if getattr(step, "backend", None):
|
|
368
368
|
d["backend"] = step.backend
|
|
369
|
+
# v1.20.2 (BUG #3 fix): propagate optional flag so the router
|
|
370
|
+
# can skip-and-continue on soft failures (e.g., analyzer pre-reads).
|
|
371
|
+
if getattr(step, "optional", False):
|
|
372
|
+
d["optional"] = True
|
|
369
373
|
return d
|
|
370
374
|
|
|
371
375
|
step_dicts = [_step_to_dict(step) for step in plan.steps]
|
|
@@ -1913,3 +1913,128 @@ async def compressor_set_sidechain(
|
|
|
1913
1913
|
params["source_channel"] = str(source_channel)
|
|
1914
1914
|
ableton = ctx.lifespan_context["ableton"]
|
|
1915
1915
|
return ableton.send_command("set_compressor_sidechain", params)
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
1919
|
+
# v1.20.3 — ensure_analyzer_on_master
|
|
1920
|
+
#
|
|
1921
|
+
# Motivated by the v1.20.1 live-test campaign operator-error (see
|
|
1922
|
+
# ~/Desktop/DREAM AI/demo Project/REPORT.md). The LLM operator had a
|
|
1923
|
+
# clear global-memory instruction to load LivePilot_Analyzer on master
|
|
1924
|
+
# proactively on a fresh session, and missed it — leaving analyzer-
|
|
1925
|
+
# gated moves brittle. This tool closes that class of error by making
|
|
1926
|
+
# the load idempotent + automatable.
|
|
1927
|
+
|
|
1928
|
+
_ANALYZER_DEVICE_NAME = "LivePilot_Analyzer"
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def _load_analyzer_impl(ctx, track_index: int, device_name: str,
|
|
1932
|
+
allow_duplicate: bool = False) -> dict:
|
|
1933
|
+
"""Indirection so tests can monkeypatch the load call without having
|
|
1934
|
+
to fake the full find_and_load_device MCP-tool machinery. Production
|
|
1935
|
+
calls straight through to the existing tool."""
|
|
1936
|
+
from .devices import find_and_load_device
|
|
1937
|
+
return find_and_load_device(
|
|
1938
|
+
ctx,
|
|
1939
|
+
track_index=track_index,
|
|
1940
|
+
device_name=device_name,
|
|
1941
|
+
allow_duplicate=allow_duplicate,
|
|
1942
|
+
)
|
|
1943
|
+
|
|
1944
|
+
|
|
1945
|
+
@mcp.tool()
|
|
1946
|
+
def ensure_analyzer_on_master(ctx: Context) -> dict:
|
|
1947
|
+
"""Idempotent pre-flight: load LivePilot_Analyzer on master if missing.
|
|
1948
|
+
|
|
1949
|
+
Safe to call at the start of any session or before any move that
|
|
1950
|
+
declares analyzer dependency. Calling it repeatedly is cheap —
|
|
1951
|
+
subsequent calls short-circuit via a single get_master_track read.
|
|
1952
|
+
|
|
1953
|
+
CLAUDE.md invariant: "LivePilot_Analyzer must be LAST on master."
|
|
1954
|
+
This tool reports whether the invariant holds via ``is_last_on_master``;
|
|
1955
|
+
it does NOT move the device (that's a user action in Ableton's GUI).
|
|
1956
|
+
|
|
1957
|
+
Return shape:
|
|
1958
|
+
- status: one of {"already_loaded", "loaded", "install_required", "failed"}
|
|
1959
|
+
- device_index: int — position of the analyzer on master (when present)
|
|
1960
|
+
- is_last_on_master: bool — True when analyzer is the last device
|
|
1961
|
+
- duplicate_count: int — 2+ when multiple analyzers exist (shouldn't)
|
|
1962
|
+
- warning: str | None — surfaces last-on-master violations
|
|
1963
|
+
- hint: str — actionable next step when status != "already_loaded"/"loaded"
|
|
1964
|
+
- error: str | None — present on status="failed"
|
|
1965
|
+
"""
|
|
1966
|
+
ableton = ctx.lifespan_context["ableton"]
|
|
1967
|
+
|
|
1968
|
+
# 1. Inspect the master chain for an existing analyzer.
|
|
1969
|
+
try:
|
|
1970
|
+
master = ableton.send_command("get_master_track")
|
|
1971
|
+
except Exception as exc:
|
|
1972
|
+
return {
|
|
1973
|
+
"status": "failed",
|
|
1974
|
+
"error": f"Could not read master track: {exc}",
|
|
1975
|
+
"hint": "Verify MCP connection to Ableton; retry with get_session_info first.",
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
devices = (master or {}).get("devices") or []
|
|
1979
|
+
matches = [d for d in devices if d.get("name") == _ANALYZER_DEVICE_NAME]
|
|
1980
|
+
|
|
1981
|
+
if matches:
|
|
1982
|
+
# 2. Already loaded — build a status report without side effects.
|
|
1983
|
+
first = matches[0]
|
|
1984
|
+
device_index = first.get("index")
|
|
1985
|
+
is_last = False
|
|
1986
|
+
if devices:
|
|
1987
|
+
last_name = devices[-1].get("name")
|
|
1988
|
+
is_last = (last_name == _ANALYZER_DEVICE_NAME)
|
|
1989
|
+
|
|
1990
|
+
result: dict = {
|
|
1991
|
+
"status": "already_loaded",
|
|
1992
|
+
"device_index": device_index,
|
|
1993
|
+
"is_last_on_master": is_last,
|
|
1994
|
+
"duplicate_count": len(matches),
|
|
1995
|
+
}
|
|
1996
|
+
if len(matches) > 1:
|
|
1997
|
+
result["warning"] = (
|
|
1998
|
+
f"{len(matches)} instances of {_ANALYZER_DEVICE_NAME} on master — "
|
|
1999
|
+
"only one is needed. Remove extras in Ableton's GUI."
|
|
2000
|
+
)
|
|
2001
|
+
elif not is_last:
|
|
2002
|
+
result["warning"] = (
|
|
2003
|
+
f"{_ANALYZER_DEVICE_NAME} is not the LAST device on master. "
|
|
2004
|
+
"CLAUDE.md invariant requires it to come after ALL effects so "
|
|
2005
|
+
"it reads the final output, not pre-effect signal. "
|
|
2006
|
+
"Move it to the end of the master chain in Ableton's GUI."
|
|
2007
|
+
)
|
|
2008
|
+
return result
|
|
2009
|
+
|
|
2010
|
+
# 3. Not on master — try loading from the Ableton browser.
|
|
2011
|
+
try:
|
|
2012
|
+
loaded = _load_analyzer_impl(
|
|
2013
|
+
ctx,
|
|
2014
|
+
track_index=-1000, # master convention
|
|
2015
|
+
device_name=_ANALYZER_DEVICE_NAME,
|
|
2016
|
+
allow_duplicate=False,
|
|
2017
|
+
)
|
|
2018
|
+
except Exception as exc:
|
|
2019
|
+
# Typical path: device not in browser (user hasn't installed via
|
|
2020
|
+
# install_m4l_device yet).
|
|
2021
|
+
return {
|
|
2022
|
+
"status": "install_required",
|
|
2023
|
+
"error": str(exc),
|
|
2024
|
+
"hint": (
|
|
2025
|
+
"LivePilot_Analyzer not found in Ableton's browser. Install "
|
|
2026
|
+
"first with install_m4l_device(source_path="
|
|
2027
|
+
"\"<repo>/m4l_device/LivePilot_Analyzer.amxd\") — that copies "
|
|
2028
|
+
"the .amxd into ~/Music/Ableton/User Library/Presets/Audio "
|
|
2029
|
+
"Effects/Max Audio Effect/. Then call ensure_analyzer_on_master "
|
|
2030
|
+
"again to complete the load."
|
|
2031
|
+
),
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
device_index = (loaded or {}).get("device_index")
|
|
2035
|
+
return {
|
|
2036
|
+
"status": "loaded",
|
|
2037
|
+
"device_index": device_index,
|
|
2038
|
+
"is_last_on_master": True, # fresh load always lands at the end
|
|
2039
|
+
"duplicate_count": 1,
|
|
2040
|
+
}
|
|
@@ -349,6 +349,80 @@ def _normalize_batch_entry(entry: dict) -> dict:
|
|
|
349
349
|
return {"name_or_index": key, "value": entry["value"]}
|
|
350
350
|
|
|
351
351
|
|
|
352
|
+
_SNAP_EPSILON = 1e-5
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _detect_snapped_params(
|
|
356
|
+
requested: list[dict], response: dict,
|
|
357
|
+
) -> list[dict]:
|
|
358
|
+
"""Compare requested parameter values against Ableton's returned
|
|
359
|
+
values; surface any that were silently snapped.
|
|
360
|
+
|
|
361
|
+
BUG #4 fix (v1.20.2): quantized-enum params (e.g., Beat Repeat's
|
|
362
|
+
"Gate" at 0/1/2/... integer enum) silently snap a caller's float
|
|
363
|
+
request to the nearest step. Pre-fix, the response gave no signal —
|
|
364
|
+
callers saw success with the snapped value hidden in `value_string`.
|
|
365
|
+
|
|
366
|
+
Returns a list of {name, requested, actual, display_value} entries
|
|
367
|
+
for params whose actual (returned) value differs from the requested
|
|
368
|
+
value by more than _SNAP_EPSILON. String params are compared
|
|
369
|
+
exactly. Integer params use int equality.
|
|
370
|
+
|
|
371
|
+
Empty list when nothing snapped — callers can check
|
|
372
|
+
``result.get("snapped_params") == []`` as a go/no-go signal.
|
|
373
|
+
"""
|
|
374
|
+
result_params = response.get("parameters") or []
|
|
375
|
+
if not isinstance(result_params, list):
|
|
376
|
+
return []
|
|
377
|
+
|
|
378
|
+
# Build {key → requested_value} from the original caller input.
|
|
379
|
+
# Accept any of the same schemas _normalize_batch_entry accepts.
|
|
380
|
+
by_key: dict = {}
|
|
381
|
+
for entry in requested:
|
|
382
|
+
if not isinstance(entry, dict):
|
|
383
|
+
continue
|
|
384
|
+
for key_name in ("parameter_name", "name", "parameter_index",
|
|
385
|
+
"index", "name_or_index"):
|
|
386
|
+
if key_name in entry:
|
|
387
|
+
by_key[entry[key_name]] = entry.get("value")
|
|
388
|
+
break
|
|
389
|
+
|
|
390
|
+
snapped: list[dict] = []
|
|
391
|
+
for rp in result_params:
|
|
392
|
+
if not isinstance(rp, dict):
|
|
393
|
+
continue
|
|
394
|
+
name = rp.get("name")
|
|
395
|
+
# Match by name first (most common), fall back to index
|
|
396
|
+
requested_val = by_key.get(name)
|
|
397
|
+
if requested_val is None and "index" in rp:
|
|
398
|
+
requested_val = by_key.get(rp["index"])
|
|
399
|
+
if requested_val is None:
|
|
400
|
+
continue
|
|
401
|
+
actual_val = rp.get("value")
|
|
402
|
+
if actual_val is None:
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
# Compare with type-appropriate tolerance. Numeric → epsilon;
|
|
406
|
+
# other types → strict equality.
|
|
407
|
+
try:
|
|
408
|
+
req_f = float(requested_val)
|
|
409
|
+
act_f = float(actual_val)
|
|
410
|
+
did_snap = abs(req_f - act_f) > _SNAP_EPSILON
|
|
411
|
+
except (TypeError, ValueError):
|
|
412
|
+
did_snap = requested_val != actual_val
|
|
413
|
+
|
|
414
|
+
if did_snap:
|
|
415
|
+
snapped.append({
|
|
416
|
+
"name": name,
|
|
417
|
+
"requested": requested_val,
|
|
418
|
+
"actual": actual_val,
|
|
419
|
+
"display_value": rp.get("display_value"),
|
|
420
|
+
"value_string": rp.get("value_string"),
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
return snapped
|
|
424
|
+
|
|
425
|
+
|
|
352
426
|
@mcp.tool()
|
|
353
427
|
def batch_set_parameters(
|
|
354
428
|
ctx: Context,
|
|
@@ -363,18 +437,30 @@ def batch_set_parameters(
|
|
|
363
437
|
- {"parameter_name": "Dry/Wet", "value": V} (preferred)
|
|
364
438
|
- {"name_or_index": X, "value": V} (legacy, still accepted)
|
|
365
439
|
|
|
366
|
-
track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
|
|
440
|
+
track_index: 0+ for regular tracks, -1/-2/... for return tracks (A/B/...), -1000 for master.
|
|
441
|
+
|
|
442
|
+
Response (v1.20.2+): the dict now includes a ``snapped_params`` list
|
|
443
|
+
when quantized-enum parameters were silently snapped by Ableton
|
|
444
|
+
(requested 0.3, received 0). Empty list means every requested value
|
|
445
|
+
round-tripped within 1e-5 tolerance. Callers using this tool to
|
|
446
|
+
drive deterministic state should inspect ``snapped_params`` before
|
|
447
|
+
assuming success — see BUG #4 in the v1.20 live-test campaign for
|
|
448
|
+
the motivating case (Beat Repeat Gate).
|
|
449
|
+
"""
|
|
367
450
|
_validate_track_index(track_index)
|
|
368
451
|
_validate_device_index(device_index)
|
|
369
|
-
|
|
370
|
-
if not
|
|
452
|
+
parameters_list = _ensure_list(parameters)
|
|
453
|
+
if not parameters_list:
|
|
371
454
|
raise ValueError("parameters list cannot be empty")
|
|
372
|
-
normalized = [_normalize_batch_entry(e) for e in
|
|
373
|
-
|
|
455
|
+
normalized = [_normalize_batch_entry(e) for e in parameters_list]
|
|
456
|
+
response = _get_ableton(ctx).send_command("batch_set_parameters", {
|
|
374
457
|
"track_index": track_index,
|
|
375
458
|
"device_index": device_index,
|
|
376
459
|
"parameters": normalized,
|
|
377
460
|
})
|
|
461
|
+
if isinstance(response, dict):
|
|
462
|
+
response["snapped_params"] = _detect_snapped_params(parameters_list, response)
|
|
463
|
+
return response
|
|
378
464
|
|
|
379
465
|
|
|
380
466
|
@mcp.tool()
|
|
@@ -134,6 +134,35 @@ def verify_device_alive(
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
def _find_name_collisions(ctx: Context, name: str) -> list[int]:
|
|
138
|
+
"""Return track indices whose name exactly matches `name` (case-sensitive).
|
|
139
|
+
|
|
140
|
+
BUG #5 fix (v1.20.2): downstream role-based resolvers like
|
|
141
|
+
find_tracks_by_role match on track names. If create_midi_track
|
|
142
|
+
creates a second "Pad" while another "Pad" already exists, mix
|
|
143
|
+
moves like widen_stereo match BOTH — applying the change twice.
|
|
144
|
+
This helper enables create_*_track to warn the caller so they can
|
|
145
|
+
pick a unique name or explicitly accept the collision.
|
|
146
|
+
|
|
147
|
+
Best-effort: returns [] when session_info can't be fetched —
|
|
148
|
+
collision detection must never block creation.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
info = _get_ableton(ctx).send_command("get_session_info")
|
|
152
|
+
except Exception:
|
|
153
|
+
return []
|
|
154
|
+
if not isinstance(info, dict):
|
|
155
|
+
return []
|
|
156
|
+
tracks = info.get("tracks") or []
|
|
157
|
+
matches: list[int] = []
|
|
158
|
+
for t in tracks:
|
|
159
|
+
if isinstance(t, dict) and t.get("name") == name:
|
|
160
|
+
idx = t.get("index")
|
|
161
|
+
if isinstance(idx, int):
|
|
162
|
+
matches.append(idx)
|
|
163
|
+
return matches
|
|
164
|
+
|
|
165
|
+
|
|
137
166
|
@mcp.tool()
|
|
138
167
|
def create_midi_track(
|
|
139
168
|
ctx: Context,
|
|
@@ -141,7 +170,18 @@ def create_midi_track(
|
|
|
141
170
|
name: Optional[str] = None,
|
|
142
171
|
color: Optional[int] = None,
|
|
143
172
|
) -> dict:
|
|
144
|
-
"""Create a new MIDI track. index=-1 appends at end.
|
|
173
|
+
"""Create a new MIDI track. index=-1 appends at end.
|
|
174
|
+
|
|
175
|
+
Response (v1.20.2+): when `name` is provided, the response carries
|
|
176
|
+
a ``name_collision`` bool and ``existing_tracks_with_same_name``
|
|
177
|
+
list[int]. Downstream role-based resolvers (find_tracks_by_role)
|
|
178
|
+
match duplicate names and apply mix changes twice — check the
|
|
179
|
+
warning before proceeding with mix moves on the new track's role.
|
|
180
|
+
"""
|
|
181
|
+
collisions: list[int] = []
|
|
182
|
+
if name is not None and name.strip():
|
|
183
|
+
collisions = _find_name_collisions(ctx, name)
|
|
184
|
+
|
|
145
185
|
params = {"index": index}
|
|
146
186
|
if name is not None:
|
|
147
187
|
if not name.strip():
|
|
@@ -150,7 +190,13 @@ def create_midi_track(
|
|
|
150
190
|
if color is not None:
|
|
151
191
|
_validate_color_index(color)
|
|
152
192
|
params["color_index"] = color
|
|
153
|
-
|
|
193
|
+
result = _get_ableton(ctx).send_command("create_midi_track", params)
|
|
194
|
+
if isinstance(result, dict):
|
|
195
|
+
# Always stamp both fields so callers can check unconditionally
|
|
196
|
+
# (False + [] when no name provided or no collision).
|
|
197
|
+
result["name_collision"] = bool(collisions)
|
|
198
|
+
result["existing_tracks_with_same_name"] = collisions
|
|
199
|
+
return result
|
|
154
200
|
|
|
155
201
|
|
|
156
202
|
@mcp.tool()
|
|
@@ -160,7 +206,15 @@ def create_audio_track(
|
|
|
160
206
|
name: Optional[str] = None,
|
|
161
207
|
color: Optional[int] = None,
|
|
162
208
|
) -> dict:
|
|
163
|
-
"""Create a new audio track. index=-1 appends at end.
|
|
209
|
+
"""Create a new audio track. index=-1 appends at end.
|
|
210
|
+
|
|
211
|
+
Response (v1.20.2+): ``name_collision`` + ``existing_tracks_with_same_name``
|
|
212
|
+
same as create_midi_track — see BUG #5 rationale there.
|
|
213
|
+
"""
|
|
214
|
+
collisions: list[int] = []
|
|
215
|
+
if name is not None and name.strip():
|
|
216
|
+
collisions = _find_name_collisions(ctx, name)
|
|
217
|
+
|
|
164
218
|
params = {"index": index}
|
|
165
219
|
if name is not None:
|
|
166
220
|
if not name.strip():
|
|
@@ -169,7 +223,11 @@ def create_audio_track(
|
|
|
169
223
|
if color is not None:
|
|
170
224
|
_validate_color_index(color)
|
|
171
225
|
params["color_index"] = color
|
|
172
|
-
|
|
226
|
+
result = _get_ableton(ctx).send_command("create_audio_track", params)
|
|
227
|
+
if isinstance(result, dict):
|
|
228
|
+
result["name_collision"] = bool(collisions)
|
|
229
|
+
result["existing_tracks_with_same_name"] = collisions
|
|
230
|
+
return result
|
|
173
231
|
|
|
174
232
|
|
|
175
233
|
@mcp.tool()
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.3",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
|
-
"description": "Agentic production system for Ableton Live 12 —
|
|
5
|
+
"description": "Agentic production system for Ableton Live 12 — 430 tools, 53 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 engines)",
|
|
6
6
|
"author": "Pilot Studio",
|
|
7
7
|
"license": "BSL-1.1",
|
|
8
8
|
"type": "commonjs",
|
|
@@ -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.20.
|
|
8
|
+
__version__ = "1.20.3"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|
package/server.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.dreamrec/livepilot",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "430-tool agentic MCP production system for Ableton Live 12 — device atlas, sample engine, composer",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "https://github.com/dreamrec/LivePilot",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.3",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "livepilot",
|
|
14
|
-
"version": "1.20.
|
|
14
|
+
"version": "1.20.3",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|