livepilot 1.9.24 → 1.10.1

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.
Files changed (185) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +223 -0
  4. package/CONTRIBUTING.md +2 -2
  5. package/LICENSE +62 -21
  6. package/README.md +291 -276
  7. package/bin/livepilot.js +87 -0
  8. package/installer/codex.js +147 -0
  9. package/livepilot/.Codex-plugin/plugin.json +2 -2
  10. package/livepilot/.claude-plugin/plugin.json +2 -2
  11. package/livepilot/skills/livepilot-arrangement/SKILL.md +18 -1
  12. package/livepilot/skills/livepilot-core/SKILL.md +22 -5
  13. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
  14. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
  15. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
  16. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
  17. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
  18. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
  19. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
  20. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
  21. package/livepilot/skills/livepilot-core/references/overview.md +13 -9
  22. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
  23. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
  24. package/livepilot/skills/livepilot-devices/SKILL.md +39 -4
  25. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  26. package/livepilot/skills/livepilot-release/SKILL.md +23 -19
  27. package/livepilot/skills/livepilot-sample-engine/SKILL.md +105 -0
  28. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
  29. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
  30. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
  31. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
  32. package/livepilot/skills/livepilot-wonder/SKILL.md +17 -0
  33. package/livepilot.mcpb +0 -0
  34. package/m4l_device/livepilot_bridge.js +1 -1
  35. package/manifest.json +4 -4
  36. package/mcp_server/__init__.py +1 -1
  37. package/mcp_server/atlas/__init__.py +357 -0
  38. package/mcp_server/atlas/device_atlas.json +44067 -0
  39. package/mcp_server/atlas/enrichments/__init__.py +111 -0
  40. package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
  41. package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
  42. package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
  43. package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
  44. package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
  45. package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
  46. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
  47. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
  48. package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
  49. package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
  50. package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
  51. package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
  52. package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
  53. package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
  54. package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
  55. package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
  56. package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
  57. package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
  58. package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
  59. package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
  60. package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
  61. package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
  62. package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
  63. package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
  64. package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
  65. package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
  66. package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
  67. package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
  68. package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
  69. package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
  70. package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
  71. package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
  72. package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
  73. package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
  74. package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
  75. package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
  76. package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
  77. package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
  78. package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
  79. package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
  80. package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
  81. package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
  82. package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
  83. package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
  84. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
  85. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
  86. package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
  87. package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
  88. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
  89. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
  90. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
  91. package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
  92. package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
  93. package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
  94. package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
  95. package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
  96. package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
  97. package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
  98. package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
  99. package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
  100. package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
  101. package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
  102. package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
  103. package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
  104. package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
  105. package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
  106. package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
  107. package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
  108. package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
  109. package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
  110. package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
  111. package/mcp_server/atlas/scanner.py +236 -0
  112. package/mcp_server/atlas/tools.py +224 -0
  113. package/mcp_server/composer/__init__.py +1 -0
  114. package/mcp_server/composer/engine.py +532 -0
  115. package/mcp_server/composer/layer_planner.py +427 -0
  116. package/mcp_server/composer/prompt_parser.py +329 -0
  117. package/mcp_server/composer/sample_resolver.py +153 -0
  118. package/mcp_server/composer/tools.py +211 -0
  119. package/mcp_server/connection.py +53 -8
  120. package/mcp_server/corpus/__init__.py +377 -0
  121. package/mcp_server/device_forge/__init__.py +1 -0
  122. package/mcp_server/device_forge/builder.py +377 -0
  123. package/mcp_server/device_forge/models.py +142 -0
  124. package/mcp_server/device_forge/templates.py +483 -0
  125. package/mcp_server/device_forge/tools.py +162 -0
  126. package/mcp_server/m4l_bridge.py +1 -0
  127. package/mcp_server/memory/taste_accessors.py +47 -0
  128. package/mcp_server/preview_studio/engine.py +9 -2
  129. package/mcp_server/preview_studio/tools.py +78 -35
  130. package/mcp_server/project_brain/tools.py +34 -0
  131. package/mcp_server/runtime/capability_probe.py +21 -2
  132. package/mcp_server/runtime/execution_router.py +184 -38
  133. package/mcp_server/runtime/live_version.py +102 -0
  134. package/mcp_server/runtime/mcp_dispatch.py +46 -0
  135. package/mcp_server/runtime/remote_commands.py +13 -5
  136. package/mcp_server/runtime/tools.py +66 -29
  137. package/mcp_server/sample_engine/__init__.py +1 -0
  138. package/mcp_server/sample_engine/analyzer.py +216 -0
  139. package/mcp_server/sample_engine/critics.py +390 -0
  140. package/mcp_server/sample_engine/models.py +193 -0
  141. package/mcp_server/sample_engine/moves.py +127 -0
  142. package/mcp_server/sample_engine/planner.py +186 -0
  143. package/mcp_server/sample_engine/slice_workflow.py +190 -0
  144. package/mcp_server/sample_engine/sources.py +540 -0
  145. package/mcp_server/sample_engine/techniques.py +908 -0
  146. package/mcp_server/sample_engine/tools.py +545 -0
  147. package/mcp_server/semantic_moves/__init__.py +3 -0
  148. package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
  149. package/mcp_server/semantic_moves/mix_moves.py +8 -8
  150. package/mcp_server/semantic_moves/models.py +7 -7
  151. package/mcp_server/semantic_moves/performance_moves.py +4 -4
  152. package/mcp_server/semantic_moves/sample_compilers.py +377 -0
  153. package/mcp_server/semantic_moves/sound_design_moves.py +4 -4
  154. package/mcp_server/semantic_moves/tools.py +63 -10
  155. package/mcp_server/semantic_moves/transition_moves.py +4 -4
  156. package/mcp_server/server.py +71 -1
  157. package/mcp_server/session_continuity/tracker.py +4 -1
  158. package/mcp_server/sound_design/critics.py +89 -1
  159. package/mcp_server/splice_client/__init__.py +1 -0
  160. package/mcp_server/splice_client/client.py +347 -0
  161. package/mcp_server/splice_client/models.py +96 -0
  162. package/mcp_server/splice_client/protos/__init__.py +1 -0
  163. package/mcp_server/splice_client/protos/app_pb2.py +319 -0
  164. package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
  165. package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
  166. package/mcp_server/tools/_conductor.py +16 -0
  167. package/mcp_server/tools/_planner_engine.py +24 -0
  168. package/mcp_server/tools/analyzer.py +2 -0
  169. package/mcp_server/tools/arrangement.py +69 -0
  170. package/mcp_server/tools/automation.py +15 -2
  171. package/mcp_server/tools/devices.py +117 -6
  172. package/mcp_server/tools/notes.py +37 -4
  173. package/mcp_server/tools/planner.py +3 -0
  174. package/mcp_server/wonder_mode/diagnosis.py +5 -0
  175. package/mcp_server/wonder_mode/engine.py +144 -14
  176. package/mcp_server/wonder_mode/tools.py +33 -1
  177. package/package.json +14 -4
  178. package/remote_script/LivePilot/__init__.py +8 -1
  179. package/remote_script/LivePilot/arrangement.py +114 -0
  180. package/remote_script/LivePilot/browser.py +56 -1
  181. package/remote_script/LivePilot/devices.py +246 -6
  182. package/remote_script/LivePilot/mixing.py +8 -3
  183. package/remote_script/LivePilot/server.py +5 -1
  184. package/remote_script/LivePilot/transport.py +3 -0
  185. package/remote_script/LivePilot/version_detect.py +78 -0
@@ -0,0 +1,532 @@
1
+ """ComposerEngine — orchestrate prompt → layers → executable plan.
2
+
3
+ Pure computation engine. Does NOT call MCP tools directly.
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.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Optional
28
+
29
+ from .prompt_parser import CompositionIntent, parse_prompt
30
+ from .layer_planner import LayerSpec, plan_layers, plan_sections
31
+ from .sample_resolver import resolve_sample_for_layer
32
+
33
+
34
+ # ── Result Models ──────────────────────────────────────────────────
35
+
36
+ @dataclass
37
+ class CompositionResult:
38
+ """Result of a full composition run."""
39
+
40
+ intent: CompositionIntent = field(default_factory=CompositionIntent)
41
+ layers: list[LayerSpec] = field(default_factory=list)
42
+ sections: list[dict] = field(default_factory=list)
43
+ plan: list[dict] = field(default_factory=list) # executable steps only
44
+ credits_estimated: int = 0
45
+ dry_run: bool = False
46
+ warnings: list[str] = field(default_factory=list)
47
+ resolved_samples: dict = field(default_factory=dict) # role -> local_path
48
+
49
+ def to_dict(self) -> dict:
50
+ return {
51
+ "intent": self.intent.to_dict(),
52
+ "layer_count": len(self.layers),
53
+ "layers": [l.to_dict() for l in self.layers],
54
+ "sections": self.sections,
55
+ "plan_step_count": len(self.plan),
56
+ "plan": self.plan,
57
+ "credits_estimated": self.credits_estimated,
58
+ "dry_run": self.dry_run,
59
+ "warnings": self.warnings,
60
+ "resolved_samples": self.resolved_samples,
61
+ }
62
+
63
+
64
+ @dataclass
65
+ class AugmentResult:
66
+ """Result of an augmentation run."""
67
+
68
+ request: str = ""
69
+ intent: CompositionIntent = field(default_factory=CompositionIntent)
70
+ new_layers: list[LayerSpec] = field(default_factory=list)
71
+ plan: list[dict] = field(default_factory=list)
72
+ credits_estimated: int = 0
73
+ warnings: list[str] = field(default_factory=list)
74
+ resolved_samples: dict = field(default_factory=dict)
75
+
76
+ def to_dict(self) -> dict:
77
+ return {
78
+ "request": self.request,
79
+ "intent": self.intent.to_dict(),
80
+ "new_layer_count": len(self.new_layers),
81
+ "new_layers": [l.to_dict() for l in self.new_layers],
82
+ "plan_step_count": len(self.plan),
83
+ "plan": self.plan,
84
+ "credits_estimated": self.credits_estimated,
85
+ "warnings": self.warnings,
86
+ "resolved_samples": self.resolved_samples,
87
+ }
88
+
89
+
90
+ # ── Step builders ──────────────────────────────────────────────────
91
+
92
+ def _step_set_tempo(tempo: int) -> dict:
93
+ return {
94
+ "tool": "set_tempo",
95
+ "params": {"tempo": tempo},
96
+ "description": f"Set tempo to {tempo} BPM",
97
+ }
98
+
99
+
100
+ def _step_create_midi_track(track_index: int, role: str, step_id: str) -> dict:
101
+ return {
102
+ "step_id": step_id,
103
+ "tool": "create_midi_track",
104
+ "params": {"index": track_index},
105
+ "description": f"Create MIDI track for {role}",
106
+ "role": role,
107
+ }
108
+
109
+
110
+ def _step_set_track_name(track_index: int, role: str) -> dict:
111
+ return {
112
+ "tool": "set_track_name",
113
+ "params": {"track_index": track_index, "name": role.title()},
114
+ "description": f"Name track: {role.title()}",
115
+ "role": role,
116
+ }
117
+
118
+
119
+ def _step_load_sample_to_simpler(track_index: int, layer: LayerSpec, file_path: str) -> dict:
120
+ return {
121
+ "tool": "load_sample_to_simpler",
122
+ "params": {"track_index": track_index, "file_path": file_path},
123
+ "description": f"Load sample into Simpler on track {track_index}",
124
+ "backend": "mcp_tool",
125
+ "role": layer.role,
126
+ }
127
+
128
+
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
+ """
136
+ return {
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}",
140
+ "role": layer.role,
141
+ }
142
+
143
+
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
+ """
156
+ steps: list[dict] = []
157
+ for dev_idx, device in enumerate(layer.processing):
158
+ device_name = device.get("name", "")
159
+ if not device_name:
160
+ continue
161
+ step_id = f"layer_{layer_idx}_dev_{dev_idx}"
162
+ steps.append({
163
+ "step_id": step_id,
164
+ "tool": "insert_device",
165
+ "params": {
166
+ "track_index": track_index,
167
+ "device_name": device_name,
168
+ },
169
+ "description": f"Insert {device_name} on track {track_index}",
170
+ "role": layer.role,
171
+ })
172
+ for param_name, param_value in device.get("params", {}).items():
173
+ steps.append({
174
+ "tool": "set_device_parameter",
175
+ "params": {
176
+ "track_index": track_index,
177
+ "device_index": {"$from_step": step_id, "path": "device_index"},
178
+ "parameter_name": param_name,
179
+ "value": param_value,
180
+ },
181
+ "description": f"Set {device_name} {param_name} = {param_value}",
182
+ "role": layer.role,
183
+ })
184
+ return steps
185
+
186
+
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))
191
+ steps.append({
192
+ "tool": "set_track_volume",
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)",
195
+ "role": layer.role,
196
+ })
197
+ if layer.pan != 0.0:
198
+ steps.append({
199
+ "tool": "set_track_pan",
200
+ "params": {"track_index": track_index, "pan": layer.pan},
201
+ "description": f"Set {layer.role} pan to {layer.pan}",
202
+ "role": layer.role,
203
+ })
204
+ return steps
205
+
206
+
207
+ def _arrangement_steps(
208
+ track_index: int,
209
+ layer: LayerSpec,
210
+ sections: list[dict],
211
+ ) -> list[dict]:
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
+
231
+ steps: list[dict] = []
232
+
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
+ })
246
+
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
+ })
265
+
266
+ # 3. One arrangement clip per section this layer appears in
267
+ for section in active_sections:
268
+ start_bar = section["start_bar"]
269
+ bar_count = section["bars"]
270
+ steps.append({
271
+ "tool": "create_arrangement_clip",
272
+ "params": {
273
+ "track_index": track_index,
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
278
+ },
279
+ "description": (
280
+ f"Arrange {layer.role} into '{section['name']}' "
281
+ f"(bar {start_bar}, {bar_count} bars)"
282
+ ),
283
+ "role": layer.role,
284
+ "section": section["name"],
285
+ })
286
+
287
+ return steps
288
+
289
+
290
+ # ── Engine ─────────────────────────────────────────────────────────
291
+
292
+ class ComposerEngine:
293
+ """Orchestrates the full composition pipeline.
294
+
295
+ Pure computation — returns compiled plan dicts.
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.
301
+ """
302
+
303
+ async def compose(
304
+ self,
305
+ intent: CompositionIntent,
306
+ dry_run: bool = False,
307
+ max_credits: int = 10,
308
+ search_roots: Optional[list] = None,
309
+ splice_client: object = None,
310
+ browser_client: object = None,
311
+ ) -> CompositionResult:
312
+ """Plan a full multi-layer composition from a CompositionIntent.
313
+
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).
322
+ """
323
+ result = CompositionResult(intent=intent, dry_run=dry_run)
324
+
325
+ layers = plan_layers(intent)
326
+ sections = plan_sections(intent)
327
+ result.layers = layers
328
+ result.sections = sections
329
+ result.credits_estimated = len(layers)
330
+
331
+ if result.credits_estimated > max_credits:
332
+ result.warnings.append(
333
+ f"Estimated {result.credits_estimated} credits needed, "
334
+ f"but budget is {max_credits}. Some layers may use "
335
+ f"downloaded samples or browser fallback."
336
+ )
337
+
338
+ plan: list[dict] = []
339
+
340
+ # Step 1: Tempo
341
+ plan.append(_step_set_tempo(intent.tempo))
342
+
343
+ # Step 2: Per-layer build, resolving samples at plan time
344
+ for layer_idx, layer in enumerate(layers):
345
+ track_index = layer_idx
346
+
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
360
+
361
+ result.resolved_samples[layer.role] = {"path": file_path, "source": source}
362
+
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))
366
+
367
+ plan.append(_step_load_sample_to_simpler(track_index, layer, file_path))
368
+
369
+ if layer.technique_id:
370
+ plan.append(_step_suggest_technique(track_index, layer))
371
+
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))
375
+
376
+ result.plan = plan
377
+ return result
378
+
379
+ async def augment(
380
+ self,
381
+ request: str,
382
+ max_credits: int = 3,
383
+ max_layers: int = 3,
384
+ search_roots: Optional[list] = None,
385
+ splice_client: object = None,
386
+ browser_client: object = None,
387
+ ) -> AugmentResult:
388
+ """Plan augmentation layers to add to an existing session.
389
+
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().
396
+ """
397
+ intent = parse_prompt(request)
398
+ intent.layer_count = min(intent.layer_count or max_layers, max_layers)
399
+
400
+ result = AugmentResult(request=request, intent=intent)
401
+
402
+ layers = plan_layers(intent)[:max_layers]
403
+ result.new_layers = layers
404
+ result.credits_estimated = len(layers)
405
+
406
+ if result.credits_estimated > max_credits:
407
+ result.warnings.append(
408
+ f"Estimated {result.credits_estimated} credits needed, "
409
+ f"but budget is {max_credits}."
410
+ )
411
+
412
+ plan: list[dict] = []
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"
436
+ plan.append({
437
+ "step_id": track_step_id,
438
+ "tool": "create_midi_track",
439
+ "params": {"index": -1}, # append at end — Remote Script convention
440
+ "description": f"Create MIDI track for {layer.role}",
441
+ "role": layer.role,
442
+ })
443
+
444
+ track_ref = {"$from_step": track_step_id, "path": "index"}
445
+
446
+ plan.append({
447
+ "tool": "set_track_name",
448
+ "params": {"track_index": track_ref, "name": f"+ {layer.role.title()}"},
449
+ "description": f"Name new track: + {layer.role.title()}",
450
+ "role": layer.role,
451
+ })
452
+
453
+ plan.append({
454
+ "tool": "load_sample_to_simpler",
455
+ "params": {"track_index": track_ref, "file_path": file_path},
456
+ "description": f"Load sample into Simpler",
457
+ "backend": "mcp_tool",
458
+ "role": layer.role,
459
+ })
460
+
461
+ if layer.technique_id:
462
+ plan.append({
463
+ "tool": "suggest_sample_technique",
464
+ "params": {"technique_id": layer.technique_id},
465
+ "description": f"Get technique recipe '{layer.technique_id}'",
466
+ "role": layer.role,
467
+ })
468
+
469
+ for dev_idx, device in enumerate(layer.processing):
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}"
474
+ plan.append({
475
+ "step_id": dev_step_id,
476
+ "tool": "insert_device",
477
+ "params": {"track_index": track_ref, "device_name": device_name},
478
+ "description": f"Insert {device_name}",
479
+ "role": layer.role,
480
+ })
481
+ for param_name, param_value in device.get("params", {}).items():
482
+ plan.append({
483
+ "tool": "set_device_parameter",
484
+ "params": {
485
+ "track_index": track_ref,
486
+ "device_index": {"$from_step": dev_step_id, "path": "device_index"},
487
+ "parameter_name": param_name,
488
+ "value": param_value,
489
+ },
490
+ "description": f"Set {device_name} {param_name} = {param_value}",
491
+ "role": layer.role,
492
+ })
493
+
494
+ linear = max(0.0, min(1.0, 10 ** (layer.volume_db / 20.0) * 0.85))
495
+ plan.append({
496
+ "tool": "set_track_volume",
497
+ "params": {"track_index": track_ref, "volume": round(linear, 3)},
498
+ "description": f"Set {layer.role} volume to {layer.volume_db}dB",
499
+ "role": layer.role,
500
+ })
501
+ if layer.pan != 0.0:
502
+ plan.append({
503
+ "tool": "set_track_pan",
504
+ "params": {"track_index": track_ref, "pan": layer.pan},
505
+ "description": f"Set {layer.role} pan to {layer.pan}",
506
+ "role": layer.role,
507
+ })
508
+
509
+ result.plan = plan
510
+ return result
511
+
512
+ async def get_plan(
513
+ self,
514
+ intent: CompositionIntent,
515
+ search_roots: Optional[list] = None,
516
+ splice_client: object = None,
517
+ browser_client: object = None,
518
+ ) -> dict:
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
+ )
532
+ return result.to_dict()