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,329 @@
1
+ """Prompt parser — natural language → structured CompositionIntent.
2
+
3
+ Extracts genre, mood, tempo, key, descriptors, and explicit element requests
4
+ from free-form text prompts. Pure computation, no I/O.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional
12
+
13
+
14
+ # ── Data Model ─────────────────────────────────────────────────────
15
+
16
+ @dataclass
17
+ class CompositionIntent:
18
+ """Structured representation of a composition request."""
19
+
20
+ genre: str = ""
21
+ sub_genre: str = ""
22
+ mood: str = ""
23
+ tempo: int = 0 # 0 = auto-detect from genre
24
+ key: str = "" # "" = auto-pick based on mood
25
+ descriptors: list[str] = field(default_factory=list)
26
+ explicit_elements: list[str] = field(default_factory=list)
27
+ energy: float = 0.5 # 0.0-1.0
28
+ layer_count: int = 0 # 0 = auto (genre determines)
29
+ duration_bars: int = 64 # total arrangement length
30
+
31
+ def to_dict(self) -> dict:
32
+ return {
33
+ "genre": self.genre,
34
+ "sub_genre": self.sub_genre,
35
+ "mood": self.mood,
36
+ "tempo": self.tempo,
37
+ "key": self.key,
38
+ "descriptors": self.descriptors,
39
+ "explicit_elements": self.explicit_elements,
40
+ "energy": self.energy,
41
+ "layer_count": self.layer_count,
42
+ "duration_bars": self.duration_bars,
43
+ }
44
+
45
+
46
+ # ── Genre Defaults ─────────────────────────────────────────────────
47
+ # genre → (default_tempo, default_keys, default_energy, layer_range)
48
+
49
+ GENRE_DEFAULTS: dict[str, dict] = {
50
+ "techno": {
51
+ "tempo": 128, "keys": ["Am", "Cm"], "energy": 0.7,
52
+ "layers_min": 5, "layers_max": 7,
53
+ },
54
+ "house": {
55
+ "tempo": 124, "keys": ["Cm", "Fm"], "energy": 0.6,
56
+ "layers_min": 5, "layers_max": 6,
57
+ },
58
+ "hip hop": {
59
+ "tempo": 90, "keys": ["Cm", "Gm"], "energy": 0.5,
60
+ "layers_min": 4, "layers_max": 6,
61
+ },
62
+ "ambient": {
63
+ "tempo": 80, "keys": ["C", "Am"], "energy": 0.2,
64
+ "layers_min": 3, "layers_max": 5,
65
+ },
66
+ "drum and bass": {
67
+ "tempo": 174, "keys": ["Am", "Em"], "energy": 0.8,
68
+ "layers_min": 5, "layers_max": 7,
69
+ },
70
+ "trap": {
71
+ "tempo": 140, "keys": ["Cm", "Bbm"], "energy": 0.6,
72
+ "layers_min": 4, "layers_max": 6,
73
+ },
74
+ "lo-fi": {
75
+ "tempo": 85, "keys": ["Fm", "Cm"], "energy": 0.3,
76
+ "layers_min": 3, "layers_max": 5,
77
+ },
78
+ }
79
+
80
+ # Aliases that map to canonical genre names
81
+ _GENRE_ALIASES: dict[str, str] = {
82
+ "dnb": "drum and bass",
83
+ "d&b": "drum and bass",
84
+ "jungle": "drum and bass",
85
+ "lofi": "lo-fi",
86
+ "lo fi": "lo-fi",
87
+ "hiphop": "hip hop",
88
+ "hip-hop": "hip hop",
89
+ "deep house": "house",
90
+ "tech house": "house",
91
+ "acid techno": "techno",
92
+ "hard techno": "techno",
93
+ "industrial techno": "techno",
94
+ "minimal techno": "techno",
95
+ "detroit techno": "techno",
96
+ "dub techno": "techno",
97
+ }
98
+
99
+
100
+ # ── Mood Mapping ───────────────────────────────────────────────────
101
+ # mood → (energy_range, key_bias_list)
102
+
103
+ MOOD_MAPPING: dict[str, dict] = {
104
+ "dark": {
105
+ "energy_min": 0.4, "energy_max": 0.6,
106
+ "key_bias": ["Am", "Cm", "Em", "Dm"],
107
+ },
108
+ "euphoric": {
109
+ "energy_min": 0.8, "energy_max": 1.0,
110
+ "key_bias": ["C", "G", "F", "A"],
111
+ },
112
+ "melancholic": {
113
+ "energy_min": 0.2, "energy_max": 0.4,
114
+ "key_bias": ["Fm", "Cm", "Dm", "Bbm"],
115
+ },
116
+ "aggressive": {
117
+ "energy_min": 0.8, "energy_max": 0.9,
118
+ "key_bias": ["Am", "Em", "Bm", "F#m"],
119
+ },
120
+ "dreamy": {
121
+ "energy_min": 0.2, "energy_max": 0.3,
122
+ "key_bias": ["C", "F", "Bb", "Eb"],
123
+ },
124
+ "chill": {
125
+ "energy_min": 0.2, "energy_max": 0.4,
126
+ "key_bias": ["Fm", "Cm", "Gm", "Dm"],
127
+ },
128
+ "hypnotic": {
129
+ "energy_min": 0.5, "energy_max": 0.7,
130
+ "key_bias": ["Am", "Em", "Dm"],
131
+ },
132
+ "ethereal": {
133
+ "energy_min": 0.2, "energy_max": 0.4,
134
+ "key_bias": ["C", "F", "Ab", "Eb"],
135
+ },
136
+ "driving": {
137
+ "energy_min": 0.7, "energy_max": 0.9,
138
+ "key_bias": ["Am", "Em", "Cm"],
139
+ },
140
+ "warm": {
141
+ "energy_min": 0.3, "energy_max": 0.5,
142
+ "key_bias": ["F", "Bb", "Eb", "Ab"],
143
+ },
144
+ }
145
+
146
+
147
+ # ── Sub-genre keywords ─────────────────────────────────────────────
148
+
149
+ _SUB_GENRE_KEYWORDS: list[str] = [
150
+ "minimal", "deep", "acid", "industrial", "detroit", "dub",
151
+ "progressive", "melodic", "hard", "dark", "atmospheric",
152
+ "organic", "analog", "modular", "breakbeat", "uk garage",
153
+ "2-step", "drill", "boom bap", "old school", "new wave",
154
+ ]
155
+
156
+
157
+ # ── Descriptor keywords (adjectives that color the composition) ────
158
+
159
+ _DESCRIPTOR_KEYWORDS: list[str] = [
160
+ "industrial", "ghostly", "warm", "cold", "metallic", "organic",
161
+ "spacious", "intimate", "raw", "polished", "gritty", "clean",
162
+ "distorted", "saturated", "lush", "sparse", "dense", "airy",
163
+ "punchy", "soft", "crisp", "muddy", "bright", "muted",
164
+ "psychedelic", "glitchy", "cinematic", "underground", "futuristic",
165
+ "retro", "vintage", "modern", "classic", "experimental",
166
+ ]
167
+
168
+
169
+ # ── Element extraction patterns ────────────────────────────────────
170
+
171
+ _ELEMENT_PATTERNS: list[tuple[str, str]] = [
172
+ # (regex_pattern, element_name)
173
+ (r"\bwith\s+vocals?\b", "vocal"),
174
+ (r"\bwith\s+strings?\b", "strings"),
175
+ (r"\badd\s+strings?\b", "strings"),
176
+ (r"\b808\s*bass\b", "808"),
177
+ (r"\bwith\s+808\b", "808"),
178
+ (r"\bwith\s+synth\b", "synth"),
179
+ (r"\bwith\s+pads?\b", "pad"),
180
+ (r"\bwith\s+piano\b", "piano"),
181
+ (r"\bwith\s+guitar\b", "guitar"),
182
+ (r"\bwith\s+brass\b", "brass"),
183
+ (r"\bwith\s+horns?\b", "brass"),
184
+ (r"\bwith\s+(?:fx|effects?)\b", "fx"),
185
+ (r"\bwith\s+risers?\b", "fx"),
186
+ (r"\bwith\s+(?:perc|percussion)\b", "percussion"),
187
+ (r"\bwith\s+textures?\b", "texture"),
188
+ (r"\bghostly\s+vocals?\b", "vocal"),
189
+ (r"\bvocal\s+chops?\b", "vocal"),
190
+ (r"\bvocal\s+stabs?\b", "vocal"),
191
+ (r"\bsub\s*bass\b", "bass"),
192
+ (r"\breese\s*bass\b", "bass"),
193
+ (r"\bamen\s+break\b", "drums"),
194
+ (r"\bbreakbeat\b", "drums"),
195
+ (r"\bfoley\b", "texture"),
196
+ (r"\bfield\s+recordings?\b", "texture"),
197
+ (r"\batmospheric\b", "texture"),
198
+ ]
199
+
200
+
201
+ # ── Regex helpers ──────────────────────────────────────────────────
202
+
203
+ _TEMPO_RE = re.compile(r"\b(\d{2,3})\s*bpm\b", re.IGNORECASE)
204
+
205
+ # Key patterns: C, Cm, C#, C# minor, Db, Dbm, F# minor, Bb major
206
+ _KEY_RE = re.compile(
207
+ r"\b([A-Ga-g][#b]?)\s*(minor|major|min|maj|m)?\b"
208
+ )
209
+
210
+
211
+ # ── Parser ─────────────────────────────────────────────────────────
212
+
213
+ def parse_prompt(text: str) -> CompositionIntent:
214
+ """Parse a natural language composition prompt into structured intent.
215
+
216
+ Examples:
217
+ "dark minimal techno 128bpm Cm"
218
+ "euphoric deep house with vocals"
219
+ "lo-fi hip hop 85bpm F minor dreamy"
220
+ "aggressive drum and bass 174bpm Am"
221
+ """
222
+ intent = CompositionIntent()
223
+ text_lower = text.lower().strip()
224
+
225
+ # 1. Extract tempo
226
+ tempo_match = _TEMPO_RE.search(text)
227
+ if tempo_match:
228
+ intent.tempo = int(tempo_match.group(1))
229
+
230
+ # 2. Extract key (search original text to preserve case)
231
+ key_match = _KEY_RE.search(text)
232
+ if key_match:
233
+ root = key_match.group(1)
234
+ # Normalize root: uppercase first letter
235
+ root = root[0].upper() + root[1:] if len(root) > 1 else root.upper()
236
+ quality = key_match.group(2) or ""
237
+ quality_lower = quality.lower()
238
+ if quality_lower in ("minor", "min", "m"):
239
+ intent.key = f"{root}m"
240
+ elif quality_lower in ("major", "maj"):
241
+ intent.key = root
242
+ else:
243
+ # Standalone note — check if followed by 'm' in the original
244
+ intent.key = root
245
+
246
+ # 3. Match genre (check aliases first, then canonical names)
247
+ # Sort by length descending to match longer aliases first
248
+ all_genres = list(_GENRE_ALIASES.items()) + [
249
+ (g, g) for g in GENRE_DEFAULTS
250
+ ]
251
+ all_genres.sort(key=lambda x: -len(x[0]))
252
+
253
+ for alias, canonical in all_genres:
254
+ if alias in text_lower:
255
+ intent.genre = canonical
256
+ # Extract sub-genre from the alias if it differs
257
+ if alias != canonical and " " in alias:
258
+ parts = alias.split()
259
+ for part in parts:
260
+ if part != canonical and part in text_lower:
261
+ intent.sub_genre = part
262
+ break
263
+
264
+ # 4. Check for sub-genre keywords not caught by alias matching
265
+ if not intent.sub_genre:
266
+ for kw in _SUB_GENRE_KEYWORDS:
267
+ if kw in text_lower and kw != intent.genre:
268
+ intent.sub_genre = kw
269
+ break
270
+
271
+ # 5. Match mood
272
+ for mood_name in MOOD_MAPPING:
273
+ if mood_name in text_lower:
274
+ intent.mood = mood_name
275
+ break
276
+
277
+ # 6. Extract descriptors
278
+ for descriptor in _DESCRIPTOR_KEYWORDS:
279
+ if descriptor in text_lower and descriptor != intent.mood and descriptor != intent.sub_genre:
280
+ intent.descriptors.append(descriptor)
281
+
282
+ # 7. Extract explicit elements
283
+ seen_elements: set[str] = set()
284
+ for pattern, element in _ELEMENT_PATTERNS:
285
+ if re.search(pattern, text_lower) and element not in seen_elements:
286
+ intent.explicit_elements.append(element)
287
+ seen_elements.add(element)
288
+
289
+ # 8. Apply genre defaults for missing fields
290
+ genre_info = GENRE_DEFAULTS.get(intent.genre, {})
291
+
292
+ if intent.tempo == 0 and genre_info:
293
+ intent.tempo = genre_info["tempo"]
294
+
295
+ if not intent.key:
296
+ # Use mood bias if available, otherwise genre default
297
+ if intent.mood and intent.mood in MOOD_MAPPING:
298
+ intent.key = MOOD_MAPPING[intent.mood]["key_bias"][0]
299
+ elif genre_info:
300
+ intent.key = genre_info["keys"][0]
301
+
302
+ # 9. Compute energy from mood, fallback to genre
303
+ if intent.mood and intent.mood in MOOD_MAPPING:
304
+ mood_info = MOOD_MAPPING[intent.mood]
305
+ intent.energy = (mood_info["energy_min"] + mood_info["energy_max"]) / 2.0
306
+ elif genre_info:
307
+ intent.energy = genre_info["energy"]
308
+ else:
309
+ intent.energy = 0.5
310
+
311
+ # 10. Determine layer count from genre + energy
312
+ if intent.layer_count == 0 and genre_info:
313
+ base_min = genre_info["layers_min"]
314
+ base_max = genre_info["layers_max"]
315
+ # Higher energy → more layers
316
+ energy_factor = intent.energy
317
+ intent.layer_count = round(
318
+ base_min + (base_max - base_min) * energy_factor
319
+ )
320
+
321
+ # Fallback defaults
322
+ if intent.tempo == 0:
323
+ intent.tempo = 120
324
+ if not intent.key:
325
+ intent.key = "Am"
326
+ if intent.layer_count == 0:
327
+ intent.layer_count = 5
328
+
329
+ return intent
@@ -0,0 +1,153 @@
1
+ """Local-first sample resolution for composer plans.
2
+
3
+ Moves sample resolution from execution time (where the old pseudo-tool
4
+ _agent_pick_best_sample was supposed to "figure it out") to plan time.
5
+
6
+ Async because splice_remote downloads real samples over gRPC. Filesystem-only
7
+ callers still work synchronously from an async perspective — the function
8
+ only awaits when it actually has to hit the network.
9
+
10
+ Returns (local_path, source) where source is one of:
11
+ 'filesystem' — hit in a provided search_root directory (no network)
12
+ 'splice_local' — Splice catalog hit that's already downloaded (no credit spend)
13
+ 'splice_remote' — Splice catalog hit that required download (1 credit)
14
+ 'browser' — Ableton browser match with a local path
15
+ 'unresolved' — no match; caller drops the layer from the plan and warns
16
+
17
+ Preference order is fixed: filesystem > splice_local > splice_remote > browser.
18
+ Filesystem wins even if Splice has a faster hit — local files are free.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path
24
+ from typing import Optional, Tuple
25
+
26
+ from .layer_planner import LayerSpec
27
+
28
+
29
+ _AUDIO_EXTENSIONS = (".wav", ".aif", ".aiff", ".flac")
30
+
31
+
32
+ def _query_tokens(query: str) -> list[str]:
33
+ """Return lowercase query tokens meaningful for matching (len > 2)."""
34
+ return [t.lower() for t in query.split() if len(t) > 2]
35
+
36
+
37
+ def _iter_candidates(root: Path):
38
+ """Yield all audio-format files beneath root."""
39
+ if not root.exists():
40
+ return
41
+ for ext in _AUDIO_EXTENSIONS:
42
+ yield from root.rglob(f"*{ext}")
43
+
44
+
45
+ def _filesystem_match(layer: LayerSpec, search_roots: list[Path]) -> Optional[str]:
46
+ """First filename-substring match on role or any query token.
47
+
48
+ Sync helper — no network, no async needed.
49
+ """
50
+ tokens = _query_tokens(layer.search_query)
51
+ role = layer.role.lower()
52
+ for root in search_roots:
53
+ for path in _iter_candidates(Path(root)):
54
+ name = path.name.lower()
55
+ if role and role in name:
56
+ return str(path)
57
+ if any(tok in name for tok in tokens):
58
+ return str(path)
59
+ return None
60
+
61
+
62
+ async def _splice_resolve(
63
+ layer: LayerSpec,
64
+ splice_client: object,
65
+ credit_budget: int,
66
+ ) -> Tuple[Optional[str], str]:
67
+ """Query Splice for the layer. Returns (path, source) or (None, 'unresolved').
68
+
69
+ Tries local hits first (free), then remote downloads (1 credit each,
70
+ respecting the hard floor). Stops on first success.
71
+ """
72
+ if splice_client is None or not getattr(splice_client, "connected", False):
73
+ return None, "unresolved"
74
+
75
+ try:
76
+ result = await splice_client.search_samples(
77
+ query=layer.search_query,
78
+ per_page=5,
79
+ )
80
+ except Exception:
81
+ return None, "unresolved"
82
+
83
+ samples = list(result.samples) if result and hasattr(result, "samples") else []
84
+ if not samples:
85
+ return None, "unresolved"
86
+
87
+ # 1. Prefer already-local Splice hits (zero credit spend)
88
+ for sample in samples:
89
+ lp = getattr(sample, "local_path", "") or ""
90
+ if lp and Path(lp).exists():
91
+ return lp, "splice_local"
92
+
93
+ # 2. Remote download — respect the credit hard floor
94
+ for sample in samples:
95
+ if getattr(sample, "local_path", ""):
96
+ continue # already handled above
97
+ file_hash = getattr(sample, "file_hash", "")
98
+ if not file_hash:
99
+ continue
100
+ try:
101
+ can, _remaining = await splice_client.can_afford(1, credit_budget)
102
+ if not can:
103
+ break # credit floor hit — stop trying, don't try next sample
104
+ downloaded = await splice_client.download_sample(file_hash)
105
+ if downloaded and Path(downloaded).exists():
106
+ return downloaded, "splice_remote"
107
+ except Exception:
108
+ continue # try next hit
109
+
110
+ return None, "unresolved"
111
+
112
+
113
+ async def resolve_sample_for_layer(
114
+ layer: LayerSpec,
115
+ search_roots: Optional[list] = None,
116
+ splice_client: object = None,
117
+ browser_client: object = None,
118
+ credit_budget: int = 1,
119
+ ) -> Tuple[Optional[str], str]:
120
+ """Resolve a layer's sample to a concrete local file path.
121
+
122
+ Preference order: filesystem > splice_local > splice_remote > browser.
123
+ Unresolved layers return (None, 'unresolved'); callers drop them from
124
+ the plan and surface a warning.
125
+
126
+ search_roots accepts Path or str entries. Missing dirs are silently
127
+ skipped. None entries are filtered out.
128
+ """
129
+ roots = [Path(r) for r in (search_roots or []) if r]
130
+
131
+ # 1. Filesystem — always try first, no network
132
+ fs_hit = _filesystem_match(layer, roots)
133
+ if fs_hit:
134
+ return fs_hit, "filesystem"
135
+
136
+ # 2 & 3. Splice (local hits + remote download)
137
+ path, source = await _splice_resolve(layer, splice_client, credit_budget)
138
+ if path is not None:
139
+ return path, source
140
+
141
+ # 4. Browser (sync, optional)
142
+ if browser_client is not None:
143
+ try:
144
+ search = getattr(browser_client, "search", None)
145
+ hits = search(layer.search_query, limit=5) if callable(search) else []
146
+ for hit in hits or []:
147
+ lp = hit.get("file_path") if isinstance(hit, dict) else None
148
+ if lp and Path(lp).exists():
149
+ return lp, "browser"
150
+ except Exception:
151
+ pass
152
+
153
+ return None, "unresolved"
@@ -0,0 +1,211 @@
1
+ """Composer Engine MCP tools — 3 tools for auto-composition.
2
+
3
+ compose: full multi-layer composition from text prompt
4
+ augment_with_samples: add layers to existing session
5
+ get_composition_plan: dry run preview
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from fastmcp import Context
13
+
14
+ from ..server import mcp
15
+ from .prompt_parser import parse_prompt
16
+ from .engine import ComposerEngine
17
+
18
+
19
+ # Singleton engine — stateless, safe to reuse
20
+ _engine = ComposerEngine()
21
+
22
+
23
+ def _get_search_roots(ctx: Context) -> list:
24
+ """Pull sample-search roots from ctx (if the server wired any) plus
25
+ environment fallbacks.
26
+ """
27
+ roots = []
28
+ try:
29
+ cfg = ctx.lifespan_context.get("sample_search_roots") if hasattr(ctx, "lifespan_context") else None
30
+ if cfg:
31
+ roots.extend(cfg)
32
+ except Exception:
33
+ pass
34
+ return roots
35
+
36
+
37
+ async def _credit_safety_prelude(splice_client, max_credits: int) -> tuple[int, int | None, list[str]]:
38
+ """Apply the hard floor / budget trimming rules upfront.
39
+
40
+ Returns (adjusted_max_credits, credits_remaining_or_None, warnings).
41
+ """
42
+ warnings: list[str] = []
43
+ credits_remaining: int | None = None
44
+
45
+ if splice_client is None or not getattr(splice_client, "connected", False):
46
+ warnings.append(
47
+ "Splice not connected. Plan will use browser/filesystem fallback "
48
+ "for sample search."
49
+ )
50
+ return max_credits, None, warnings
51
+
52
+ try:
53
+ info = await splice_client.get_credits()
54
+ credits_remaining = getattr(info, "credits", None)
55
+ except Exception:
56
+ credits_remaining = None
57
+
58
+ if credits_remaining is None:
59
+ return max_credits, None, warnings
60
+
61
+ if credits_remaining <= 5:
62
+ warnings.append(
63
+ f"Splice credits critically low ({credits_remaining}). "
64
+ f"Using downloaded samples only."
65
+ )
66
+ max_credits = 0
67
+ elif max_credits > credits_remaining - 5:
68
+ safe_budget = max(0, credits_remaining - 5)
69
+ warnings.append(
70
+ f"Budget capped at {safe_budget} credits "
71
+ f"(remaining: {credits_remaining}, floor: 5)."
72
+ )
73
+ max_credits = safe_budget
74
+
75
+ return max_credits, credits_remaining, warnings
76
+
77
+
78
+ @mcp.tool()
79
+ async def compose(
80
+ ctx: Context,
81
+ prompt: str,
82
+ max_credits: int = 50,
83
+ dry_run: bool = False,
84
+ ) -> dict:
85
+ """Plan a full multi-layer composition from a text prompt.
86
+
87
+ Parses the prompt into genre/mood/tempo/key, plans layers using role
88
+ templates, and compiles an executable plan of tool calls. Does NOT
89
+ execute — returns the plan for the agent to step through.
90
+
91
+ prompt: "dark minimal techno 128bpm with industrial textures and ghostly vocals"
92
+ max_credits: maximum Splice credits budget for the plan (default 50, 0 = downloaded only)
93
+ dry_run: if True, return the plan without credit checks
94
+
95
+ Returns a compiled plan with step-by-step tool calls. The agent
96
+ executes each step by calling the referenced tools in sequence.
97
+ """
98
+ intent = parse_prompt(prompt)
99
+
100
+ splice_client = ctx.lifespan_context.get("splice_client") if hasattr(ctx, "lifespan_context") else None
101
+ search_roots = _get_search_roots(ctx)
102
+
103
+ max_credits, credits_remaining, warnings = await _credit_safety_prelude(splice_client, max_credits)
104
+
105
+ result = await _engine.compose(
106
+ intent,
107
+ dry_run=dry_run,
108
+ max_credits=max_credits,
109
+ search_roots=search_roots,
110
+ splice_client=splice_client,
111
+ )
112
+ result.warnings.extend(warnings)
113
+
114
+ output = result.to_dict()
115
+ output["prompt"] = prompt
116
+
117
+ if credits_remaining is not None:
118
+ output["credits_remaining"] = credits_remaining
119
+ output["credits_budget"] = max_credits
120
+
121
+ return output
122
+
123
+
124
+ @mcp.tool()
125
+ async def augment_with_samples(
126
+ ctx: Context,
127
+ request: str,
128
+ max_credits: int = 10,
129
+ max_layers: int = 3,
130
+ ) -> dict:
131
+ """Plan sample-based layers to add to the existing session.
132
+
133
+ Parses the request and builds a plan for new tracks with sample
134
+ search queries, processing techniques, and volume/pan settings.
135
+ Does NOT execute — returns the plan for the agent to step through.
136
+
137
+ request: "add organic textures" or "layer a vocal chop over the verse"
138
+ max_credits: maximum Splice credits budget for the plan (default 10)
139
+ max_layers: maximum number of new tracks in the plan (default 3)
140
+
141
+ Returns a compiled plan with step-by-step tool calls.
142
+ """
143
+ splice_client = ctx.lifespan_context.get("splice_client") if hasattr(ctx, "lifespan_context") else None
144
+ search_roots = _get_search_roots(ctx)
145
+
146
+ max_credits, credits_remaining, warnings = await _credit_safety_prelude(splice_client, max_credits)
147
+
148
+ # Pull current session info for tempo context
149
+ session_context: dict = {}
150
+ try:
151
+ ableton = ctx.lifespan_context.get("ableton")
152
+ if ableton:
153
+ info = ableton.send_command("get_session_info", {})
154
+ session_context["tempo"] = info.get("tempo", 120)
155
+ session_context["track_count"] = info.get("track_count", 0)
156
+ except Exception:
157
+ pass
158
+
159
+ result = await _engine.augment(
160
+ request=request,
161
+ max_credits=max_credits,
162
+ max_layers=max_layers,
163
+ search_roots=search_roots,
164
+ splice_client=splice_client,
165
+ )
166
+
167
+ if session_context.get("tempo"):
168
+ result.intent.tempo = int(session_context["tempo"])
169
+
170
+ result.warnings.extend(warnings)
171
+
172
+ output = result.to_dict()
173
+ output["request"] = request
174
+
175
+ if session_context:
176
+ output["session_context"] = session_context
177
+ if credits_remaining is not None:
178
+ output["credits_remaining"] = credits_remaining
179
+ output["credits_budget"] = max_credits
180
+
181
+ return output
182
+
183
+
184
+ @mcp.tool()
185
+ async def get_composition_plan(
186
+ ctx: Context,
187
+ prompt: str,
188
+ ) -> dict:
189
+ """Preview what compose would do without executing or spending credits.
190
+
191
+ Returns the full layer plan with search queries, technique selections,
192
+ processing chains, and arrangement sections. Use to review before
193
+ committing to a full composition.
194
+
195
+ prompt: "dark minimal techno 128bpm with industrial textures"
196
+ """
197
+ intent = parse_prompt(prompt)
198
+ splice_client = ctx.lifespan_context.get("splice_client") if hasattr(ctx, "lifespan_context") else None
199
+ search_roots = _get_search_roots(ctx)
200
+ plan = await _engine.get_plan(
201
+ intent,
202
+ search_roots=search_roots,
203
+ splice_client=splice_client,
204
+ )
205
+ plan["prompt"] = prompt
206
+ plan["note"] = (
207
+ "This is a dry run. No samples searched or loaded. "
208
+ "Use compose() to get the full plan with credit checks, "
209
+ "then step through each tool call in sequence."
210
+ )
211
+ return plan