livepilot 1.27.2 → 1.27.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 +66 -0
- package/README.md +10 -7
- package/bin/livepilot.js +97 -39
- package/livepilot/.Codex-plugin/plugin.json +1 -1
- package/livepilot/.claude-plugin/plugin.json +1 -1
- package/livepilot/skills/livepilot-core/SKILL.md +33 -1
- package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
- package/livepilot/skills/livepilot-core/references/overview.md +18 -11
- package/livepilot/skills/livepilot-core/references/perception.md +125 -0
- package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
- package/livepilot/skills/livepilot-release/SKILL.md +1 -1
- package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +6 -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/atlas/__init__.py +181 -40
- package/mcp_server/atlas/overlays.py +46 -3
- package/mcp_server/atlas/tools.py +226 -86
- package/mcp_server/audit/checks.py +50 -22
- package/mcp_server/audit/state.py +10 -2
- package/mcp_server/audit/tools.py +27 -6
- package/mcp_server/composer/develop/apply.py +7 -7
- package/mcp_server/composer/fast/apply.py +29 -23
- package/mcp_server/composer/framework/atlas_resolver.py +16 -1
- package/mcp_server/composer/full/apply.py +40 -34
- package/mcp_server/composer/full/engine.py +66 -32
- package/mcp_server/composer/tools.py +4 -3
- package/mcp_server/connection.py +67 -9
- package/mcp_server/creative_constraints/engine.py +10 -2
- package/mcp_server/creative_constraints/tools.py +24 -7
- package/mcp_server/curves.py +30 -7
- package/mcp_server/device_forge/builder.py +40 -11
- package/mcp_server/device_forge/models.py +9 -0
- package/mcp_server/device_forge/tools.py +30 -11
- package/mcp_server/experiment/engine.py +29 -22
- package/mcp_server/experiment/tools.py +15 -5
- package/mcp_server/m4l_bridge.py +69 -7
- package/mcp_server/memory/taste_graph.py +43 -0
- package/mcp_server/memory/technique_store.py +26 -3
- package/mcp_server/memory/tools.py +62 -4
- package/mcp_server/mix_engine/critics.py +187 -30
- package/mcp_server/mix_engine/models.py +21 -1
- package/mcp_server/mix_engine/state_builder.py +87 -8
- package/mcp_server/mix_engine/tools.py +16 -4
- package/mcp_server/performance_engine/tools.py +56 -8
- package/mcp_server/persistence/base_store.py +11 -0
- package/mcp_server/persistence/project_store.py +132 -8
- package/mcp_server/persistence/taste_store.py +90 -7
- package/mcp_server/preview_studio/engine.py +40 -10
- package/mcp_server/preview_studio/models.py +40 -0
- package/mcp_server/preview_studio/tools.py +56 -12
- package/mcp_server/project_brain/arrangement_graph.py +4 -0
- package/mcp_server/project_brain/models.py +2 -0
- package/mcp_server/project_brain/role_graph.py +13 -7
- package/mcp_server/reference_engine/gap_analyzer.py +58 -3
- package/mcp_server/reference_engine/profile_builder.py +47 -4
- package/mcp_server/reference_engine/tools.py +6 -0
- package/mcp_server/runtime/execution_router.py +51 -1
- package/mcp_server/runtime/tools.py +0 -1
- package/mcp_server/sample_engine/sources.py +0 -2
- package/mcp_server/sample_engine/tools.py +19 -38
- package/mcp_server/semantic_moves/mix_compilers.py +276 -51
- package/mcp_server/semantic_moves/performance_compilers.py +51 -17
- package/mcp_server/semantic_moves/resolvers.py +45 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
- package/mcp_server/semantic_moves/tools.py +80 -2
- package/mcp_server/semantic_moves/transition_compilers.py +26 -9
- package/mcp_server/server.py +26 -24
- package/mcp_server/session_continuity/tracker.py +51 -3
- package/mcp_server/song_brain/builder.py +47 -5
- package/mcp_server/song_brain/tools.py +21 -7
- package/mcp_server/sound_design/critics.py +1 -0
- package/mcp_server/splice_client/client.py +117 -33
- package/mcp_server/splice_client/http_bridge.py +15 -3
- package/mcp_server/splice_client/quota.py +28 -0
- package/mcp_server/stuckness_detector/detector.py +8 -5
- package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
- package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
- package/mcp_server/tools/_perception_engine.py +6 -0
- package/mcp_server/tools/agent_os.py +4 -1
- package/mcp_server/tools/analyzer.py +198 -209
- package/mcp_server/tools/arrangement.py +7 -4
- package/mcp_server/tools/automation.py +24 -4
- package/mcp_server/tools/browser.py +25 -11
- package/mcp_server/tools/clips.py +6 -0
- package/mcp_server/tools/composition.py +33 -2
- package/mcp_server/tools/devices.py +53 -53
- package/mcp_server/tools/generative.py +14 -14
- package/mcp_server/tools/harmony.py +7 -7
- package/mcp_server/tools/mixing.py +4 -4
- package/mcp_server/tools/planner.py +68 -6
- package/mcp_server/tools/research.py +20 -2
- package/mcp_server/tools/theory.py +10 -10
- package/mcp_server/tools/transport.py +7 -2
- package/mcp_server/transition_engine/critics.py +13 -1
- package/mcp_server/user_corpus/tools.py +30 -1
- package/mcp_server/wonder_mode/engine.py +82 -9
- package/mcp_server/wonder_mode/session.py +32 -10
- package/mcp_server/wonder_mode/tools.py +14 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +93 -33
- package/remote_script/LivePilot/browser.py +60 -4
- package/remote_script/LivePilot/devices.py +132 -62
- package/remote_script/LivePilot/mixing.py +31 -5
- package/remote_script/LivePilot/server.py +94 -22
- package/remote_script/LivePilot/transport.py +11 -0
- package/requirements.txt +5 -5
- package/server.json +2 -2
|
@@ -208,20 +208,46 @@ def _arrangement_steps(
|
|
|
208
208
|
layer: LayerSpec,
|
|
209
209
|
sections: list[dict],
|
|
210
210
|
) -> list[dict]:
|
|
211
|
-
"""Emit the
|
|
211
|
+
"""Emit the Session-View source-clip scaffold for a layer.
|
|
212
212
|
|
|
213
213
|
For each layer that appears in at least one section, we emit:
|
|
214
214
|
|
|
215
215
|
1. create_clip — a 1-bar MIDI clip in session slot 0 (the source)
|
|
216
216
|
2. add_notes — a single C3 trigger note so Simpler actually sounds
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
217
|
+
|
|
218
|
+
The trigger-clip approach is intentionally minimal: Simpler in classic
|
|
219
|
+
mode plays the full sample on every note, so a single C3 at bar 0 is
|
|
220
|
+
enough for a playable, auditionable baseline in Session View. The
|
|
221
|
+
suggest_sample_technique step elsewhere in the plan produces a recipe
|
|
222
|
+
the agent can use later to replace the trigger pattern with something
|
|
223
|
+
more musical.
|
|
224
|
+
|
|
225
|
+
BUG-FULL-MODE-18 (rescoped): this used to also emit one
|
|
226
|
+
`create_arrangement_clip` per section, tiling the SAME 1-bar/single-note
|
|
227
|
+
source across every section (intro/build/drop/breakdown/outro all play
|
|
228
|
+
the identical loop). That isn't arrangement — it's pattern
|
|
229
|
+
multiplication dressed up as one, and it's worse than not placing
|
|
230
|
+
anything at all because it *looks* like a finished arrangement to a
|
|
231
|
+
caller inspecting `plan`. `ComposerEngine` is a pure-computation engine
|
|
232
|
+
with no per-section creative content generation (no LLM in the loop,
|
|
233
|
+
no per-section note variation) — that capability lives in the
|
|
234
|
+
agent-designed `apply_full_plan_v2` variant-slot system
|
|
235
|
+
(`composer/full/apply.py`), which real callers of `compose_full_apply`
|
|
236
|
+
already use instead of this deterministic engine (its own docstring
|
|
237
|
+
says as much: "Replaces the deterministic engine path that was prone
|
|
238
|
+
to flat single-pattern arrangements (BUG-FULL-MODE-18)").
|
|
239
|
+
`ComposerEngine.compose()` is reachable through a second path though —
|
|
240
|
+
`commit_experiment` → `escalate_composer_branch` — which has no agent
|
|
241
|
+
turn to design real per-section variants. Porting the variant-slot
|
|
242
|
+
system here would require synthesizing per-section musical content
|
|
243
|
+
from nothing, which is out of scope for a mechanical renumbering/
|
|
244
|
+
dead-code fix and would just relocate the same static content into
|
|
245
|
+
more slots without solving the actual "flat" complaint. So: this
|
|
246
|
+
engine now emits ONLY the Session-View scaffold (source clip + track),
|
|
247
|
+
honestly leaving Arrangement placement undone rather than faking it.
|
|
248
|
+
Callers that need real per-section arrangement variation should use
|
|
249
|
+
`compose_full_apply` (or fall back to the branch-hypothesis scaffold
|
|
250
|
+
plan, which never claimed arrangement placement in the first place).
|
|
225
251
|
"""
|
|
226
252
|
active_sections = [s for s in sections if s["name"] in layer.sections]
|
|
227
253
|
if not active_sections:
|
|
@@ -273,27 +299,6 @@ def _arrangement_steps(
|
|
|
273
299
|
"role": layer.role,
|
|
274
300
|
})
|
|
275
301
|
|
|
276
|
-
# 3. One arrangement clip per section this layer appears in
|
|
277
|
-
for section in active_sections:
|
|
278
|
-
start_bar = section["start_bar"]
|
|
279
|
-
bar_count = section["bars"]
|
|
280
|
-
steps.append({
|
|
281
|
-
"tool": "create_arrangement_clip",
|
|
282
|
-
"params": {
|
|
283
|
-
"track_index": track_index,
|
|
284
|
-
"clip_slot_index": SOURCE_SLOT,
|
|
285
|
-
"start_time": float(start_bar * 4.0), # bars → beats
|
|
286
|
-
"length": float(bar_count * 4.0),
|
|
287
|
-
"loop_length": SOURCE_BEATS, # tile 1-bar source
|
|
288
|
-
},
|
|
289
|
-
"description": (
|
|
290
|
-
f"Arrange {layer.role} into '{section['name']}' "
|
|
291
|
-
f"(bar {start_bar}, {bar_count} bars)"
|
|
292
|
-
),
|
|
293
|
-
"role": layer.role,
|
|
294
|
-
"section": section["name"],
|
|
295
|
-
})
|
|
296
|
-
|
|
297
302
|
return steps
|
|
298
303
|
|
|
299
304
|
|
|
@@ -354,9 +359,18 @@ class ComposerEngine:
|
|
|
354
359
|
plan.append(_step_set_tempo(intent.tempo))
|
|
355
360
|
|
|
356
361
|
# Step 2: Per-layer build, resolving samples at plan time
|
|
362
|
+
#
|
|
363
|
+
# BUG-FULL-MODE-15: track_index used to be `layer_idx` — the layer's
|
|
364
|
+
# position in the ORIGINAL (pre-drop) layer list. If an earlier layer
|
|
365
|
+
# dropped as unresolved (no continue happened for it, so no track was
|
|
366
|
+
# ever created for that index), later layers still emitted their
|
|
367
|
+
# stale original index — e.g. layers 0,3,4 survive out of 0..4, and
|
|
368
|
+
# `create_midi_track(index=3)` fails because only 1 track exists.
|
|
369
|
+
# `next_track_index` instead counts only ACTUAL track creations, so
|
|
370
|
+
# surviving layers get contiguous indices (0, 1, 2, ...) matching
|
|
371
|
+
# the tracks that really get created.
|
|
372
|
+
next_track_index = 0
|
|
357
373
|
for layer_idx, layer in enumerate(layers):
|
|
358
|
-
track_index = layer_idx
|
|
359
|
-
|
|
360
374
|
file_path, source = await resolve_sample_for_layer(
|
|
361
375
|
layer,
|
|
362
376
|
search_roots=search_roots,
|
|
@@ -371,6 +385,9 @@ class ComposerEngine:
|
|
|
371
385
|
)
|
|
372
386
|
continue
|
|
373
387
|
|
|
388
|
+
track_index = next_track_index
|
|
389
|
+
next_track_index += 1
|
|
390
|
+
|
|
374
391
|
result.resolved_samples[layer.role] = {"path": file_path, "source": source}
|
|
375
392
|
|
|
376
393
|
track_step_id = f"layer_{layer_idx}_track"
|
|
@@ -387,6 +404,23 @@ class ComposerEngine:
|
|
|
387
404
|
plan.extend(_mix_steps(track_index, layer))
|
|
388
405
|
plan.extend(_arrangement_steps(track_index, layer, sections))
|
|
389
406
|
|
|
407
|
+
# BUG-FULL-MODE-18 (rescoped): this engine only emits a Session-View
|
|
408
|
+
# scaffold per layer (source clip + trigger note) — it does not
|
|
409
|
+
# place anything in Arrangement, since it has no per-section
|
|
410
|
+
# creative content to place. Flag this once so callers (e.g.
|
|
411
|
+
# escalate_composer_branch) don't mistake `plan` for a finished
|
|
412
|
+
# arrangement. Real per-section arrangement variation lives in the
|
|
413
|
+
# agent-designed apply_full_plan_v2 flow (compose_full_apply).
|
|
414
|
+
if layers:
|
|
415
|
+
result.warnings.append(
|
|
416
|
+
"This scaffold places one Session-View source clip per "
|
|
417
|
+
"resolved layer and does not write anything to Arrangement "
|
|
418
|
+
"View — per-section arrangement variation is intentionally "
|
|
419
|
+
"out of scope for this deterministic engine. Use "
|
|
420
|
+
"compose_full_apply's agent-designed variant plan for real "
|
|
421
|
+
"per-section arrangement content."
|
|
422
|
+
)
|
|
423
|
+
|
|
390
424
|
result.plan = plan
|
|
391
425
|
return result
|
|
392
426
|
|
|
@@ -19,6 +19,7 @@ from .full.engine import ComposerEngine
|
|
|
19
19
|
from . import fast as fast_compose
|
|
20
20
|
from .fast.apply import apply_fast_plan
|
|
21
21
|
from .full.apply import apply_full_plan
|
|
22
|
+
import asyncio
|
|
22
23
|
import logging
|
|
23
24
|
import time
|
|
24
25
|
|
|
@@ -276,8 +277,8 @@ async def compose(
|
|
|
276
277
|
return brief
|
|
277
278
|
|
|
278
279
|
if mode == "fast":
|
|
279
|
-
brief =
|
|
280
|
-
ctx, intent, bars=int(bars), reference=reference,
|
|
280
|
+
brief = await asyncio.to_thread(
|
|
281
|
+
_build_fast_brief, ctx, intent, bars=int(bars), reference=reference,
|
|
281
282
|
)
|
|
282
283
|
brief["prompt"] = prompt
|
|
283
284
|
return brief
|
|
@@ -651,7 +652,7 @@ async def augment_with_samples(
|
|
|
651
652
|
try:
|
|
652
653
|
ableton = ctx.lifespan_context.get("ableton")
|
|
653
654
|
if ableton:
|
|
654
|
-
info = ableton.
|
|
655
|
+
info = await ableton.send_command_async("get_session_info", {})
|
|
655
656
|
session_context["tempo"] = info.get("tempo", 120)
|
|
656
657
|
session_context["track_count"] = info.get("track_count", 0)
|
|
657
658
|
except Exception as exc:
|
package/mcp_server/connection.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import json
|
|
6
7
|
import os
|
|
7
8
|
import socket
|
|
@@ -79,9 +80,20 @@ def _identify_other_tcp_client(host: str, port: int) -> str | None:
|
|
|
79
80
|
out = subprocess.check_output(
|
|
80
81
|
["lsof", "-nP", f"-iTCP:{port}"],
|
|
81
82
|
text=True,
|
|
82
|
-
|
|
83
|
+
# Bounded tight: this runs inside the send lock on a socket
|
|
84
|
+
# timeout, so a slow lsof would extend the lock-hold (blocking
|
|
85
|
+
# every concurrent tool). TimeoutExpired is a SubprocessError —
|
|
86
|
+
# NOT a CalledProcessError — so it must be caught explicitly or
|
|
87
|
+
# it propagates out of the error-diagnostic path and crashes it.
|
|
88
|
+
timeout=1,
|
|
83
89
|
)
|
|
84
|
-
except (
|
|
90
|
+
except (
|
|
91
|
+
subprocess.CalledProcessError,
|
|
92
|
+
subprocess.TimeoutExpired,
|
|
93
|
+
FileNotFoundError,
|
|
94
|
+
ValueError,
|
|
95
|
+
OSError,
|
|
96
|
+
):
|
|
85
97
|
return None
|
|
86
98
|
|
|
87
99
|
target = f"->{host}:{port}"
|
|
@@ -119,6 +131,10 @@ class AbletonConnection:
|
|
|
119
131
|
|
|
120
132
|
def connect(self) -> None:
|
|
121
133
|
"""Open a TCP connection to the Remote Script."""
|
|
134
|
+
# Close any socket we still hold before opening a new one, otherwise the
|
|
135
|
+
# previous fd/socket leaks if connect() is called without a disconnect().
|
|
136
|
+
if self._socket is not None:
|
|
137
|
+
self.disconnect()
|
|
122
138
|
try:
|
|
123
139
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
124
140
|
sock.settimeout(CONNECT_TIMEOUT)
|
|
@@ -283,12 +299,36 @@ class AbletonConnection:
|
|
|
283
299
|
message = err.get("message", str(err)) if isinstance(err, dict) else str(err)
|
|
284
300
|
log_entry["error"] = code
|
|
285
301
|
self._command_log.append(log_entry)
|
|
302
|
+
# Client-eviction visibility: when this is the single-client guard
|
|
303
|
+
# rejection (this process lost the socket to another connected
|
|
304
|
+
# client — even after the retry-once path above), always try to
|
|
305
|
+
# surface WHO holds it so the user doesn't have to go hunting.
|
|
306
|
+
if _is_single_client_state_error(response):
|
|
307
|
+
other_client = _identify_other_tcp_client(self.host, self.port)
|
|
308
|
+
if other_client:
|
|
309
|
+
message = (
|
|
310
|
+
f"{message} Another LivePilot client appears to be "
|
|
311
|
+
f"connected on {self.host}:{self.port} ({other_client}). "
|
|
312
|
+
"Disconnect the other client and retry."
|
|
313
|
+
)
|
|
286
314
|
friendly = _friendly_error(code, message, command_type)
|
|
287
315
|
raise AbletonConnectionError(friendly)
|
|
288
316
|
|
|
289
317
|
self._command_log.append(log_entry)
|
|
290
318
|
return response.get("result", {})
|
|
291
319
|
|
|
320
|
+
async def send_command_async(self, command_type: str, params: Optional[dict] = None) -> dict:
|
|
321
|
+
"""Async wrapper around :meth:`send_command`.
|
|
322
|
+
|
|
323
|
+
``send_command`` performs a blocking TCP round-trip guarded by
|
|
324
|
+
``self._lock``. Async MCP tools run on FastMCP's single event loop, so
|
|
325
|
+
calling ``send_command`` directly from one would freeze every other
|
|
326
|
+
concurrent coroutine (other tool calls, the UDP analyzer bridge, etc.)
|
|
327
|
+
for the duration of the round-trip. Offload it to a worker thread so
|
|
328
|
+
the loop stays free.
|
|
329
|
+
"""
|
|
330
|
+
return await asyncio.to_thread(self.send_command, command_type, params)
|
|
331
|
+
|
|
292
332
|
# ------------------------------------------------------------------
|
|
293
333
|
# Command log
|
|
294
334
|
# ------------------------------------------------------------------
|
|
@@ -340,7 +380,9 @@ class AbletonConnection:
|
|
|
340
380
|
err._send_completed = True
|
|
341
381
|
raise err
|
|
342
382
|
except socket.timeout as exc:
|
|
343
|
-
|
|
383
|
+
# Timeout is fatal to the connection: disconnect() below wipes
|
|
384
|
+
# _recv_buf to b"", so preserving the partial `buf` here would be
|
|
385
|
+
# dead, self-contradicting state. Drop it.
|
|
344
386
|
self.disconnect()
|
|
345
387
|
other_client = _identify_other_tcp_client(self.host, self.port)
|
|
346
388
|
if other_client:
|
|
@@ -352,7 +394,7 @@ class AbletonConnection:
|
|
|
352
394
|
err._send_completed = True
|
|
353
395
|
raise err from exc
|
|
354
396
|
err = AbletonConnectionError(
|
|
355
|
-
f"Timeout waiting for response from Ableton ({
|
|
397
|
+
f"Timeout waiting for response from Ableton ({recv_timeout}s)"
|
|
356
398
|
)
|
|
357
399
|
err._send_completed = True
|
|
358
400
|
raise err from exc
|
|
@@ -368,11 +410,27 @@ class AbletonConnection:
|
|
|
368
410
|
line, remainder = buf.split(b"\n", 1)
|
|
369
411
|
self._recv_buf = remainder
|
|
370
412
|
try:
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
413
|
+
try:
|
|
414
|
+
parsed = json.loads(line)
|
|
415
|
+
except json.JSONDecodeError as exc:
|
|
416
|
+
raise AbletonConnectionError(
|
|
417
|
+
f"Invalid JSON from Ableton: {line[:200]}"
|
|
418
|
+
) from exc
|
|
419
|
+
|
|
420
|
+
# Correlate the response against the command we just sent. The
|
|
421
|
+
# Remote Script echoes our request id; a mismatch means we read an
|
|
422
|
+
# orphan / mis-paired frame and must not return it as this
|
|
423
|
+
# command's result.
|
|
424
|
+
resp_id = parsed.get("id") if isinstance(parsed, dict) else None
|
|
425
|
+
if resp_id is not None and resp_id != envelope["id"]:
|
|
426
|
+
self.disconnect()
|
|
427
|
+
err = AbletonConnectionError(
|
|
428
|
+
f"Response id mismatch from Ableton "
|
|
429
|
+
f"(expected {envelope['id']}, got {resp_id})"
|
|
430
|
+
)
|
|
431
|
+
err._send_completed = True
|
|
432
|
+
raise err
|
|
433
|
+
return parsed
|
|
376
434
|
finally:
|
|
377
435
|
if self._socket is not None:
|
|
378
436
|
try:
|
|
@@ -103,14 +103,22 @@ def validate_plan_against_constraints(
|
|
|
103
103
|
|
|
104
104
|
elif constraint == "subtraction_only":
|
|
105
105
|
add_actions = {"create_clip", "create_midi_track", "create_audio_track",
|
|
106
|
-
"duplicate_clip", "duplicate_track"
|
|
106
|
+
"duplicate_clip", "duplicate_track",
|
|
107
|
+
# The most common content-ADDING tools were missing, so
|
|
108
|
+
# subtraction_only let additions through. Real tool names:
|
|
109
|
+
"add_notes", "add_arrangement_notes", "create_scene",
|
|
110
|
+
"duplicate_scene", "insert_simpler_slice", "add_drum_rack_pad",
|
|
111
|
+
"create_arrangement_clip", "insert_device", "insert_rack_chain"}
|
|
107
112
|
for step in steps:
|
|
108
113
|
if step.get("action", "") in add_actions:
|
|
109
114
|
violations.append(f"Step adds content ({step['action']}) — violates subtraction_only")
|
|
110
115
|
|
|
111
116
|
elif constraint == "arrangement_only":
|
|
117
|
+
# NOTE: "set_track_send" is the REAL registered send tool; the old
|
|
118
|
+
# "set_send_level" is a dead name that never matched a compiled step,
|
|
119
|
+
# so send-level moves silently passed under arrangement_only.
|
|
112
120
|
mix_actions = {"set_device_parameter", "set_track_volume", "set_track_pan",
|
|
113
|
-
"
|
|
121
|
+
"set_track_send"}
|
|
114
122
|
for step in steps:
|
|
115
123
|
if step.get("action", "") in mix_actions:
|
|
116
124
|
violations.append(f"Step modifies mix ({step['action']}) — violates arrangement_only")
|
|
@@ -118,16 +118,23 @@ def distill_reference_principles(
|
|
|
118
118
|
except Exception as exc:
|
|
119
119
|
logger.debug("distill_reference_principles failed: %s", exc)
|
|
120
120
|
|
|
121
|
-
# Try the built-in style profile builder
|
|
122
|
-
|
|
121
|
+
# Try the built-in style profile builder. build_style_reference_profile
|
|
122
|
+
# takes a LIST OF TACTIC DICTS (StyleTactic.to_dict()), not a raw string —
|
|
123
|
+
# get_style_tactics resolves a NAMED style (artist/genre) to that data.
|
|
124
|
+
# Passing the bare style_name string made the builder iterate the string's
|
|
125
|
+
# characters, raise AttributeError on `char.get(...)`, and silently fall
|
|
126
|
+
# through to the text-keyword path below — so this whole branch was dead.
|
|
127
|
+
if not reference_profile and style_name:
|
|
123
128
|
try:
|
|
124
129
|
from ..reference_engine.profile_builder import build_style_reference_profile
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
)
|
|
128
|
-
|
|
130
|
+
from ..tools._research_engine import get_style_tactics
|
|
131
|
+
tactics = get_style_tactics(style_name)
|
|
132
|
+
tactic_dicts = [t.to_dict() for t in tactics]
|
|
133
|
+
if tactic_dicts:
|
|
134
|
+
profile = build_style_reference_profile(tactic_dicts)
|
|
135
|
+
reference_profile = profile.to_dict() if profile else {}
|
|
129
136
|
except Exception as exc:
|
|
130
|
-
logger.debug("distill_reference_principles failed: %s", exc)
|
|
137
|
+
logger.debug("distill_reference_principles style-profile failed: %s", exc)
|
|
131
138
|
|
|
132
139
|
# Text-keyword fallback ALWAYS merges in. Style tactics + profile
|
|
133
140
|
# builder typically leave some fields empty; the description's
|
|
@@ -247,6 +254,14 @@ def generate_constrained_variants(
|
|
|
247
254
|
# variants to status='blocked', and count blocked_count in the
|
|
248
255
|
# response so callers can detect the "all variants filtered" case.
|
|
249
256
|
blocked_count = 0
|
|
257
|
+
# Surface which constraints are advisory-only (recognized but NOT
|
|
258
|
+
# enforced by the filter). These depend on the constraint SET, not the
|
|
259
|
+
# plan, so compute once. Without this, a caller requesting a purely
|
|
260
|
+
# advisory constraint gets blocked_count=0 with no signal that
|
|
261
|
+
# enforcement was advisory-only.
|
|
262
|
+
constraint_meta = engine.validate_plan_against_constraints({"steps": []}, active)
|
|
263
|
+
unenforced_constraints = constraint_meta.get("unenforced_constraints", [])
|
|
264
|
+
constraint_warnings = constraint_meta.get("warnings", [])
|
|
250
265
|
for v in ps.variants:
|
|
251
266
|
v.what_preserved = (
|
|
252
267
|
f"{v.what_preserved} | Constraints: "
|
|
@@ -291,6 +306,8 @@ def generate_constrained_variants(
|
|
|
291
306
|
return {
|
|
292
307
|
"preview_set": ps.to_dict(),
|
|
293
308
|
"constraints_applied": active.constraints,
|
|
309
|
+
"unenforced_constraints": unenforced_constraints,
|
|
310
|
+
"constraint_warnings": constraint_warnings,
|
|
294
311
|
"blocked_count": blocked_count,
|
|
295
312
|
"executable_count": len(ps.variants) - blocked_count,
|
|
296
313
|
"note": note,
|
package/mcp_server/curves.py
CHANGED
|
@@ -151,9 +151,15 @@ def generate_curve(
|
|
|
151
151
|
|
|
152
152
|
points = gen(**kwargs)
|
|
153
153
|
|
|
154
|
-
# Auto-calculate point duration if not specified
|
|
154
|
+
# Auto-calculate point duration if not specified. Use the actual spacing
|
|
155
|
+
# between consecutive points rather than duration/len(points): density
|
|
156
|
+
# generators that span the closed interval [0, duration] space points at
|
|
157
|
+
# duration/(density-1), so duration/len would under-shoot and the final
|
|
158
|
+
# step would overshoot the clip end. Reading the real gap keeps the step
|
|
159
|
+
# width consistent with however the generator placed its points.
|
|
155
160
|
if point_duration <= 0 and len(points) > 1:
|
|
156
|
-
|
|
161
|
+
spacing = points[1]["time"] - points[0]["time"]
|
|
162
|
+
point_duration = spacing if spacing > 0 else duration / len(points)
|
|
157
163
|
|
|
158
164
|
# Apply transforms
|
|
159
165
|
for p in points:
|
|
@@ -227,7 +233,9 @@ def _sine(duration: float, density: int, center: float, amplitude: float,
|
|
|
227
233
|
"""Periodic oscillation. frequency = cycles per duration."""
|
|
228
234
|
points = []
|
|
229
235
|
for i in range(density):
|
|
230
|
-
|
|
236
|
+
# Half-open interval [0, duration): never emit t == duration, which
|
|
237
|
+
# for a periodic curve duplicates the t == 0 sample at the loop seam.
|
|
238
|
+
t = i / density if density > 0 else 0.0
|
|
231
239
|
angle = 2 * math.pi * frequency * t + phase * 2 * math.pi
|
|
232
240
|
points.append({
|
|
233
241
|
"time": t * duration,
|
|
@@ -241,7 +249,9 @@ def _sawtooth(duration: float, density: int, start: float, end: float,
|
|
|
241
249
|
"""Ramp up then reset. frequency = resets per duration."""
|
|
242
250
|
points = []
|
|
243
251
|
for i in range(density):
|
|
244
|
-
|
|
252
|
+
# Half-open interval [0, duration): a periodic sawtooth that emits
|
|
253
|
+
# t == duration duplicates the t == 0 sample at the loop seam.
|
|
254
|
+
t = i / density if density > 0 else 0.0
|
|
245
255
|
# Position within current cycle (0.0 to 1.0)
|
|
246
256
|
cycle_pos = (t * frequency) % 1.0
|
|
247
257
|
points.append({
|
|
@@ -268,7 +278,9 @@ def _square(duration: float, density: int, low: float, high: float,
|
|
|
268
278
|
"""Binary on/off toggle."""
|
|
269
279
|
points = []
|
|
270
280
|
for i in range(density):
|
|
271
|
-
|
|
281
|
+
# Half-open interval [0, duration): a periodic gate that emits
|
|
282
|
+
# t == duration duplicates the t == 0 sample at the loop seam.
|
|
283
|
+
t = i / density if density > 0 else 0.0
|
|
272
284
|
cycle_pos = (t * frequency) % 1.0
|
|
273
285
|
points.append({
|
|
274
286
|
"time": t * duration,
|
|
@@ -483,10 +495,16 @@ def _easing(duration: float, density: int, start: float = 0.0,
|
|
|
483
495
|
return 7.5625 * t * t + 0.984375
|
|
484
496
|
|
|
485
497
|
def _elastic(t: float) -> float:
|
|
498
|
+
# easeOutElastic: spring-like overshoot that RINGS DOWN and settles
|
|
499
|
+
# at 1.0. The old easeInElastic form held near 0 for almost the whole
|
|
500
|
+
# span then snapped to 1 at t==1 — after the [0,1] clamp in
|
|
501
|
+
# generate_curve that collapsed to a near-step function (P2-42).
|
|
502
|
+
# easeOutElastic oscillates with decaying amplitude across the span,
|
|
503
|
+
# so the characteristic ring survives the clamp.
|
|
486
504
|
if t == 0 or t == 1:
|
|
487
505
|
return t
|
|
488
|
-
|
|
489
|
-
return
|
|
506
|
+
c4 = (2 * math.pi) / 3
|
|
507
|
+
return 2 ** (-10 * t) * math.sin((t * 10 - 0.75) * c4) + 1.0
|
|
490
508
|
|
|
491
509
|
def _back(t: float) -> float:
|
|
492
510
|
s = 1.70158 # overshoot amount
|
|
@@ -539,6 +557,11 @@ def _euclidean(duration: float, density: int, start: float = 0.0,
|
|
|
539
557
|
5 filter opens across 8 beats. 3 reverb throws across 16 steps.
|
|
540
558
|
Produces non-obvious but musically satisfying rhythmic modulation.
|
|
541
559
|
"""
|
|
560
|
+
# Guard: steps must be >= 1 — Bjorklund on 0 slots yields an empty
|
|
561
|
+
# pattern, and dividing duration by len([]) raises ZeroDivisionError.
|
|
562
|
+
if steps < 1:
|
|
563
|
+
raise ValueError("euclidean 'steps' must be >= 1")
|
|
564
|
+
|
|
542
565
|
# Bjorklund algorithm
|
|
543
566
|
def _bjorklund(hits_n: int, steps_n: int) -> list:
|
|
544
567
|
if hits_n >= steps_n:
|
|
@@ -55,22 +55,25 @@ def _make_line(src_id: str, src_outlet: int,
|
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def _ensure_safety_clip(code: str) -> str:
|
|
58
|
-
"""
|
|
59
|
-
safe = code.rstrip()
|
|
60
|
-
if "clip(" in safe.lower():
|
|
61
|
-
return safe
|
|
58
|
+
"""Clamp each gen~ output to [-1, 1] to prevent speaker damage.
|
|
62
59
|
|
|
63
|
-
|
|
60
|
+
Each `outN = ...` assignment is clamped UNLESS that specific line already
|
|
61
|
+
clips its own output. The previous version bailed entirely when the
|
|
62
|
+
substring 'clip(' appeared ANYWHERE (a comment, an unrelated helper, or
|
|
63
|
+
clipping only ONE of several outputs), shipping the remaining outputs
|
|
64
|
+
unclamped — a real loud-output hazard on Live's master/instrument chain.
|
|
65
|
+
"""
|
|
66
|
+
safe = code.rstrip()
|
|
64
67
|
lines = safe.split("\n")
|
|
65
68
|
new_lines = []
|
|
66
69
|
for line in lines:
|
|
67
70
|
stripped = line.strip()
|
|
68
|
-
|
|
71
|
+
new_lines.append(line)
|
|
72
|
+
is_output = stripped.startswith("out") and "=" in stripped
|
|
73
|
+
already_clipped = "clip(" in stripped.lower()
|
|
74
|
+
if is_output and not already_clipped:
|
|
69
75
|
var = stripped.split("=")[0].strip()
|
|
70
|
-
new_lines.append(line)
|
|
71
76
|
new_lines.append(f"{var} = clip({var}, -1, 1);")
|
|
72
|
-
else:
|
|
73
|
-
new_lines.append(line)
|
|
74
77
|
return "\n".join(new_lines)
|
|
75
78
|
|
|
76
79
|
|
|
@@ -214,12 +217,27 @@ def _build_audio_effect_patcher(spec: DeviceSpec) -> dict:
|
|
|
214
217
|
# plugin~ R -> plugout~ R (direct passthrough)
|
|
215
218
|
lines.append(_make_line(plugin_id, 1, plugout_id, 1))
|
|
216
219
|
|
|
217
|
-
# live.dial for each parameter
|
|
220
|
+
# live.dial for each parameter, WIRED to the gen~ param of the same name.
|
|
221
|
+
# Previously the dials had no outgoing patchline, so they were decorative —
|
|
222
|
+
# moving a knob changed no DSP. live.dial emits its float on outlet 1
|
|
223
|
+
# (outlettype ["", "float"]); routing it through [prepend <name>] produces
|
|
224
|
+
# the "<name> <value>" message that sets the matching `param <name>` object
|
|
225
|
+
# inside the gen~ sub-patcher (gen~ accepts param-set messages on inlet 0
|
|
226
|
+
# alongside the audio signal).
|
|
218
227
|
for i, param in enumerate(spec.params):
|
|
219
228
|
did = nid()
|
|
220
229
|
x = 10.0 + i * 54.0
|
|
221
230
|
boxes.append(param.to_live_dial_json(did, [x, 10.0, 44.0, 48.0]))
|
|
222
231
|
|
|
232
|
+
prepend_id = nid()
|
|
233
|
+
boxes.append(_make_box(
|
|
234
|
+
prepend_id, "newobj", f"prepend {param.name}", 1, 1, [""],
|
|
235
|
+
[x, 70.0, 90.0, 22.0],
|
|
236
|
+
))
|
|
237
|
+
# dial float outlet (1) -> prepend -> gen~ inlet 0
|
|
238
|
+
lines.append(_make_line(did, 1, prepend_id, 0))
|
|
239
|
+
lines.append(_make_line(prepend_id, 0, gen_id, 0))
|
|
240
|
+
|
|
223
241
|
# Title comment
|
|
224
242
|
tid = nid()
|
|
225
243
|
boxes.append({
|
|
@@ -289,12 +307,23 @@ _PATCHER_BUILDERS = {
|
|
|
289
307
|
DeviceType.AUDIO_EFFECT: _build_audio_effect_patcher,
|
|
290
308
|
DeviceType.MIDI_EFFECT: _build_midi_effect_patcher,
|
|
291
309
|
DeviceType.INSTRUMENT: _build_instrument_patcher,
|
|
310
|
+
# MIDI generator / transformation are MIDI-domain devices (Live's "Max MIDI
|
|
311
|
+
# Effect" preset family — see _SUBDIR_MAP), so they share the midiin→midiout
|
|
312
|
+
# patcher. Without these entries build_patcher_json raised an uncaught
|
|
313
|
+
# KeyError for two advertised, reachable device types.
|
|
314
|
+
DeviceType.MIDI_GENERATOR: _build_midi_effect_patcher,
|
|
315
|
+
DeviceType.MIDI_TRANSFORMATION: _build_midi_effect_patcher,
|
|
292
316
|
}
|
|
293
317
|
|
|
294
318
|
|
|
295
319
|
def build_patcher_json(spec: DeviceSpec) -> dict:
|
|
296
320
|
"""Build the complete .maxpat JSON patcher dict for a device spec."""
|
|
297
|
-
|
|
321
|
+
builder = _PATCHER_BUILDERS.get(spec.device_type)
|
|
322
|
+
if builder is None:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f"INVALID_PARAM: no patcher builder for device_type {spec.device_type!r}"
|
|
325
|
+
)
|
|
326
|
+
return builder(spec)
|
|
298
327
|
|
|
299
328
|
|
|
300
329
|
def build_amxd_binary(spec: DeviceSpec) -> bytes:
|
|
@@ -61,6 +61,15 @@ class GenExprParam:
|
|
|
61
61
|
unit_style: int = UNIT_STYLE_FLOAT
|
|
62
62
|
exponent: float = 1.0 # 1.0 = linear
|
|
63
63
|
|
|
64
|
+
def __post_init__(self):
|
|
65
|
+
# gen~ Param names and the live.dial `prepend <name>` wiring are
|
|
66
|
+
# single-token: a name with internal whitespace ("Filter Cutoff")
|
|
67
|
+
# would emit `prepend Filter Cutoff` -> gen~ reads param "Filter" plus
|
|
68
|
+
# stray tokens and the knob silently drives no DSP. Normalize internal
|
|
69
|
+
# whitespace to underscores so both the dial wiring and the gen~ `param`
|
|
70
|
+
# declaration stay consistent (tier1a-1).
|
|
71
|
+
self.name = re.sub(r"\s+", "_", str(self.name).strip())
|
|
72
|
+
|
|
64
73
|
def to_genexpr(self) -> str:
|
|
65
74
|
"""Generate the Param declaration for gen~ codebox."""
|
|
66
75
|
return f"Param {self.name}({self.default});"
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import asyncio
|
|
8
9
|
import tempfile
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
@@ -48,11 +49,13 @@ async def generate_m4l_effect(
|
|
|
48
49
|
}
|
|
49
50
|
dt = type_map.get(device_type)
|
|
50
51
|
if dt is None:
|
|
51
|
-
return {"error": f"Invalid device_type: {device_type}. Use:
|
|
52
|
+
return {"error": f"INVALID_PARAM: Invalid device_type: {device_type}. Use one of: {', '.join(type_map)}"}
|
|
52
53
|
|
|
53
54
|
parsed_params = []
|
|
54
55
|
if params:
|
|
55
56
|
for p in params:
|
|
57
|
+
if not isinstance(p, dict) or "name" not in p:
|
|
58
|
+
return {"error": f"INVALID_PARAM: each param requires a 'name' key, got: {p!r}"}
|
|
56
59
|
parsed_params.append(GenExprParam(
|
|
57
60
|
name=p["name"],
|
|
58
61
|
default=p.get("default", 0.5),
|
|
@@ -72,9 +75,16 @@ async def generate_m4l_effect(
|
|
|
72
75
|
if install:
|
|
73
76
|
subdir = _SUBDIR_MAP[dt]
|
|
74
77
|
output_dir = ABLETON_USER_LIBRARY / subdir
|
|
75
|
-
path = build_device(spec, output_dir=output_dir)
|
|
76
78
|
else:
|
|
77
|
-
|
|
79
|
+
# Reuse ONE forge directory under the system temp instead of spawning a
|
|
80
|
+
# fresh mkdtemp() per call (those accumulated, never cleaned). The .amxd
|
|
81
|
+
# is the returned deliverable so it must persist for the caller.
|
|
82
|
+
output_dir = Path(tempfile.gettempdir()) / "livepilot_forge"
|
|
83
|
+
|
|
84
|
+
# build_device does blocking filesystem I/O (mkdir + write_bytes). This is
|
|
85
|
+
# an async tool running on the event loop, so offload to a worker thread to
|
|
86
|
+
# avoid freezing all other handlers + the bridge UDP endpoint.
|
|
87
|
+
path = await asyncio.to_thread(build_device, spec, output_dir)
|
|
78
88
|
|
|
79
89
|
return {
|
|
80
90
|
"status": "created",
|
|
@@ -113,15 +123,11 @@ async def list_genexpr_templates(
|
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
ctx: Context,
|
|
119
|
-
source_path: str,
|
|
120
|
-
) -> dict:
|
|
121
|
-
"""Copy a .amxd file to Ableton's User Library.
|
|
126
|
+
def _install_m4l_device_sync(source_path: str) -> dict:
|
|
127
|
+
"""Blocking filesystem work for install_m4l_device (read/mkdir/write).
|
|
122
128
|
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
Run via asyncio.to_thread from the async tool below so the event loop
|
|
130
|
+
(and the bridge UDP endpoint) never stalls on disk I/O.
|
|
125
131
|
"""
|
|
126
132
|
src = Path(source_path)
|
|
127
133
|
if not src.exists():
|
|
@@ -160,3 +166,16 @@ async def install_m4l_device(
|
|
|
160
166
|
"path": str(dest),
|
|
161
167
|
"hint": "Device now available in Ableton's browser.",
|
|
162
168
|
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@mcp.tool()
|
|
172
|
+
async def install_m4l_device(
|
|
173
|
+
ctx: Context,
|
|
174
|
+
source_path: str,
|
|
175
|
+
) -> dict:
|
|
176
|
+
"""Copy a .amxd file to Ableton's User Library.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
source_path: Path to the .amxd file to install
|
|
180
|
+
"""
|
|
181
|
+
return await asyncio.to_thread(_install_m4l_device_sync, source_path)
|