livepilot 1.10.0 → 1.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +214 -0
- package/CONTRIBUTING.md +2 -2
- package/LICENSE +62 -21
- package/README.md +264 -286
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-arrangement/SKILL.md +18 -1
- package/livepilot/skills/livepilot-core/SKILL.md +5 -5
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-devices/SKILL.md +23 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +21 -17
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +2 -1
- package/livepilot/skills/livepilot-wonder/SKILL.md +8 -6
- package/livepilot.mcpb +0 -0
- package/m4l_device/LivePilot_Analyzer.adv +0 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +4 -4
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/composer/engine.py +249 -169
- package/mcp_server/composer/sample_resolver.py +153 -0
- package/mcp_server/composer/tools.py +97 -87
- package/mcp_server/memory/taste_accessors.py +47 -0
- package/mcp_server/preview_studio/engine.py +9 -2
- package/mcp_server/preview_studio/tools.py +78 -35
- package/mcp_server/project_brain/tools.py +34 -0
- package/mcp_server/runtime/execution_router.py +180 -38
- package/mcp_server/runtime/mcp_dispatch.py +46 -0
- package/mcp_server/runtime/remote_commands.py +4 -1
- package/mcp_server/runtime/tools.py +55 -32
- package/mcp_server/sample_engine/moves.py +12 -12
- package/mcp_server/sample_engine/slice_workflow.py +190 -0
- package/mcp_server/sample_engine/tools.py +104 -1
- package/mcp_server/semantic_moves/device_creation_moves.py +7 -7
- package/mcp_server/semantic_moves/mix_moves.py +8 -8
- package/mcp_server/semantic_moves/models.py +7 -7
- package/mcp_server/semantic_moves/performance_moves.py +4 -4
- package/mcp_server/semantic_moves/sample_compilers.py +14 -9
- package/mcp_server/semantic_moves/sound_design_moves.py +4 -4
- package/mcp_server/semantic_moves/tools.py +63 -10
- package/mcp_server/semantic_moves/transition_moves.py +4 -4
- package/mcp_server/server.py +20 -1
- package/mcp_server/session_continuity/tracker.py +4 -1
- package/mcp_server/tools/_conductor.py +16 -0
- package/mcp_server/tools/_planner_engine.py +24 -0
- package/mcp_server/tools/analyzer.py +2 -0
- package/mcp_server/tools/planner.py +3 -0
- package/mcp_server/wonder_mode/engine.py +59 -13
- package/mcp_server/wonder_mode/tools.py +33 -1
- package/package.json +8 -8
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/devices.py +10 -0
|
@@ -1,16 +1,34 @@
|
|
|
1
|
-
"""ComposerEngine — orchestrate prompt → layers →
|
|
1
|
+
"""ComposerEngine — orchestrate prompt → layers → executable plan.
|
|
2
2
|
|
|
3
3
|
Pure computation engine. Does NOT call MCP tools directly.
|
|
4
4
|
Returns compiled plan dicts that the tool layer (tools.py) executes.
|
|
5
|
+
|
|
6
|
+
Executability contract (Phase 7 rewrite)
|
|
7
|
+
----------------------------------------
|
|
8
|
+
The returned plan contains only REAL tool calls with concrete params. It
|
|
9
|
+
never emits:
|
|
10
|
+
- pseudo-tools like _agent_pick_best_sample or _apply_technique
|
|
11
|
+
- placeholder strings like "{downloaded_path}"
|
|
12
|
+
- invalid sentinels like device_index: -1 or track_index: -1
|
|
13
|
+
- hardcoded clip_slot_index: 0 for tracks with no source clip
|
|
14
|
+
|
|
15
|
+
Samples are resolved at PLAN time via sample_resolver.resolve_sample_for_layer.
|
|
16
|
+
Layers that don't resolve to a concrete local file are dropped from `plan`
|
|
17
|
+
but kept in `layers` for descriptive output, and the unresolved role is
|
|
18
|
+
named in `warnings`. Processing chains use step_id + $from_step bindings
|
|
19
|
+
to bind set_device_parameter.device_index to the actual inserted device
|
|
20
|
+
position returned by insert_device.
|
|
5
21
|
"""
|
|
6
22
|
|
|
7
23
|
from __future__ import annotations
|
|
8
24
|
|
|
9
25
|
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
10
27
|
from typing import Any, Optional
|
|
11
28
|
|
|
12
29
|
from .prompt_parser import CompositionIntent, parse_prompt
|
|
13
30
|
from .layer_planner import LayerSpec, plan_layers, plan_sections
|
|
31
|
+
from .sample_resolver import resolve_sample_for_layer
|
|
14
32
|
|
|
15
33
|
|
|
16
34
|
# ── Result Models ──────────────────────────────────────────────────
|
|
@@ -22,10 +40,11 @@ class CompositionResult:
|
|
|
22
40
|
intent: CompositionIntent = field(default_factory=CompositionIntent)
|
|
23
41
|
layers: list[LayerSpec] = field(default_factory=list)
|
|
24
42
|
sections: list[dict] = field(default_factory=list)
|
|
25
|
-
plan: list[dict] = field(default_factory=list) #
|
|
43
|
+
plan: list[dict] = field(default_factory=list) # executable steps only
|
|
26
44
|
credits_estimated: int = 0
|
|
27
45
|
dry_run: bool = False
|
|
28
46
|
warnings: list[str] = field(default_factory=list)
|
|
47
|
+
resolved_samples: dict = field(default_factory=dict) # role -> local_path
|
|
29
48
|
|
|
30
49
|
def to_dict(self) -> dict:
|
|
31
50
|
return {
|
|
@@ -38,6 +57,7 @@ class CompositionResult:
|
|
|
38
57
|
"credits_estimated": self.credits_estimated,
|
|
39
58
|
"dry_run": self.dry_run,
|
|
40
59
|
"warnings": self.warnings,
|
|
60
|
+
"resolved_samples": self.resolved_samples,
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
|
|
@@ -51,6 +71,7 @@ class AugmentResult:
|
|
|
51
71
|
plan: list[dict] = field(default_factory=list)
|
|
52
72
|
credits_estimated: int = 0
|
|
53
73
|
warnings: list[str] = field(default_factory=list)
|
|
74
|
+
resolved_samples: dict = field(default_factory=dict)
|
|
54
75
|
|
|
55
76
|
def to_dict(self) -> dict:
|
|
56
77
|
return {
|
|
@@ -62,13 +83,13 @@ class AugmentResult:
|
|
|
62
83
|
"plan": self.plan,
|
|
63
84
|
"credits_estimated": self.credits_estimated,
|
|
64
85
|
"warnings": self.warnings,
|
|
86
|
+
"resolved_samples": self.resolved_samples,
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
|
|
68
|
-
# ──
|
|
90
|
+
# ── Step builders ──────────────────────────────────────────────────
|
|
69
91
|
|
|
70
|
-
def
|
|
71
|
-
"""Compile a set_tempo step."""
|
|
92
|
+
def _step_set_tempo(tempo: int) -> dict:
|
|
72
93
|
return {
|
|
73
94
|
"tool": "set_tempo",
|
|
74
95
|
"params": {"tempo": tempo},
|
|
@@ -76,83 +97,70 @@ def _compile_set_tempo_step(tempo: int) -> dict:
|
|
|
76
97
|
}
|
|
77
98
|
|
|
78
99
|
|
|
79
|
-
def
|
|
80
|
-
"""Compile a create_midi_track step."""
|
|
100
|
+
def _step_create_midi_track(track_index: int, role: str, step_id: str) -> dict:
|
|
81
101
|
return {
|
|
102
|
+
"step_id": step_id,
|
|
82
103
|
"tool": "create_midi_track",
|
|
83
104
|
"params": {"index": track_index},
|
|
84
105
|
"description": f"Create MIDI track for {role}",
|
|
106
|
+
"role": role,
|
|
85
107
|
}
|
|
86
108
|
|
|
87
109
|
|
|
88
|
-
def
|
|
89
|
-
"""Compile a set_track_name step."""
|
|
110
|
+
def _step_set_track_name(track_index: int, role: str) -> dict:
|
|
90
111
|
return {
|
|
91
112
|
"tool": "set_track_name",
|
|
92
113
|
"params": {"track_index": track_index, "name": role.title()},
|
|
93
114
|
"description": f"Name track: {role.title()}",
|
|
115
|
+
"role": role,
|
|
94
116
|
}
|
|
95
117
|
|
|
96
118
|
|
|
97
|
-
def
|
|
98
|
-
"""Compile a Splice search step."""
|
|
99
|
-
return {
|
|
100
|
-
"tool": "search_samples",
|
|
101
|
-
"params": {
|
|
102
|
-
"query": layer.search_query,
|
|
103
|
-
"source": "splice",
|
|
104
|
-
"max_results": 10,
|
|
105
|
-
**{k: v for k, v in layer.splice_filters.items()
|
|
106
|
-
if k in ("key", "bpm_range", "material_type")},
|
|
107
|
-
},
|
|
108
|
-
"description": f"Search Splice: {layer.search_query}",
|
|
109
|
-
"role": layer.role,
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def _compile_download_step(layer: LayerSpec) -> dict:
|
|
114
|
-
"""Compile a Splice download step (placeholder — filled at runtime)."""
|
|
115
|
-
return {
|
|
116
|
-
"tool": "_splice_download",
|
|
117
|
-
"params": {"file_hash": "{best_match.file_hash}"},
|
|
118
|
-
"description": f"Download best match for {layer.role}",
|
|
119
|
-
"role": layer.role,
|
|
120
|
-
"conditional": True, # only if not already downloaded
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _compile_load_sample_step(track_index: int, layer: LayerSpec) -> dict:
|
|
125
|
-
"""Compile a load_sample_to_simpler step."""
|
|
119
|
+
def _step_load_sample_to_simpler(track_index: int, layer: LayerSpec, file_path: str) -> dict:
|
|
126
120
|
return {
|
|
127
121
|
"tool": "load_sample_to_simpler",
|
|
128
|
-
"params": {
|
|
129
|
-
"track_index": track_index,
|
|
130
|
-
"file_path": "{downloaded_path}",
|
|
131
|
-
},
|
|
122
|
+
"params": {"track_index": track_index, "file_path": file_path},
|
|
132
123
|
"description": f"Load sample into Simpler on track {track_index}",
|
|
124
|
+
"backend": "mcp_tool",
|
|
133
125
|
"role": layer.role,
|
|
134
126
|
}
|
|
135
127
|
|
|
136
128
|
|
|
137
|
-
def
|
|
138
|
-
"""
|
|
129
|
+
def _step_suggest_technique(track_index: int, layer: LayerSpec) -> dict:
|
|
130
|
+
"""Real tool — returns technique recipe for the agent to interpret.
|
|
131
|
+
|
|
132
|
+
Not a pseudo-tool: suggest_sample_technique is a registered MCP tool.
|
|
133
|
+
The agent reads the returned recipe and applies the steps manually; we
|
|
134
|
+
don't try to auto-apply here because the recipe is open-ended.
|
|
135
|
+
"""
|
|
139
136
|
return {
|
|
140
|
-
"tool": "
|
|
141
|
-
"params": {
|
|
142
|
-
|
|
143
|
-
"technique_id": layer.technique_id,
|
|
144
|
-
},
|
|
145
|
-
"description": f"Apply technique '{layer.technique_id}' on track {track_index}",
|
|
137
|
+
"tool": "suggest_sample_technique",
|
|
138
|
+
"params": {"technique_id": layer.technique_id},
|
|
139
|
+
"description": f"Get technique recipe '{layer.technique_id}' for track {track_index}",
|
|
146
140
|
"role": layer.role,
|
|
147
141
|
}
|
|
148
142
|
|
|
149
143
|
|
|
150
|
-
def
|
|
151
|
-
|
|
144
|
+
def _processing_steps_with_binding(
|
|
145
|
+
track_index: int,
|
|
146
|
+
layer: LayerSpec,
|
|
147
|
+
layer_idx: int,
|
|
148
|
+
) -> list[dict]:
|
|
149
|
+
"""Build insert_device + set_device_parameter pairs using step_id bindings.
|
|
150
|
+
|
|
151
|
+
Each insert_device carries a unique step_id like 'layer_0_dev_1'. The
|
|
152
|
+
following set_device_parameter steps bind their device_index param to
|
|
153
|
+
that id via $from_step — the async router resolves it to the real
|
|
154
|
+
device index returned by insert_device at runtime.
|
|
155
|
+
"""
|
|
152
156
|
steps: list[dict] = []
|
|
153
|
-
for
|
|
157
|
+
for dev_idx, device in enumerate(layer.processing):
|
|
154
158
|
device_name = device.get("name", "")
|
|
159
|
+
if not device_name:
|
|
160
|
+
continue
|
|
161
|
+
step_id = f"layer_{layer_idx}_dev_{dev_idx}"
|
|
155
162
|
steps.append({
|
|
163
|
+
"step_id": step_id,
|
|
156
164
|
"tool": "insert_device",
|
|
157
165
|
"params": {
|
|
158
166
|
"track_index": track_index,
|
|
@@ -161,13 +169,12 @@ def _compile_processing_steps(track_index: int, layer: LayerSpec) -> list[dict]:
|
|
|
161
169
|
"description": f"Insert {device_name} on track {track_index}",
|
|
162
170
|
"role": layer.role,
|
|
163
171
|
})
|
|
164
|
-
# Parameter setting
|
|
165
172
|
for param_name, param_value in device.get("params", {}).items():
|
|
166
173
|
steps.append({
|
|
167
174
|
"tool": "set_device_parameter",
|
|
168
175
|
"params": {
|
|
169
176
|
"track_index": track_index,
|
|
170
|
-
"device_index":
|
|
177
|
+
"device_index": {"$from_step": step_id, "path": "device_index"},
|
|
171
178
|
"parameter_name": param_name,
|
|
172
179
|
"value": param_value,
|
|
173
180
|
},
|
|
@@ -177,13 +184,14 @@ def _compile_processing_steps(track_index: int, layer: LayerSpec) -> list[dict]:
|
|
|
177
184
|
return steps
|
|
178
185
|
|
|
179
186
|
|
|
180
|
-
def
|
|
181
|
-
|
|
182
|
-
|
|
187
|
+
def _mix_steps(track_index: int, layer: LayerSpec) -> list[dict]:
|
|
188
|
+
steps: list[dict] = []
|
|
189
|
+
# dB to linear with 0dB -> 0.85 convention (Ableton native scale)
|
|
190
|
+
linear = max(0.0, min(1.0, 10 ** (layer.volume_db / 20.0) * 0.85))
|
|
183
191
|
steps.append({
|
|
184
192
|
"tool": "set_track_volume",
|
|
185
|
-
"params": {"track_index": track_index, "
|
|
186
|
-
"description": f"Set {layer.role} volume to {layer.volume_db}dB",
|
|
193
|
+
"params": {"track_index": track_index, "volume": round(linear, 3)},
|
|
194
|
+
"description": f"Set {layer.role} volume to {layer.volume_db}dB ({linear:.3f} linear)",
|
|
187
195
|
"role": layer.role,
|
|
188
196
|
})
|
|
189
197
|
if layer.pan != 0.0:
|
|
@@ -196,60 +204,86 @@ def _compile_mix_steps(track_index: int, layer: LayerSpec) -> list[dict]:
|
|
|
196
204
|
return steps
|
|
197
205
|
|
|
198
206
|
|
|
199
|
-
def
|
|
207
|
+
def _arrangement_steps(
|
|
200
208
|
track_index: int,
|
|
201
209
|
layer: LayerSpec,
|
|
202
210
|
sections: list[dict],
|
|
203
211
|
) -> list[dict]:
|
|
204
|
-
"""
|
|
212
|
+
"""Emit the full arrangement sequence for a layer.
|
|
213
|
+
|
|
214
|
+
For each layer that appears in at least one section, we emit:
|
|
215
|
+
|
|
216
|
+
1. create_clip — a 1-bar MIDI clip in session slot 0 (the source)
|
|
217
|
+
2. add_notes — a single C3 trigger note so Simpler actually sounds
|
|
218
|
+
3. create_arrangement_clip (×N) — one per section the layer is in,
|
|
219
|
+
tiling the 1-bar source across each section's bar count
|
|
220
|
+
|
|
221
|
+
The trigger-clip-plus-tile approach is intentionally minimal: Simpler
|
|
222
|
+
in classic mode plays the full sample on every note, so a single C3
|
|
223
|
+
at bar 0 is enough for a playable baseline. The suggest_sample_technique
|
|
224
|
+
step elsewhere in the plan produces a recipe the agent can use later
|
|
225
|
+
to replace the trigger pattern with something more musical.
|
|
226
|
+
"""
|
|
227
|
+
active_sections = [s for s in sections if s["name"] in layer.sections]
|
|
228
|
+
if not active_sections:
|
|
229
|
+
return []
|
|
230
|
+
|
|
205
231
|
steps: list[dict] = []
|
|
206
232
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
233
|
+
# 1. Source session clip — 1 bar = 4 beats at 4/4
|
|
234
|
+
SOURCE_SLOT = 0
|
|
235
|
+
SOURCE_BEATS = 4.0
|
|
236
|
+
steps.append({
|
|
237
|
+
"tool": "create_clip",
|
|
238
|
+
"params": {
|
|
239
|
+
"track_index": track_index,
|
|
240
|
+
"clip_index": SOURCE_SLOT,
|
|
241
|
+
"length": SOURCE_BEATS,
|
|
242
|
+
},
|
|
243
|
+
"description": f"Create 1-bar source clip for {layer.role} (slot {SOURCE_SLOT})",
|
|
244
|
+
"role": layer.role,
|
|
245
|
+
})
|
|
210
246
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
247
|
+
# 2. Single trigger note at beat 0 — C3 (MIDI 60), 1 beat duration.
|
|
248
|
+
# Simpler plays the full sample from this note; shorter durations
|
|
249
|
+
# are fine because Simpler doesn't gate on note-off in classic mode.
|
|
250
|
+
steps.append({
|
|
251
|
+
"tool": "add_notes",
|
|
252
|
+
"params": {
|
|
253
|
+
"track_index": track_index,
|
|
254
|
+
"clip_index": SOURCE_SLOT,
|
|
255
|
+
"notes": [{
|
|
256
|
+
"pitch": 60, # C3
|
|
257
|
+
"start_time": 0.0,
|
|
258
|
+
"duration": 1.0, # 1 beat
|
|
259
|
+
"velocity": 100,
|
|
260
|
+
}],
|
|
261
|
+
},
|
|
262
|
+
"description": f"Add C3 trigger note to {layer.role} source clip",
|
|
263
|
+
"role": layer.role,
|
|
264
|
+
})
|
|
220
265
|
|
|
266
|
+
# 3. One arrangement clip per section this layer appears in
|
|
267
|
+
for section in active_sections:
|
|
221
268
|
start_bar = section["start_bar"]
|
|
222
269
|
bar_count = section["bars"]
|
|
223
|
-
|
|
224
270
|
steps.append({
|
|
225
271
|
"tool": "create_arrangement_clip",
|
|
226
272
|
"params": {
|
|
227
273
|
"track_index": track_index,
|
|
228
|
-
"
|
|
229
|
-
"
|
|
274
|
+
"clip_slot_index": SOURCE_SLOT,
|
|
275
|
+
"start_time": float(start_bar * 4.0), # bars → beats
|
|
276
|
+
"length": float(bar_count * 4.0),
|
|
277
|
+
"loop_length": SOURCE_BEATS, # tile 1-bar source
|
|
230
278
|
},
|
|
231
|
-
"description":
|
|
232
|
-
|
|
279
|
+
"description": (
|
|
280
|
+
f"Arrange {layer.role} into '{section['name']}' "
|
|
281
|
+
f"(bar {start_bar}, {bar_count} bars)"
|
|
282
|
+
),
|
|
233
283
|
"role": layer.role,
|
|
234
284
|
"section": section["name"],
|
|
235
285
|
})
|
|
236
286
|
|
|
237
|
-
# Add volume automation at section boundaries if there's an offset
|
|
238
|
-
if volume_offset_db != 0.0:
|
|
239
|
-
steps.append({
|
|
240
|
-
"tool": "set_arrangement_automation",
|
|
241
|
-
"params": {
|
|
242
|
-
"track_index": track_index,
|
|
243
|
-
"parameter_name": "Volume",
|
|
244
|
-
"time": start_bar * 4.0,
|
|
245
|
-
"value": layer.volume_db + volume_offset_db,
|
|
246
|
-
},
|
|
247
|
-
"description": f"Automate volume fade: {layer.role} at {volume_offset_db}dB "
|
|
248
|
-
f"in {section['name']}",
|
|
249
|
-
"role": layer.role,
|
|
250
|
-
"section": section["name"],
|
|
251
|
-
})
|
|
252
|
-
|
|
253
287
|
return steps
|
|
254
288
|
|
|
255
289
|
|
|
@@ -260,104 +294,113 @@ class ComposerEngine:
|
|
|
260
294
|
|
|
261
295
|
Pure computation — returns compiled plan dicts.
|
|
262
296
|
The tool layer (tools.py) handles actual execution.
|
|
297
|
+
|
|
298
|
+
Async because sample resolution may download from Splice over gRPC.
|
|
299
|
+
Filesystem-only callers still get near-instant resolution — the resolver
|
|
300
|
+
only awaits when it actually hits the network.
|
|
263
301
|
"""
|
|
264
302
|
|
|
265
|
-
def compose(
|
|
303
|
+
async def compose(
|
|
266
304
|
self,
|
|
267
305
|
intent: CompositionIntent,
|
|
268
306
|
dry_run: bool = False,
|
|
269
307
|
max_credits: int = 10,
|
|
308
|
+
search_roots: Optional[list] = None,
|
|
309
|
+
splice_client: object = None,
|
|
310
|
+
browser_client: object = None,
|
|
270
311
|
) -> CompositionResult:
|
|
271
312
|
"""Plan a full multi-layer composition from a CompositionIntent.
|
|
272
313
|
|
|
273
|
-
Returns a CompositionResult
|
|
314
|
+
Returns a CompositionResult where `plan` contains only executable
|
|
315
|
+
steps. Unresolved layers are kept in `layers` (descriptive) but
|
|
316
|
+
dropped from `plan`, with warnings naming the unresolved roles.
|
|
317
|
+
|
|
318
|
+
splice_client is typically `ctx.lifespan_context["splice_client"]`
|
|
319
|
+
from the tool layer. When connected, its catalog is searched after
|
|
320
|
+
filesystem and remote samples are downloaded one credit at a time
|
|
321
|
+
(subject to the hard floor).
|
|
274
322
|
"""
|
|
275
|
-
result = CompositionResult(
|
|
276
|
-
intent=intent,
|
|
277
|
-
dry_run=dry_run,
|
|
278
|
-
)
|
|
323
|
+
result = CompositionResult(intent=intent, dry_run=dry_run)
|
|
279
324
|
|
|
280
|
-
# Plan layers and sections
|
|
281
325
|
layers = plan_layers(intent)
|
|
282
326
|
sections = plan_sections(intent)
|
|
283
327
|
result.layers = layers
|
|
284
328
|
result.sections = sections
|
|
329
|
+
result.credits_estimated = len(layers)
|
|
285
330
|
|
|
286
|
-
|
|
287
|
-
credits_needed = len(layers)
|
|
288
|
-
result.credits_estimated = credits_needed
|
|
289
|
-
|
|
290
|
-
if credits_needed > max_credits:
|
|
331
|
+
if result.credits_estimated > max_credits:
|
|
291
332
|
result.warnings.append(
|
|
292
|
-
f"Estimated {
|
|
333
|
+
f"Estimated {result.credits_estimated} credits needed, "
|
|
293
334
|
f"but budget is {max_credits}. Some layers may use "
|
|
294
335
|
f"downloaded samples or browser fallback."
|
|
295
336
|
)
|
|
296
337
|
|
|
297
|
-
# Compile the execution plan
|
|
298
338
|
plan: list[dict] = []
|
|
299
339
|
|
|
300
|
-
# Step 1:
|
|
301
|
-
plan.append(
|
|
302
|
-
|
|
303
|
-
# Step 2: Create tracks and layers
|
|
304
|
-
for track_idx, layer in enumerate(layers):
|
|
305
|
-
# Create track
|
|
306
|
-
plan.append(_compile_create_track_step(track_idx, layer.role))
|
|
307
|
-
plan.append(_compile_name_track_step(track_idx, layer.role))
|
|
340
|
+
# Step 1: Tempo
|
|
341
|
+
plan.append(_step_set_tempo(intent.tempo))
|
|
308
342
|
|
|
309
|
-
|
|
310
|
-
|
|
343
|
+
# Step 2: Per-layer build, resolving samples at plan time
|
|
344
|
+
for layer_idx, layer in enumerate(layers):
|
|
345
|
+
track_index = layer_idx
|
|
311
346
|
|
|
312
|
-
|
|
313
|
-
|
|
347
|
+
file_path, source = await resolve_sample_for_layer(
|
|
348
|
+
layer,
|
|
349
|
+
search_roots=search_roots,
|
|
350
|
+
splice_client=splice_client,
|
|
351
|
+
browser_client=browser_client,
|
|
352
|
+
credit_budget=max_credits,
|
|
353
|
+
)
|
|
354
|
+
if not file_path:
|
|
355
|
+
result.warnings.append(
|
|
356
|
+
f"Unresolved sample for layer '{layer.role}' "
|
|
357
|
+
f"(query: {layer.search_query!r}). Dropped from plan."
|
|
358
|
+
)
|
|
359
|
+
continue
|
|
314
360
|
|
|
315
|
-
|
|
316
|
-
plan.append(_compile_load_sample_step(track_idx, layer))
|
|
361
|
+
result.resolved_samples[layer.role] = {"path": file_path, "source": source}
|
|
317
362
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
363
|
+
track_step_id = f"layer_{layer_idx}_track"
|
|
364
|
+
plan.append(_step_create_midi_track(track_index, layer.role, track_step_id))
|
|
365
|
+
plan.append(_step_set_track_name(track_index, layer.role))
|
|
321
366
|
|
|
322
|
-
|
|
323
|
-
plan.extend(_compile_processing_steps(track_idx, layer))
|
|
367
|
+
plan.append(_step_load_sample_to_simpler(track_index, layer, file_path))
|
|
324
368
|
|
|
325
|
-
|
|
326
|
-
|
|
369
|
+
if layer.technique_id:
|
|
370
|
+
plan.append(_step_suggest_technique(track_index, layer))
|
|
327
371
|
|
|
328
|
-
|
|
329
|
-
plan.extend(
|
|
372
|
+
plan.extend(_processing_steps_with_binding(track_index, layer, layer_idx))
|
|
373
|
+
plan.extend(_mix_steps(track_index, layer))
|
|
374
|
+
plan.extend(_arrangement_steps(track_index, layer, sections))
|
|
330
375
|
|
|
331
376
|
result.plan = plan
|
|
332
377
|
return result
|
|
333
378
|
|
|
334
|
-
def augment(
|
|
379
|
+
async def augment(
|
|
335
380
|
self,
|
|
336
381
|
request: str,
|
|
337
382
|
max_credits: int = 3,
|
|
338
383
|
max_layers: int = 3,
|
|
384
|
+
search_roots: Optional[list] = None,
|
|
385
|
+
splice_client: object = None,
|
|
386
|
+
browser_client: object = None,
|
|
339
387
|
) -> AugmentResult:
|
|
340
388
|
"""Plan augmentation layers to add to an existing session.
|
|
341
389
|
|
|
342
|
-
|
|
390
|
+
Like compose(), resolves samples at plan time and drops unresolved
|
|
391
|
+
layers. Since the actual track count isn't known at plan time, this
|
|
392
|
+
uses track_index: -1 only for create_midi_track (where the Remote
|
|
393
|
+
Script interprets -1 as append-at-end) and then binds later steps
|
|
394
|
+
to the actual created track via $from_step — same pattern as the
|
|
395
|
+
device_index binding in compose().
|
|
343
396
|
"""
|
|
344
397
|
intent = parse_prompt(request)
|
|
345
|
-
|
|
346
|
-
# Override layer count to respect max_layers
|
|
347
398
|
intent.layer_count = min(intent.layer_count or max_layers, max_layers)
|
|
348
399
|
|
|
349
|
-
result = AugmentResult(
|
|
350
|
-
request=request,
|
|
351
|
-
intent=intent,
|
|
352
|
-
)
|
|
400
|
+
result = AugmentResult(request=request, intent=intent)
|
|
353
401
|
|
|
354
|
-
|
|
355
|
-
layers = plan_layers(intent)
|
|
356
|
-
# Limit to max_layers
|
|
357
|
-
layers = layers[:max_layers]
|
|
402
|
+
layers = plan_layers(intent)[:max_layers]
|
|
358
403
|
result.new_layers = layers
|
|
359
|
-
|
|
360
|
-
# Estimate credits
|
|
361
404
|
result.credits_estimated = len(layers)
|
|
362
405
|
|
|
363
406
|
if result.credits_estimated > max_credits:
|
|
@@ -366,50 +409,72 @@ class ComposerEngine:
|
|
|
366
409
|
f"but budget is {max_credits}."
|
|
367
410
|
)
|
|
368
411
|
|
|
369
|
-
# Compile augmentation plan
|
|
370
|
-
# Track indices start from a placeholder — the tool layer will
|
|
371
|
-
# determine the actual track offset at runtime
|
|
372
412
|
plan: list[dict] = []
|
|
373
|
-
for offset, layer in enumerate(layers):
|
|
374
|
-
track_placeholder = f"{{existing_track_count}} + {offset}"
|
|
375
413
|
|
|
414
|
+
for layer_idx, layer in enumerate(layers):
|
|
415
|
+
file_path, source = await resolve_sample_for_layer(
|
|
416
|
+
layer,
|
|
417
|
+
search_roots=search_roots,
|
|
418
|
+
splice_client=splice_client,
|
|
419
|
+
browser_client=browser_client,
|
|
420
|
+
credit_budget=max_credits,
|
|
421
|
+
)
|
|
422
|
+
if not file_path:
|
|
423
|
+
result.warnings.append(
|
|
424
|
+
f"Unresolved sample for layer '{layer.role}' "
|
|
425
|
+
f"(query: {layer.search_query!r}). Dropped from plan."
|
|
426
|
+
)
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
result.resolved_samples[layer.role] = {"path": file_path, "source": source}
|
|
430
|
+
|
|
431
|
+
# We don't know the absolute track index yet. create_midi_track's
|
|
432
|
+
# result carries "index" (via Remote Script) — later steps bind
|
|
433
|
+
# track_index to that via $from_step. The composer tools layer
|
|
434
|
+
# passes existing_track_count in via a hint when available.
|
|
435
|
+
track_step_id = f"aug_layer_{layer_idx}_track"
|
|
376
436
|
plan.append({
|
|
437
|
+
"step_id": track_step_id,
|
|
377
438
|
"tool": "create_midi_track",
|
|
378
|
-
"params": {"index": -1}, # append at end
|
|
439
|
+
"params": {"index": -1}, # append at end — Remote Script convention
|
|
379
440
|
"description": f"Create MIDI track for {layer.role}",
|
|
380
441
|
"role": layer.role,
|
|
381
442
|
})
|
|
382
443
|
|
|
444
|
+
track_ref = {"$from_step": track_step_id, "path": "index"}
|
|
445
|
+
|
|
383
446
|
plan.append({
|
|
384
447
|
"tool": "set_track_name",
|
|
385
|
-
"params": {"track_index":
|
|
448
|
+
"params": {"track_index": track_ref, "name": f"+ {layer.role.title()}"},
|
|
386
449
|
"description": f"Name new track: + {layer.role.title()}",
|
|
387
450
|
"role": layer.role,
|
|
388
451
|
})
|
|
389
452
|
|
|
390
|
-
plan.append(_compile_search_step(layer))
|
|
391
|
-
plan.append(_compile_download_step(layer))
|
|
392
|
-
|
|
393
453
|
plan.append({
|
|
394
454
|
"tool": "load_sample_to_simpler",
|
|
395
|
-
"params": {"track_index":
|
|
455
|
+
"params": {"track_index": track_ref, "file_path": file_path},
|
|
396
456
|
"description": f"Load sample into Simpler",
|
|
457
|
+
"backend": "mcp_tool",
|
|
397
458
|
"role": layer.role,
|
|
398
459
|
})
|
|
399
460
|
|
|
400
461
|
if layer.technique_id:
|
|
401
462
|
plan.append({
|
|
402
|
-
"tool": "
|
|
403
|
-
"params": {"
|
|
404
|
-
"description": f"
|
|
463
|
+
"tool": "suggest_sample_technique",
|
|
464
|
+
"params": {"technique_id": layer.technique_id},
|
|
465
|
+
"description": f"Get technique recipe '{layer.technique_id}'",
|
|
405
466
|
"role": layer.role,
|
|
406
467
|
})
|
|
407
468
|
|
|
408
|
-
for device in layer.processing:
|
|
469
|
+
for dev_idx, device in enumerate(layer.processing):
|
|
409
470
|
device_name = device.get("name", "")
|
|
471
|
+
if not device_name:
|
|
472
|
+
continue
|
|
473
|
+
dev_step_id = f"aug_layer_{layer_idx}_dev_{dev_idx}"
|
|
410
474
|
plan.append({
|
|
475
|
+
"step_id": dev_step_id,
|
|
411
476
|
"tool": "insert_device",
|
|
412
|
-
"params": {"track_index":
|
|
477
|
+
"params": {"track_index": track_ref, "device_name": device_name},
|
|
413
478
|
"description": f"Insert {device_name}",
|
|
414
479
|
"role": layer.role,
|
|
415
480
|
})
|
|
@@ -417,8 +482,8 @@ class ComposerEngine:
|
|
|
417
482
|
plan.append({
|
|
418
483
|
"tool": "set_device_parameter",
|
|
419
484
|
"params": {
|
|
420
|
-
"track_index":
|
|
421
|
-
"device_index":
|
|
485
|
+
"track_index": track_ref,
|
|
486
|
+
"device_index": {"$from_step": dev_step_id, "path": "device_index"},
|
|
422
487
|
"parameter_name": param_name,
|
|
423
488
|
"value": param_value,
|
|
424
489
|
},
|
|
@@ -426,16 +491,17 @@ class ComposerEngine:
|
|
|
426
491
|
"role": layer.role,
|
|
427
492
|
})
|
|
428
493
|
|
|
494
|
+
linear = max(0.0, min(1.0, 10 ** (layer.volume_db / 20.0) * 0.85))
|
|
429
495
|
plan.append({
|
|
430
496
|
"tool": "set_track_volume",
|
|
431
|
-
"params": {"track_index":
|
|
497
|
+
"params": {"track_index": track_ref, "volume": round(linear, 3)},
|
|
432
498
|
"description": f"Set {layer.role} volume to {layer.volume_db}dB",
|
|
433
499
|
"role": layer.role,
|
|
434
500
|
})
|
|
435
501
|
if layer.pan != 0.0:
|
|
436
502
|
plan.append({
|
|
437
503
|
"tool": "set_track_pan",
|
|
438
|
-
"params": {"track_index":
|
|
504
|
+
"params": {"track_index": track_ref, "pan": layer.pan},
|
|
439
505
|
"description": f"Set {layer.role} pan to {layer.pan}",
|
|
440
506
|
"role": layer.role,
|
|
441
507
|
})
|
|
@@ -443,10 +509,24 @@ class ComposerEngine:
|
|
|
443
509
|
result.plan = plan
|
|
444
510
|
return result
|
|
445
511
|
|
|
446
|
-
def get_plan(
|
|
512
|
+
async def get_plan(
|
|
447
513
|
self,
|
|
448
514
|
intent: CompositionIntent,
|
|
515
|
+
search_roots: Optional[list] = None,
|
|
516
|
+
splice_client: object = None,
|
|
517
|
+
browser_client: object = None,
|
|
449
518
|
) -> dict:
|
|
450
|
-
"""Dry run — return the full composition plan without execution.
|
|
451
|
-
|
|
519
|
+
"""Dry run — return the full composition plan without execution.
|
|
520
|
+
|
|
521
|
+
Passes resolution dependencies through to compose() so the dry-run
|
|
522
|
+
accurately reflects which layers would resolve.
|
|
523
|
+
"""
|
|
524
|
+
result = await self.compose(
|
|
525
|
+
intent,
|
|
526
|
+
dry_run=True,
|
|
527
|
+
max_credits=0,
|
|
528
|
+
search_roots=search_roots,
|
|
529
|
+
splice_client=splice_client,
|
|
530
|
+
browser_client=browser_client,
|
|
531
|
+
)
|
|
452
532
|
return result.to_dict()
|