livepilot 1.23.6 → 1.25.0
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 +107 -0
- package/README.md +60 -14
- 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 +17 -3
- package/mcp_server/atlas/explore_tools.py +332 -0
- package/mcp_server/atlas/tools.py +161 -0
- package/mcp_server/audit/__init__.py +6 -0
- package/mcp_server/audit/checks.py +618 -0
- package/mcp_server/audit/tools.py +232 -0
- package/mcp_server/composer/branch_producer.py +5 -2
- package/mcp_server/composer/develop/__init__.py +19 -0
- package/mcp_server/composer/develop/apply.py +217 -0
- package/mcp_server/composer/develop/brief_builder.py +269 -0
- package/mcp_server/composer/develop/seed_introspector.py +195 -0
- package/mcp_server/composer/engine.py +15 -521
- package/mcp_server/composer/fast/__init__.py +62 -0
- package/mcp_server/composer/fast/apply.py +533 -0
- package/mcp_server/composer/fast/brief_builder.py +1479 -0
- package/mcp_server/composer/fast/tier_classification.py +159 -0
- package/mcp_server/composer/framework/__init__.py +0 -0
- package/mcp_server/composer/framework/applier.py +179 -0
- package/mcp_server/composer/framework/artist_loader.py +63 -0
- package/mcp_server/composer/framework/atlas_resolver.py +554 -0
- package/mcp_server/composer/framework/brief.py +79 -0
- package/mcp_server/composer/framework/event_lexicon.py +71 -0
- package/mcp_server/composer/framework/genre_loader.py +77 -0
- package/mcp_server/composer/framework/intent_source.py +137 -0
- package/mcp_server/composer/framework/knowledge_pack.py +140 -0
- package/mcp_server/composer/framework/plan_compiler.py +10 -0
- package/mcp_server/composer/full/__init__.py +10 -0
- package/mcp_server/composer/full/apply.py +1139 -0
- package/mcp_server/composer/full/brief_builder.py +227 -0
- package/mcp_server/composer/full/engine.py +541 -0
- package/mcp_server/composer/full/layer_planner.py +491 -0
- package/mcp_server/composer/layer_planner.py +19 -465
- package/mcp_server/composer/sample_resolver.py +80 -7
- package/mcp_server/composer/tools.py +626 -28
- package/mcp_server/server.py +1 -0
- package/mcp_server/splice_client/client.py +7 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +172 -7
- package/mcp_server/tools/_planner_engine.py +25 -63
- package/mcp_server/tools/analyzer.py +10 -4
- package/mcp_server/tools/browser.py +102 -19
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""audit_layer — single-tool replacement for the §5 layer-precision checklist.
|
|
2
|
+
|
|
3
|
+
Fetches once, runs 8 server-side checks, returns a structured report with
|
|
4
|
+
ranked fixes. Replaces the manual sequence:
|
|
5
|
+
|
|
6
|
+
solo + get_master_spectrum
|
|
7
|
+
get_notes (per clip) + sequence critique
|
|
8
|
+
get_track_info (pan/width)
|
|
9
|
+
get_masking_report (filter for this track)
|
|
10
|
+
get_device_parameters (modulation routings)
|
|
11
|
+
get_device_parameters (default-detection)
|
|
12
|
+
get_simpler_slices + classify_simpler_slices
|
|
13
|
+
track_info.devices (effects coverage)
|
|
14
|
+
|
|
15
|
+
…which was 8+ tool calls of LLM-driven ceremony. Now: one call.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import time
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from fastmcp import Context
|
|
25
|
+
|
|
26
|
+
from ..server import mcp
|
|
27
|
+
from . import checks
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_ableton(ctx: Context):
|
|
33
|
+
return ctx.lifespan_context["ableton"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _safe_call(ableton, command: str, params: dict | None = None) -> dict | None:
|
|
37
|
+
try:
|
|
38
|
+
return ableton.send_command(command, params or {})
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
logger.debug("audit_layer: %s failed: %s", command, exc)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fetch_notes_for_clips(ableton, track_index: int, clip_slots: list[dict]) -> list[list[dict]]:
|
|
45
|
+
"""Pull notes for every populated clip slot. Skips empty/audio clips."""
|
|
46
|
+
out: list[list[dict]] = []
|
|
47
|
+
for slot in clip_slots or []:
|
|
48
|
+
if not slot.get("has_clip"):
|
|
49
|
+
continue
|
|
50
|
+
clip_index = slot.get("index")
|
|
51
|
+
if clip_index is None:
|
|
52
|
+
continue
|
|
53
|
+
result = _safe_call(ableton, "get_notes", {
|
|
54
|
+
"track_index": track_index,
|
|
55
|
+
"clip_index": clip_index,
|
|
56
|
+
"from_pitch": 0,
|
|
57
|
+
"pitch_span": 128,
|
|
58
|
+
"from_time": 0.0,
|
|
59
|
+
})
|
|
60
|
+
if result and "notes" in result:
|
|
61
|
+
out.append(result["notes"])
|
|
62
|
+
return out
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _count_wavetable_routings(ableton, track_index: int, devices: list[dict]) -> int:
|
|
66
|
+
"""Sum non-zero mod-matrix routings across any Wavetable on the track."""
|
|
67
|
+
total = 0
|
|
68
|
+
for i, dev in enumerate(devices or []):
|
|
69
|
+
if dev.get("class_name") != "Wavetable":
|
|
70
|
+
continue
|
|
71
|
+
mod = _safe_call(ableton, "get_wavetable_mod_matrix", {
|
|
72
|
+
"track_index": track_index,
|
|
73
|
+
"device_index": i,
|
|
74
|
+
})
|
|
75
|
+
if not mod:
|
|
76
|
+
continue
|
|
77
|
+
# Response shape: {"matrix": [{"source": ..., "target": ..., "amount": ...}, ...]}
|
|
78
|
+
entries = mod.get("matrix") or mod.get("entries") or []
|
|
79
|
+
for e in entries:
|
|
80
|
+
if abs(float(e.get("amount", 0.0))) > 0.001:
|
|
81
|
+
total += 1
|
|
82
|
+
return total
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _has_clip_automation(ableton, track_index: int, clip_slots: list[dict]) -> bool:
|
|
86
|
+
"""Check if any clip on this track has automation envelopes."""
|
|
87
|
+
for slot in clip_slots or []:
|
|
88
|
+
if not slot.get("has_clip"):
|
|
89
|
+
continue
|
|
90
|
+
clip_index = slot.get("index")
|
|
91
|
+
if clip_index is None:
|
|
92
|
+
continue
|
|
93
|
+
result = _safe_call(ableton, "get_clip_automation", {
|
|
94
|
+
"track_index": track_index,
|
|
95
|
+
"clip_index": clip_index,
|
|
96
|
+
})
|
|
97
|
+
if not result:
|
|
98
|
+
continue
|
|
99
|
+
envelopes = result.get("envelopes") or result.get("automation") or []
|
|
100
|
+
if envelopes:
|
|
101
|
+
return True
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _maybe_get_timbre_fingerprint(ctx: Context, track_index: int) -> dict | None:
|
|
106
|
+
"""Pull a per-track timbre fingerprint via the synthesis_brain timbre helper.
|
|
107
|
+
|
|
108
|
+
Returns None when the M4L bridge is offline or no fingerprint is available.
|
|
109
|
+
Does NOT solo the track — uses cached spectral data only.
|
|
110
|
+
"""
|
|
111
|
+
try:
|
|
112
|
+
from ..synthesis_brain.timbre import extract_timbre_fingerprint # type: ignore
|
|
113
|
+
except Exception:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
result = extract_timbre_fingerprint(ctx, track_index) # type: ignore[arg-type]
|
|
117
|
+
if isinstance(result, dict) and result.get("bands"):
|
|
118
|
+
return result
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
logger.debug("audit_layer timbre fetch failed: %s", exc)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@mcp.tool()
|
|
125
|
+
def audit_layer(
|
|
126
|
+
ctx: Context,
|
|
127
|
+
track_index: int,
|
|
128
|
+
role: Optional[str] = None,
|
|
129
|
+
include_masking: bool = True,
|
|
130
|
+
include_timbre: bool = False,
|
|
131
|
+
) -> dict:
|
|
132
|
+
"""Run the §5 layer-precision audit on a single track in one call.
|
|
133
|
+
|
|
134
|
+
Replaces 8 manual checks (timbre, sequence, stereo, masking, modulation,
|
|
135
|
+
params, samples, effects) with one server-side aggregation. Returns
|
|
136
|
+
structured report with PASS/WARN/FAIL per check + ranked fixes.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
track_index: Track to audit.
|
|
140
|
+
role: Optional role override ("kick"/"snare"/"hat"/"perc"/"bass"/
|
|
141
|
+
"pad"/"lead"/"atmos"/"vox"/"fx"). If omitted, inferred from
|
|
142
|
+
track name + first instrument class.
|
|
143
|
+
include_masking: If True (default), pulls cross-track masking report
|
|
144
|
+
and filters for this track. Adds ~200-600ms.
|
|
145
|
+
include_timbre: If True, pulls per-track timbre fingerprint via the
|
|
146
|
+
M4L bridge. Costs an extra spectral read; default False so the
|
|
147
|
+
tool stays fast on bridge-less sessions.
|
|
148
|
+
|
|
149
|
+
Returns one structured report — no follow-up calls needed.
|
|
150
|
+
"""
|
|
151
|
+
started = time.time()
|
|
152
|
+
ableton = _get_ableton(ctx)
|
|
153
|
+
|
|
154
|
+
track_info = _safe_call(ableton, "get_track_info", {"track_index": track_index})
|
|
155
|
+
if not track_info:
|
|
156
|
+
return {
|
|
157
|
+
"track_index": track_index,
|
|
158
|
+
"error": "get_track_info failed",
|
|
159
|
+
"checks": {},
|
|
160
|
+
"recommended_fixes": [],
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
track_name = track_info.get("name", f"track_{track_index}")
|
|
164
|
+
devices = track_info.get("devices", []) or []
|
|
165
|
+
clip_slots = track_info.get("clip_slots", []) or []
|
|
166
|
+
|
|
167
|
+
# Role: caller-given or inferred
|
|
168
|
+
inferred_role = role or checks.infer_role(track_name, devices)
|
|
169
|
+
|
|
170
|
+
# Pull per-clip notes only when MIDI clips exist (audio tracks return [])
|
|
171
|
+
notes_per_clip = _fetch_notes_for_clips(ableton, track_index, clip_slots)
|
|
172
|
+
|
|
173
|
+
# Modulation signals: clip automation + wavetable mod matrix routings
|
|
174
|
+
automation_present = _has_clip_automation(ableton, track_index, clip_slots)
|
|
175
|
+
wt_routings = _count_wavetable_routings(ableton, track_index, devices)
|
|
176
|
+
|
|
177
|
+
# Optional masking — note that get_masking_report is an MCP-server-side
|
|
178
|
+
# tool (mix_engine.tools), NOT a Remote Script command. We import and
|
|
179
|
+
# call its python function directly. Going through ableton.send_command
|
|
180
|
+
# would dispatch to the LOM bridge which has no such handler and silently
|
|
181
|
+
# fails (BUG-D, caught by 4-way parallel live test 2026-05-01).
|
|
182
|
+
masking_report = None
|
|
183
|
+
if include_masking:
|
|
184
|
+
try:
|
|
185
|
+
from ..mix_engine.tools import get_masking_report as _get_masking_report_impl
|
|
186
|
+
masking_report = _get_masking_report_impl(ctx) # type: ignore[arg-type]
|
|
187
|
+
except Exception as exc:
|
|
188
|
+
logger.debug("audit_layer: masking fetch failed: %s", exc)
|
|
189
|
+
fingerprint = _maybe_get_timbre_fingerprint(ctx, track_index) if include_timbre else None
|
|
190
|
+
|
|
191
|
+
# Slice classifications only if Simpler with slices is present
|
|
192
|
+
slice_classes = None
|
|
193
|
+
if any(d.get("class_name") == "Simpler" for d in devices):
|
|
194
|
+
result = _safe_call(ableton, "classify_simpler_slices", {"track_index": track_index})
|
|
195
|
+
if result:
|
|
196
|
+
slice_classes = result.get("slices") or result.get("classifications")
|
|
197
|
+
|
|
198
|
+
# Run the 8 checks
|
|
199
|
+
check_results = {
|
|
200
|
+
"timbre": checks.check_timbre(inferred_role, fingerprint),
|
|
201
|
+
"sequence": checks.check_sequence(inferred_role, notes_per_clip),
|
|
202
|
+
"stereo": checks.check_stereo(inferred_role, track_info),
|
|
203
|
+
"masking": checks.check_masking(track_index, masking_report),
|
|
204
|
+
"modulation": checks.check_modulation(
|
|
205
|
+
inferred_role, devices, automation_present, wt_routings
|
|
206
|
+
),
|
|
207
|
+
"params": checks.check_params(inferred_role, devices),
|
|
208
|
+
"samples": checks.check_samples(inferred_role, devices, slice_classes),
|
|
209
|
+
"effects": checks.check_effects(inferred_role, devices),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
overall = checks.rollup_severity(check_results)
|
|
213
|
+
fixes = checks.rank_fixes(check_results)
|
|
214
|
+
|
|
215
|
+
duration_ms = int((time.time() - started) * 1000)
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"track_index": track_index,
|
|
219
|
+
"track_name": track_name,
|
|
220
|
+
"role": inferred_role,
|
|
221
|
+
"role_inferred": role is None,
|
|
222
|
+
"overall_severity": overall,
|
|
223
|
+
"checks": check_results,
|
|
224
|
+
"recommended_fixes": fixes,
|
|
225
|
+
"metadata": {
|
|
226
|
+
"duration_ms": duration_ms,
|
|
227
|
+
"clip_count": len([s for s in clip_slots if s.get("has_clip")]),
|
|
228
|
+
"device_count": len(devices),
|
|
229
|
+
"timbre_source": "fresh" if fingerprint else "skipped" if not include_timbre else "unavailable",
|
|
230
|
+
"masking_source": "fresh" if masking_report else "skipped" if not include_masking else "unavailable",
|
|
231
|
+
},
|
|
232
|
+
}
|
|
@@ -28,8 +28,8 @@ from typing import Optional
|
|
|
28
28
|
|
|
29
29
|
from ..branches import BranchSeed, freeform_seed
|
|
30
30
|
from .prompt_parser import parse_prompt, CompositionIntent
|
|
31
|
-
from .layer_planner import plan_layers, plan_sections
|
|
32
|
-
from .engine import ComposerEngine, CompositionResult
|
|
31
|
+
from .full.layer_planner import plan_layers, plan_sections
|
|
32
|
+
from .full.engine import ComposerEngine, CompositionResult
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
# Strategy registry — each function takes an intent and returns (modified
|
|
@@ -302,6 +302,9 @@ def _build_section_hypothesis_plan(intent: CompositionIntent, strategy_name: str
|
|
|
302
302
|
|
|
303
303
|
Returns a dict with {"steps", "step_count", "summary"}.
|
|
304
304
|
"""
|
|
305
|
+
# v1.24: SECTION_TEMPLATES removed per vocabulary-not-form principle (Task 12).
|
|
306
|
+
# plan_layers and plan_sections will raise until Task 14 rewires this.
|
|
307
|
+
# DEPRECATED in v1.24.
|
|
305
308
|
layers = plan_layers(intent)
|
|
306
309
|
sections = plan_sections(intent)
|
|
307
310
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Develop compose mode — extends an existing seed loop into a full track.
|
|
2
|
+
|
|
3
|
+
Two-phase LLM-creative flow (mirrors fast/full): Phase 1 returns a brief
|
|
4
|
+
with seed identity + vocabulary, Phase 2 (agent) designs variants, Phase 3
|
|
5
|
+
applies them to the live session.
|
|
6
|
+
"""
|
|
7
|
+
from .seed_introspector import classify_track, infer_role_from_name, introspect_seed
|
|
8
|
+
from .brief_builder import build_develop_brief, extract_artist_refs, detect_research_hooks
|
|
9
|
+
from .apply import apply_develop_plan
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"classify_track",
|
|
13
|
+
"infer_role_from_name",
|
|
14
|
+
"introspect_seed",
|
|
15
|
+
"build_develop_brief",
|
|
16
|
+
"extract_artist_refs",
|
|
17
|
+
"detect_research_hooks",
|
|
18
|
+
"apply_develop_plan",
|
|
19
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Develop compose Phase-3 executor — applies agent-designed variant plan to session.
|
|
2
|
+
|
|
3
|
+
Receives a plan dict from the agent (free-form variant set: agent decides
|
|
4
|
+
count, names, scenes, MIDI), then materializes each variant as a session
|
|
5
|
+
clip. Uses the shared Applier for pre-flight (analyzer/bridge) and
|
|
6
|
+
post-flight (back_to_arranger) so develop benefits from the same fixes
|
|
7
|
+
as fast and full modes.
|
|
8
|
+
|
|
9
|
+
Develop mode does NOT create new tracks — it only writes session clips
|
|
10
|
+
to existing tracks. So postflight passes an empty applied_track_indices
|
|
11
|
+
list (skips per-track monitoring set, but still calls back_to_arranger).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from fastmcp import Context
|
|
21
|
+
|
|
22
|
+
from ..framework.applier import Applier
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Applier wiring helpers ─────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
async def _ensure_analyzer_stub(ctx: Any) -> dict:
|
|
30
|
+
"""Develop mode is read-mostly; analyzer is nice-to-have, not required.
|
|
31
|
+
|
|
32
|
+
We still call ensure_analyzer_on_master if available, but failures are
|
|
33
|
+
non-fatal (unlike full mode where the analyzer is integral).
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
from ...tools.analyzer import ensure_analyzer_on_master # type: ignore
|
|
37
|
+
result = ensure_analyzer_on_master(ctx)
|
|
38
|
+
if isinstance(result, dict):
|
|
39
|
+
return result
|
|
40
|
+
return {"status": "unknown"}
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
logger.debug("apply_develop: ensure_analyzer non-fatal failure: %s", exc)
|
|
43
|
+
return {"status": "skipped"}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _reconnect_bridge_stub(ctx: Any) -> dict:
|
|
47
|
+
"""Reconnect bridge if available; non-fatal failure for develop."""
|
|
48
|
+
try:
|
|
49
|
+
from ...tools.analyzer import reconnect_bridge # type: ignore
|
|
50
|
+
result = reconnect_bridge(ctx)
|
|
51
|
+
if isinstance(result, dict):
|
|
52
|
+
connected = bool(result.get("connected") or result.get("ok"))
|
|
53
|
+
return {"connected": connected}
|
|
54
|
+
return {"connected": False}
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
logger.debug("apply_develop: reconnect_bridge non-fatal failure: %s", exc)
|
|
57
|
+
return {"connected": False}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _bridge_ping_stub(ctx: Any) -> dict:
|
|
61
|
+
"""Lightweight bridge ping — develop doesn't need bridge for clip writes."""
|
|
62
|
+
bridge = None
|
|
63
|
+
if hasattr(ctx, "lifespan_context"):
|
|
64
|
+
bridge = ctx.lifespan_context.get("m4l_bridge")
|
|
65
|
+
if bridge is None:
|
|
66
|
+
raise RuntimeError("bridge not available")
|
|
67
|
+
return await bridge.send_command("ping", {"timeout": 0.5})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _back_to_arranger(ctx: Any) -> dict:
|
|
71
|
+
"""Call the back_to_arranger MCP primitive."""
|
|
72
|
+
ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
|
|
73
|
+
if ableton is None:
|
|
74
|
+
return {"ok": False}
|
|
75
|
+
try:
|
|
76
|
+
return ableton.send_command("back_to_arranger", {})
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
logger.warning("apply_develop: back_to_arranger failed: %s", exc)
|
|
79
|
+
return {"ok": False}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _build_applier() -> Applier:
|
|
83
|
+
return Applier(
|
|
84
|
+
ensure_analyzer_fn=_ensure_analyzer_stub,
|
|
85
|
+
reconnect_bridge_fn=_reconnect_bridge_stub,
|
|
86
|
+
bridge_ping_fn=_bridge_ping_stub,
|
|
87
|
+
set_track_input_monitoring_fn=None, # develop creates no new tracks
|
|
88
|
+
back_to_arranger_fn=_back_to_arranger,
|
|
89
|
+
handshake_max_attempts=2, # develop is bridge-non-critical; short retry
|
|
90
|
+
handshake_gap_seconds=0.1,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── plan validation ────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def _validate_plan(plan: dict) -> str | None:
|
|
97
|
+
"""Return error message if plan is invalid, else None."""
|
|
98
|
+
if not isinstance(plan, dict):
|
|
99
|
+
return "plan must be a dict"
|
|
100
|
+
if plan.get("scope") not in (None, "develop"):
|
|
101
|
+
return f"plan scope must be 'develop' (got {plan.get('scope')!r})"
|
|
102
|
+
variants = plan.get("variants")
|
|
103
|
+
if not isinstance(variants, list) or len(variants) == 0:
|
|
104
|
+
return "plan.variants must be a non-empty list"
|
|
105
|
+
for i, v in enumerate(variants):
|
|
106
|
+
if not isinstance(v, dict):
|
|
107
|
+
return f"variants[{i}] must be a dict"
|
|
108
|
+
for required in ("track_index", "scene_index"):
|
|
109
|
+
if required not in v:
|
|
110
|
+
return f"variants[{i}] missing required field '{required}'"
|
|
111
|
+
if "notes" in v and not isinstance(v["notes"], list):
|
|
112
|
+
return f"variants[{i}].notes must be a list (or omitted)"
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── main entry point ───────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
async def apply_develop_plan(ctx: Context, plan: dict) -> dict:
|
|
119
|
+
"""Apply an agent-designed develop plan to the live session.
|
|
120
|
+
|
|
121
|
+
See module docstring for plan shape. Returns:
|
|
122
|
+
{
|
|
123
|
+
"status": "ok" | "partial" | "error",
|
|
124
|
+
"clips_created": int,
|
|
125
|
+
"scenes_populated": list[int],
|
|
126
|
+
"sample_swaps": int,
|
|
127
|
+
"preflight": dict, # Applier.preflight() result
|
|
128
|
+
"postflight": dict, # Applier.postflight() result
|
|
129
|
+
"errors": list[dict], # per-variant failures (variant index + reason)
|
|
130
|
+
"duration_ms": int,
|
|
131
|
+
}
|
|
132
|
+
"""
|
|
133
|
+
started = time.time()
|
|
134
|
+
|
|
135
|
+
err = _validate_plan(plan)
|
|
136
|
+
if err:
|
|
137
|
+
return {"status": "error", "error": err, "phase": "validate"}
|
|
138
|
+
|
|
139
|
+
ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
|
|
140
|
+
if ableton is None:
|
|
141
|
+
return {"status": "error", "error": "ableton client not available", "phase": "setup"}
|
|
142
|
+
|
|
143
|
+
applier = _build_applier()
|
|
144
|
+
preflight_result = await applier.preflight(ctx)
|
|
145
|
+
# Develop is bridge-non-critical; do NOT abort on bridge failure
|
|
146
|
+
|
|
147
|
+
# Tempo override (only if plan specifies a different tempo)
|
|
148
|
+
plan_tempo = plan.get("tempo")
|
|
149
|
+
if plan_tempo is not None:
|
|
150
|
+
try:
|
|
151
|
+
session = ableton.send_command("get_session_info", {})
|
|
152
|
+
current_tempo = float(session.get("tempo", 0.0))
|
|
153
|
+
if abs(current_tempo - float(plan_tempo)) > 0.01:
|
|
154
|
+
ableton.send_command("set_tempo", {"tempo": float(plan_tempo)})
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
logger.warning("apply_develop: tempo set failed: %s", exc)
|
|
157
|
+
|
|
158
|
+
clip_length = float(plan.get("clip_length_beats", 4.0))
|
|
159
|
+
clips_created = 0
|
|
160
|
+
sample_swaps = 0
|
|
161
|
+
scenes_populated: set[int] = set()
|
|
162
|
+
errors: list[dict] = []
|
|
163
|
+
|
|
164
|
+
for i, v in enumerate(plan["variants"]):
|
|
165
|
+
track_index = int(v["track_index"])
|
|
166
|
+
scene_index = int(v["scene_index"])
|
|
167
|
+
name = v.get("name") or f"v{i}"
|
|
168
|
+
notes = v.get("notes", [])
|
|
169
|
+
sample_uri = v.get("sample_uri")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# Optional sample swap (sample-trigger layers only)
|
|
173
|
+
if sample_uri:
|
|
174
|
+
ableton.send_command(
|
|
175
|
+
"load_browser_item",
|
|
176
|
+
{"track_index": track_index, "uri": sample_uri},
|
|
177
|
+
)
|
|
178
|
+
sample_swaps += 1
|
|
179
|
+
|
|
180
|
+
# Create clip
|
|
181
|
+
ableton.send_command(
|
|
182
|
+
"create_clip",
|
|
183
|
+
{"track_index": track_index, "clip_index": scene_index, "length": clip_length},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Add notes (skip if empty — empty clip is a valid drum-dropout pattern)
|
|
187
|
+
if notes:
|
|
188
|
+
ableton.send_command(
|
|
189
|
+
"add_notes",
|
|
190
|
+
{"track_index": track_index, "clip_index": scene_index, "notes": notes},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Name the clip
|
|
194
|
+
ableton.send_command(
|
|
195
|
+
"set_clip_name",
|
|
196
|
+
{"track_index": track_index, "clip_index": scene_index, "name": name},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
clips_created += 1
|
|
200
|
+
scenes_populated.add(scene_index)
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
logger.warning("apply_develop: variant[%d] failed: %s", i, exc)
|
|
203
|
+
errors.append({"variant_index": i, "reason": str(exc)})
|
|
204
|
+
|
|
205
|
+
# Postflight — develop creates no tracks, but still call back_to_arranger
|
|
206
|
+
postflight_result = await applier.postflight(ctx, applied_track_indices=[])
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
"status": "ok" if not errors else "partial",
|
|
210
|
+
"clips_created": clips_created,
|
|
211
|
+
"scenes_populated": sorted(scenes_populated),
|
|
212
|
+
"sample_swaps": sample_swaps,
|
|
213
|
+
"preflight": preflight_result,
|
|
214
|
+
"postflight": postflight_result,
|
|
215
|
+
"errors": errors,
|
|
216
|
+
"duration_ms": int((time.time() - started) * 1000),
|
|
217
|
+
}
|