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.
- package/CHANGELOG.md +168 -0
- package/README.md +12 -10
- package/bin/livepilot.js +168 -30
- package/installer/install.js +117 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +215 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +132 -33
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- package/mcp_server/creative_constraints/tools.py +206 -33
- package/mcp_server/experiment/engine.py +7 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +60 -9
- package/mcp_server/m4l_bridge.py +68 -12
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/performance_engine/tools.py +112 -29
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +22 -6
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +55 -5
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +47 -6
- package/mcp_server/runtime/execution_router.py +66 -2
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +10 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +42 -4
- package/mcp_server/sample_engine/tools.py +48 -14
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +15 -4
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +75 -5
- package/mcp_server/services/singletons.py +68 -0
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tracker.py +14 -1
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +77 -13
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +29 -8
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +41 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
- package/mcp_server/tools/_composition_engine/__init__.py +2 -2
- package/mcp_server/tools/_composition_engine/harmony.py +90 -0
- package/mcp_server/tools/_composition_engine/sections.py +47 -4
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +20 -3
- package/mcp_server/tools/analyzer.py +105 -6
- package/mcp_server/tools/clips.py +46 -1
- package/mcp_server/tools/composition.py +66 -23
- package/mcp_server/tools/devices.py +22 -1
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +23 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +49 -3
- package/mcp_server/tools/research.py +24 -0
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +77 -2
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/router.py +13 -1
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcpbignore +0 -57
- package/AGENTS.md +0 -46
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -131
- 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
|
|
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:
|
|
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
|
-
|
|
134
|
-
|
|
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 +=
|
|
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
|
|
189
|
+
break
|
|
146
190
|
|
|
147
|
-
# Genre scoring: 20pts primary, 10pts secondary
|
|
148
|
-
|
|
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
|
-
|
|
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="
|
|
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(
|
|
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":
|
|
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
|
-
#
|
|
325
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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":
|
|
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
|
-
#
|
|
211
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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)
|
package/mcp_server/connection.py
CHANGED
|
@@ -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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
383
|
+
"""Get the global corpus instance (lazy-loaded, thread-safe)."""
|
|
374
384
|
global _corpus_instance
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return
|
|
385
|
+
instance = _corpus_holder.get()
|
|
386
|
+
_corpus_instance = instance
|
|
387
|
+
return instance
|