livepilot 1.20.0 → 1.20.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 +129 -0
- 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/devices.py +91 -5
- package/mcp_server/tools/tracks.py +62 -4
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,134 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.20.2 — 5 bugs + 1 race condition from the live-test campaign (April 24 2026)
|
|
4
|
+
|
|
5
|
+
Patch release fixing every issue surfaced during the v1.20.1 five-project
|
|
6
|
+
live-test campaign documented at `~/Desktop/DREAM AI/demo Project/REPORT.md`.
|
|
7
|
+
Each fix landed as its own atomic commit with TDD contract tests. Full
|
|
8
|
+
test suite: 2985 → 3037 pass (+52 new tests), zero regressions.
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
|
|
12
|
+
**🐛 #1 — Device Forge: all 7 `create_*` moves ship broken (CRITICAL).**
|
|
13
|
+
Each move's `plan_template` emitted `generate_m4l_effect` WITHOUT the
|
|
14
|
+
required `gen_code` argument, so every move failed with `missing 1
|
|
15
|
+
required positional argument: 'gen_code'` in explore mode. The 7
|
|
16
|
+
GenExpr templates already existed in `mcp_server/device_forge/
|
|
17
|
+
templates.py` (lorenz_attractor, wavefolder, bitcrusher, etc.) but
|
|
18
|
+
weren't wired. Fix: `device_creation_compilers._MOVE_TO_TEMPLATE`
|
|
19
|
+
routes each move_id to its template and the compiler injects `gen_code`
|
|
20
|
+
at compile time. (commit `61abbeb`)
|
|
21
|
+
|
|
22
|
+
**🐛 #2 — Sample family: `{sample_file_path}` template placeholder leaked
|
|
23
|
+
to compiled plans.** `_resolve_sample_path` returned a literal
|
|
24
|
+
`"{sample_file_path}"` string when the kernel had no path set —
|
|
25
|
+
falling through to `load_sample_to_simpler` with a non-existent file.
|
|
26
|
+
Fix: resolver now reads `seed_args["file_path"]` (v1.20 convention),
|
|
27
|
+
falls back to legacy `kernel["sample_file_path"]` (wonder_mode
|
|
28
|
+
setter), returns `None` on miss. Each of the 6 sample moves rejects
|
|
29
|
+
with a non-executable plan + actionable warning when path is None.
|
|
30
|
+
(commit `26de33c`)
|
|
31
|
+
|
|
32
|
+
**🐛 #3 — Analyzer-gated moves hard-fail their mutation steps.**
|
|
33
|
+
`tighten_low_end` and `make_kick_bass_lock` emitted
|
|
34
|
+
`get_master_spectrum` as a pre-read. When the analyzer wasn't loaded
|
|
35
|
+
on master, step 0 failed and `execute_plan_steps_async`
|
|
36
|
+
`stop_on_failure=True` halted the plan BEFORE the mutation steps
|
|
37
|
+
(bass volume change) ran. Fix: general `CompiledStep.optional: bool`
|
|
38
|
+
field + router skip-and-continue on optional failures; affected
|
|
39
|
+
compilers tag their analyzer pre-reads as `optional=True`. The
|
|
40
|
+
mechanism is reusable for any future soft-gated diagnostic step.
|
|
41
|
+
(commit `5f9f0ae`)
|
|
42
|
+
|
|
43
|
+
**🐛 #4 — `batch_set_parameters` silently snaps quantized enum params.**
|
|
44
|
+
Beat Repeat's `Gate=0.3` and `Variation=0.8` became `Gate=0` / `Variation=0`
|
|
45
|
+
— valid snaps for quantized enum params, but the response gave callers
|
|
46
|
+
no signal that their intent was discarded. Fix: `batch_set_parameters`
|
|
47
|
+
post-processes Ableton's response, comparing requested vs returned
|
|
48
|
+
values with 1e-5 epsilon; appends a `snapped_params` list when
|
|
49
|
+
mismatches occur, each carrying `{name, requested, actual,
|
|
50
|
+
display_value, value_string}`. Empty list = nothing snapped.
|
|
51
|
+
(commit `b472976`)
|
|
52
|
+
|
|
53
|
+
**🐛 #5 — `create_midi_track` can create duplicate-name tracks silently.**
|
|
54
|
+
When `set_track_name(2, "Pad")` runs and then `create_midi_track(index=2,
|
|
55
|
+
name="Pad")` shifts the existing track to index 4 while retaining its
|
|
56
|
+
name, the session ends up with two "Pad" tracks. Downstream
|
|
57
|
+
`find_tracks_by_role` matches both and mix moves apply twice. Fix:
|
|
58
|
+
`create_midi_track` and `create_audio_track` now pre-query session
|
|
59
|
+
for tracks with the requested name and stamp the response with
|
|
60
|
+
`name_collision: bool` + `existing_tracks_with_same_name: list[int]`.
|
|
61
|
+
Doesn't block creation — callers decide whether to rename or accept.
|
|
62
|
+
(commit `69bc545`)
|
|
63
|
+
|
|
64
|
+
**🔁 Race condition — "Connection closed by Ableton" on UI transitions.**
|
|
65
|
+
Observed 3× during the campaign: after `Cmd+N` (new live set), the
|
|
66
|
+
next MCP call would drop with `Connection closed by Ableton`.
|
|
67
|
+
Ableton's Remote Script briefly rejects commands during UI state
|
|
68
|
+
transitions. Fix: `connection.send_command` now retries once with
|
|
69
|
+
400ms backoff on that specific error, reconnecting between attempts.
|
|
70
|
+
Timeouts still raise immediately (mutation-duplicate risk). Retry
|
|
71
|
+
budget capped at 1 — second failure raises cleanly. (commit `cf019d5`)
|
|
72
|
+
|
|
73
|
+
### Scope of the campaign
|
|
74
|
+
|
|
75
|
+
See `~/Desktop/DREAM AI/demo Project/` for the 5 `.als` files, `PLAN.md`,
|
|
76
|
+
and `REPORT.md` that produced this backlog:
|
|
77
|
+
- `01 basic-channel-dub.als` — dub techno @ 130
|
|
78
|
+
- `02 dilla-swing-drums.als` — hip-hop @ 90 with MIDI-native swing
|
|
79
|
+
- `03 opn-wonder-texture.als` — ambient @ 70 (Device Forge failure noted)
|
|
80
|
+
- `04 aphex-destruction.als` — IDM @ 155 (Beat Repeat snap noted)
|
|
81
|
+
- `05 mix-polish.als` — house @ 125 (analyzer-gate failure noted)
|
|
82
|
+
|
|
83
|
+
### CI status
|
|
84
|
+
|
|
85
|
+
All 9 CI jobs expected green: python-tests × {ubuntu, macos, windows} ×
|
|
86
|
+
{3.11, 3.12}, metadata-drift, amxd-freeze-drift, js-entrypoint.
|
|
87
|
+
|
|
88
|
+
### Non-goals
|
|
89
|
+
|
|
90
|
+
No new moves in v1.20.2 — every change is a fix to existing surfaces.
|
|
91
|
+
v1.21 remains the consolidation release (see `docs/plans/v1.21-structural-plan.md`).
|
|
92
|
+
|
|
93
|
+
## 1.20.1 — CI hardening: Windows UTF-8 encoding + .amxd ping drift (April 24 2026)
|
|
94
|
+
|
|
95
|
+
Patch release fixing CI regressions that v1.20.0 shipped with (caught
|
|
96
|
+
by the actual CI run post-tag). No runtime behavior changes — tests
|
|
97
|
+
pass on every platform, and the .amxd ping now matches the repo
|
|
98
|
+
version. Zero new tests (both fixes are mechanical), zero regressions.
|
|
99
|
+
|
|
100
|
+
### Fixes
|
|
101
|
+
|
|
102
|
+
- **Windows-only `UnicodeDecodeError` in `tests/test_creative_director.py`.**
|
|
103
|
+
27 bare `Path.read_text()` calls used the locale default encoding.
|
|
104
|
+
On Windows that's cp1252, which chokes on the em-dashes (—), arrows
|
|
105
|
+
(→), and smart quotes v1.20 added to `SKILL.md` and the new
|
|
106
|
+
`phase-6-execution.md` reference. All 27 calls now pass
|
|
107
|
+
`encoding="utf-8"` explicitly. Cross-platform hygiene, applies
|
|
108
|
+
beyond the trigger case — any future markdown additions with
|
|
109
|
+
non-ASCII chars are now safe on Windows.
|
|
110
|
+
|
|
111
|
+
- **`LivePilot_Analyzer.amxd` ping reported v1.17.5 for 9 releases.**
|
|
112
|
+
The CI `amxd-freeze-drift` job has been red since v1.18.0 because
|
|
113
|
+
nobody re-froze the .amxd in Max Editor after the JS source bumps.
|
|
114
|
+
Users of v1.18.0-v1.20.0 got `{ok: true, version: "1.17.5"}` from
|
|
115
|
+
the bridge ping regardless of the installed version. Per
|
|
116
|
+
`feedback_amxd_safe_binary_patch` memory, same-length version
|
|
117
|
+
strings can be patched in-place without Max re-export. Both
|
|
118
|
+
occurrences in the binary updated to 1.20.1 (file size unchanged);
|
|
119
|
+
the JS source `livepilot_bridge.js` VERSION constant also bumped
|
|
120
|
+
so future clean freezes stay consistent.
|
|
121
|
+
|
|
122
|
+
### CI status after this release
|
|
123
|
+
|
|
124
|
+
| Job | Pre-v1.20.1 | v1.20.1 |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| python-tests (ubuntu, macos) × {3.11, 3.12} | ✓ | ✓ |
|
|
127
|
+
| python-tests (windows) × {3.11, 3.12} | ✗ UnicodeDecodeError | ✓ |
|
|
128
|
+
| metadata-drift | ✓ | ✓ |
|
|
129
|
+
| amxd-freeze-drift | ✗ stuck at 1.17.5 | ✓ |
|
|
130
|
+
| js-entrypoint | ✓ | ✓ |
|
|
131
|
+
|
|
3
132
|
## 1.20.0 — Item C phased cutover: 10 new semantic moves + Director Phase 6 rewrite (April 24 2026)
|
|
4
133
|
|
|
5
134
|
Implements the plan in `docs/plans/v1.20-structural-plan.md`. Ships 10
|
|
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.
|
|
37
|
+
var VERSION = "1.20.2";
|
|
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.2"
|
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]
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.2",
|
|
4
4
|
"mcpName": "io.github.dreamrec/livepilot",
|
|
5
5
|
"description": "Agentic production system for Ableton Live 12 — 429 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",
|
|
@@ -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.2"
|
|
9
9
|
|
|
10
10
|
from _Framework.ControlSurface import ControlSurface
|
|
11
11
|
from . import router
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/dreamrec/LivePilot",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "livepilot",
|
|
14
|
-
"version": "1.20.
|
|
14
|
+
"version": "1.20.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|