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
@@ -60,3 +60,18 @@ For the recommendation, explain:
60
60
  - Why this one over the others
61
61
  - What risk it introduces
62
62
  - What sacred elements it preserves
63
+
64
+ ## Creative Intelligence (consult before generating variants)
65
+
66
+ Wonder Mode should produce musically interesting results, not just technically correct ones. Before generating or applying any variant:
67
+
68
+ 1. Read `references/device-knowledge/automation-as-music.md` for automation shapes and macro gestures
69
+ 2. Read `references/device-knowledge/creative-thinking.md` for emotional-to-technical mapping
70
+ 3. Read `references/device-knowledge/chains-genre.md` if the session has a genre identity
71
+
72
+ Every Wonder variant should include:
73
+ - A **filter arc** — at least one element's filter evolving over the full section
74
+ - A **space arc** — reverb/delay sends breathing with arrangement density
75
+ - **Micro-modulation** — every sustained sound has sub-perceptual LFO on at least one parameter
76
+ - **2-3 macro gestures** — coordinated multi-parameter moves at section transitions
77
+ - A variant with no automation is not a real variant — automation IS the music
package/livepilot.mcpb CHANGED
Binary file
@@ -84,7 +84,7 @@ function anything() {
84
84
  function dispatch(cmd, args) {
85
85
  switch(cmd) {
86
86
  case "ping":
87
- send_response({"ok": true, "version": "1.9.22"});
87
+ send_response({"ok": true, "version": "1.10.0"});
88
88
  break;
89
89
  case "get_params":
90
90
  cmd_get_params(args);
package/manifest.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "livepilot",
4
4
  "display_name": "LivePilot — AI for Ableton Live",
5
- "version": "1.9.23",
6
- "description": "Agentic production system for Ableton Live 12. Make beats, mix tracks, design sounds, and arrange songs with 293 AI-powered tools.",
5
+ "version": "1.10.0",
6
+ "description": "Agentic production system for Ableton Live 12. Make beats, mix tracks, design sounds, and arrange songs with 316 AI-powered tools.",
7
7
  "long_description": "LivePilot is an AI production assistant that connects directly to Ableton Live 12. It can create drum patterns, program basslines, write chord progressions, design sounds, mix your tracks, analyze your audio, and arrange full songs — all through natural language.\n\n**What it does:**\n- Creates MIDI clips with notes, chords, and rhythms\n- Loads instruments and effects from Ableton's browser\n- Shapes sounds by adjusting device parameters\n- Mixes with volume, panning, sends, and automation\n- Analyzes your mix with real-time spectral data\n- Remembers your production style across sessions\n\n**How it works:**\nLivePilot installs a Remote Script in Ableton that communicates with the AI over a local TCP connection. Everything runs on your machine — no audio leaves your computer.",
8
8
  "author": {
9
9
  "name": "Pilot Studio",
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.9.23"
2
+ __version__ = "1.10.0"
@@ -0,0 +1,357 @@
1
+ """Device Atlas v2 — indexed in-memory device knowledge base.
2
+
3
+ Loads a JSON atlas file and builds indexes for fast lookup, search,
4
+ suggestion, chain building, and device comparison.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from typing import Any, Dict, List, Optional
12
+
13
+
14
+ class AtlasManager:
15
+ """In-memory device atlas with indexed lookups."""
16
+
17
+ def __init__(self, atlas_path: str):
18
+ with open(atlas_path, "r") as f:
19
+ data = json.load(f)
20
+
21
+ self._meta = data.get("meta", {})
22
+ self._devices: List[Dict[str, Any]] = data.get("devices", [])
23
+
24
+ # ── Build indexes ───────────────────────────────────────────
25
+ self._by_id: Dict[str, Dict[str, Any]] = {}
26
+ self._by_name: Dict[str, Dict[str, Any]] = {} # lowercase key
27
+ self._by_uri: Dict[str, Dict[str, Any]] = {}
28
+ self._by_category: Dict[str, List[Dict[str, Any]]] = {}
29
+ self._by_tag: Dict[str, List[Dict[str, Any]]] = {}
30
+ self._by_genre: Dict[str, List[Dict[str, Any]]] = {}
31
+
32
+ for dev in self._devices:
33
+ dev_id = dev.get("id", "")
34
+ dev_name = dev.get("name", "")
35
+ dev_uri = dev.get("uri", "")
36
+ dev_category = dev.get("category", "")
37
+
38
+ if dev_id:
39
+ self._by_id[dev_id] = dev
40
+ if dev_name:
41
+ self._by_name[dev_name.lower()] = dev
42
+ if dev_uri:
43
+ self._by_uri[dev_uri] = dev
44
+
45
+ # Category index
46
+ if dev_category:
47
+ self._by_category.setdefault(dev_category, []).append(dev)
48
+
49
+ # Tag index
50
+ for tag in dev.get("tags", []):
51
+ self._by_tag.setdefault(tag.lower(), []).append(dev)
52
+
53
+ # Genre index (primary + secondary)
54
+ for genre in dev.get("genres", {}).get("primary", []):
55
+ self._by_genre.setdefault(genre.lower(), []).append(dev)
56
+ for genre in dev.get("genres", {}).get("secondary", []):
57
+ self._by_genre.setdefault(genre.lower(), []).append(dev)
58
+
59
+ # ── Properties ──────────────────────────────────────────────────
60
+
61
+ @property
62
+ def version(self) -> str:
63
+ return self._meta.get("version", "unknown")
64
+
65
+ @property
66
+ def device_count(self) -> int:
67
+ return len(self._devices)
68
+
69
+ @property
70
+ def stats(self) -> Dict[str, Any]:
71
+ categories: Dict[str, int] = {}
72
+ for dev in self._devices:
73
+ cat = dev.get("category", "unknown")
74
+ categories[cat] = categories.get(cat, 0) + 1
75
+ return {
76
+ "version": self.version,
77
+ "device_count": self.device_count,
78
+ "categories": categories,
79
+ "index_sizes": {
80
+ "by_id": len(self._by_id),
81
+ "by_name": len(self._by_name),
82
+ "by_uri": len(self._by_uri),
83
+ "by_category": len(self._by_category),
84
+ "by_tag": len(self._by_tag),
85
+ "by_genre": len(self._by_genre),
86
+ },
87
+ }
88
+
89
+ # ── Lookup ──────────────────────────────────────────────────────
90
+
91
+ def lookup(self, name_or_id: str) -> Optional[Dict[str, Any]]:
92
+ """Exact match by ID, name (case-insensitive), or URI. Returns None on miss."""
93
+ # Try ID first
94
+ if name_or_id in self._by_id:
95
+ return self._by_id[name_or_id]
96
+ # Try name (case-insensitive)
97
+ lower = name_or_id.lower()
98
+ if lower in self._by_name:
99
+ return self._by_name[lower]
100
+ # Try URI
101
+ if name_or_id in self._by_uri:
102
+ return self._by_uri[name_or_id]
103
+ return None
104
+
105
+ # ── Search ──────────────────────────────────────────────────────
106
+
107
+ def search(
108
+ self, query: str, category: str = "all", limit: int = 10
109
+ ) -> List[Dict[str, Any]]:
110
+ """Multi-signal search scoring across name, tags, use_cases, genre, description."""
111
+ if not query:
112
+ return []
113
+
114
+ query_lower = query.lower()
115
+ query_words = query_lower.split()
116
+ results: List[Dict[str, Any]] = []
117
+
118
+ for dev in self._devices:
119
+ # Category filter
120
+ if category != "all" and dev.get("category", "") != category:
121
+ continue
122
+
123
+ score = 0
124
+ dev_name = dev.get("name", "")
125
+ dev_name_lower = dev_name.lower()
126
+
127
+ # Name scoring: 100pts exact, 50pts substring
128
+ if dev_name_lower == query_lower:
129
+ score += 100
130
+ elif query_lower in dev_name_lower:
131
+ score += 50
132
+
133
+ # Tag scoring: 30pts per matching tag
134
+ dev_tags = [t.lower() for t in dev.get("tags", [])]
135
+ for word in query_words:
136
+ if word in dev_tags:
137
+ score += 30
138
+
139
+ # Use case scoring: 25pts per match
140
+ for use_case in dev.get("use_cases", []):
141
+ use_lower = use_case.lower()
142
+ for word in query_words:
143
+ if word in use_lower:
144
+ score += 25
145
+ break # one match per use_case
146
+
147
+ # Genre scoring: 20pts primary, 10pts secondary
148
+ genres = dev.get("genres", {})
149
+ for genre in genres.get("primary", []):
150
+ if query_lower in genre.lower() or genre.lower() in query_lower:
151
+ score += 20
152
+ for genre in genres.get("secondary", []):
153
+ if query_lower in genre.lower() or genre.lower() in query_lower:
154
+ score += 10
155
+
156
+ # Description keyword scoring: 15pts
157
+ description = dev.get("description", "").lower()
158
+ for word in query_words:
159
+ if len(word) >= 3 and word in description:
160
+ score += 15
161
+
162
+ if score > 0:
163
+ results.append({"device": dev, "score": score})
164
+
165
+ # Sort by score descending, then by name for stability
166
+ results.sort(key=lambda r: (-r["score"], r["device"].get("name", "")))
167
+ return results[:limit]
168
+
169
+ # ── Suggest ─────────────────────────────────────────────────────
170
+
171
+ def suggest(
172
+ self,
173
+ intent: str,
174
+ genre: str = "",
175
+ energy: str = "medium",
176
+ limit: int = 5,
177
+ ) -> List[Dict[str, Any]]:
178
+ """Suggest devices for an intent, returning ranked list with rationale and recipe."""
179
+ # Use search to find candidates
180
+ search_query = intent
181
+ if genre:
182
+ search_query = f"{intent} {genre}"
183
+ candidates = self.search(search_query, limit=limit * 2)
184
+
185
+ results = []
186
+ for candidate in candidates[:limit]:
187
+ dev = candidate["device"]
188
+ dev_name = dev.get("name", "")
189
+ dev_category = dev.get("category", "")
190
+ dev_tags = dev.get("tags", [])
191
+ dev_sweet_spot = dev.get("sweet_spot", "")
192
+
193
+ # Build rationale
194
+ rationale_parts = []
195
+ if dev_category:
196
+ rationale_parts.append(f"{dev_name} is a {dev_category}")
197
+ if dev_tags:
198
+ rationale_parts.append(f"suited for {', '.join(dev_tags[:3])}")
199
+ if genre:
200
+ primary_genres = dev.get("genres", {}).get("primary", [])
201
+ if any(genre.lower() in g.lower() for g in primary_genres):
202
+ rationale_parts.append(f"commonly used in {genre}")
203
+ rationale = " — ".join(rationale_parts) if rationale_parts else f"{dev_name} matches your intent"
204
+
205
+ # Build recipe
206
+ recipe = {}
207
+ if dev_sweet_spot:
208
+ recipe["sweet_spot"] = dev_sweet_spot
209
+ recipe["energy"] = energy
210
+ key_params = dev.get("key_parameters", [])
211
+ if key_params:
212
+ recipe["start_with"] = key_params[:3]
213
+
214
+ results.append({
215
+ "device": dev,
216
+ "rationale": rationale,
217
+ "recipe": recipe,
218
+ })
219
+
220
+ return results
221
+
222
+ # ── Chain Suggest ───────────────────────────────────────────────
223
+
224
+ def chain_suggest(
225
+ self, role: str, genre: str = ""
226
+ ) -> Dict[str, Any]:
227
+ """Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad')."""
228
+ chain: List[Dict[str, Any]] = []
229
+ position = 0
230
+
231
+ # Determine chain structure based on role
232
+ role_lower = role.lower()
233
+
234
+ # Stage 1: Instrument (if the role implies one)
235
+ instrument_intents = {
236
+ "bass": "bass synthesizer",
237
+ "lead": "lead synthesizer",
238
+ "pad": "pad synthesizer",
239
+ "keys": "keyboard instrument",
240
+ "drums": "drum machine",
241
+ "vocal": "vocal",
242
+ }
243
+
244
+ intent = instrument_intents.get(role_lower, role_lower)
245
+ search_q = f"{intent} {genre}" if genre else intent
246
+
247
+ # Find instrument
248
+ instrument_candidates = self.search(search_q, category="instrument", limit=3)
249
+ if instrument_candidates:
250
+ best = instrument_candidates[0]["device"]
251
+ chain.append({
252
+ "position": position,
253
+ "device": best,
254
+ "reason": f"Primary {role} instrument",
255
+ })
256
+ position += 1
257
+
258
+ # Stage 2: Effects
259
+ effect_stages = [
260
+ ("eq", f"Shape the {role} tone"),
261
+ ("compression", f"Control {role} dynamics"),
262
+ ("reverb", f"Add space to {role}"),
263
+ ]
264
+
265
+ for effect_type, reason in effect_stages:
266
+ effect_q = f"{effect_type} {genre}" if genre else effect_type
267
+ effect_candidates = self.search(effect_q, category="effect", limit=2)
268
+ if effect_candidates:
269
+ best = effect_candidates[0]["device"]
270
+ chain.append({
271
+ "position": position,
272
+ "device": best,
273
+ "reason": reason,
274
+ })
275
+ position += 1
276
+
277
+ return {
278
+ "role": role,
279
+ "genre": genre,
280
+ "chain": chain,
281
+ }
282
+
283
+ # ── Compare ─────────────────────────────────────────────────────
284
+
285
+ def compare(
286
+ self, device_a: str, device_b: str, role: str = ""
287
+ ) -> Dict[str, Any]:
288
+ """Compare two devices side-by-side with a recommendation."""
289
+ dev_a = self.lookup(device_a)
290
+ dev_b = self.lookup(device_b)
291
+
292
+ if not dev_a:
293
+ return {"error": f"Device not found: {device_a}"}
294
+ if not dev_b:
295
+ return {"error": f"Device not found: {device_b}"}
296
+
297
+ def _summarize(dev: Dict[str, Any]) -> Dict[str, Any]:
298
+ return {
299
+ "name": dev.get("name", ""),
300
+ "category": dev.get("category", ""),
301
+ "tags": dev.get("tags", []),
302
+ "genres": dev.get("genres", {}),
303
+ "use_cases": dev.get("use_cases", []),
304
+ "description": dev.get("description", ""),
305
+ "cpu_weight": dev.get("cpu_weight", "unknown"),
306
+ "sweet_spot": dev.get("sweet_spot", ""),
307
+ }
308
+
309
+ summary_a = _summarize(dev_a)
310
+ summary_b = _summarize(dev_b)
311
+
312
+ # Recommendation logic: score each for the role
313
+ score_a = 0
314
+ score_b = 0
315
+ if role:
316
+ role_lower = role.lower()
317
+ # Check use_cases
318
+ for uc in dev_a.get("use_cases", []):
319
+ if role_lower in uc.lower():
320
+ score_a += 20
321
+ for uc in dev_b.get("use_cases", []):
322
+ if role_lower in uc.lower():
323
+ score_b += 20
324
+ # Check tags
325
+ for tag in dev_a.get("tags", []):
326
+ if role_lower in tag.lower():
327
+ score_a += 10
328
+ for tag in dev_b.get("tags", []):
329
+ if role_lower in tag.lower():
330
+ score_b += 10
331
+
332
+ if score_a > score_b:
333
+ recommendation = f"{summary_a['name']} is better suited for {role}" if role else f"{summary_a['name']} scores higher"
334
+ elif score_b > score_a:
335
+ recommendation = f"{summary_b['name']} is better suited for {role}" if role else f"{summary_b['name']} scores higher"
336
+ else:
337
+ recommendation = "Both devices are equally suited" + (f" for {role}" if role else "")
338
+
339
+ return {
340
+ "device_a": summary_a,
341
+ "device_b": summary_b,
342
+ "recommendation": recommendation,
343
+ }
344
+
345
+
346
+ # ── Module-level lazy loader ───────────────────────────────────────
347
+
348
+ _atlas_instance: Optional[AtlasManager] = None
349
+
350
+
351
+ def _load_atlas() -> AtlasManager:
352
+ """Lazy-load the atlas from device_atlas.json in the same directory."""
353
+ global _atlas_instance
354
+ if _atlas_instance is None:
355
+ atlas_path = os.path.join(os.path.dirname(__file__), "device_atlas.json")
356
+ _atlas_instance = AtlasManager(atlas_path)
357
+ return _atlas_instance