livepilot 1.10.6 → 1.10.8

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 (163) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +12 -10
  3. package/bin/livepilot.js +168 -30
  4. package/installer/install.js +117 -11
  5. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  6. package/m4l_device/livepilot_bridge.js +215 -3
  7. package/mcp_server/__init__.py +1 -1
  8. package/mcp_server/atlas/__init__.py +132 -33
  9. package/mcp_server/atlas/tools.py +56 -15
  10. package/mcp_server/composer/layer_planner.py +27 -0
  11. package/mcp_server/composer/prompt_parser.py +15 -6
  12. package/mcp_server/connection.py +11 -3
  13. package/mcp_server/corpus/__init__.py +14 -4
  14. package/mcp_server/creative_constraints/tools.py +206 -33
  15. package/mcp_server/experiment/engine.py +7 -9
  16. package/mcp_server/hook_hunter/analyzer.py +62 -9
  17. package/mcp_server/hook_hunter/tools.py +60 -9
  18. package/mcp_server/m4l_bridge.py +68 -12
  19. package/mcp_server/musical_intelligence/detectors.py +32 -0
  20. package/mcp_server/performance_engine/tools.py +112 -29
  21. package/mcp_server/preview_studio/engine.py +89 -8
  22. package/mcp_server/preview_studio/tools.py +22 -6
  23. package/mcp_server/project_brain/automation_graph.py +71 -19
  24. package/mcp_server/project_brain/builder.py +2 -0
  25. package/mcp_server/project_brain/tools.py +55 -5
  26. package/mcp_server/reference_engine/profile_builder.py +129 -3
  27. package/mcp_server/reference_engine/tools.py +47 -6
  28. package/mcp_server/runtime/execution_router.py +66 -2
  29. package/mcp_server/runtime/mcp_dispatch.py +75 -3
  30. package/mcp_server/runtime/remote_commands.py +10 -2
  31. package/mcp_server/sample_engine/analyzer.py +131 -4
  32. package/mcp_server/sample_engine/critics.py +29 -8
  33. package/mcp_server/sample_engine/models.py +42 -4
  34. package/mcp_server/sample_engine/tools.py +48 -14
  35. package/mcp_server/semantic_moves/__init__.py +1 -0
  36. package/mcp_server/semantic_moves/compiler.py +9 -1
  37. package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
  38. package/mcp_server/semantic_moves/mix_compilers.py +170 -0
  39. package/mcp_server/semantic_moves/mix_moves.py +1 -1
  40. package/mcp_server/semantic_moves/models.py +5 -0
  41. package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
  42. package/mcp_server/semantic_moves/tools.py +15 -4
  43. package/mcp_server/semantic_moves/transition_compilers.py +12 -19
  44. package/mcp_server/server.py +75 -5
  45. package/mcp_server/services/singletons.py +68 -0
  46. package/mcp_server/session_continuity/models.py +4 -0
  47. package/mcp_server/session_continuity/tracker.py +14 -1
  48. package/mcp_server/song_brain/builder.py +110 -12
  49. package/mcp_server/song_brain/tools.py +77 -13
  50. package/mcp_server/sound_design/tools.py +112 -1
  51. package/mcp_server/splice_client/client.py +29 -8
  52. package/mcp_server/stuckness_detector/detector.py +90 -0
  53. package/mcp_server/stuckness_detector/tools.py +41 -0
  54. package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
  55. package/mcp_server/tools/_composition_engine/__init__.py +2 -2
  56. package/mcp_server/tools/_composition_engine/harmony.py +90 -0
  57. package/mcp_server/tools/_composition_engine/sections.py +47 -4
  58. package/mcp_server/tools/_harmony_engine.py +52 -8
  59. package/mcp_server/tools/_research_engine.py +98 -19
  60. package/mcp_server/tools/_theory_engine.py +138 -9
  61. package/mcp_server/tools/agent_os.py +20 -3
  62. package/mcp_server/tools/analyzer.py +105 -6
  63. package/mcp_server/tools/clips.py +46 -1
  64. package/mcp_server/tools/composition.py +66 -23
  65. package/mcp_server/tools/devices.py +22 -1
  66. package/mcp_server/tools/harmony.py +115 -14
  67. package/mcp_server/tools/midi_io.py +23 -1
  68. package/mcp_server/tools/mixing.py +35 -1
  69. package/mcp_server/tools/motif.py +49 -3
  70. package/mcp_server/tools/research.py +24 -0
  71. package/mcp_server/tools/theory.py +108 -16
  72. package/mcp_server/tools/tracks.py +1 -1
  73. package/mcp_server/tools/transport.py +1 -1
  74. package/mcp_server/transition_engine/critics.py +18 -11
  75. package/mcp_server/translation_engine/tools.py +8 -4
  76. package/package.json +25 -3
  77. package/remote_script/LivePilot/__init__.py +77 -2
  78. package/remote_script/LivePilot/arrangement.py +12 -2
  79. package/remote_script/LivePilot/browser.py +16 -6
  80. package/remote_script/LivePilot/clips.py +69 -0
  81. package/remote_script/LivePilot/devices.py +10 -5
  82. package/remote_script/LivePilot/mixing.py +117 -0
  83. package/remote_script/LivePilot/notes.py +13 -2
  84. package/remote_script/LivePilot/router.py +13 -1
  85. package/remote_script/LivePilot/server.py +51 -13
  86. package/remote_script/LivePilot/version_detect.py +7 -4
  87. package/server.json +20 -0
  88. package/.claude-plugin/marketplace.json +0 -21
  89. package/.mcpbignore +0 -57
  90. package/AGENTS.md +0 -46
  91. package/CODE_OF_CONDUCT.md +0 -27
  92. package/CONTRIBUTING.md +0 -131
  93. package/SECURITY.md +0 -48
  94. package/livepilot/.Codex-plugin/plugin.json +0 -8
  95. package/livepilot/.claude-plugin/plugin.json +0 -8
  96. package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
  97. package/livepilot/commands/arrange.md +0 -47
  98. package/livepilot/commands/beat.md +0 -77
  99. package/livepilot/commands/evaluate.md +0 -49
  100. package/livepilot/commands/memory.md +0 -22
  101. package/livepilot/commands/mix.md +0 -44
  102. package/livepilot/commands/perform.md +0 -42
  103. package/livepilot/commands/session.md +0 -13
  104. package/livepilot/commands/sounddesign.md +0 -43
  105. package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
  106. package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
  107. package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
  108. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
  109. package/livepilot/skills/livepilot-core/SKILL.md +0 -184
  110. package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
  111. package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
  112. package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
  113. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
  114. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
  115. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
  116. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
  117. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
  118. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
  119. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
  120. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
  121. package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
  122. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
  123. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
  124. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
  125. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
  126. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
  127. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
  128. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
  129. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
  130. package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
  131. package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
  132. package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
  133. package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
  134. package/livepilot/skills/livepilot-core/references/overview.md +0 -290
  135. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
  136. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
  137. package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
  138. package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
  139. package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
  140. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
  141. package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
  142. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
  143. package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
  144. package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
  145. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
  146. package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
  147. package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
  148. package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
  149. package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
  150. package/livepilot/skills/livepilot-release/SKILL.md +0 -130
  151. package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
  152. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
  153. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
  154. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
  155. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
  156. package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
  157. package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
  158. package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
  159. package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
  160. package/manifest.json +0 -91
  161. package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
  162. package/scripts/generate_tool_catalog.py +0 -131
  163. package/scripts/sync_metadata.py +0 -132
@@ -115,26 +115,70 @@ class AtlasManager:
115
115
  query_words = query_lower.split()
116
116
  results: List[Dict[str, Any]] = []
117
117
 
118
+ # BUG-B39: the real atlas scanner emits "instruments" /
119
+ # "audio_effects" but older callers and test fixtures sometimes
120
+ # pass the singular "instrument" / "effect". Build a tolerant
121
+ # category alias set so both forms work.
122
+ _CAT_ALIASES = {
123
+ "instrument": {"instrument", "instruments"},
124
+ "instruments": {"instrument", "instruments"},
125
+ "effect": {"effect", "effects", "audio_effects"},
126
+ "effects": {"effect", "effects", "audio_effects"},
127
+ "audio_effect": {"effect", "effects", "audio_effects",
128
+ "audio_effect"},
129
+ "audio_effects": {"effect", "effects", "audio_effects",
130
+ "audio_effect"},
131
+ }
132
+ allowed_cats = (
133
+ _CAT_ALIASES.get(category, {category})
134
+ if category != "all" else None
135
+ )
136
+
118
137
  for dev in self._devices:
119
138
  # Category filter
120
- if category != "all" and dev.get("category", "") != category:
139
+ if allowed_cats is not None and dev.get("category", "") not in allowed_cats:
121
140
  continue
122
141
 
123
142
  score = 0
124
143
  dev_name = dev.get("name", "")
125
144
  dev_name_lower = dev_name.lower()
126
145
 
127
- # Name scoring: 100pts exact, 50pts substring
146
+ # Name scoring. BUG-B41 fix: dropped weight dramatically
147
+ # (was 100 exact / 50 substring) so a device literally
148
+ # named "Bass" no longer blows past character-tag matches
149
+ # for a sonic query like "warm analog bass". An exact name
150
+ # match is still the strongest single signal, but a device
151
+ # with 2+ matching character-tags now beats a name-only
152
+ # accident.
128
153
  if dev_name_lower == query_lower:
129
- score += 100
154
+ score += 45 # was 100
130
155
  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", [])]
156
+ score += 20 # was 50
157
+ else:
158
+ # Partial: any query word present in name — small signal
159
+ for word in query_words:
160
+ if len(word) >= 3 and word in dev_name_lower:
161
+ score += 5
162
+
163
+ # Tag scoring — prefer enriched character_tags.
164
+ # BUG-B40 / B41: also read character_tags so enriched devices
165
+ # actually compete with name-based matches.
166
+ dev_tags = [
167
+ t.lower() for t in (
168
+ dev.get("character_tags") or dev.get("tags", [])
169
+ )
170
+ ]
171
+ # BUG-B41: bumped to 35pts per tag so multi-tag matches beat
172
+ # a single substring-name match.
135
173
  for word in query_words:
136
174
  if word in dev_tags:
137
- score += 30
175
+ score += 35
176
+ # Partial tag match (word appears as substring in a tag)
177
+ else:
178
+ for tag in dev_tags:
179
+ if word in tag:
180
+ score += 10
181
+ break
138
182
 
139
183
  # Use case scoring: 25pts per match
140
184
  for use_case in dev.get("use_cases", []):
@@ -142,10 +186,11 @@ class AtlasManager:
142
186
  for word in query_words:
143
187
  if word in use_lower:
144
188
  score += 25
145
- break # one match per use_case
189
+ break
146
190
 
147
- # Genre scoring: 20pts primary, 10pts secondary
148
- genres = dev.get("genres", {})
191
+ # Genre scoring: 20pts primary, 10pts secondary.
192
+ # BUG-B40: also read genre_affinity (enriched field).
193
+ genres = dev.get("genre_affinity") or dev.get("genres", {}) or {}
149
194
  for genre in genres.get("primary", []):
150
195
  if query_lower in genre.lower() or genre.lower() in query_lower:
151
196
  score += 20
@@ -153,8 +198,11 @@ class AtlasManager:
153
198
  if query_lower in genre.lower() or genre.lower() in query_lower:
154
199
  score += 10
155
200
 
156
- # Description keyword scoring: 15pts
157
- description = dev.get("description", "").lower()
201
+ # Description keyword scoring: 15pts.
202
+ # BUG-B40: prefer sonic_description when present.
203
+ description = (
204
+ dev.get("sonic_description") or dev.get("description", "")
205
+ ).lower()
158
206
  for word in query_words:
159
207
  if len(word) >= 3 and word in description:
160
208
  score += 15
@@ -224,7 +272,13 @@ class AtlasManager:
224
272
  def chain_suggest(
225
273
  self, role: str, genre: str = ""
226
274
  ) -> Dict[str, Any]:
227
- """Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad')."""
275
+ """Suggest a device chain for a given role (e.g., 'bass', 'lead', 'pad').
276
+
277
+ BUG-B39 fix: the old code passed category="instrument" (singular)
278
+ and category="effect" to self.search(), but the atlas stores
279
+ devices with category="instruments" / "audio_effects" (plural).
280
+ Every filtered search missed and the chain came back empty.
281
+ """
228
282
  chain: List[Dict[str, Any]] = []
229
283
  position = 0
230
284
 
@@ -244,8 +298,8 @@ class AtlasManager:
244
298
  intent = instrument_intents.get(role_lower, role_lower)
245
299
  search_q = f"{intent} {genre}" if genre else intent
246
300
 
247
- # Find instrument
248
- instrument_candidates = self.search(search_q, category="instrument", limit=3)
301
+ # Find instrument — atlas category is "instruments" (plural)
302
+ instrument_candidates = self.search(search_q, category="instruments", limit=3)
249
303
  if instrument_candidates:
250
304
  best = instrument_candidates[0]["device"]
251
305
  chain.append({
@@ -255,7 +309,7 @@ class AtlasManager:
255
309
  })
256
310
  position += 1
257
311
 
258
- # Stage 2: Effects
312
+ # Stage 2: Effects — atlas category is "audio_effects"
259
313
  effect_stages = [
260
314
  ("eq", f"Shape the {role} tone"),
261
315
  ("compression", f"Control {role} dynamics"),
@@ -264,7 +318,9 @@ class AtlasManager:
264
318
 
265
319
  for effect_type, reason in effect_stages:
266
320
  effect_q = f"{effect_type} {genre}" if genre else effect_type
267
- effect_candidates = self.search(effect_q, category="effect", limit=2)
321
+ effect_candidates = self.search(
322
+ effect_q, category="audio_effects", limit=2,
323
+ )
268
324
  if effect_candidates:
269
325
  best = effect_candidates[0]["device"]
270
326
  chain.append({
@@ -295,37 +351,48 @@ class AtlasManager:
295
351
  return {"error": f"Device not found: {device_b}"}
296
352
 
297
353
  def _summarize(dev: Dict[str, Any]) -> Dict[str, Any]:
354
+ # BUG-B40 fix: enriched atlas entries use character_tags /
355
+ # sonic_description / genre_affinity — the old _summarize
356
+ # looked for "tags" / "description" / "genres" which are
357
+ # the UN-enriched raw scanner fields. We prefer enriched
358
+ # fields, fall back to raw when enrichment is absent.
298
359
  return {
299
360
  "name": dev.get("name", ""),
300
361
  "category": dev.get("category", ""),
301
- "tags": dev.get("tags", []),
302
- "genres": dev.get("genres", {}),
362
+ "tags": dev.get("character_tags") or dev.get("tags", []),
363
+ "genres": dev.get("genre_affinity") or dev.get("genres", {}),
303
364
  "use_cases": dev.get("use_cases", []),
304
- "description": dev.get("description", ""),
365
+ "description": (
366
+ dev.get("sonic_description")
367
+ or dev.get("description", "")
368
+ ),
305
369
  "cpu_weight": dev.get("cpu_weight", "unknown"),
306
370
  "sweet_spot": dev.get("sweet_spot", ""),
371
+ "enriched": dev.get("enriched", False),
307
372
  }
308
373
 
309
374
  summary_a = _summarize(dev_a)
310
375
  summary_b = _summarize(dev_b)
311
376
 
312
- # Recommendation logic: score each for the role
377
+ # Recommendation logic: score each for the role.
378
+ # BUG-B40: scorer also reads the enriched field names.
313
379
  score_a = 0
314
380
  score_b = 0
315
381
  if role:
316
382
  role_lower = role.lower()
317
- # Check use_cases
318
383
  for uc in dev_a.get("use_cases", []):
319
384
  if role_lower in uc.lower():
320
385
  score_a += 20
321
386
  for uc in dev_b.get("use_cases", []):
322
387
  if role_lower in uc.lower():
323
388
  score_b += 20
324
- # Check tags
325
- for tag in dev_a.get("tags", []):
389
+ # Tag scoring — prefer character_tags (enriched)
390
+ a_tags = dev_a.get("character_tags") or dev_a.get("tags", [])
391
+ b_tags = dev_b.get("character_tags") or dev_b.get("tags", [])
392
+ for tag in a_tags:
326
393
  if role_lower in tag.lower():
327
394
  score_a += 10
328
- for tag in dev_b.get("tags", []):
395
+ for tag in b_tags:
329
396
  if role_lower in tag.lower():
330
397
  score_b += 10
331
398
 
@@ -344,14 +411,46 @@ class AtlasManager:
344
411
 
345
412
 
346
413
  # ── Module-level lazy loader ───────────────────────────────────────
414
+ #
415
+ # Thread-safe via services.singletons.Singleton. The previous check-then-set
416
+ # pattern raced under FastMCP concurrency (two handlers could both construct
417
+ # AtlasManager) and never refreshed the in-memory index after a rebuild of
418
+ # device_atlas.json on disk. The Singleton helper handles both.
419
+ #
420
+ # The ``_atlas_instance`` module attribute is preserved for backward
421
+ # compatibility with call sites that read it directly (atlas/tools.py),
422
+ # but new code should call ``get_atlas()`` / ``invalidate_atlas()`` instead.
347
423
 
348
- _atlas_instance: Optional[AtlasManager] = None
424
+ from pathlib import Path
425
+ from ..services.singletons import Singleton
349
426
 
427
+ ATLAS_PATH = Path(__file__).parent / "device_atlas.json"
350
428
 
351
- def _load_atlas() -> AtlasManager:
352
- """Lazy-load the atlas from device_atlas.json in the same directory."""
429
+ _atlas_instance: Optional[AtlasManager] = None # kept for legacy imports
430
+
431
+
432
+ def _build_atlas() -> AtlasManager:
433
+ return AtlasManager(str(ATLAS_PATH))
434
+
435
+
436
+ _atlas_holder = Singleton(_build_atlas)
437
+
438
+
439
+ def get_atlas() -> AtlasManager:
440
+ """Thread-safe accessor. Re-reads device_atlas.json if its mtime advanced."""
353
441
  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
442
+ instance = _atlas_holder.get(reload_if_newer=ATLAS_PATH)
443
+ _atlas_instance = instance # keep legacy attribute in sync
444
+ return instance
445
+
446
+
447
+ def invalidate_atlas() -> None:
448
+ """Force the next get_atlas() to re-read device_atlas.json from disk."""
449
+ global _atlas_instance
450
+ _atlas_holder.invalidate()
451
+ _atlas_instance = None
452
+
453
+
454
+ def _load_atlas() -> AtlasManager:
455
+ """Legacy shim — kept so atlas/tools.py still works. Prefer get_atlas()."""
456
+ return get_atlas()
@@ -19,15 +19,17 @@ def _get_ableton(ctx: Context):
19
19
 
20
20
 
21
21
  def _get_atlas():
22
- """Get the global AtlasManager instance, loading lazily if needed."""
23
- from . import _atlas_instance, _load_atlas
24
- if _atlas_instance is None:
25
- try:
26
- _load_atlas()
27
- except FileNotFoundError:
28
- return None
29
- from . import _atlas_instance as inst
30
- return inst
22
+ """Get the global AtlasManager instance, loading lazily if needed.
23
+
24
+ Uses the thread-safe singleton helper — concurrent FastMCP tool calls no
25
+ longer race on the check-then-set, and the atlas auto-reloads from disk
26
+ if device_atlas.json's mtime advanced (e.g. after scan_full_library).
27
+ """
28
+ from . import get_atlas
29
+ try:
30
+ return get_atlas()
31
+ except FileNotFoundError:
32
+ return None
31
33
 
32
34
 
33
35
  @mcp.tool()
@@ -197,23 +199,44 @@ def scan_full_library(ctx: Context, force: bool = False) -> dict:
197
199
  stats[cat] = stats.get(cat, 0) + 1
198
200
  stats["enriched_devices"] = sum(1 for d in devices if d.get("enriched"))
199
201
 
202
+ # Read the actual running Live version from the session rather than
203
+ # hardcoding "12.3.6" — the hardcoded string was baking last year's
204
+ # version into every new user's atlas until they forced a rescan.
205
+ try:
206
+ session_info = ableton.send_command("get_session_info", {}) or {}
207
+ live_version = session_info.get("live_version", "unknown")
208
+ except Exception:
209
+ live_version = "unknown"
210
+
200
211
  # Build atlas
201
212
  atlas_data = {
202
213
  "version": "2.0.0",
203
- "live_version": "12.3.6",
214
+ "live_version": live_version,
204
215
  "scanned_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
205
216
  "stats": stats,
206
217
  "devices": devices,
207
218
  "packs": [],
208
219
  }
209
220
 
210
- # Write
211
- with open(atlas_path, "w") as f:
221
+ # Atomic write: tmp + rename. Same pattern as PersistentJsonStore. Previous
222
+ # version used open(atlas_path, "w") + json.dump with no fsync, so a crash
223
+ # mid-write produced a truncated JSON file that the next AtlasManager init
224
+ # silently read as empty-dict — devices vanished from memory.
225
+ tmp_path = atlas_path + ".tmp"
226
+ with open(tmp_path, "w") as f:
212
227
  json.dump(atlas_data, f, indent=2)
213
-
214
- # Reload into global
228
+ f.flush()
229
+ try:
230
+ os.fsync(f.fileno())
231
+ except OSError:
232
+ # fsync may be unavailable on some filesystems/Windows paths —
233
+ # best-effort; the rename below is still atomic on POSIX.
234
+ pass
235
+ os.replace(tmp_path, atlas_path)
236
+
237
+ # Invalidate singleton so next get_atlas() picks up the new file.
215
238
  import mcp_server.atlas as atlas_mod
216
- atlas_mod._atlas_instance = AtlasManager(atlas_path)
239
+ atlas_mod.invalidate_atlas()
217
240
 
218
241
  return {
219
242
  "status": "scanned",
@@ -222,3 +245,21 @@ def scan_full_library(ctx: Context, force: bool = False) -> dict:
222
245
  "stats": stats,
223
246
  "atlas_path": atlas_path,
224
247
  }
248
+
249
+
250
+ @mcp.tool()
251
+ def reload_atlas(ctx: Context) -> dict:
252
+ """Force the atlas to re-read device_atlas.json from disk.
253
+
254
+ Useful after an out-of-band rebuild (e.g. a manual edit to the JSON file,
255
+ or a scan that crashed before invalidating the cache). The next search /
256
+ suggest / compare call will see the fresh data. No-op if the atlas has
257
+ never been loaded — the first real call will load it fresh anyway.
258
+ """
259
+ from . import invalidate_atlas, get_atlas
260
+ invalidate_atlas()
261
+ atlas = get_atlas()
262
+ return {
263
+ "reloaded": True,
264
+ "device_count": atlas.device_count if atlas else 0,
265
+ }
@@ -403,6 +403,33 @@ def plan_sections(intent: CompositionIntent) -> list[dict]:
403
403
  })
404
404
  current_bar += scaled_bars
405
405
 
406
+ # Clamp overshoot. Rounding each section up to the nearest 4 bars plus
407
+ # the min-of-4-bars floor means a short duration_bars (e.g. 16) against
408
+ # a 6-section template could produce 24+ bars of sections — a 50%
409
+ # overshoot that pushed arrangement clips into unexpected territory.
410
+ # Trim from the longest non-intro section until we fit.
411
+ total_placed = sum(s["bars"] for s in sections)
412
+ overshoot = total_placed - intent.duration_bars
413
+ if overshoot > 0 and sections:
414
+ # Sort indices by section length desc, skipping the first section
415
+ # (usually intro) which we'd rather preserve at its snapped length.
416
+ trimmable = sorted(
417
+ range(1, len(sections)),
418
+ key=lambda i: -sections[i]["bars"],
419
+ ) or [0]
420
+ i = 0
421
+ while overshoot > 0 and i < len(trimmable) * 4:
422
+ idx = trimmable[i % len(trimmable)]
423
+ if sections[idx]["bars"] > 4:
424
+ sections[idx]["bars"] -= 4
425
+ overshoot -= 4
426
+ i += 1
427
+ # Recompute start_bar values after any trim
428
+ running = 0
429
+ for s in sections:
430
+ s["start_bar"] = running
431
+ running += s["bars"]
432
+
406
433
  return sections
407
434
 
408
435
 
@@ -202,9 +202,15 @@ _ELEMENT_PATTERNS: list[tuple[str, str]] = [
202
202
 
203
203
  _TEMPO_RE = re.compile(r"\b(\d{2,3})\s*bpm\b", re.IGNORECASE)
204
204
 
205
- # Key patterns: C, Cm, C#, C# minor, Db, Dbm, F# minor, Bb major
205
+ # Key patterns: must have either an accidental (C#, Db) OR an explicit
206
+ # quality word (C minor, F major, Am). The previous regex made the
207
+ # quality group optional AND allowed a bare letter — so "dark ambient"
208
+ # matched D as a key root, silently overwriting any mood-inferred key.
206
209
  _KEY_RE = re.compile(
207
- r"\b([A-Ga-g][#b]?)\s*(minor|major|min|maj|m)?\b"
210
+ # Case 1: root + quality word (explicit minor/major/min/maj/m suffix)
211
+ r"\b([A-Ga-g])\s*(minor|major|min|maj|m)\b"
212
+ # Case 2: root + accidental (optional quality)
213
+ r"|\b([A-Ga-g][#b])\s*(minor|major|min|maj|m)?\b"
208
214
  )
209
215
 
210
216
 
@@ -228,19 +234,22 @@ def parse_prompt(text: str) -> CompositionIntent:
228
234
  intent.tempo = int(tempo_match.group(1))
229
235
 
230
236
  # 2. Extract key (search original text to preserve case)
237
+ # Regex has TWO alternations (root+quality OR root-with-accidental
238
+ # +optional-quality). Take whichever branch matched.
231
239
  key_match = _KEY_RE.search(text)
232
240
  if key_match:
233
- root = key_match.group(1)
234
- # Normalize root: uppercase first letter
241
+ root = key_match.group(1) or key_match.group(3)
242
+ quality = key_match.group(2) or key_match.group(4) or ""
243
+ # Normalize root: uppercase first letter, preserve accidental
235
244
  root = root[0].upper() + root[1:] if len(root) > 1 else root.upper()
236
- quality = key_match.group(2) or ""
237
245
  quality_lower = quality.lower()
238
246
  if quality_lower in ("minor", "min", "m"):
239
247
  intent.key = f"{root}m"
240
248
  elif quality_lower in ("major", "maj"):
241
249
  intent.key = root
242
250
  else:
243
- # Standalone note check if followed by 'm' in the original
251
+ # Only reached when Case 2 matched without quality an
252
+ # accidental was present (C#, Db), so this IS a legit key root.
244
253
  intent.key = root
245
254
 
246
255
  # 3. Match genre (check aliases first, then canonical names)
@@ -213,9 +213,17 @@ class AbletonConnection:
213
213
  # The single-client guard can briefly reject an immediate reconnect
214
214
  # after this process closes a previous socket. Retry once after a
215
215
  # short delay when the command was rejected before execution.
216
- if fresh_connect and _is_single_client_state_error(response):
217
- self.disconnect()
218
- time.sleep(SINGLE_CLIENT_RETRY_DELAY)
216
+ #
217
+ # IMPORTANT: release the lock around the sleep so concurrent tool
218
+ # calls are not blocked on an idle timer. The previous version
219
+ # slept 250ms while holding the lock, which stalled every other
220
+ # async MCP handler in the server.
221
+ needs_retry = fresh_connect and _is_single_client_state_error(response)
222
+
223
+ if needs_retry:
224
+ self.disconnect()
225
+ time.sleep(SINGLE_CLIENT_RETRY_DELAY)
226
+ with self._lock:
219
227
  self.connect()
220
228
  response = self._send_raw(
221
229
  command,
@@ -365,13 +365,23 @@ def load_corpus() -> Corpus:
365
365
 
366
366
 
367
367
  # ── Module-level lazy singleton ─────────────────────────────────────────
368
+ #
369
+ # Thread-safe via services.singletons.Singleton — concurrent FastMCP
370
+ # handlers can no longer both trigger load_corpus() (which did heavy
371
+ # filesystem I/O) on a cold start.
368
372
 
373
+ from ..services.singletons import Singleton
374
+
375
+ _corpus_holder = Singleton(load_corpus)
376
+
377
+ # Preserved for backward compatibility with any code that reads the legacy
378
+ # attribute directly.
369
379
  _corpus_instance: Optional[Corpus] = None
370
380
 
371
381
 
372
382
  def get_corpus() -> Corpus:
373
- """Get the global corpus instance (lazy-loaded on first call)."""
383
+ """Get the global corpus instance (lazy-loaded, thread-safe)."""
374
384
  global _corpus_instance
375
- if _corpus_instance is None:
376
- _corpus_instance = load_corpus()
377
- return _corpus_instance
385
+ instance = _corpus_holder.get()
386
+ _corpus_instance = instance
387
+ return instance