livepilot 1.10.5 → 1.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/.mcp.json.disabled +9 -0
- package/.mcpbignore +3 -0
- package/AGENTS.md +3 -3
- package/BUGS.md +1570 -0
- package/CHANGELOG.md +92 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +28 -8
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +4 -4
- package/livepilot/skills/livepilot-core/references/overview.md +2 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +8 -8
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd.pre-presentation-backup +0 -0
- package/m4l_device/LivePilot_Analyzer.maxproj +53 -0
- package/m4l_device/livepilot_bridge.js +226 -3
- package/manifest.json +3 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +93 -26
- package/mcp_server/composer/sample_resolver.py +10 -6
- package/mcp_server/composer/tools.py +10 -6
- package/mcp_server/connection.py +6 -1
- package/mcp_server/creative_constraints/tools.py +214 -40
- package/mcp_server/experiment/engine.py +16 -14
- package/mcp_server/experiment/tools.py +9 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +74 -18
- package/mcp_server/m4l_bridge.py +32 -6
- package/mcp_server/memory/taste_graph.py +7 -2
- package/mcp_server/mix_engine/tools.py +8 -3
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/musical_intelligence/tools.py +15 -10
- package/mcp_server/performance_engine/tools.py +117 -30
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +43 -21
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +73 -15
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +54 -11
- package/mcp_server/runtime/capability_probe.py +10 -4
- package/mcp_server/runtime/execution_router.py +50 -0
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +4 -2
- package/mcp_server/runtime/tools.py +8 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +20 -1
- package/mcp_server/sample_engine/tools.py +74 -31
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +5 -1
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +78 -11
- package/mcp_server/services/motif_service.py +9 -3
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tools.py +7 -3
- package/mcp_server/session_continuity/tracker.py +23 -9
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +94 -25
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +19 -6
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +49 -5
- package/mcp_server/tools/_agent_os_engine/__init__.py +52 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +158 -0
- package/mcp_server/tools/_agent_os_engine/evaluation.py +206 -0
- package/mcp_server/tools/_agent_os_engine/models.py +132 -0
- package/mcp_server/tools/_agent_os_engine/taste.py +192 -0
- package/mcp_server/tools/_agent_os_engine/techniques.py +161 -0
- package/mcp_server/tools/_agent_os_engine/world_model.py +170 -0
- package/mcp_server/tools/_composition_engine/__init__.py +67 -0
- package/mcp_server/tools/_composition_engine/analysis.py +174 -0
- package/mcp_server/tools/_composition_engine/critics.py +522 -0
- package/mcp_server/tools/_composition_engine/gestures.py +230 -0
- package/mcp_server/tools/_composition_engine/harmony.py +160 -0
- package/mcp_server/tools/_composition_engine/models.py +193 -0
- package/mcp_server/tools/_composition_engine/sections.py +414 -0
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_perception_engine.py +18 -11
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +43 -18
- package/mcp_server/tools/analyzer.py +105 -8
- package/mcp_server/tools/automation.py +6 -1
- package/mcp_server/tools/clips.py +45 -0
- package/mcp_server/tools/composition.py +90 -38
- package/mcp_server/tools/devices.py +32 -7
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +13 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +56 -5
- package/mcp_server/tools/planner.py +6 -2
- package/mcp_server/tools/research.py +37 -10
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/transition_engine/tools.py +6 -1
- package/mcp_server/translation_engine/tools.py +8 -6
- package/mcp_server/wonder_mode/engine.py +8 -3
- package/mcp_server/wonder_mode/tools.py +29 -21
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +57 -2
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/router.py +13 -1
- package/scripts/generate_tool_catalog.py +13 -38
- package/scripts/sync_metadata.py +231 -14
- package/mcp_server/tools/_agent_os_engine.py +0 -947
- package/mcp_server/tools/_composition_engine.py +0 -1530
|
@@ -42,11 +42,17 @@ def create_preview_set(
|
|
|
42
42
|
available_moves: Optional[list[dict]] = None,
|
|
43
43
|
song_brain: Optional[dict] = None,
|
|
44
44
|
taste_graph: Optional[dict] = None,
|
|
45
|
+
kernel: Optional[dict] = None,
|
|
45
46
|
) -> PreviewSet:
|
|
46
47
|
"""Create a preview set with variant slots.
|
|
47
48
|
|
|
48
49
|
For creative_triptych, generates 3 variants: safe, strong, unexpected.
|
|
49
50
|
Each variant gets a move_id from available_moves ranked by novelty.
|
|
51
|
+
|
|
52
|
+
kernel: the live session kernel (track topology + device chains). Compilers
|
|
53
|
+
resolve targets from it — without it, variants degrade into no-ops or
|
|
54
|
+
generic reads. Callers that have a `ctx` should fetch a real kernel
|
|
55
|
+
via runtime.tools.get_session_kernel(ctx).
|
|
50
56
|
"""
|
|
51
57
|
set_id = _compute_set_id(request_text, kernel_id)
|
|
52
58
|
now = int(time.time() * 1000)
|
|
@@ -56,11 +62,15 @@ def create_preview_set(
|
|
|
56
62
|
taste_graph = taste_graph or {}
|
|
57
63
|
|
|
58
64
|
if strategy == "creative_triptych":
|
|
59
|
-
variants = _build_triptych(
|
|
65
|
+
variants = _build_triptych(
|
|
66
|
+
request_text, moves, song_brain, taste_graph, set_id, now, kernel,
|
|
67
|
+
)
|
|
60
68
|
elif strategy == "binary":
|
|
61
69
|
variants = _build_binary(request_text, moves, song_brain, set_id, now)
|
|
62
70
|
else:
|
|
63
|
-
variants = _build_triptych(
|
|
71
|
+
variants = _build_triptych(
|
|
72
|
+
request_text, moves, song_brain, taste_graph, set_id, now, kernel,
|
|
73
|
+
)
|
|
64
74
|
|
|
65
75
|
ps = PreviewSet(
|
|
66
76
|
set_id=set_id,
|
|
@@ -81,6 +91,7 @@ def _build_triptych(
|
|
|
81
91
|
taste_graph: dict,
|
|
82
92
|
set_id: str,
|
|
83
93
|
now: int,
|
|
94
|
+
kernel: Optional[dict] = None,
|
|
84
95
|
) -> list[PreviewVariant]:
|
|
85
96
|
"""Build safe / strong / unexpected variants."""
|
|
86
97
|
identity = song_brain.get("identity_core", "")
|
|
@@ -114,20 +125,34 @@ def _build_triptych(
|
|
|
114
125
|
},
|
|
115
126
|
]
|
|
116
127
|
|
|
128
|
+
# Normalize kernel for the compiler. If the caller supplied a real kernel
|
|
129
|
+
# use it; otherwise fall back to an empty-but-valid shape so compilers
|
|
130
|
+
# degrade to no-op steps and emit warnings instead of crashing.
|
|
131
|
+
compile_kernel = kernel if kernel else {
|
|
132
|
+
"session_info": {"tempo": 120, "tracks": []},
|
|
133
|
+
"mode": "improve",
|
|
134
|
+
}
|
|
135
|
+
|
|
117
136
|
variants = []
|
|
118
137
|
for i, profile in enumerate(profiles):
|
|
119
138
|
# Pick a move if available
|
|
120
139
|
move_id = ""
|
|
121
140
|
compiled_plan = None
|
|
122
|
-
if moves and i < len(moves)
|
|
123
|
-
|
|
141
|
+
move = moves[i] if moves and i < len(moves) else None
|
|
142
|
+
if move is not None:
|
|
143
|
+
move_id = move.get("move_id", "")
|
|
124
144
|
# Compile through the semantic compiler — single source of truth
|
|
125
145
|
from ..wonder_mode.engine import _compile_variant_plan
|
|
126
|
-
|
|
127
|
-
compiled_plan = _compile_variant_plan(moves[i], kernel)
|
|
146
|
+
compiled_plan = _compile_variant_plan(move, compile_kernel)
|
|
128
147
|
# No fallback to plan_template — uncompilable moves stay analytical
|
|
129
148
|
|
|
130
|
-
|
|
149
|
+
# BUG-B44 / B45: populate user-facing description fields and flag
|
|
150
|
+
# variants that lack a compiled_plan as not-executable (so callers
|
|
151
|
+
# don't commit shells).
|
|
152
|
+
description = _describe_variant(move, compiled_plan, profile)
|
|
153
|
+
executable = compiled_plan is not None and bool(move_id)
|
|
154
|
+
|
|
155
|
+
variant = PreviewVariant(
|
|
131
156
|
variant_id=f"{set_id}_{profile['label']}",
|
|
132
157
|
label=profile["label"],
|
|
133
158
|
intent=profile["intent"],
|
|
@@ -139,11 +164,67 @@ def _build_triptych(
|
|
|
139
164
|
compiled_plan=compiled_plan,
|
|
140
165
|
taste_fit=_estimate_taste_fit(profile["novelty"], taste_graph),
|
|
141
166
|
created_at_ms=now,
|
|
142
|
-
|
|
167
|
+
what_changed=description["what_changed"],
|
|
168
|
+
summary=description["summary"],
|
|
169
|
+
)
|
|
170
|
+
# Non-executable variants get status='blocked' so callers know to
|
|
171
|
+
# skip preview/commit. Stored as status since executable/blocked_reason
|
|
172
|
+
# aren't modeled yet.
|
|
173
|
+
if not executable:
|
|
174
|
+
variant.status = "blocked"
|
|
175
|
+
variants.append(variant)
|
|
143
176
|
|
|
144
177
|
return variants
|
|
145
178
|
|
|
146
179
|
|
|
180
|
+
def _describe_variant(
|
|
181
|
+
move: Optional[dict],
|
|
182
|
+
compiled_plan: Optional[dict],
|
|
183
|
+
profile: dict,
|
|
184
|
+
) -> dict:
|
|
185
|
+
"""Build user-facing description fields for a variant (BUG-B45).
|
|
186
|
+
|
|
187
|
+
Priority order:
|
|
188
|
+
1. Move's `intent` or `description` — the authored one-liner
|
|
189
|
+
2. Compiled plan's step descriptions joined with " → "
|
|
190
|
+
3. The profile label + novelty level as a last-resort fallback
|
|
191
|
+
|
|
192
|
+
Returns {"what_changed": str, "summary": str}.
|
|
193
|
+
"""
|
|
194
|
+
what_changed = ""
|
|
195
|
+
summary = ""
|
|
196
|
+
if move:
|
|
197
|
+
# Move-level narrative beats plan-level — captures intent, not execution
|
|
198
|
+
move_intent = str(move.get("intent") or move.get("description") or "")
|
|
199
|
+
if move_intent:
|
|
200
|
+
what_changed = move_intent
|
|
201
|
+
summary = move_intent[:120]
|
|
202
|
+
|
|
203
|
+
if not what_changed and compiled_plan:
|
|
204
|
+
steps = compiled_plan.get("steps") or []
|
|
205
|
+
step_descriptions = [
|
|
206
|
+
str(s.get("description") or s.get("summary") or s.get("intent") or "")
|
|
207
|
+
for s in steps
|
|
208
|
+
]
|
|
209
|
+
step_descriptions = [d for d in step_descriptions if d]
|
|
210
|
+
if step_descriptions:
|
|
211
|
+
what_changed = " → ".join(step_descriptions[:4])
|
|
212
|
+
summary = (
|
|
213
|
+
step_descriptions[0][:120]
|
|
214
|
+
if step_descriptions else ""
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if not what_changed:
|
|
218
|
+
# Final fallback — describe the profile so the UI has something
|
|
219
|
+
what_changed = (
|
|
220
|
+
f"{profile['label'].title()} variant at novelty "
|
|
221
|
+
f"{profile['novelty']:.1f} (no executable plan available)"
|
|
222
|
+
)
|
|
223
|
+
summary = what_changed
|
|
224
|
+
|
|
225
|
+
return {"what_changed": what_changed, "summary": summary}
|
|
226
|
+
|
|
227
|
+
|
|
147
228
|
def _build_binary(
|
|
148
229
|
request_text: str,
|
|
149
230
|
moves: list[dict],
|
|
@@ -15,6 +15,9 @@ from fastmcp import Context
|
|
|
15
15
|
|
|
16
16
|
from ..server import mcp
|
|
17
17
|
from . import engine
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
def _get_ableton(ctx: Context):
|
|
@@ -39,10 +42,9 @@ def _find_wonder_session_by_preview(set_id: str):
|
|
|
39
42
|
try:
|
|
40
43
|
from ..wonder_mode.session import find_session_by_preview_set
|
|
41
44
|
return find_session_by_preview_set(set_id)
|
|
42
|
-
except Exception:
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
logger.debug("_find_wonder_session_by_preview failed: %s", exc)
|
|
43
47
|
return None
|
|
44
|
-
|
|
45
|
-
|
|
46
48
|
@mcp.tool()
|
|
47
49
|
def create_preview_set(
|
|
48
50
|
ctx: Context,
|
|
@@ -148,8 +150,8 @@ def create_preview_set(
|
|
|
148
150
|
# Fallback: if no keyword match, take top 3 from full registry
|
|
149
151
|
if not available_moves:
|
|
150
152
|
available_moves = registry.list_moves()[:3]
|
|
151
|
-
except Exception:
|
|
152
|
-
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.debug("create_preview_set failed: %s", exc)
|
|
153
155
|
|
|
154
156
|
# Get song brain if available
|
|
155
157
|
song_brain: dict = {}
|
|
@@ -177,8 +179,18 @@ def create_preview_set(
|
|
|
177
179
|
persistent_store=persistent,
|
|
178
180
|
)
|
|
179
181
|
taste_graph = graph.to_dict()
|
|
180
|
-
except Exception:
|
|
181
|
-
|
|
182
|
+
except Exception as exc:
|
|
183
|
+
logger.debug("create_preview_set failed: %s", exc)
|
|
184
|
+
|
|
185
|
+
# Fetch a real session kernel so compilers resolve targets from the live
|
|
186
|
+
# set instead of an empty placeholder. Degrades gracefully when Ableton
|
|
187
|
+
# is unreachable (unit tests, no-connection environments).
|
|
188
|
+
live_kernel: dict = {}
|
|
189
|
+
try:
|
|
190
|
+
from ..runtime.tools import get_session_kernel
|
|
191
|
+
live_kernel = get_session_kernel(ctx, request_text=request_text) or {}
|
|
192
|
+
except Exception as exc:
|
|
193
|
+
logger.debug("create_preview_set: could not fetch session kernel: %s", exc)
|
|
182
194
|
|
|
183
195
|
ps = engine.create_preview_set(
|
|
184
196
|
request_text=request_text,
|
|
@@ -187,6 +199,7 @@ def create_preview_set(
|
|
|
187
199
|
available_moves=available_moves,
|
|
188
200
|
song_brain=song_brain,
|
|
189
201
|
taste_graph=taste_graph,
|
|
202
|
+
kernel=live_kernel,
|
|
190
203
|
)
|
|
191
204
|
|
|
192
205
|
return ps.to_dict()
|
|
@@ -345,8 +358,8 @@ async def commit_preview_variant(
|
|
|
345
358
|
)
|
|
346
359
|
if ws.creative_thread_id:
|
|
347
360
|
resolve_thread(ws.creative_thread_id)
|
|
348
|
-
except Exception:
|
|
349
|
-
|
|
361
|
+
except Exception as exc:
|
|
362
|
+
logger.debug("commit_preview_variant failed: %s", exc)
|
|
350
363
|
|
|
351
364
|
# Update taste graph (with persistent backing)
|
|
352
365
|
try:
|
|
@@ -373,8 +386,8 @@ async def commit_preview_variant(
|
|
|
373
386
|
family=family,
|
|
374
387
|
kept=True,
|
|
375
388
|
)
|
|
376
|
-
except Exception:
|
|
377
|
-
|
|
389
|
+
except Exception as exc:
|
|
390
|
+
logger.debug("commit_preview_variant failed: %s", exc)
|
|
378
391
|
|
|
379
392
|
result["wonder_session_id"] = ws.session_id
|
|
380
393
|
|
|
@@ -434,7 +447,12 @@ async def render_preview_variant(
|
|
|
434
447
|
plan = variant.compiled_plan
|
|
435
448
|
steps = plan if isinstance(plan, list) else plan.get("steps", [])
|
|
436
449
|
|
|
437
|
-
from ..runtime.execution_router import execute_plan_steps_async
|
|
450
|
+
from ..runtime.execution_router import execute_plan_steps_async, filter_apply_steps
|
|
451
|
+
|
|
452
|
+
# Read-only verification steps (meters/spectrum/info) don't create undo
|
|
453
|
+
# points in Ableton — counting them and then undoing walks back earlier
|
|
454
|
+
# user edits. Separate writes from reads before the apply pass.
|
|
455
|
+
apply_steps = filter_apply_steps(steps)
|
|
438
456
|
|
|
439
457
|
applied_count = 0
|
|
440
458
|
playback_started = False
|
|
@@ -451,16 +469,16 @@ async def render_preview_variant(
|
|
|
451
469
|
# ── 1. Capture BEFORE metadata ──
|
|
452
470
|
before_info = ableton.send_command("get_session_info", {}) or {}
|
|
453
471
|
|
|
454
|
-
# ── 2. Apply the variant ──
|
|
472
|
+
# ── 2. Apply the variant (write steps only) ──
|
|
455
473
|
exec_results = await execute_plan_steps_async(
|
|
456
|
-
|
|
474
|
+
apply_steps,
|
|
457
475
|
ableton=ableton,
|
|
458
476
|
bridge=bridge,
|
|
459
477
|
mcp_registry=mcp_registry,
|
|
460
478
|
ctx=ctx,
|
|
461
479
|
)
|
|
462
480
|
applied_count = sum(1 for r in exec_results if r.ok)
|
|
463
|
-
if applied_count == 0 and
|
|
481
|
+
if applied_count == 0 and apply_steps:
|
|
464
482
|
return {
|
|
465
483
|
"error": "Variant failed to apply any steps",
|
|
466
484
|
"variant_id": variant_id,
|
|
@@ -487,8 +505,9 @@ async def render_preview_variant(
|
|
|
487
505
|
ableton.send_command("start_playback", {})
|
|
488
506
|
playback_started = True
|
|
489
507
|
|
|
490
|
-
import
|
|
491
|
-
|
|
508
|
+
import asyncio as _asyncio
|
|
509
|
+
|
|
510
|
+
await _asyncio.sleep(play_seconds)
|
|
492
511
|
|
|
493
512
|
spectral_after = cache.get_all()
|
|
494
513
|
|
|
@@ -496,7 +515,8 @@ async def render_preview_variant(
|
|
|
496
515
|
playback_started = False
|
|
497
516
|
|
|
498
517
|
preview_mode = "audible_preview"
|
|
499
|
-
except Exception:
|
|
518
|
+
except Exception as exc:
|
|
519
|
+
logger.debug("render_preview_variant failed: %s", exc)
|
|
500
520
|
# Spectral capture is best-effort; keep preview_mode as metadata_only
|
|
501
521
|
pass
|
|
502
522
|
|
|
@@ -507,12 +527,14 @@ async def render_preview_variant(
|
|
|
507
527
|
if playback_started:
|
|
508
528
|
try:
|
|
509
529
|
ableton.send_command("stop_playback", {})
|
|
510
|
-
except Exception:
|
|
511
|
-
|
|
530
|
+
except Exception as exc:
|
|
531
|
+
logger.debug("render_preview_variant failed: %s", exc)
|
|
532
|
+
|
|
512
533
|
for _ in range(applied_count):
|
|
513
534
|
try:
|
|
514
535
|
ableton.send_command("undo")
|
|
515
|
-
except Exception:
|
|
536
|
+
except Exception as exc:
|
|
537
|
+
logger.debug("render_preview_variant failed: %s", exc)
|
|
516
538
|
break
|
|
517
539
|
|
|
518
540
|
variant.status = "rendered"
|
|
@@ -11,8 +11,10 @@ from .models import AutomationGraph
|
|
|
11
11
|
def build_automation_graph(
|
|
12
12
|
track_infos: list[dict],
|
|
13
13
|
sections: list[dict] | None = None,
|
|
14
|
+
clip_automation: list[dict] | None = None,
|
|
14
15
|
) -> AutomationGraph:
|
|
15
|
-
"""Build an AutomationGraph
|
|
16
|
+
"""Build an AutomationGraph covering both device-parameter automation
|
|
17
|
+
hints and real clip envelopes (BUG-E2).
|
|
16
18
|
|
|
17
19
|
Args:
|
|
18
20
|
track_infos: list of per-track info dicts. Each may contain:
|
|
@@ -20,18 +22,52 @@ def build_automation_graph(
|
|
|
20
22
|
- name: track name
|
|
21
23
|
- devices: [{name, class_name, parameters: [{name, value, is_automated, ...}]}]
|
|
22
24
|
sections: optional list of section dicts (for density_by_section).
|
|
25
|
+
clip_automation: optional list of per-clip envelope descriptors:
|
|
26
|
+
[{section_id, track_index, track_name, clip_index,
|
|
27
|
+
parameter_name, parameter_type, device_name}].
|
|
28
|
+
This is the ground truth — `device.parameters[i].is_automated`
|
|
29
|
+
only reflects mapping state, not the presence of an envelope.
|
|
23
30
|
|
|
24
31
|
Returns:
|
|
25
32
|
AutomationGraph with automated_params and density_by_section.
|
|
26
33
|
"""
|
|
27
34
|
graph = AutomationGraph()
|
|
28
35
|
|
|
29
|
-
if not track_infos:
|
|
36
|
+
if not track_infos and not clip_automation:
|
|
30
37
|
return graph
|
|
31
38
|
|
|
32
|
-
automated_params = []
|
|
39
|
+
automated_params: list[dict] = []
|
|
40
|
+
# Track which (track_index, device_name, param_name) we've already seen
|
|
41
|
+
# so device-hint entries don't duplicate clip-envelope entries.
|
|
42
|
+
seen: set[tuple[int, str, str]] = set()
|
|
33
43
|
|
|
34
|
-
|
|
44
|
+
# 1) Seed with real clip envelopes. These are the source of truth.
|
|
45
|
+
per_section_counts: dict[str, int] = {}
|
|
46
|
+
for env in clip_automation or []:
|
|
47
|
+
t_idx = int(env.get("track_index", 0))
|
|
48
|
+
dev = str(env.get("device_name") or env.get("parameter_type") or "")
|
|
49
|
+
name = str(env.get("parameter_name") or "")
|
|
50
|
+
key = (t_idx, dev, name)
|
|
51
|
+
if key in seen:
|
|
52
|
+
continue
|
|
53
|
+
seen.add(key)
|
|
54
|
+
automated_params.append({
|
|
55
|
+
"track_index": t_idx,
|
|
56
|
+
"track_name": env.get("track_name", ""),
|
|
57
|
+
"device_name": dev or None,
|
|
58
|
+
"param_name": name,
|
|
59
|
+
"parameter_type": env.get("parameter_type", ""),
|
|
60
|
+
"clip_index": env.get("clip_index"),
|
|
61
|
+
"section_id": env.get("section_id"),
|
|
62
|
+
"source": "clip_envelope",
|
|
63
|
+
})
|
|
64
|
+
sec = env.get("section_id")
|
|
65
|
+
if sec:
|
|
66
|
+
per_section_counts[sec] = per_section_counts.get(sec, 0) + 1
|
|
67
|
+
|
|
68
|
+
# 2) Add device-level hints (track-wide is_automated flags) that
|
|
69
|
+
# aren't already covered by an envelope entry.
|
|
70
|
+
for track in track_infos or []:
|
|
35
71
|
t_idx = track.get("index", 0)
|
|
36
72
|
t_name = track.get("name", "")
|
|
37
73
|
devices = track.get("devices", [])
|
|
@@ -41,29 +77,45 @@ def build_automation_graph(
|
|
|
41
77
|
parameters = device.get("parameters", [])
|
|
42
78
|
|
|
43
79
|
for param in parameters:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
is_flagged = (
|
|
81
|
+
param.get("is_automated", False)
|
|
82
|
+
or param.get("automation_state", 0) > 0
|
|
83
|
+
)
|
|
84
|
+
if not is_flagged:
|
|
85
|
+
continue
|
|
86
|
+
p_name = param.get("name", "")
|
|
87
|
+
key = (t_idx, str(device_name), str(p_name))
|
|
88
|
+
if key in seen:
|
|
89
|
+
continue
|
|
90
|
+
seen.add(key)
|
|
91
|
+
automated_params.append({
|
|
92
|
+
"track_index": t_idx,
|
|
93
|
+
"track_name": t_name,
|
|
94
|
+
"device_name": device_name,
|
|
95
|
+
"param_name": p_name,
|
|
96
|
+
"param_value": param.get("value"),
|
|
97
|
+
"source": "device_hint",
|
|
98
|
+
})
|
|
52
99
|
|
|
53
100
|
graph.automated_params = automated_params
|
|
54
101
|
|
|
55
|
-
# Compute density_by_section
|
|
102
|
+
# Compute density_by_section.
|
|
56
103
|
if sections:
|
|
57
104
|
total_automated = len(automated_params)
|
|
58
105
|
for sec in sections:
|
|
59
106
|
section_id = sec.get("section_id", "")
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
107
|
+
if per_section_counts:
|
|
108
|
+
# Use real per-section counts when we have them.
|
|
109
|
+
count = per_section_counts.get(section_id, 0)
|
|
110
|
+
# Normalize by max(1, largest section count) so the
|
|
111
|
+
# densest section is 1.0 and others fall below.
|
|
112
|
+
max_ct = max(per_section_counts.values()) or 1
|
|
113
|
+
graph.density_by_section[section_id] = round(count / max_ct, 3)
|
|
114
|
+
elif total_automated > 0:
|
|
115
|
+
# Fallback: approximate from section density (old behavior)
|
|
116
|
+
sec_density = sec.get("density", 0.0)
|
|
65
117
|
graph.density_by_section[section_id] = round(
|
|
66
|
-
sec_density * min(total_automated / max(len(track_infos), 1), 1.0),
|
|
118
|
+
sec_density * min(total_automated / max(len(track_infos or []), 1), 1.0),
|
|
67
119
|
3,
|
|
68
120
|
)
|
|
69
121
|
else:
|
|
@@ -23,6 +23,7 @@ def build_project_state_from_data(
|
|
|
23
23
|
track_infos: Optional[list[dict]] = None,
|
|
24
24
|
notes_map: Optional[dict[str, dict[int, list[dict]]]] = None,
|
|
25
25
|
arrangement_clips: Optional[dict] = None,
|
|
26
|
+
clip_automation: Optional[list[dict]] = None,
|
|
26
27
|
analyzer_ok: bool = False,
|
|
27
28
|
flucoma_ok: bool = False,
|
|
28
29
|
plugin_health: Optional[dict[str, Any]] = None,
|
|
@@ -105,6 +106,7 @@ def build_project_state_from_data(
|
|
|
105
106
|
state.automation_graph = build_automation_graph(
|
|
106
107
|
track_infos=track_infos or [],
|
|
107
108
|
sections=section_dicts_for_auto,
|
|
109
|
+
clip_automation=clip_automation or [],
|
|
108
110
|
)
|
|
109
111
|
state.automation_graph.freshness.mark_fresh(state.revision)
|
|
110
112
|
|
|
@@ -11,6 +11,10 @@ from fastmcp import Context
|
|
|
11
11
|
|
|
12
12
|
from ..server import mcp
|
|
13
13
|
from .builder import build_project_state_from_data
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
def _get_ableton(ctx: Context):
|
|
@@ -39,7 +43,8 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
39
43
|
try:
|
|
40
44
|
scenes_resp = ableton.send_command("get_scenes_info")
|
|
41
45
|
scenes = scenes_resp.get("scenes", [])
|
|
42
|
-
except Exception:
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
logger.debug("build_project_brain failed: %s", exc)
|
|
43
48
|
scenes = session_info.get("scenes", [])
|
|
44
49
|
|
|
45
50
|
# 3. Get clip matrix (scene_matrix)
|
|
@@ -47,8 +52,8 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
47
52
|
try:
|
|
48
53
|
matrix_resp = ableton.send_command("get_scene_matrix")
|
|
49
54
|
clip_matrix = matrix_resp.get("matrix", [])
|
|
50
|
-
except Exception:
|
|
51
|
-
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
logger.debug("build_project_brain failed: %s", exc)
|
|
52
57
|
|
|
53
58
|
# 4. Gather per-track info with devices
|
|
54
59
|
track_infos = []
|
|
@@ -58,7 +63,8 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
58
63
|
"track_index": track["index"],
|
|
59
64
|
})
|
|
60
65
|
track_infos.append(info)
|
|
61
|
-
except Exception:
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
logger.debug("build_project_brain failed: %s", exc)
|
|
62
68
|
track_infos.append({
|
|
63
69
|
"index": track.get("index", 0),
|
|
64
70
|
"name": track.get("name", ""),
|
|
@@ -75,21 +81,27 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
75
81
|
clips = arr.get("clips", [])
|
|
76
82
|
if clips:
|
|
77
83
|
arrangement_clips[track["index"]] = clips
|
|
78
|
-
except Exception:
|
|
79
|
-
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
logger.debug("build_project_brain failed: %s", exc)
|
|
80
86
|
|
|
81
87
|
# 5b. Build notes_map for role inference.
|
|
82
88
|
# Shape: {section_id: {track_index: [notes]}}. Without this, role_graph
|
|
83
89
|
# falls back to "assume all tracks active in every section" which destroys
|
|
84
90
|
# section-scoped role confidence.
|
|
91
|
+
#
|
|
92
|
+
# BUG-E1: section_id must match what build_section_graph_from_scenes emits.
|
|
93
|
+
# The composition engine emits `sec_{i:02d}` using the RAW enumerate index
|
|
94
|
+
# of the scene — it skips unnamed scenes (gap-preserving), so e.g. scenes
|
|
95
|
+
# ["Intro", "", "Verse"] become sections sec_00 and sec_02, not sec_01.
|
|
96
|
+
# Our notes_map must mirror that or keys won't align.
|
|
85
97
|
notes_map: dict[str, dict[int, list[dict]]] = {}
|
|
86
98
|
try:
|
|
87
99
|
for scene_idx, scene in enumerate(scenes or []):
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
100
|
+
scene_name = str(scene.get("name", "")).strip()
|
|
101
|
+
if not scene_name:
|
|
102
|
+
continue # mirror _ce_build_sections: unnamed scenes skipped
|
|
103
|
+
section_id = f"sec_{scene_idx:02d}"
|
|
104
|
+
|
|
93
105
|
per_track: dict[int, list[dict]] = {}
|
|
94
106
|
for track in tracks:
|
|
95
107
|
t_idx = track.get("index", 0)
|
|
@@ -102,15 +114,60 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
102
114
|
notes = notes_resp.get("notes", [])
|
|
103
115
|
if notes:
|
|
104
116
|
per_track[t_idx] = notes
|
|
105
|
-
except Exception:
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
logger.debug("build_project_brain failed: %s", exc)
|
|
106
119
|
# Individual note fetch failing is fine — continue with others
|
|
107
120
|
continue
|
|
108
121
|
if per_track:
|
|
109
122
|
notes_map[section_id] = per_track
|
|
110
|
-
except Exception:
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
logger.debug("build_project_brain failed: %s", exc)
|
|
111
125
|
# Overall failure: empty map, degrade to "all tracks active" fallback
|
|
112
126
|
notes_map = {}
|
|
113
127
|
|
|
128
|
+
# 5c. Scan clip automation across the session (BUG-E2).
|
|
129
|
+
# Device-parameter is_automated flags only reflect whether a parameter
|
|
130
|
+
# is mapped somewhere — they don't reveal clip envelopes. Ableton's
|
|
131
|
+
# automation actually lives on each clip (session + arrangement). We
|
|
132
|
+
# walk every clip slot that has a clip and ask get_clip_automation, then
|
|
133
|
+
# aggregate into a flat list keyed by section.
|
|
134
|
+
clip_automation: list[dict] = []
|
|
135
|
+
try:
|
|
136
|
+
# Iterate session scenes x tracks, plus arrangement clips we already have.
|
|
137
|
+
# Use the raw enumerate index for section_id so it stays aligned with
|
|
138
|
+
# arrangement_graph sections (which use the same scheme — see E1 fix).
|
|
139
|
+
for scene_idx, scene in enumerate(scenes or []):
|
|
140
|
+
scene_name = str(scene.get("name", "")).strip()
|
|
141
|
+
if not scene_name:
|
|
142
|
+
continue
|
|
143
|
+
section_id = f"sec_{scene_idx:02d}"
|
|
144
|
+
for track in tracks:
|
|
145
|
+
t_idx = track.get("index", 0)
|
|
146
|
+
try:
|
|
147
|
+
auto_resp = ableton.send_command("get_clip_automation", {
|
|
148
|
+
"track_index": t_idx,
|
|
149
|
+
"clip_index": scene_idx,
|
|
150
|
+
})
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
# No clip in slot, or remote script rejected — skip
|
|
153
|
+
logger.debug("build_project_brain automation skip: %s", exc)
|
|
154
|
+
continue
|
|
155
|
+
if not isinstance(auto_resp, dict):
|
|
156
|
+
continue
|
|
157
|
+
envs = auto_resp.get("envelopes") or []
|
|
158
|
+
for env in envs:
|
|
159
|
+
clip_automation.append({
|
|
160
|
+
"section_id": section_id,
|
|
161
|
+
"track_index": t_idx,
|
|
162
|
+
"track_name": track.get("name", ""),
|
|
163
|
+
"clip_index": scene_idx,
|
|
164
|
+
"parameter_name": env.get("parameter_name", ""),
|
|
165
|
+
"parameter_type": env.get("parameter_type", ""),
|
|
166
|
+
"device_name": env.get("device_name"),
|
|
167
|
+
})
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
logger.debug("build_project_brain automation scan failed: %s", exc)
|
|
170
|
+
|
|
114
171
|
# 6. Probe capabilities (direct SpectralCache access, not TCP)
|
|
115
172
|
analyzer_ok = False
|
|
116
173
|
analyzer_fresh = False
|
|
@@ -127,8 +184,8 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
127
184
|
if spectral.get(key) is not None:
|
|
128
185
|
flucoma_ok = True
|
|
129
186
|
break
|
|
130
|
-
except Exception:
|
|
131
|
-
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
logger.debug("build_project_brain failed: %s", exc)
|
|
132
189
|
|
|
133
190
|
# 7. Build state
|
|
134
191
|
state = build_project_state_from_data(
|
|
@@ -138,6 +195,7 @@ def build_project_brain(ctx: Context) -> dict:
|
|
|
138
195
|
track_infos=track_infos if track_infos else None,
|
|
139
196
|
notes_map=notes_map if notes_map else None,
|
|
140
197
|
arrangement_clips=arrangement_clips if arrangement_clips else None,
|
|
198
|
+
clip_automation=clip_automation if clip_automation else None,
|
|
141
199
|
analyzer_ok=analyzer_ok,
|
|
142
200
|
flucoma_ok=flucoma_ok,
|
|
143
201
|
session_ok=True,
|