livepilot 1.23.6 → 1.24.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 +37 -0
- package/README.md +59 -13
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +17 -3
- 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/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 +49 -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 +144 -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 +162 -6
- package/mcp_server/tools/_planner_engine.py +25 -63
- package/mcp_server/tools/analyzer.py +10 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -15,12 +15,17 @@ from fastmcp import Context
|
|
|
15
15
|
|
|
16
16
|
from ..server import mcp
|
|
17
17
|
from .prompt_parser import parse_prompt
|
|
18
|
-
from .engine import ComposerEngine
|
|
18
|
+
from .full.engine import ComposerEngine
|
|
19
|
+
from . import fast as fast_compose
|
|
20
|
+
from .fast.apply import apply_fast_plan
|
|
21
|
+
from .full.apply import apply_full_plan
|
|
19
22
|
import logging
|
|
23
|
+
import time
|
|
20
24
|
|
|
21
25
|
logger = logging.getLogger(__name__)
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
# Backward-compatible alias — tests import _apply_fast_plan from this module.
|
|
28
|
+
_apply_fast_plan = apply_fast_plan
|
|
24
29
|
|
|
25
30
|
# Singleton engine — stateless, safe to reuse
|
|
26
31
|
_engine = ComposerEngine()
|
|
@@ -82,50 +87,533 @@ async def _credit_safety_prelude(splice_client, max_credits: int) -> tuple[int,
|
|
|
82
87
|
return max_credits, credits_remaining, warnings
|
|
83
88
|
|
|
84
89
|
|
|
90
|
+
def _build_fast_brief(
|
|
91
|
+
ctx: Context, intent, bars: int, reference: str | None = None,
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""Phase-1 fast mode (2026-05-01 redesign per user feedback).
|
|
94
|
+
|
|
95
|
+
Returns a CREATIVE BRIEF for the agent to read and design a layer plan
|
|
96
|
+
around. Does fresh-project cleanup (analyzer load + default-track
|
|
97
|
+
delete) and tempo set up front so the agent can focus on creative
|
|
98
|
+
content. Does NOT generate any patterns or load any instruments — the
|
|
99
|
+
agent picks instruments from instruments_by_role and writes notes
|
|
100
|
+
inline, then submits the plan to compose_fast_apply.
|
|
101
|
+
|
|
102
|
+
`reference` (Tier 2): when set (e.g. "Ricardo Villalobos"), the brief
|
|
103
|
+
includes artist-specific search queries the agent fires against the
|
|
104
|
+
Ableton Knowledge MCP to design USING that artist's techniques.
|
|
105
|
+
"""
|
|
106
|
+
started = time.time()
|
|
107
|
+
ableton = ctx.lifespan_context.get("ableton") if hasattr(ctx, "lifespan_context") else None
|
|
108
|
+
if ableton is None:
|
|
109
|
+
return {"error": "Ableton connection not available", "phase": "brief"}
|
|
110
|
+
|
|
111
|
+
# Pre-flight: read the session
|
|
112
|
+
session = ableton.send_command("get_session_info", {})
|
|
113
|
+
starting_track_count = int(session.get("track_count", 0))
|
|
114
|
+
|
|
115
|
+
# Fresh-project detection + cleanup. Identify default tracks; load the
|
|
116
|
+
# analyzer on master proactively; queue defaults for deletion. We
|
|
117
|
+
# delete BEFORE returning the brief so the agent's apply-step lands
|
|
118
|
+
# cleanly without leftover MIDI 1 / Audio 1 tracks.
|
|
119
|
+
fresh_project = False
|
|
120
|
+
fresh_actions: list[str] = []
|
|
121
|
+
if fast_compose.detect_fresh_project(session):
|
|
122
|
+
candidates: list[int] = []
|
|
123
|
+
for i in range(starting_track_count):
|
|
124
|
+
try:
|
|
125
|
+
ti = ableton.send_command("get_track_info", {"track_index": i})
|
|
126
|
+
if fast_compose.track_is_empty(ti):
|
|
127
|
+
candidates.append(i)
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
logger.debug("fast: fresh-check get_track_info(%s) failed: %s", i, exc)
|
|
130
|
+
if len(candidates) == starting_track_count and starting_track_count > 0:
|
|
131
|
+
fresh_project = True
|
|
132
|
+
fresh_actions.append(f"detected_fresh_project_{starting_track_count}_default_tracks")
|
|
133
|
+
# Load analyzer on master proactively
|
|
134
|
+
try:
|
|
135
|
+
from ..tools.analyzer import ensure_analyzer_on_master as _ensure_analyzer
|
|
136
|
+
_ensure_analyzer(ctx)
|
|
137
|
+
fresh_actions.append("analyzer_loaded_on_master")
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
logger.debug("fast: ensure_analyzer_on_master failed: %s", exc)
|
|
140
|
+
# Delete defaults in reverse order. We can't delete the LAST
|
|
141
|
+
# track (Ableton requires ≥1), so leave one default in place;
|
|
142
|
+
# the agent's compose_fast_apply will add new tracks first,
|
|
143
|
+
# then we'll prune the leftover survivor in apply.
|
|
144
|
+
deletable = sorted(candidates, reverse=True)[:-1] # leave 1
|
|
145
|
+
deleted_count = 0
|
|
146
|
+
for idx in deletable:
|
|
147
|
+
try:
|
|
148
|
+
ableton.send_command("delete_track", {"track_index": idx})
|
|
149
|
+
deleted_count += 1
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
logger.debug("fast: delete_track(%s) failed: %s", idx, exc)
|
|
152
|
+
if deleted_count:
|
|
153
|
+
fresh_actions.append(f"deleted_{deleted_count}_default_tracks")
|
|
154
|
+
|
|
155
|
+
# Set tempo proactively (so the agent's plan plays at the right BPM)
|
|
156
|
+
tempo_set = False
|
|
157
|
+
if intent.tempo and intent.tempo > 0:
|
|
158
|
+
try:
|
|
159
|
+
ableton.send_command("set_tempo", {"tempo": float(intent.tempo)})
|
|
160
|
+
tempo_set = True
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
logger.debug("fast: set_tempo failed: %s", exc)
|
|
163
|
+
|
|
164
|
+
# Atlas access for instrument candidates
|
|
165
|
+
atlas_obj = None
|
|
166
|
+
try:
|
|
167
|
+
from ..atlas import tools as atlas_module
|
|
168
|
+
atlas_obj = atlas_module._get_atlas()
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
logger.debug("fast: atlas access failed: %s", exc)
|
|
171
|
+
|
|
172
|
+
# Anti-repeat: read all currently-loaded device names from the session
|
|
173
|
+
# so the brief picker can bias candidates AWAY from already-used devices.
|
|
174
|
+
# User feedback 2026-05-01: Tree Tone always wins for pad → boring.
|
|
175
|
+
post_cleanup_session = ableton.send_command("get_session_info", {})
|
|
176
|
+
loaded_device_names: set[str] = set()
|
|
177
|
+
track_count_after_cleanup = int(post_cleanup_session.get("track_count", 0))
|
|
178
|
+
for i in range(track_count_after_cleanup):
|
|
179
|
+
try:
|
|
180
|
+
ti = ableton.send_command("get_track_info", {"track_index": i})
|
|
181
|
+
for dev in (ti.get("devices") or []):
|
|
182
|
+
n = dev.get("name") or ""
|
|
183
|
+
if n:
|
|
184
|
+
loaded_device_names.add(n)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
logger.debug("fast: anti-repeat read failed for track %s: %s", i, exc)
|
|
187
|
+
|
|
188
|
+
fresh_state = {
|
|
189
|
+
"detected": fresh_project,
|
|
190
|
+
"actions_taken": fresh_actions,
|
|
191
|
+
"tempo_set": tempo_set,
|
|
192
|
+
"starting_track_count_after_cleanup": track_count_after_cleanup,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
brief = fast_compose.build_creative_brief(
|
|
196
|
+
intent=intent,
|
|
197
|
+
atlas=atlas_obj,
|
|
198
|
+
fresh_project_state=fresh_state,
|
|
199
|
+
bars=bars,
|
|
200
|
+
reference=reference,
|
|
201
|
+
exclude_loaded_device_names=loaded_device_names,
|
|
202
|
+
)
|
|
203
|
+
brief["duration_ms"] = int((time.time() - started) * 1000)
|
|
204
|
+
return brief
|
|
205
|
+
|
|
206
|
+
|
|
85
207
|
@mcp.tool()
|
|
86
208
|
async def compose(
|
|
87
209
|
ctx: Context,
|
|
88
210
|
prompt: str,
|
|
211
|
+
mode: str = "full",
|
|
212
|
+
bars: int = 4,
|
|
213
|
+
target_scene: Optional[int] = None,
|
|
214
|
+
seed_scene_index: int = 0,
|
|
89
215
|
max_credits: int = 50,
|
|
90
216
|
dry_run: bool = False,
|
|
217
|
+
reference: Optional[str] = None,
|
|
91
218
|
) -> dict:
|
|
92
|
-
"""Plan a
|
|
219
|
+
"""Plan, brief, or execute a multi-layer composition from a text prompt
|
|
220
|
+
or an existing seed loop.
|
|
221
|
+
|
|
222
|
+
Three modes:
|
|
223
|
+
|
|
224
|
+
``mode="full"`` (default) — plan-only. Parses prompt into genre/mood/
|
|
225
|
+
tempo/key, plans layers using role templates, returns an executable
|
|
226
|
+
plan of tool calls for the agent to step through. This is the rich
|
|
227
|
+
composition path.
|
|
228
|
+
|
|
229
|
+
``mode="fast"`` — **LLM-creative two-phase flow** (2026-05-01 redesign):
|
|
230
|
+
Phase 1 (this call): returns a CREATIVE BRIEF with parsed intent,
|
|
231
|
+
atlas-filtered instrument candidates per role, scale-pitch context,
|
|
232
|
+
genre creative guidance. Does NOT generate any musical content.
|
|
233
|
+
Pre-flight handles fresh-project detection, analyzer load, default-
|
|
234
|
+
track cleanup, and tempo set so the agent can focus on creativity.
|
|
235
|
+
|
|
236
|
+
Phase 2 (agent's job): read the brief, pick instruments creatively
|
|
237
|
+
from instruments_by_role, design MIDI notes inline (genuinely fresh
|
|
238
|
+
per call, not from templates), submit a complete plan to
|
|
239
|
+
``compose_fast_apply``.
|
|
240
|
+
|
|
241
|
+
Phase 3 (compose_fast_apply): server-side execute the plan — create
|
|
242
|
+
tracks, load instruments, populate clips with the agent's notes,
|
|
243
|
+
fire scene.
|
|
244
|
+
|
|
245
|
+
``mode="develop"`` — extend an existing 8-bar loop into a fuller
|
|
246
|
+
arrangement. Reads the seed at seed_scene_index (default 0),
|
|
247
|
+
builds a brief with identity + vocabulary, returns it. Agent
|
|
248
|
+
designs the variant set, calls develop_apply.
|
|
249
|
+
|
|
250
|
+
prompt: "dark minimal techno 128bpm" / "downtempo lo-fi Cm" / "trap"
|
|
251
|
+
mode: "full" | "fast" | "develop"
|
|
252
|
+
bars: clip length in bars (fast mode only — default 4)
|
|
253
|
+
target_scene: scene index to populate (full mode legacy param; fast
|
|
254
|
+
mode now lets the agent pick via compose_fast_apply)
|
|
255
|
+
seed_scene_index: scene to read as the seed (develop mode only,
|
|
256
|
+
default 0)
|
|
257
|
+
max_credits: max Splice credits budget for full-mode plans (default 50)
|
|
258
|
+
dry_run: full-mode only — skip credit checks
|
|
259
|
+
|
|
260
|
+
Fast mode returns: a brief dict with creative context. Call
|
|
261
|
+
compose_fast_apply with your designed plan to actually create tracks.
|
|
262
|
+
Develop mode returns: a brief dict with seed_state + design_targets.
|
|
263
|
+
Call develop_apply with your designed variant plan.
|
|
264
|
+
Full mode returns the existing plan dict.
|
|
265
|
+
"""
|
|
266
|
+
intent = parse_prompt(prompt)
|
|
93
267
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
268
|
+
if mode == "develop":
|
|
269
|
+
from .develop.seed_introspector import introspect_seed
|
|
270
|
+
from .develop.brief_builder import build_develop_brief
|
|
271
|
+
seed = introspect_seed(ctx, scene_index=seed_scene_index)
|
|
272
|
+
if seed.get("error"):
|
|
273
|
+
return {"status": "error", "error": seed["error"], "phase": "introspect_seed"}
|
|
274
|
+
brief = build_develop_brief(ctx, seed, prompt_directive=prompt or None)
|
|
275
|
+
brief["prompt"] = prompt
|
|
276
|
+
return brief
|
|
277
|
+
|
|
278
|
+
if mode == "fast":
|
|
279
|
+
brief = _build_fast_brief(
|
|
280
|
+
ctx, intent, bars=int(bars), reference=reference,
|
|
281
|
+
)
|
|
282
|
+
brief["prompt"] = prompt
|
|
283
|
+
return brief
|
|
284
|
+
|
|
285
|
+
# mode == "full" — v1.24 LLM-creative two-phase flow
|
|
286
|
+
# Phase 1: return a FullBrief vocabulary so the agent can design form +
|
|
287
|
+
# variants + events. Phase 3: agent submits the designed plan to
|
|
288
|
+
# compose_full_apply → apply_full_plan_v2.
|
|
289
|
+
# The old deterministic engine path (step_plan) is deprecated
|
|
290
|
+
# (BUG-FULL-MODE-18: flat single-pattern arrangements).
|
|
291
|
+
from .full.brief_builder import build_full_brief
|
|
292
|
+
brief = build_full_brief(ctx, prompt=prompt, seed_state=None)
|
|
293
|
+
brief["prompt"] = prompt
|
|
294
|
+
return brief
|
|
97
295
|
|
|
98
|
-
prompt: "dark minimal techno 128bpm with industrial textures and ghostly vocals"
|
|
99
|
-
max_credits: maximum Splice credits budget for the plan (default 50, 0 = downloaded only)
|
|
100
|
-
dry_run: if True, return the plan without credit checks
|
|
101
296
|
|
|
102
|
-
|
|
103
|
-
|
|
297
|
+
@mcp.tool()
|
|
298
|
+
async def compose_fast_apply(ctx: Context, plan: dict) -> dict:
|
|
299
|
+
"""Phase-3 of the LLM-creative fast mode (2026-05-01).
|
|
300
|
+
|
|
301
|
+
Receives a complete layer plan designed by the agent (informed by
|
|
302
|
+
the brief returned from ``compose(mode="fast")``) and bulk-executes
|
|
303
|
+
it server-side: creates MIDI tracks, loads instruments by URI,
|
|
304
|
+
creates clips, populates them with the agent's notes, fires the
|
|
305
|
+
scene. ALL underlying TCP commands run in this single call so the
|
|
306
|
+
agent doesn't pay round-trip cost between create_track / load /
|
|
307
|
+
clip / notes.
|
|
308
|
+
|
|
309
|
+
Plan shape:
|
|
310
|
+
{
|
|
311
|
+
"layers": [
|
|
312
|
+
{
|
|
313
|
+
"role": "kick" | "snare" | "hat" | "perc" | "clap" | "bass" |
|
|
314
|
+
"pad" | "lead" | "atmos" | "vox" | "fx",
|
|
315
|
+
"uri": "atlas URI from brief.instruments_by_role[role]",
|
|
316
|
+
"track_name": "optional display name (defaults to ROLE)",
|
|
317
|
+
"notes": [
|
|
318
|
+
{"pitch": int 0-127, "start_time": float beats from clip start,
|
|
319
|
+
"duration": float beats, "velocity": int 0-127},
|
|
320
|
+
...
|
|
321
|
+
],
|
|
322
|
+
# Phase B (2026-05-01): native-device effect chain on this
|
|
323
|
+
# track, applied AFTER the instrument loads. Each entry
|
|
324
|
+
# inserts one device (insert_device — 12.3+ API) and
|
|
325
|
+
# optionally sets a few of its parameters.
|
|
326
|
+
# Brief.creative_guidance.effect_chain_hints[role] is a
|
|
327
|
+
# canonical starting point, but the agent should adapt
|
|
328
|
+
# values to fit the prompt's mood (subtler in ambient,
|
|
329
|
+
# heavier in trap, etc.).
|
|
330
|
+
"effects": [
|
|
331
|
+
{"device": "Saturator", "params": {"Drive": 0.4}},
|
|
332
|
+
{"device": "EQ Eight", "params": {}},
|
|
333
|
+
...
|
|
334
|
+
],
|
|
335
|
+
# Phase B: track sends. return_name is case-insensitive;
|
|
336
|
+
# if no return matches, the entry is skipped (no fail).
|
|
337
|
+
"sends": [
|
|
338
|
+
{"return_name": "A-Reverb", "value": 0.25},
|
|
339
|
+
{"send_index": 1, "value": 0.10},
|
|
340
|
+
...
|
|
341
|
+
]
|
|
342
|
+
},
|
|
343
|
+
...
|
|
344
|
+
],
|
|
345
|
+
"scene_index": int or null (auto-pick first empty if null),
|
|
346
|
+
"bars": int (clip length, default 4),
|
|
347
|
+
"tempo": int or null (skip if already set in brief)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
The agent should design notes creatively per call — don't reuse a
|
|
351
|
+
template. Variation is the whole point of this two-phase flow.
|
|
352
|
+
|
|
353
|
+
Returns: tracks_created, scene_fired, per-layer load+note status,
|
|
354
|
+
effects_loaded + sends_set totals, plus techniques_used aggregating
|
|
355
|
+
each layer's applied_technique (Tier-1C) so the user sees per-layer
|
|
356
|
+
provenance: what producer-voice snippet from which Ableton tutorial
|
|
357
|
+
informed each layer's design.
|
|
104
358
|
"""
|
|
105
|
-
|
|
359
|
+
return await apply_fast_plan(ctx, plan)
|
|
106
360
|
|
|
107
|
-
splice_client = ctx.lifespan_context.get("splice_client") if hasattr(ctx, "lifespan_context") else None
|
|
108
|
-
search_roots = _get_search_roots(ctx)
|
|
109
361
|
|
|
110
|
-
|
|
362
|
+
@mcp.tool()
|
|
363
|
+
def consult_ableton_knowledge(
|
|
364
|
+
ctx: Context,
|
|
365
|
+
question: str,
|
|
366
|
+
session_context: Optional[dict] = None,
|
|
367
|
+
) -> dict:
|
|
368
|
+
"""Tier-3: Ableton Knowledge consultation orchestrator.
|
|
369
|
+
|
|
370
|
+
Takes a free-text production question + optional session context,
|
|
371
|
+
returns a structured consultation plan: which Ableton Knowledge MCP
|
|
372
|
+
tools to fire (search_transcripts / search_live_manual / search_videos /
|
|
373
|
+
search_knowledge_base), with what queries, in what order, plus a
|
|
374
|
+
synthesis template for the agent to combine the results into a
|
|
375
|
+
direct answer for the user.
|
|
376
|
+
|
|
377
|
+
The agent runs the recommended searches inline, synthesizes per the
|
|
378
|
+
template, and surfaces sources alongside the answer.
|
|
379
|
+
|
|
380
|
+
Examples:
|
|
381
|
+
consult_ableton_knowledge("how do I make my kick punchier?")
|
|
382
|
+
→ plan: [search_live_manual("Saturator"), search_transcripts("kick punch"),
|
|
383
|
+
search_videos("kick design tutorial")] + synthesis template
|
|
384
|
+
consult_ableton_knowledge("what's the difference between Operator and Wavetable?")
|
|
385
|
+
→ plan: [search_live_manual("Operator"), search_live_manual("Wavetable"),
|
|
386
|
+
search_transcripts("Operator vs Wavetable")]
|
|
387
|
+
|
|
388
|
+
session_context (optional): {
|
|
389
|
+
"current_genre": "techno",
|
|
390
|
+
"current_key": "Am",
|
|
391
|
+
"tracks": [{role, instrument}, ...]
|
|
392
|
+
} — informs query specificity (e.g. "kick punch" becomes "techno kick punch").
|
|
111
393
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
394
|
+
Returns:
|
|
395
|
+
{
|
|
396
|
+
"question": str,
|
|
397
|
+
"intent_classification": "sound_design" | "arrangement" | "mixing" | "device" | "general",
|
|
398
|
+
"search_plan": [{tool, query, why}, ...],
|
|
399
|
+
"synthesis_template": str,
|
|
400
|
+
"expected_response_shape": dict,
|
|
401
|
+
}
|
|
402
|
+
"""
|
|
403
|
+
q = (question or "").strip()
|
|
404
|
+
if not q:
|
|
405
|
+
return {
|
|
406
|
+
"error": "question is empty",
|
|
407
|
+
"question": question,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
sc = session_context or {}
|
|
411
|
+
genre = (sc.get("current_genre") or "").strip().lower()
|
|
412
|
+
|
|
413
|
+
# Lightweight intent classification — keyword-based, deliberately
|
|
414
|
+
# simple so this tool is fast and predictable.
|
|
415
|
+
q_lower = q.lower()
|
|
416
|
+
intent_class = _classify_consultation_intent(q_lower)
|
|
417
|
+
|
|
418
|
+
# Build a search plan keyed off intent + question keywords + genre context
|
|
419
|
+
plan = _build_consultation_plan(q, q_lower, intent_class, genre)
|
|
420
|
+
|
|
421
|
+
synthesis_template = (
|
|
422
|
+
"After firing the searches in `search_plan`, synthesize a 2-3 paragraph answer that:\n"
|
|
423
|
+
"1. Directly answers the question (lead with the answer, not history).\n"
|
|
424
|
+
"2. Cites 1-2 specific snippets from the search results inline.\n"
|
|
425
|
+
"3. Lists the source URLs/sections alongside the answer.\n"
|
|
426
|
+
"4. If session_context is given, tailor the answer to that genre/key/setup.\n"
|
|
427
|
+
"5. Suggest 1 concrete next experiment the user could try in their session."
|
|
118
428
|
)
|
|
119
|
-
result.warnings.extend(warnings)
|
|
120
429
|
|
|
121
|
-
|
|
122
|
-
|
|
430
|
+
return {
|
|
431
|
+
"question": q,
|
|
432
|
+
"intent_classification": intent_class,
|
|
433
|
+
"session_context_used": bool(session_context),
|
|
434
|
+
"genre_context": genre or None,
|
|
435
|
+
"search_plan": plan,
|
|
436
|
+
"synthesis_template": synthesis_template,
|
|
437
|
+
"expected_response_shape": {
|
|
438
|
+
"answer": "2-3 paragraph synthesis",
|
|
439
|
+
"sources": [{"title": "...", "url": "...", "snippet": "..."}],
|
|
440
|
+
"next_experiment": "concrete suggestion",
|
|
441
|
+
},
|
|
442
|
+
"next_step": (
|
|
443
|
+
"Run each search in `search_plan` in order via the Ableton Knowledge "
|
|
444
|
+
"MCP tools. Synthesize per `synthesis_template`. Surface sources to "
|
|
445
|
+
"the user. If session_context was given, tailor the answer to it."
|
|
446
|
+
),
|
|
447
|
+
}
|
|
123
448
|
|
|
124
|
-
if credits_remaining is not None:
|
|
125
|
-
output["credits_remaining"] = credits_remaining
|
|
126
|
-
output["credits_budget"] = max_credits
|
|
127
449
|
|
|
128
|
-
|
|
450
|
+
# Lightweight keyword classifier for the consultation tool. Deliberately
|
|
451
|
+
# simple — Ableton Knowledge MCP does the heavy lifting; this just routes
|
|
452
|
+
# the question to the right starting tool set.
|
|
453
|
+
_CONSULTATION_INTENT_KEYWORDS: dict[str, tuple[str, ...]] = {
|
|
454
|
+
"sound_design": (
|
|
455
|
+
"kick", "snare", "hat", "drum", "bass", "pad", "lead", "808",
|
|
456
|
+
"saturate", "saturation", "compress", "sidechain", "punch",
|
|
457
|
+
"warm", "bright", "thick", "thin",
|
|
458
|
+
),
|
|
459
|
+
"device": (
|
|
460
|
+
"operator", "wavetable", "drift", "analog", "simpler", "sampler",
|
|
461
|
+
"auto filter", "echo", "reverb", "compressor", "saturator",
|
|
462
|
+
"eq eight", "frequency shifter", "redux", "corpus", "tension",
|
|
463
|
+
"max for live", "m4l",
|
|
464
|
+
),
|
|
465
|
+
"arrangement": (
|
|
466
|
+
"arrangement", "structure", "verse", "chorus", "drop", "build",
|
|
467
|
+
"transition", "intro", "outro", "section",
|
|
468
|
+
),
|
|
469
|
+
"mixing": (
|
|
470
|
+
"mix", "balance", "level", "pan", "stereo", "width", "loud",
|
|
471
|
+
"loudness", "lufs", "master", "mastering", "headroom",
|
|
472
|
+
),
|
|
473
|
+
"rhythm": (
|
|
474
|
+
"swing", "groove", "humanize", "shuffle", "syncopat", "polyrhythm",
|
|
475
|
+
"ghost note",
|
|
476
|
+
),
|
|
477
|
+
"harmony": (
|
|
478
|
+
"chord", "progression", "scale", "mode", "minor", "major",
|
|
479
|
+
"voice lead", "voicing",
|
|
480
|
+
),
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _classify_consultation_intent(q_lower: str) -> str:
|
|
485
|
+
"""Return the most likely intent class for a consultation question.
|
|
486
|
+
|
|
487
|
+
Uses whole-word matching for single-word keywords to avoid the
|
|
488
|
+
classic substring-trap (e.g. "hat" matching inside "what"). Multi-
|
|
489
|
+
word keywords like "auto filter" use plain substring match because
|
|
490
|
+
whitespace already provides word boundaries.
|
|
491
|
+
"""
|
|
492
|
+
import re
|
|
493
|
+
scores: dict[str, int] = {}
|
|
494
|
+
words = set(re.findall(r"\b[\w-]+\b", q_lower))
|
|
495
|
+
for cls, keywords in _CONSULTATION_INTENT_KEYWORDS.items():
|
|
496
|
+
for kw in keywords:
|
|
497
|
+
kw_lower = kw.lower()
|
|
498
|
+
if " " in kw_lower:
|
|
499
|
+
# Multi-word keyword: substring match is safe
|
|
500
|
+
if kw_lower in q_lower:
|
|
501
|
+
scores[cls] = scores.get(cls, 0) + 1
|
|
502
|
+
else:
|
|
503
|
+
# Single-word keyword: whole-word match avoids "hat" in "what"
|
|
504
|
+
if kw_lower in words:
|
|
505
|
+
scores[cls] = scores.get(cls, 0) + 1
|
|
506
|
+
if not scores:
|
|
507
|
+
return "general"
|
|
508
|
+
return max(scores.items(), key=lambda kv: kv[1])[0]
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _build_consultation_plan(
|
|
512
|
+
q: str, q_lower: str, intent_class: str, genre: str,
|
|
513
|
+
) -> list[dict]:
|
|
514
|
+
"""Build the ordered search plan based on intent classification.
|
|
515
|
+
|
|
516
|
+
Returns a list of {tool, query, why} entries the agent fires in
|
|
517
|
+
sequence to gather evidence before synthesizing.
|
|
518
|
+
"""
|
|
519
|
+
plan: list[dict] = []
|
|
520
|
+
genre_prefix = f"{genre} " if genre else ""
|
|
521
|
+
|
|
522
|
+
if intent_class == "device":
|
|
523
|
+
plan.append({
|
|
524
|
+
"tool": "search_live_manual",
|
|
525
|
+
"query": q,
|
|
526
|
+
"why": "device-specific question — official manual is authoritative",
|
|
527
|
+
})
|
|
528
|
+
plan.append({
|
|
529
|
+
"tool": "search_videos",
|
|
530
|
+
"query": q,
|
|
531
|
+
"why": "Ableton's tutorial videos cover device usage in depth",
|
|
532
|
+
})
|
|
533
|
+
plan.append({
|
|
534
|
+
"tool": "search_transcripts",
|
|
535
|
+
"query": q,
|
|
536
|
+
"why": "transcript semantic search may surface specific use cases",
|
|
537
|
+
})
|
|
538
|
+
elif intent_class == "sound_design":
|
|
539
|
+
plan.append({
|
|
540
|
+
"tool": "search_transcripts",
|
|
541
|
+
"query": f"{genre_prefix}{q}",
|
|
542
|
+
"why": "producer-voice technique snippets are the most useful here",
|
|
543
|
+
})
|
|
544
|
+
plan.append({
|
|
545
|
+
"tool": "search_videos",
|
|
546
|
+
"query": f"{genre_prefix}{q} tutorial",
|
|
547
|
+
"why": "tutorial videos for hands-on technique",
|
|
548
|
+
})
|
|
549
|
+
plan.append({
|
|
550
|
+
"tool": "search_knowledge_base",
|
|
551
|
+
"query": q,
|
|
552
|
+
"why": "support articles often have step-by-step recipes",
|
|
553
|
+
})
|
|
554
|
+
elif intent_class == "arrangement":
|
|
555
|
+
plan.append({
|
|
556
|
+
"tool": "search_transcripts",
|
|
557
|
+
"query": f"{genre_prefix}{q} arrangement",
|
|
558
|
+
"why": "arrangement is often discussed in long-form video content",
|
|
559
|
+
})
|
|
560
|
+
plan.append({
|
|
561
|
+
"tool": "search_videos",
|
|
562
|
+
"query": f"{genre_prefix}arrangement structure",
|
|
563
|
+
"why": "arrangement-specific tutorials",
|
|
564
|
+
})
|
|
565
|
+
elif intent_class == "mixing":
|
|
566
|
+
plan.append({
|
|
567
|
+
"tool": "search_transcripts",
|
|
568
|
+
"query": q,
|
|
569
|
+
"why": "mixing techniques live in producer videos",
|
|
570
|
+
})
|
|
571
|
+
plan.append({
|
|
572
|
+
"tool": "search_live_manual",
|
|
573
|
+
"query": q,
|
|
574
|
+
"why": "manual covers Live's mixing tools (EQ Eight, Compressor, etc.)",
|
|
575
|
+
})
|
|
576
|
+
plan.append({
|
|
577
|
+
"tool": "search_knowledge_base",
|
|
578
|
+
"query": q,
|
|
579
|
+
"why": "support articles have mixing tips",
|
|
580
|
+
})
|
|
581
|
+
elif intent_class == "rhythm":
|
|
582
|
+
plan.append({
|
|
583
|
+
"tool": "search_transcripts",
|
|
584
|
+
"query": f"{genre_prefix}{q}",
|
|
585
|
+
"why": "groove/rhythm techniques are best from producer voices",
|
|
586
|
+
})
|
|
587
|
+
plan.append({
|
|
588
|
+
"tool": "search_live_manual",
|
|
589
|
+
"query": "groove pool",
|
|
590
|
+
"why": "Live has a Groove Pool — manual is authoritative",
|
|
591
|
+
})
|
|
592
|
+
elif intent_class == "harmony":
|
|
593
|
+
plan.append({
|
|
594
|
+
"tool": "search_transcripts",
|
|
595
|
+
"query": f"{genre_prefix}{q}",
|
|
596
|
+
"why": "harmonic techniques from real producer examples",
|
|
597
|
+
})
|
|
598
|
+
plan.append({
|
|
599
|
+
"tool": "search_videos",
|
|
600
|
+
"query": "music theory chord progression",
|
|
601
|
+
"why": "Ableton's theory-adjacent tutorials",
|
|
602
|
+
})
|
|
603
|
+
else:
|
|
604
|
+
# General fallback
|
|
605
|
+
plan.append({
|
|
606
|
+
"tool": "search_transcripts",
|
|
607
|
+
"query": q,
|
|
608
|
+
"why": "general semantic search across producer voice content",
|
|
609
|
+
})
|
|
610
|
+
plan.append({
|
|
611
|
+
"tool": "search_knowledge_base",
|
|
612
|
+
"query": q,
|
|
613
|
+
"why": "support articles for direct answers",
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
return plan
|
|
129
617
|
|
|
130
618
|
|
|
131
619
|
@mcp.tool()
|
|
@@ -270,3 +758,113 @@ def propose_composer_branches(
|
|
|
270
758
|
"seeds": seeds,
|
|
271
759
|
"compiled_plans": plans,
|
|
272
760
|
}
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# ── Creative chop-mode helpers (2026-05-01) ───────────────────────
|
|
764
|
+
#
|
|
765
|
+
# Auto-warping every loop to project tempo is production-safe but kills
|
|
766
|
+
# the creative latitude of intentional tempo mismatch (J Dilla / Madlib /
|
|
767
|
+
# IDM territory — a 90-bpm loop in a 122-bpm project produces interesting
|
|
768
|
+
# rhythmic chopping when the source/project ratio is musically meaningful).
|
|
769
|
+
#
|
|
770
|
+
# These helpers + the `warp_strategy` parameter on compose_full_apply
|
|
771
|
+
# give the user three modes:
|
|
772
|
+
# "always" (default): warp every loop — production-safe.
|
|
773
|
+
# "smart": warp tonal layers (pad/bass/lead/vocal) always;
|
|
774
|
+
# leave drum/perc loops un-warped IF source/project
|
|
775
|
+
# ratio is in the magic set ±2%. Detected ratios:
|
|
776
|
+
# 0.5 (half-time), 0.667 (2:3), 0.75 (3:4 cross),
|
|
777
|
+
# 0.8 (4:5), 1.25 (5:4), 1.333 (4:3), 1.5 (3:2),
|
|
778
|
+
# 2.0 (double-time).
|
|
779
|
+
# "chop": never warp — full creative chopping mode.
|
|
780
|
+
|
|
781
|
+
# Backward-compatible aliases — tests import these from this module.
|
|
782
|
+
_apply_full_plan = apply_full_plan
|
|
783
|
+
from .full.apply import ( # noqa: E402
|
|
784
|
+
_resolve_from_step,
|
|
785
|
+
_extract_bpm_from_filename,
|
|
786
|
+
_is_meaningful_ratio,
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
@mcp.tool()
|
|
790
|
+
async def compose_full_apply(
|
|
791
|
+
ctx: Context,
|
|
792
|
+
plan: dict,
|
|
793
|
+
) -> dict:
|
|
794
|
+
"""Phase-3 of full mode (v1.24 LLM-creative): execute the agent-designed plan.
|
|
795
|
+
|
|
796
|
+
compose(mode="full") returns a FullBrief with genre/artist vocabulary,
|
|
797
|
+
the 42-event structural lexicon, and atlas instrument candidates. The
|
|
798
|
+
agent reads the brief, designs the song's form (section sequence, bar
|
|
799
|
+
counts, drop placement, variant per track per section), and submits
|
|
800
|
+
that designed plan here.
|
|
801
|
+
|
|
802
|
+
See mcp_server.composer.full.apply.apply_full_plan_v2 for the full
|
|
803
|
+
plan shape. Required fields: form (list of section dicts), tracks
|
|
804
|
+
(list of track specs with variants + arrangement_clips).
|
|
805
|
+
|
|
806
|
+
Replaces the deterministic engine path (BUG-FULL-MODE-18 fix):
|
|
807
|
+
the old flow tiled one source clip across all sections; the new flow
|
|
808
|
+
emits one source clip per variant so each section can have a genuinely
|
|
809
|
+
different pattern.
|
|
810
|
+
"""
|
|
811
|
+
from .full.apply import apply_full_plan_v2
|
|
812
|
+
return await apply_full_plan_v2(ctx, plan)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
# ── v1.24 develop mode ─────────────────────────────────────────────
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
@mcp.tool()
|
|
819
|
+
async def analyze_loop_for_extension(
|
|
820
|
+
ctx: Context,
|
|
821
|
+
scene_index: int = 0,
|
|
822
|
+
) -> dict:
|
|
823
|
+
"""Read-only analyzer for develop mode — returns SeedState for a scene.
|
|
824
|
+
|
|
825
|
+
Inspects the scene's clips, classifies each track as sample_trigger
|
|
826
|
+
or midi_riff, infers role from track name, and reports key/tempo/
|
|
827
|
+
time signature. The agent uses this BEFORE calling compose(mode='develop')
|
|
828
|
+
to verify the loop is extendable, OR as a standalone diagnostic.
|
|
829
|
+
|
|
830
|
+
Returns: dict per mcp_server.composer.develop.seed_introspector.introspect_seed.
|
|
831
|
+
No writes to the session.
|
|
832
|
+
"""
|
|
833
|
+
from .develop.seed_introspector import introspect_seed
|
|
834
|
+
return introspect_seed(ctx, scene_index=scene_index)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
@mcp.tool()
|
|
838
|
+
async def develop_apply(
|
|
839
|
+
ctx: Context,
|
|
840
|
+
plan: dict,
|
|
841
|
+
) -> dict:
|
|
842
|
+
"""Phase-3 develop mode: server-side execute the agent's variant plan.
|
|
843
|
+
|
|
844
|
+
Receives a plan with the agent-designed variant set:
|
|
845
|
+
{
|
|
846
|
+
"scope": "develop",
|
|
847
|
+
"clip_length_beats": float (default 4.0),
|
|
848
|
+
"tempo": float (optional override),
|
|
849
|
+
"variants": [
|
|
850
|
+
{
|
|
851
|
+
"track_index": int,
|
|
852
|
+
"scene_index": int,
|
|
853
|
+
"name": str,
|
|
854
|
+
"notes": [{"pitch": int, "start_time": float, "duration": float,
|
|
855
|
+
"velocity": int}, ...]
|
|
856
|
+
"sample_uri": str (optional — for sample-trigger swaps)
|
|
857
|
+
},
|
|
858
|
+
...
|
|
859
|
+
]
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
The agent decides variant count, names, scenes, MIDI per call — no
|
|
863
|
+
fixed taxonomy. Empty notes list creates an empty clip (drum-dropout
|
|
864
|
+
pattern).
|
|
865
|
+
|
|
866
|
+
Returns: status, clips_created, scenes_populated, sample_swaps,
|
|
867
|
+
preflight result, postflight result, errors list.
|
|
868
|
+
"""
|
|
869
|
+
from .develop.apply import apply_develop_plan
|
|
870
|
+
return await apply_develop_plan(ctx, plan)
|