livepilot 1.9.23 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +119 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +144 -13
  6. package/bin/livepilot.js +87 -0
  7. package/installer/codex.js +147 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/skills/livepilot-core/SKILL.md +21 -4
  11. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
  12. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
  13. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
  14. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
  15. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
  16. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
  17. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
  18. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
  19. package/livepilot/skills/livepilot-core/references/overview.md +13 -9
  20. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
  21. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
  22. package/livepilot/skills/livepilot-devices/SKILL.md +16 -2
  23. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  24. package/livepilot/skills/livepilot-release/SKILL.md +19 -5
  25. package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -0
  26. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
  27. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
  28. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
  29. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
  30. package/livepilot/skills/livepilot-wonder/SKILL.md +15 -0
  31. package/livepilot.mcpb +0 -0
  32. package/m4l_device/livepilot_bridge.js +1 -1
  33. package/manifest.json +2 -2
  34. package/mcp_server/__init__.py +1 -1
  35. package/mcp_server/atlas/__init__.py +357 -0
  36. package/mcp_server/atlas/device_atlas.json +44067 -0
  37. package/mcp_server/atlas/enrichments/__init__.py +111 -0
  38. package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
  39. package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
  40. package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
  41. package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
  42. package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
  43. package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
  44. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
  45. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
  46. package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
  47. package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
  48. package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
  49. package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
  50. package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
  51. package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
  52. package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
  53. package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
  54. package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
  55. package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
  56. package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
  57. package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
  58. package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
  59. package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
  60. package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
  61. package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
  62. package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
  63. package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
  64. package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
  65. package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
  66. package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
  67. package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
  68. package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
  69. package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
  70. package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
  71. package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
  72. package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
  73. package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
  74. package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
  75. package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
  76. package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
  77. package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
  78. package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
  79. package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
  80. package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
  81. package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
  82. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
  83. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
  84. package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
  85. package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
  86. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
  87. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
  88. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
  89. package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
  90. package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
  91. package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
  92. package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
  93. package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
  94. package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
  95. package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
  96. package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
  97. package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
  98. package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
  99. package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
  100. package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
  101. package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
  102. package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
  103. package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
  104. package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
  105. package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
  106. package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
  107. package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
  108. package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
  109. package/mcp_server/atlas/scanner.py +236 -0
  110. package/mcp_server/atlas/tools.py +224 -0
  111. package/mcp_server/composer/__init__.py +1 -0
  112. package/mcp_server/composer/engine.py +452 -0
  113. package/mcp_server/composer/layer_planner.py +427 -0
  114. package/mcp_server/composer/prompt_parser.py +329 -0
  115. package/mcp_server/composer/tools.py +201 -0
  116. package/mcp_server/connection.py +53 -8
  117. package/mcp_server/corpus/__init__.py +377 -0
  118. package/mcp_server/device_forge/__init__.py +1 -0
  119. package/mcp_server/device_forge/builder.py +377 -0
  120. package/mcp_server/device_forge/models.py +142 -0
  121. package/mcp_server/device_forge/templates.py +483 -0
  122. package/mcp_server/device_forge/tools.py +162 -0
  123. package/mcp_server/hook_hunter/analyzer.py +23 -0
  124. package/mcp_server/hook_hunter/models.py +1 -0
  125. package/mcp_server/hook_hunter/tools.py +4 -2
  126. package/mcp_server/m4l_bridge.py +1 -0
  127. package/mcp_server/memory/taste_graph.py +68 -1
  128. package/mcp_server/memory/tools.py +15 -4
  129. package/mcp_server/musical_intelligence/detectors.py +14 -1
  130. package/mcp_server/musical_intelligence/tools.py +11 -8
  131. package/mcp_server/persistence/__init__.py +1 -0
  132. package/mcp_server/persistence/base_store.py +82 -0
  133. package/mcp_server/persistence/project_store.py +106 -0
  134. package/mcp_server/persistence/taste_store.py +122 -0
  135. package/mcp_server/preview_studio/models.py +1 -0
  136. package/mcp_server/preview_studio/tools.py +56 -13
  137. package/mcp_server/runtime/capability.py +66 -0
  138. package/mcp_server/runtime/capability_probe.py +137 -0
  139. package/mcp_server/runtime/execution_router.py +143 -0
  140. package/mcp_server/runtime/live_version.py +102 -0
  141. package/mcp_server/runtime/remote_commands.py +87 -0
  142. package/mcp_server/runtime/tools.py +18 -4
  143. package/mcp_server/sample_engine/__init__.py +1 -0
  144. package/mcp_server/sample_engine/analyzer.py +216 -0
  145. package/mcp_server/sample_engine/critics.py +390 -0
  146. package/mcp_server/sample_engine/models.py +193 -0
  147. package/mcp_server/sample_engine/moves.py +127 -0
  148. package/mcp_server/sample_engine/planner.py +186 -0
  149. package/mcp_server/sample_engine/sources.py +540 -0
  150. package/mcp_server/sample_engine/techniques.py +908 -0
  151. package/mcp_server/sample_engine/tools.py +442 -0
  152. package/mcp_server/semantic_moves/__init__.py +3 -0
  153. package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
  154. package/mcp_server/semantic_moves/mix_moves.py +41 -41
  155. package/mcp_server/semantic_moves/performance_moves.py +13 -13
  156. package/mcp_server/semantic_moves/sample_compilers.py +372 -0
  157. package/mcp_server/semantic_moves/sound_design_moves.py +15 -15
  158. package/mcp_server/semantic_moves/tools.py +18 -17
  159. package/mcp_server/semantic_moves/transition_moves.py +16 -16
  160. package/mcp_server/server.py +51 -0
  161. package/mcp_server/services/__init__.py +1 -0
  162. package/mcp_server/services/motif_service.py +67 -0
  163. package/mcp_server/session_continuity/tracker.py +29 -1
  164. package/mcp_server/song_brain/builder.py +28 -1
  165. package/mcp_server/song_brain/models.py +4 -0
  166. package/mcp_server/song_brain/tools.py +20 -2
  167. package/mcp_server/sound_design/critics.py +89 -1
  168. package/mcp_server/splice_client/__init__.py +1 -0
  169. package/mcp_server/splice_client/client.py +347 -0
  170. package/mcp_server/splice_client/models.py +96 -0
  171. package/mcp_server/splice_client/protos/__init__.py +1 -0
  172. package/mcp_server/splice_client/protos/app_pb2.py +319 -0
  173. package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
  174. package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
  175. package/mcp_server/tools/arrangement.py +69 -0
  176. package/mcp_server/tools/automation.py +15 -2
  177. package/mcp_server/tools/devices.py +117 -6
  178. package/mcp_server/tools/notes.py +37 -4
  179. package/mcp_server/wonder_mode/diagnosis.py +5 -0
  180. package/mcp_server/wonder_mode/engine.py +85 -1
  181. package/mcp_server/wonder_mode/tools.py +6 -1
  182. package/package.json +12 -2
  183. package/remote_script/LivePilot/__init__.py +8 -1
  184. package/remote_script/LivePilot/arrangement.py +114 -0
  185. package/remote_script/LivePilot/browser.py +56 -1
  186. package/remote_script/LivePilot/devices.py +236 -6
  187. package/remote_script/LivePilot/mixing.py +8 -3
  188. package/remote_script/LivePilot/server.py +5 -1
  189. package/remote_script/LivePilot/transport.py +3 -0
  190. package/remote_script/LivePilot/version_detect.py +78 -0
  191. package/scripts/sync_metadata.py +132 -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,201 @@
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
+ @mcp.tool()
24
+ async def compose(
25
+ ctx: Context,
26
+ prompt: str,
27
+ max_credits: int = 50,
28
+ dry_run: bool = False,
29
+ ) -> dict:
30
+ """Create a full multi-layer composition from a text prompt.
31
+
32
+ Searches Splice's catalog, selects matching samples with critic scoring,
33
+ downloads them, loads into Ableton, applies processing techniques, and
34
+ arranges into genre-appropriate sections.
35
+
36
+ prompt: "dark minimal techno 128bpm with industrial textures and ghostly vocals"
37
+ max_credits: maximum Splice credits to spend (default 50, 0 = use only downloaded)
38
+ dry_run: if True, return the plan without executing (same as get_composition_plan)
39
+
40
+ Returns a compiled plan with all execution steps. When dry_run is False,
41
+ the plan is ready for step-by-step execution by the agent.
42
+ """
43
+ # Parse the prompt into structured intent
44
+ intent = parse_prompt(prompt)
45
+
46
+ # Credit safety check
47
+ splice_client = None
48
+ credits_remaining = None
49
+ try:
50
+ lifespan = ctx.lifespan_context
51
+ if lifespan and "splice" in lifespan:
52
+ splice_client = lifespan["splice"]
53
+ if splice_client and splice_client.connected:
54
+ credits_remaining = await splice_client.get_credits_remaining()
55
+ except Exception:
56
+ pass
57
+
58
+ warnings: list[str] = []
59
+
60
+ if credits_remaining is not None:
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
+ if splice_client is None or not getattr(splice_client, "connected", False):
76
+ warnings.append(
77
+ "Splice not connected. Plan will use browser/filesystem fallback "
78
+ "for sample search."
79
+ )
80
+
81
+ # Compose
82
+ result = _engine.compose(intent, dry_run=dry_run, max_credits=max_credits)
83
+
84
+ # Merge warnings
85
+ result.warnings.extend(warnings)
86
+
87
+ output = result.to_dict()
88
+ output["prompt"] = prompt
89
+
90
+ if credits_remaining is not None:
91
+ output["credits_remaining"] = credits_remaining
92
+ output["credits_budget"] = max_credits
93
+
94
+ return output
95
+
96
+
97
+ @mcp.tool()
98
+ async def augment_with_samples(
99
+ ctx: Context,
100
+ request: str,
101
+ max_credits: int = 10,
102
+ max_layers: int = 3,
103
+ ) -> dict:
104
+ """Add sample-based layers to the existing session.
105
+
106
+ Analyzes the request, searches Splice for complementary samples,
107
+ and creates a plan to add new tracks with appropriate processing.
108
+
109
+ request: "add organic textures" or "layer a vocal chop over the verse"
110
+ max_credits: maximum Splice credits to spend (default 10)
111
+ max_layers: maximum number of new tracks to add (default 3)
112
+
113
+ Returns a compiled plan for adding new layers to the session.
114
+ """
115
+ # Credit safety
116
+ splice_client = None
117
+ credits_remaining = None
118
+ try:
119
+ lifespan = ctx.lifespan_context
120
+ if lifespan and "splice" in lifespan:
121
+ splice_client = lifespan["splice"]
122
+ if splice_client and splice_client.connected:
123
+ credits_remaining = await splice_client.get_credits_remaining()
124
+ except Exception:
125
+ pass
126
+
127
+ warnings: list[str] = []
128
+
129
+ if credits_remaining is not None:
130
+ if credits_remaining <= 5:
131
+ warnings.append(
132
+ f"Splice credits critically low ({credits_remaining}). "
133
+ f"Using downloaded samples only."
134
+ )
135
+ max_credits = 0
136
+ elif max_credits > credits_remaining - 5:
137
+ safe_budget = max(0, credits_remaining - 5)
138
+ max_credits = safe_budget
139
+
140
+ if splice_client is None or not getattr(splice_client, "connected", False):
141
+ warnings.append(
142
+ "Splice not connected. Will use browser/filesystem fallback."
143
+ )
144
+
145
+ # Get current session info for context
146
+ session_context: dict = {}
147
+ try:
148
+ ableton = ctx.lifespan_context.get("ableton")
149
+ if ableton:
150
+ info = ableton.send_command("get_session_info", {})
151
+ session_context["tempo"] = info.get("tempo", 120)
152
+ session_context["track_count"] = info.get("track_count", 0)
153
+ except Exception:
154
+ pass
155
+
156
+ # Augment
157
+ result = _engine.augment(
158
+ request=request,
159
+ max_credits=max_credits,
160
+ max_layers=max_layers,
161
+ )
162
+
163
+ # Override tempo from session if available
164
+ if session_context.get("tempo"):
165
+ result.intent.tempo = int(session_context["tempo"])
166
+
167
+ result.warnings.extend(warnings)
168
+
169
+ output = result.to_dict()
170
+ output["request"] = request
171
+
172
+ if session_context:
173
+ output["session_context"] = session_context
174
+ if credits_remaining is not None:
175
+ output["credits_remaining"] = credits_remaining
176
+ output["credits_budget"] = max_credits
177
+
178
+ return output
179
+
180
+
181
+ @mcp.tool()
182
+ async def get_composition_plan(
183
+ ctx: Context,
184
+ prompt: str,
185
+ ) -> dict:
186
+ """Preview what compose would do without executing or spending credits.
187
+
188
+ Returns the full layer plan with search queries, technique selections,
189
+ processing chains, and arrangement sections. Use to review before
190
+ committing to a full composition.
191
+
192
+ prompt: "dark minimal techno 128bpm with industrial textures"
193
+ """
194
+ intent = parse_prompt(prompt)
195
+ plan = _engine.get_plan(intent)
196
+ plan["prompt"] = prompt
197
+ plan["note"] = (
198
+ "This is a dry run. No samples searched, downloaded, or loaded. "
199
+ "Use compose() to execute this plan."
200
+ )
201
+ return plan
@@ -14,6 +14,11 @@ from typing import Optional
14
14
 
15
15
  CONNECT_TIMEOUT = 5
16
16
  RECV_TIMEOUT = 20
17
+ SINGLE_CLIENT_RETRY_DELAY = 0.25
18
+ COMMAND_RECV_TIMEOUTS = {
19
+ # Server-side slow write window is 35s; give the client a small buffer.
20
+ "freeze_track": 40,
21
+ }
17
22
 
18
23
 
19
24
  class AbletonConnectionError(Exception):
@@ -47,6 +52,19 @@ def _friendly_error(code: str, message: str, command_type: str) -> str:
47
52
  return " ".join(parts)
48
53
 
49
54
 
55
+ def _is_single_client_state_error(response: dict) -> bool:
56
+ """Return True when the server rejected a fresh connection due to single-client guard."""
57
+ if response.get("ok") is not False:
58
+ return False
59
+ err = response.get("error", {})
60
+ if not isinstance(err, dict):
61
+ return False
62
+ return (
63
+ err.get("code") == "STATE_ERROR"
64
+ and "Another client is already connected" in str(err.get("message", ""))
65
+ )
66
+
67
+
50
68
  def _identify_other_tcp_client(host: str, port: int) -> str | None:
51
69
  """Return a short description of another established client on the Live port."""
52
70
  try:
@@ -134,9 +152,7 @@ class AbletonConnection:
134
152
  def ping(self) -> bool:
135
153
  """Send a ping and return True if a pong is received."""
136
154
  try:
137
- with self._lock:
138
- resp = self._send_raw({"type": "ping"})
139
- return resp.get("result", {}).get("pong") is True
155
+ return self.send_command("ping").get("pong") is True
140
156
  except Exception:
141
157
  return False
142
158
 
@@ -151,7 +167,8 @@ class AbletonConnection:
151
167
  """
152
168
  with self._lock:
153
169
  # Ensure we have a connection
154
- if not self.is_connected():
170
+ fresh_connect = not self.is_connected()
171
+ if fresh_connect:
155
172
  self.connect()
156
173
 
157
174
  command: dict = {"type": command_type}
@@ -159,7 +176,10 @@ class AbletonConnection:
159
176
  command["params"] = params
160
177
 
161
178
  try:
162
- response = self._send_raw(command)
179
+ response = self._send_raw(
180
+ command,
181
+ recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
182
+ )
163
183
  except AbletonConnectionError as exc:
164
184
  # If the send phase succeeded (data left this process),
165
185
  # Ableton may have already applied the command. Never
@@ -172,12 +192,30 @@ class AbletonConnection:
172
192
  # Send itself failed — safe to retry with a fresh connection
173
193
  self.disconnect()
174
194
  self.connect()
175
- response = self._send_raw(command)
195
+ response = self._send_raw(
196
+ command,
197
+ recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
198
+ )
176
199
  except OSError:
177
200
  # Socket error before send — safe to retry
178
201
  self.disconnect()
179
202
  self.connect()
180
- response = self._send_raw(command)
203
+ response = self._send_raw(
204
+ command,
205
+ recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
206
+ )
207
+
208
+ # The single-client guard can briefly reject an immediate reconnect
209
+ # after this process closes a previous socket. Retry once after a
210
+ # short delay when the command was rejected before execution.
211
+ if fresh_connect and _is_single_client_state_error(response):
212
+ self.disconnect()
213
+ time.sleep(SINGLE_CLIENT_RETRY_DELAY)
214
+ self.connect()
215
+ response = self._send_raw(
216
+ command,
217
+ recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
218
+ )
181
219
 
182
220
  # Log and error handling outside the lock (no socket access needed)
183
221
  log_entry = {
@@ -214,7 +252,7 @@ class AbletonConnection:
214
252
  # Low-level transport
215
253
  # ------------------------------------------------------------------
216
254
 
217
- def _send_raw(self, command: dict) -> dict:
255
+ def _send_raw(self, command: dict, recv_timeout: int = RECV_TIMEOUT) -> dict:
218
256
  """Send a JSON command (with request_id) and read the response."""
219
257
  if self._socket is None:
220
258
  raise AbletonConnectionError("Not connected to Ableton Live")
@@ -222,6 +260,7 @@ class AbletonConnection:
222
260
  # Don't mutate the caller's dict
223
261
  envelope = {**command, "id": str(uuid.uuid4())[:8]}
224
262
  payload = json.dumps(envelope) + "\n"
263
+ self._socket.settimeout(recv_timeout)
225
264
 
226
265
  try:
227
266
  self._socket.sendall(payload.encode("utf-8"))
@@ -283,3 +322,9 @@ class AbletonConnection:
283
322
  raise AbletonConnectionError(
284
323
  f"Invalid JSON from Ableton: {line[:200]}"
285
324
  ) from exc
325
+ finally:
326
+ if self._socket is not None:
327
+ try:
328
+ self._socket.settimeout(RECV_TIMEOUT)
329
+ except OSError:
330
+ pass